Skip to content

Commit e1e58ad

Browse files
committed
feat: add typed-express-router package
1 parent 94b9835 commit e1e58ad

File tree

16 files changed

+1103
-42
lines changed

16 files changed

+1103
-42
lines changed

package-lock.json

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/express-wrapper/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
},
2525
"devDependencies": {
2626
"@api-ts/superagent-wrapper": "0.0.0-semantically-released",
27+
"@api-ts/typed-express-router": "0.0.0-semantically-released",
2728
"@ava/typescript": "3.0.1",
2829
"@types/express": "4.17.13",
2930
"ava": "4.3.1",

packages/express-wrapper/src/index.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
import express from 'express';
77

88
import { ApiSpec, HttpRoute } from '@api-ts/io-ts-http';
9+
import { createRouter } from '@api-ts/typed-express-router';
910

10-
import { apiTsPathToExpress } from './path';
11-
import { decodeRequestAndEncodeResponse, RouteHandler } from './request';
11+
import { handleRequest, onDecodeError, onEncodeError, RouteHandler } from './request';
1212
import { defaultResponseEncoder, ResponseEncoder } from './response';
1313

1414
export { middlewareFn, MiddlewareChain, MiddlewareChainOutput } from './middleware';
@@ -33,7 +33,10 @@ export function routerForApiSpec<Spec extends ApiSpec>({
3333
routeHandlers,
3434
encoder = defaultResponseEncoder,
3535
}: CreateRouterProps<Spec>) {
36-
const router = express.Router();
36+
const router = createRouter(spec, {
37+
onDecodeError,
38+
onEncodeError,
39+
});
3740
for (const apiName of Object.keys(spec)) {
3841
const resource = spec[apiName] as Spec[string];
3942
for (const method of Object.keys(resource)) {
@@ -42,17 +45,15 @@ export function routerForApiSpec<Spec extends ApiSpec>({
4245
}
4346
const httpRoute: HttpRoute = resource[method]!;
4447
const routeHandler = routeHandlers[apiName]![method]!;
45-
const expressRouteHandler = decodeRequestAndEncodeResponse(
48+
const expressRouteHandler = handleRequest(
4649
apiName,
4750
httpRoute,
48-
// FIXME: TS is complaining that `routeHandler` is not necessarily guaranteed to be a
49-
// `ServiceFunction`, because subtypes of Spec[string][string] can have arbitrary extra keys.
50-
routeHandler as RouteHandler<any>,
51+
routeHandler as RouteHandler<HttpRoute>,
5152
encoder,
5253
);
5354

54-
const expressPath = apiTsPathToExpress(httpRoute.path);
55-
router[method](expressPath, expressRouteHandler);
55+
// FIXME: Can't prove to TS here that `apiName` is valid to pass to the generalized `router[method]`
56+
(router[method] as any)(apiName, [expressRouteHandler]);
5657
}
5758
}
5859

packages/express-wrapper/src/request.ts

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
*/
55

66
import express from 'express';
7-
import * as E from 'fp-ts/Either';
87
import * as PathReporter from 'io-ts/lib/PathReporter';
98

10-
import { HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http';
9+
import { ApiSpec, HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http';
10+
import {
11+
OnDecodeErrorFn,
12+
OnEncodeErrorFn,
13+
TypedRequestHandler,
14+
} from '@api-ts/typed-express-router';
1115

1216
import {
1317
runMiddlewareChain,
@@ -90,58 +94,48 @@ const createNamedFunction = <F extends (...args: any) => void>(
9094
fn: F,
9195
): F => Object.defineProperty(fn, 'name', { value: name });
9296

93-
export const decodeRequestAndEncodeResponse = (
97+
export const onDecodeError: OnDecodeErrorFn = (errs, _req, res) => {
98+
const validationErrors = PathReporter.failure(errs);
99+
const validationErrorMessage = validationErrors.join('\n');
100+
res.writeHead(400, { 'Content-Type': 'application/json' });
101+
res.write(JSON.stringify({ error: validationErrorMessage }));
102+
res.end();
103+
};
104+
105+
export const onEncodeError: OnEncodeErrorFn = (err, _req, res) => {
106+
console.warn('Error in route handler:', err);
107+
res.status(500).end();
108+
};
109+
110+
export const handleRequest = (
94111
apiName: string,
95112
httpRoute: HttpRoute,
96113
handler: RouteHandler<HttpRoute>,
97114
responseEncoder: ResponseEncoder,
98-
): express.RequestHandler => {
115+
): TypedRequestHandler<ApiSpec, string, string> => {
99116
return createNamedFunction(
100117
'decodeRequestAndEncodeResponse' + httpRoute.method + apiName,
101118
async (req, res, next) => {
102-
const maybeRequest = httpRoute.request.decode(req);
103-
if (E.isLeft(maybeRequest)) {
104-
const validationErrors = PathReporter.failure(maybeRequest.left);
105-
const validationErrorMessage = validationErrors.join('\n');
106-
res.writeHead(400, { 'Content-Type': 'application/json' });
107-
res.write(JSON.stringify({ error: validationErrorMessage }));
108-
res.end();
109-
return;
110-
}
111-
112-
let rawResponse:
113-
| ResponseType<HttpRoute>
114-
| KeyedResponseType<HttpRoute>
115-
| undefined;
116119
try {
117120
const handlerParams =
118121
MiddlewareBrand in handler
119-
? await runMiddlewareChain(
120-
maybeRequest.right,
121-
getMiddleware(handler),
122-
req,
123-
res,
124-
)
122+
? await runMiddlewareChain(req.decoded, getMiddleware(handler), req, res)
125123
: await runMiddlewareChainIgnoringResults(
126-
E.getOrElseW(() => {
127-
throw Error('Request failed to decode');
128-
})(maybeRequest),
124+
req.decoded,
129125
getMiddleware(handler),
130126
req,
131127
res,
132128
);
133129
const serviceFn = getServiceFunction(handler);
134130

135-
rawResponse = await serviceFn(handlerParams);
131+
const response = await serviceFn(handlerParams);
132+
responseEncoder(httpRoute, response)(req, res, next);
136133
} catch (err) {
137134
console.warn('Error in route handler:', err);
138135
res.status(500).end();
139136
next();
140137
return;
141138
}
142-
143-
const expressHandler = responseEncoder(httpRoute, rawResponse);
144-
expressHandler(req, res, next);
145139
},
146140
);
147141
};

packages/express-wrapper/src/response.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ export type KeyedResponseType<R extends HttpRoute> = {
1515
};
1616
}[keyof R['response'] & keyof HttpToKeyStatus];
1717

18-
// TODO: Use HKT (using fp-ts or a similar workaround method, or who knows maybe they'll add
19-
// official support) to allow for polymorphic ResponseType<_>.
2018
export type ResponseEncoder = (
2119
route: HttpRoute,
2220
serviceFnResponse: ResponseType<HttpRoute>,

packages/express-wrapper/tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
},
1111
{
1212
"path": "../superagent-wrapper"
13+
},
14+
{
15+
"path": "../typed-express-router"
1316
}
1417
]
1518
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Copyright 2022 BitGo Inc
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# @api-ts/typed-express-router
2+
3+
A thin wrapper around Express's `Router`
4+
5+
## Goals
6+
7+
- Define Express routes that are associated with routes in an api-ts `apiSpec`
8+
- Augment the existing Express request with the decoded request object
9+
- Augment the existing Express response with a type-checked `encode` function
10+
- Allow customization of what to do on decode/encode errors, per-route if desired
11+
- 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)
14+
- Follow the express router api as closely as possible otherwise
15+
16+
## Non-Goals
17+
18+
- Enforce that all routes listed in an `apiSpec` have an associated route handler
19+
- Layer anything on top of the `express.RequestHandler[]` chain beyond the additional
20+
properties described in `Goals` (projects and other libraries can do this)
21+
22+
## Usage
23+
24+
### Creating a router
25+
26+
Two very similar functions are provided by this library that respectively create or wrap
27+
an Express router:
28+
29+
```ts
30+
import { createRouter, wrapRouter } from '@api-ts/typed-express-router';
31+
import express from 'express';
32+
33+
import { MyApi } from 'my-api-package';
34+
35+
const app = express();
36+
37+
const typedRouter = createRouter(MyApi);
38+
app.use(typedRouter);
39+
```
40+
41+
### Adding routes
42+
43+
Once you have the `typedRouter`, you can start adding routes by the api-ts api name:
44+
45+
```ts
46+
typedRouter.get('hello.world', [HelloWorldHandler]);
47+
```
48+
49+
Here, `HelloWorldHandler` is a almost like an Express request handler, but `req` and
50+
`res` have an extra property. `req.decoded` contains the validated and decoded request.
51+
On the response side, there is an extra `res.sendEncoded(status, payload)` function that
52+
will enforce types on the payload and encode types appropriately (e.g.
53+
`BigIntFromString` will be converted to a string). The exported `TypedRequestHandler`
54+
type may be used to infer the parameter types for these functions.
55+
56+
### Aliased routes
57+
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:
61+
62+
```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();
75+
});
76+
```
77+
78+
### Hooks and error handlers
79+
80+
The `createRouter`, `wrapRouter`, and individual route methods all take an optional last
81+
parameter where a post-response and error handling function may be provided. Ones
82+
specified for a specific route take precedence over the top-level ones. These may be
83+
used to customize error responses and perform other actions like metrics collection or
84+
logging.
85+
86+
```ts
87+
const typedRouter = createRouter(MyApi, {
88+
onDecodeError: (errs, req, res) => {
89+
// Format `errs` however you want
90+
res.send(400).json({ message: 'Bad request' }).end();
91+
},
92+
onEncodeError: (err, req, res) => {
93+
// Ideally won't happen unless type safety is violated, so it's a 500
94+
res.send(500).json({ message: 'Internal server error' }).end();
95+
},
96+
afterEncodedResponseSent: (status, payload, req, res) => {
97+
// Perform side effects or other things, `res` should be ended by this point
98+
endRequestMetricsCollection(req);
99+
},
100+
});
101+
102+
// Override the decode error handler on one route
103+
typedRouter.get('hello.world', [HelloWorldHandler], {
104+
onDecodeError: customHelloDecodeErrorHandler,
105+
});
106+
```
107+
108+
### Other usage
109+
110+
Other than what is documented above, a wrapped router should behave like a regular
111+
Express one, so things like `typedRouter.use()` should behave the same.

0 commit comments

Comments
 (0)