Skip to content

Commit 6e4f9f9

Browse files
Merge pull request #195 from bitgopatmcl/router-api-updates
feat: update typed-express-router api
2 parents 2d43885 + 4be80a7 commit 6e4f9f9

File tree

4 files changed

+265
-172
lines changed

4 files changed

+265
-172
lines changed

packages/typed-express-router/README.md

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ A thin wrapper around Express's `Router`
55
## Goals
66

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

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

56-
### Aliased routes
57+
### Route aliases
5758

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

6263
```ts
63-
typedRouter.getAlias('/oldDeprecatedHelloWorld', 'hello.world', [HelloWorldHandler]);
64-
```
65-
66-
### Unchecked routes
67-
68-
For convenience, the original router's `get`/`post`/`put`/`delete` methods can still be
69-
used via `getUnchecked` (or similar):
70-
71-
```ts
72-
// Just a normal express route
73-
typedRouter.getUnchecked('/api/foo/bar', (req, res) => {
74-
res.send(200).end();
64+
typedRouter.get('hello.world', [HelloWorldHandler], {
65+
routeAliases: ['/oldDeprecatedHelloWorld'],
7566
});
7667
```
7768

@@ -105,6 +96,31 @@ typedRouter.get('hello.world', [HelloWorldHandler], {
10596
});
10697
```
10798

99+
### Unchecked routes
100+
101+
If you need custom behavior on decode errors that is more involved than just sending an
102+
error response, then the unchecked variant of the router functions can be used. They do
103+
not fail and call `onDecodeError` when a request is invalid. Instead, they will still
104+
populate `req.decoded`, except this time it'll contain the
105+
`Either<Errors, DecodedRequest>` type for route handlers to inspect.
106+
107+
```ts
108+
// Just a normal express route
109+
typedRouter.getUnchecked('hello.world', (req, res) => {
110+
if (E.isLeft(req.decoded)) {
111+
console.warn('Route failed to decode! Continuing anyway');
112+
})
113+
114+
res.send(200).end();
115+
});
116+
```
117+
118+
### Router middleware
119+
120+
Middleware added with `typedRouter.use()` is ran just after the request is decoded but
121+
before it is validated, even on checked routes. It'll have access to `req.decoded` in
122+
the same way that unchecked routes do.
123+
108124
### Other usage
109125

110126
Other than what is documented above, a wrapped router should behave like a regular

packages/typed-express-router/src/index.ts

Lines changed: 87 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { pipe } from 'fp-ts/function';
55
import { defaultOnDecodeError, defaultOnEncodeError } from './errors';
66
import { apiTsPathToExpress } from './path';
77
import {
8-
AddAliasRouteHandler,
98
AddRouteHandler,
9+
AddUncheckedRouteHandler,
1010
Methods,
11+
UncheckedRequestHandler,
12+
WrappedRequest,
13+
WrappedResponse,
1114
WrappedRouteOptions,
1215
WrappedRouter,
1316
WrappedRouterOptions,
@@ -18,6 +21,7 @@ export type {
1821
OnDecodeErrorFn,
1922
OnEncodeErrorFn,
2023
TypedRequestHandler,
24+
UncheckedRequestHandler,
2125
WrappedRouter,
2226
WrappedRouteOptions,
2327
WrappedRouterOptions,
@@ -64,72 +68,94 @@ export function wrapRouter<Spec extends ApiSpec>(
6468
onDecodeError = defaultOnDecodeError,
6569
onEncodeError = defaultOnEncodeError,
6670
afterEncodedResponseSent = () => {},
67-
}: WrappedRouteOptions<HttpRoute>,
71+
}: WrappedRouteOptions,
6872
): WrappedRouter<Spec> {
69-
function makeAddAliasRoute<Method extends Methods>(
73+
const routerMiddleware: UncheckedRequestHandler[] = [];
74+
75+
function makeAddUncheckedRoute<Method extends Methods>(
7076
method: Method,
71-
): AddAliasRouteHandler<Spec, Method> {
72-
return (path, apiName, handlers, options) => {
77+
): AddUncheckedRouteHandler<Spec, Method> {
78+
return (apiName, handlers, options) => {
7379
const route: HttpRoute = spec[apiName as keyof Spec]![method]!;
74-
const wrapReqAndRes: express.RequestHandler = (req, res, next) => {
75-
pipe(
76-
route.request.decode(req),
77-
E.matchW(
78-
(errs) => (options?.onDecodeError ?? onDecodeError)(errs, req, res),
79-
(decoded) => {
80-
// Gotta cast to mutate this in place
81-
(req as any).decoded = decoded;
82-
(res as any).sendEncoded = (
83-
status: keyof typeof route['response'],
84-
payload: any,
85-
) => {
86-
try {
87-
const codec = route.response[status];
88-
if (!codec) {
89-
throw new Error(`no codec defined for response status ${status}`);
90-
}
91-
const statusCode =
92-
typeof status === 'number'
93-
? status
94-
: KeyToHttpStatus[status as keyof KeyToHttpStatus];
95-
if (statusCode === undefined) {
96-
throw new Error(`unknown HTTP status code for key ${status}`);
97-
} else if (!codec.is(payload)) {
98-
throw new Error(
99-
`response does not match expected type ${codec.name}`,
100-
);
101-
}
102-
const encoded = codec.encode(payload);
103-
res.status(statusCode).json(encoded).end();
104-
(options?.afterEncodedResponseSent ?? afterEncodedResponseSent)(
105-
status,
106-
payload,
107-
req,
108-
res,
109-
);
110-
} catch (err) {
111-
(options?.onEncodeError ?? onEncodeError)(err, req, res);
112-
}
113-
};
114-
next();
115-
},
116-
),
117-
);
80+
const wrapReqAndRes: UncheckedRequestHandler = (req, res, next) => {
81+
const decoded = route.request.decode(req);
82+
req.decoded = decoded;
83+
req.apiName = apiName;
84+
req.httpRoute = route;
85+
res.sendEncoded = (
86+
status: keyof typeof route['response'],
87+
payload: unknown,
88+
) => {
89+
try {
90+
const codec = route.response[status];
91+
if (!codec) {
92+
throw new Error(`no codec defined for response status ${status}`);
93+
}
94+
const statusCode =
95+
typeof status === 'number'
96+
? status
97+
: KeyToHttpStatus[status as keyof KeyToHttpStatus];
98+
if (statusCode === undefined) {
99+
throw new Error(`unknown HTTP status code for key ${status}`);
100+
} else if (!codec.is(payload)) {
101+
throw new Error(`response does not match expected type ${codec.name}`);
102+
}
103+
const encoded = codec.encode(payload);
104+
res.status(statusCode).json(encoded).end();
105+
(options?.afterEncodedResponseSent ?? afterEncodedResponseSent)(
106+
statusCode,
107+
payload,
108+
req as WrappedRequest,
109+
res as WrappedResponse,
110+
);
111+
} catch (err) {
112+
(options?.onEncodeError ?? onEncodeError)(
113+
err,
114+
req as WrappedRequest,
115+
res as WrappedResponse,
116+
);
117+
}
118+
};
119+
next();
118120
};
119121

120-
router[method](path, [wrapReqAndRes, ...(handlers as express.RequestHandler[])]);
122+
const middlewareChain = [
123+
wrapReqAndRes,
124+
...routerMiddleware,
125+
...handlers,
126+
] as express.RequestHandler[];
127+
128+
const path = spec[apiName as keyof typeof spec]![method]!.path;
129+
router[method](apiTsPathToExpress(path), middlewareChain);
130+
131+
options?.routeAliases?.forEach((alias) => {
132+
router[method](alias, middlewareChain);
133+
});
121134
};
122135
}
123136

124137
function makeAddRoute<Method extends Methods>(
125138
method: Method,
126139
): AddRouteHandler<Spec, Method> {
127140
return (apiName, handlers, options) => {
128-
const path = spec[apiName as keyof typeof spec]![method]!.path;
129-
return makeAddAliasRoute(method)(
130-
apiTsPathToExpress(path),
141+
const validateMiddleware: UncheckedRequestHandler = (req, res, next) => {
142+
pipe(
143+
req.decoded,
144+
E.matchW(
145+
(errs) => {
146+
(options?.onDecodeError ?? onDecodeError)(errs, req, res);
147+
},
148+
(value) => {
149+
req.decoded = value;
150+
next();
151+
},
152+
),
153+
);
154+
};
155+
156+
return makeAddUncheckedRoute(method)(
131157
apiName,
132-
handlers,
158+
[validateMiddleware, ...handlers],
133159
options,
134160
);
135161
};
@@ -144,14 +170,13 @@ export function wrapRouter<Spec extends ApiSpec>(
144170
post: makeAddRoute('post'),
145171
put: makeAddRoute('put'),
146172
delete: makeAddRoute('delete'),
147-
getAlias: makeAddAliasRoute('get'),
148-
postAlias: makeAddAliasRoute('post'),
149-
putAlias: makeAddAliasRoute('put'),
150-
deleteAlias: makeAddAliasRoute('delete'),
151-
getUnchecked: router.get,
152-
postUnchecked: router.post,
153-
putUnchecked: router.put,
154-
deleteUnchecked: router.delete,
173+
getUnchecked: makeAddUncheckedRoute('get'),
174+
postUnchecked: makeAddUncheckedRoute('post'),
175+
putUnchecked: makeAddUncheckedRoute('put'),
176+
deleteUnchecked: makeAddUncheckedRoute('delete'),
177+
use: (middleware: UncheckedRequestHandler) => {
178+
routerMiddleware.push(middleware);
179+
},
155180
},
156181
);
157182

0 commit comments

Comments
 (0)