Skip to content

Commit

Permalink
Merge pull request #195 from bitgopatmcl/router-api-updates
Browse files Browse the repository at this point in the history
feat: update typed-express-router api
  • Loading branch information
ericcrosson-bitgo authored Aug 11, 2022
2 parents 2d43885 + 4be80a7 commit 6e4f9f9
Show file tree
Hide file tree
Showing 4 changed files with 265 additions and 172 deletions.
54 changes: 35 additions & 19 deletions packages/typed-express-router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ A thin wrapper around Express's `Router`
## Goals

- Define Express routes that are associated with routes in an api-ts `apiSpec`
- Augment the existing Express request with the decoded request object
- Augment the existing Express request with the decoded request object and api-ts route
metadata
- Augment the existing Express response with a type-checked `encode` function
- Allow customization of what to do on decode/encode errors, per-route if desired
- Allow action to be performed after an encoded response is sent, per-route if desired
- Allow routes to be defined with path that is different than the one specified in the
`httpRoute` (e.g. for aliases)
- Allow routes to define alias routes with path that is different than the one specified
in the `httpRoute`
- Follow the express router api as closely as possible otherwise

## Non-Goals
Expand Down Expand Up @@ -53,25 +54,15 @@ will enforce types on the payload and encode types appropriately (e.g.
`BigIntFromString` will be converted to a string). The exported `TypedRequestHandler`
type may be used to infer the parameter types for these functions.

### Aliased routes
### Route aliases

If more flexibility is needed in the route path, the `getAlias`-style route functions
may be used. They take a path that is directly interpreted by Express, but otherwise
work like the regular route methods:
If more flexibility is needed in the route path, a `routeAliases` function may be
provided to match multiple paths. These paths may use the full Express matching syntax,
but take care to preserve any path parameters or else you will likely get decode errors.

```ts
typedRouter.getAlias('/oldDeprecatedHelloWorld', 'hello.world', [HelloWorldHandler]);
```

### Unchecked routes

For convenience, the original router's `get`/`post`/`put`/`delete` methods can still be
used via `getUnchecked` (or similar):

```ts
// Just a normal express route
typedRouter.getUnchecked('/api/foo/bar', (req, res) => {
res.send(200).end();
typedRouter.get('hello.world', [HelloWorldHandler], {
routeAliases: ['/oldDeprecatedHelloWorld'],
});
```

Expand Down Expand Up @@ -105,6 +96,31 @@ typedRouter.get('hello.world', [HelloWorldHandler], {
});
```

### Unchecked routes

If you need custom behavior on decode errors that is more involved than just sending an
error response, then the unchecked variant of the router functions can be used. They do
not fail and call `onDecodeError` when a request is invalid. Instead, they will still
populate `req.decoded`, except this time it'll contain the
`Either<Errors, DecodedRequest>` type for route handlers to inspect.

```ts
// Just a normal express route
typedRouter.getUnchecked('hello.world', (req, res) => {
if (E.isLeft(req.decoded)) {
console.warn('Route failed to decode! Continuing anyway');
})

res.send(200).end();
});
```

### Router middleware

Middleware added with `typedRouter.use()` is ran just after the request is decoded but
before it is validated, even on checked routes. It'll have access to `req.decoded` in
the same way that unchecked routes do.

### Other usage

Other than what is documented above, a wrapped router should behave like a regular
Expand Down
149 changes: 87 additions & 62 deletions packages/typed-express-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@ import { pipe } from 'fp-ts/function';
import { defaultOnDecodeError, defaultOnEncodeError } from './errors';
import { apiTsPathToExpress } from './path';
import {
AddAliasRouteHandler,
AddRouteHandler,
AddUncheckedRouteHandler,
Methods,
UncheckedRequestHandler,
WrappedRequest,
WrappedResponse,
WrappedRouteOptions,
WrappedRouter,
WrappedRouterOptions,
Expand All @@ -18,6 +21,7 @@ export type {
OnDecodeErrorFn,
OnEncodeErrorFn,
TypedRequestHandler,
UncheckedRequestHandler,
WrappedRouter,
WrappedRouteOptions,
WrappedRouterOptions,
Expand Down Expand Up @@ -64,72 +68,94 @@ export function wrapRouter<Spec extends ApiSpec>(
onDecodeError = defaultOnDecodeError,
onEncodeError = defaultOnEncodeError,
afterEncodedResponseSent = () => {},
}: WrappedRouteOptions<HttpRoute>,
}: WrappedRouteOptions,
): WrappedRouter<Spec> {
function makeAddAliasRoute<Method extends Methods>(
const routerMiddleware: UncheckedRequestHandler[] = [];

function makeAddUncheckedRoute<Method extends Methods>(
method: Method,
): AddAliasRouteHandler<Spec, Method> {
return (path, apiName, handlers, options) => {
): AddUncheckedRouteHandler<Spec, Method> {
return (apiName, handlers, options) => {
const route: HttpRoute = spec[apiName as keyof Spec]![method]!;
const wrapReqAndRes: express.RequestHandler = (req, res, next) => {
pipe(
route.request.decode(req),
E.matchW(
(errs) => (options?.onDecodeError ?? onDecodeError)(errs, req, res),
(decoded) => {
// Gotta cast to mutate this in place
(req as any).decoded = decoded;
(res as any).sendEncoded = (
status: keyof typeof route['response'],
payload: any,
) => {
try {
const codec = route.response[status];
if (!codec) {
throw new Error(`no codec defined for response status ${status}`);
}
const statusCode =
typeof status === 'number'
? status
: KeyToHttpStatus[status as keyof KeyToHttpStatus];
if (statusCode === undefined) {
throw new Error(`unknown HTTP status code for key ${status}`);
} else if (!codec.is(payload)) {
throw new Error(
`response does not match expected type ${codec.name}`,
);
}
const encoded = codec.encode(payload);
res.status(statusCode).json(encoded).end();
(options?.afterEncodedResponseSent ?? afterEncodedResponseSent)(
status,
payload,
req,
res,
);
} catch (err) {
(options?.onEncodeError ?? onEncodeError)(err, req, res);
}
};
next();
},
),
);
const wrapReqAndRes: UncheckedRequestHandler = (req, res, next) => {
const decoded = route.request.decode(req);
req.decoded = decoded;
req.apiName = apiName;
req.httpRoute = route;
res.sendEncoded = (
status: keyof typeof route['response'],
payload: unknown,
) => {
try {
const codec = route.response[status];
if (!codec) {
throw new Error(`no codec defined for response status ${status}`);
}
const statusCode =
typeof status === 'number'
? status
: KeyToHttpStatus[status as keyof KeyToHttpStatus];
if (statusCode === undefined) {
throw new Error(`unknown HTTP status code for key ${status}`);
} else if (!codec.is(payload)) {
throw new Error(`response does not match expected type ${codec.name}`);
}
const encoded = codec.encode(payload);
res.status(statusCode).json(encoded).end();
(options?.afterEncodedResponseSent ?? afterEncodedResponseSent)(
statusCode,
payload,
req as WrappedRequest,
res as WrappedResponse,
);
} catch (err) {
(options?.onEncodeError ?? onEncodeError)(
err,
req as WrappedRequest,
res as WrappedResponse,
);
}
};
next();
};

router[method](path, [wrapReqAndRes, ...(handlers as express.RequestHandler[])]);
const middlewareChain = [
wrapReqAndRes,
...routerMiddleware,
...handlers,
] as express.RequestHandler[];

const path = spec[apiName as keyof typeof spec]![method]!.path;
router[method](apiTsPathToExpress(path), middlewareChain);

options?.routeAliases?.forEach((alias) => {
router[method](alias, middlewareChain);
});
};
}

function makeAddRoute<Method extends Methods>(
method: Method,
): AddRouteHandler<Spec, Method> {
return (apiName, handlers, options) => {
const path = spec[apiName as keyof typeof spec]![method]!.path;
return makeAddAliasRoute(method)(
apiTsPathToExpress(path),
const validateMiddleware: UncheckedRequestHandler = (req, res, next) => {
pipe(
req.decoded,
E.matchW(
(errs) => {
(options?.onDecodeError ?? onDecodeError)(errs, req, res);
},
(value) => {
req.decoded = value;
next();
},
),
);
};

return makeAddUncheckedRoute(method)(
apiName,
handlers,
[validateMiddleware, ...handlers],
options,
);
};
Expand All @@ -144,14 +170,13 @@ export function wrapRouter<Spec extends ApiSpec>(
post: makeAddRoute('post'),
put: makeAddRoute('put'),
delete: makeAddRoute('delete'),
getAlias: makeAddAliasRoute('get'),
postAlias: makeAddAliasRoute('post'),
putAlias: makeAddAliasRoute('put'),
deleteAlias: makeAddAliasRoute('delete'),
getUnchecked: router.get,
postUnchecked: router.post,
putUnchecked: router.put,
deleteUnchecked: router.delete,
getUnchecked: makeAddUncheckedRoute('get'),
postUnchecked: makeAddUncheckedRoute('post'),
putUnchecked: makeAddUncheckedRoute('put'),
deleteUnchecked: makeAddUncheckedRoute('delete'),
use: (middleware: UncheckedRequestHandler) => {
routerMiddleware.push(middleware);
},
},
);

Expand Down
Loading

0 comments on commit 6e4f9f9

Please sign in to comment.