Skip to content

Commit 7dd2fff

Browse files
committed
feat: handle status codes or response keys in express-wrapper
1 parent 4e191cf commit 7dd2fff

File tree

8 files changed

+171
-115
lines changed

8 files changed

+171
-115
lines changed

package-lock.json

+2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/express-wrapper/src/index.ts

+14-92
Original file line numberDiff line numberDiff line change
@@ -4,102 +4,25 @@
44
*/
55

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

9-
import { ApiSpec, HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http';
8+
import { ApiSpec, HttpRoute } from '@api-ts/io-ts-http';
109

1110
import { apiTsPathToExpress } from './path';
12-
13-
export type Function<R extends HttpRoute> = (
14-
input: RequestType<R>,
15-
) => ResponseType<R> | Promise<ResponseType<R>>;
16-
export type RouteStack<R extends HttpRoute> = [
17-
...express.RequestHandler[],
18-
Function<R>,
19-
];
20-
21-
/**
22-
* Dynamically assign a function name to avoid anonymous functions in stack traces
23-
* https://stackoverflow.com/a/69465672
24-
*/
25-
const createNamedFunction = <F extends (...args: any) => void>(
26-
name: string,
27-
fn: F,
28-
): F => Object.defineProperty(fn, 'name', { value: name });
29-
30-
const decodeRequestAndEncodeResponse = <Route extends HttpRoute>(
31-
apiName: string,
32-
httpRoute: Route,
33-
handler: Function<Route>,
34-
): express.RequestHandler => {
35-
return createNamedFunction(
36-
'decodeRequestAndEncodeResponse' + httpRoute.method + apiName,
37-
async (req, res) => {
38-
const maybeRequest = httpRoute.request.decode(req);
39-
if (maybeRequest._tag === 'Left') {
40-
console.log('Request failed to decode');
41-
const validationErrors = PathReporter.failure(maybeRequest.left);
42-
const validationErrorMessage = validationErrors.join('\n');
43-
res.writeHead(400, { 'Content-Type': 'application/json' });
44-
res.write(JSON.stringify({ error: validationErrorMessage }));
45-
res.end();
46-
return;
47-
}
48-
49-
let rawResponse: ResponseType<Route> | undefined;
50-
try {
51-
rawResponse = await handler(maybeRequest.right);
52-
} catch (err) {
53-
console.warn('Error in route handler:', err);
54-
res.statusCode = 500;
55-
res.end();
56-
return;
57-
}
58-
59-
for (const [statusCodeStr, responseCodec] of Object.entries(httpRoute.response)) {
60-
const statusCode = Number(statusCodeStr);
61-
if (!Number.isInteger(statusCode) || rawResponse.type !== statusCode) {
62-
continue;
63-
}
64-
65-
// We expect that some route implementations may "beat the type
66-
// system away with a stick" and return some unexpected values
67-
// that fail to encode, so we catch errors here just in case
68-
let response: unknown;
69-
try {
70-
response = responseCodec.encode(rawResponse.payload);
71-
} catch (err) {
72-
console.warn(
73-
"Unable to encode route's return value, did you return the expected type?",
74-
err,
75-
);
76-
res.statusCode = 500;
77-
res.end();
78-
return;
79-
}
80-
res.writeHead(statusCode, {
81-
'Content-Type': 'application/json',
82-
});
83-
res.write(JSON.stringify(response));
84-
res.end();
85-
return;
86-
}
87-
88-
// If we got here then we got an unexpected response
89-
res.status(500);
90-
res.end();
91-
},
92-
);
93-
};
11+
import {
12+
decodeRequestAndEncodeResponse,
13+
getMiddleware,
14+
getServiceFunction,
15+
RouteHandler,
16+
} from './request';
9417

9518
const isHttpVerb = (verb: string): verb is 'get' | 'put' | 'post' | 'delete' =>
96-
({ get: 1, put: 1, post: 1, delete: 1 }.hasOwnProperty(verb));
19+
verb === 'get' || verb === 'put' || verb === 'post' || verb === 'delete';
9720

9821
export function createServer<Spec extends ApiSpec>(
9922
spec: Spec,
10023
configureExpressApplication: (app: express.Application) => {
10124
[ApiName in keyof Spec]: {
102-
[Method in keyof Spec[ApiName]]: RouteStack<Spec[ApiName][Method]>;
25+
[Method in keyof Spec[ApiName]]: RouteHandler<Spec[ApiName][Method]>;
10326
};
10427
},
10528
) {
@@ -114,14 +37,13 @@ export function createServer<Spec extends ApiSpec>(
11437
continue;
11538
}
11639
const httpRoute: HttpRoute = resource[method]!;
117-
const stack = routes[apiName]![method]!;
118-
// Note: `stack` is guaranteed to be non-empty thanks to our function's type signature
119-
const handler = decodeRequestAndEncodeResponse(
40+
const routeHandler = routes[apiName]![method]!;
41+
const expressRouteHandler = decodeRequestAndEncodeResponse(
12042
apiName,
121-
httpRoute,
122-
stack[stack.length - 1] as Function<HttpRoute>,
43+
httpRoute as any, // TODO: wat
44+
getServiceFunction(routeHandler),
12345
);
124-
const handlers = [...stack.slice(0, stack.length - 1), handler];
46+
const handlers = [...getMiddleware(routeHandler), expressRouteHandler];
12547

12648
const expressPath = apiTsPathToExpress(httpRoute.path);
12749
router[method](expressPath, handlers);
+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* express-wrapper
3+
* A simple, type-safe web server
4+
*/
5+
6+
import express from 'express';
7+
import * as t from 'io-ts';
8+
import * as PathReporter from 'io-ts/lib/PathReporter';
9+
10+
import {
11+
HttpRoute,
12+
HttpToKeyStatus,
13+
KeyToHttpStatus,
14+
RequestType,
15+
ResponseType,
16+
} from '@api-ts/io-ts-http';
17+
18+
type NumericOrKeyedResponseType<R extends HttpRoute> =
19+
| ResponseType<R>
20+
| {
21+
[S in keyof R['response']]: S extends keyof HttpToKeyStatus
22+
? {
23+
type: HttpToKeyStatus[S];
24+
payload: t.TypeOf<R['response'][S]>;
25+
}
26+
: never;
27+
}[keyof R['response']];
28+
29+
export type ServiceFunction<R extends HttpRoute> = (
30+
input: RequestType<R>,
31+
) => NumericOrKeyedResponseType<R> | Promise<NumericOrKeyedResponseType<R>>;
32+
33+
export type RouteHandler<R extends HttpRoute> =
34+
| ServiceFunction<R>
35+
| { middleware: express.RequestHandler[]; handler: ServiceFunction<R> };
36+
37+
export const getServiceFunction = <R extends HttpRoute>(
38+
routeHandler: RouteHandler<R>,
39+
): ServiceFunction<R> =>
40+
'handler' in routeHandler ? routeHandler.handler : routeHandler;
41+
42+
export const getMiddleware = <R extends HttpRoute>(
43+
routeHandler: RouteHandler<R>,
44+
): express.RequestHandler[] =>
45+
'middleware' in routeHandler ? routeHandler.middleware : [];
46+
47+
/**
48+
* Dynamically assign a function name to avoid anonymous functions in stack traces
49+
* https://stackoverflow.com/a/69465672
50+
*/
51+
const createNamedFunction = <F extends (...args: any) => void>(
52+
name: string,
53+
fn: F,
54+
): F => Object.defineProperty(fn, 'name', { value: name });
55+
56+
export const decodeRequestAndEncodeResponse = <Route extends HttpRoute>(
57+
apiName: string,
58+
httpRoute: Route,
59+
handler: ServiceFunction<Route>,
60+
): express.RequestHandler => {
61+
return createNamedFunction(
62+
'decodeRequestAndEncodeResponse' + httpRoute.method + apiName,
63+
async (req, res) => {
64+
const maybeRequest = httpRoute.request.decode(req);
65+
if (maybeRequest._tag === 'Left') {
66+
console.log('Request failed to decode');
67+
const validationErrors = PathReporter.failure(maybeRequest.left);
68+
const validationErrorMessage = validationErrors.join('\n');
69+
res.writeHead(400, { 'Content-Type': 'application/json' });
70+
res.write(JSON.stringify({ error: validationErrorMessage }));
71+
res.end();
72+
return;
73+
}
74+
75+
let rawResponse: NumericOrKeyedResponseType<Route> | undefined;
76+
try {
77+
rawResponse = await handler(maybeRequest.right);
78+
} catch (err) {
79+
console.warn('Error in route handler:', err);
80+
res.status(500).end();
81+
return;
82+
}
83+
84+
const { type, payload } = rawResponse;
85+
const status = typeof type === 'number' ? type : (KeyToHttpStatus as any)[type];
86+
if (status === undefined) {
87+
console.warn('Unknown status code returned');
88+
res.status(500).end();
89+
return;
90+
}
91+
const responseCodec = httpRoute.response[status];
92+
if (responseCodec === undefined || !responseCodec.is(payload)) {
93+
console.warn(
94+
"Unable to encode route's return value, did you return the expected type?",
95+
);
96+
res.status(500).end();
97+
return;
98+
}
99+
100+
res.status(status).json(responseCodec.encode(payload)).end();
101+
},
102+
);
103+
};

packages/express-wrapper/test/server.test.ts

+13-13
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const CreateHelloWorld = async (parameters: {
9999

100100
const GetHelloWorld = async (params: { id: string }) =>
101101
({
102-
type: 200,
102+
type: 'ok',
103103
payload: params,
104104
} as const);
105105

@@ -110,8 +110,8 @@ test('should offer a delightful developer experience', async (t) => {
110110
app.use(appMiddleware);
111111
return {
112112
'hello.world': {
113-
put: [routeMiddleware, CreateHelloWorld],
114-
get: [GetHelloWorld],
113+
put: { middleware: [routeMiddleware], handler: CreateHelloWorld },
114+
get: GetHelloWorld,
115115
},
116116
};
117117
});
@@ -139,8 +139,8 @@ test('should handle io-ts-http formatted path parameters', async (t) => {
139139
app.use(appMiddleware);
140140
return {
141141
'hello.world': {
142-
put: [routeMiddleware, CreateHelloWorld],
143-
get: [GetHelloWorld],
142+
put: { middleware: [routeMiddleware], handler: CreateHelloWorld },
143+
get: GetHelloWorld,
144144
},
145145
};
146146
});
@@ -163,8 +163,8 @@ test('should invoke app-level middleware', async (t) => {
163163
app.use(appMiddleware);
164164
return {
165165
'hello.world': {
166-
put: [CreateHelloWorld],
167-
get: [GetHelloWorld],
166+
put: CreateHelloWorld,
167+
get: GetHelloWorld,
168168
},
169169
};
170170
});
@@ -186,8 +186,8 @@ test('should invoke route-level middleware', async (t) => {
186186
app.use(express.json());
187187
return {
188188
'hello.world': {
189-
put: [routeMiddleware, CreateHelloWorld],
190-
get: [GetHelloWorld],
189+
put: { middleware: [routeMiddleware], handler: CreateHelloWorld },
190+
get: GetHelloWorld,
191191
},
192192
};
193193
});
@@ -209,8 +209,8 @@ test('should infer status code from response type', async (t) => {
209209
app.use(express.json());
210210
return {
211211
'hello.world': {
212-
put: [CreateHelloWorld],
213-
get: [GetHelloWorld],
212+
put: CreateHelloWorld,
213+
get: GetHelloWorld,
214214
},
215215
};
216216
});
@@ -232,8 +232,8 @@ test('should return a 400 when request fails to decode', async (t) => {
232232
app.use(express.json());
233233
return {
234234
'hello.world': {
235-
put: [CreateHelloWorld],
236-
get: [GetHelloWorld],
235+
put: CreateHelloWorld,
236+
get: GetHelloWorld,
237237
},
238238
};
239239
});

packages/io-ts-http/docs/httpRoute.md

+10-10
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ httpRoute({
2121
},
2222
}),
2323
response: {
24-
ok: t.string,
24+
200: t.string,
2525
},
2626
});
2727
```
@@ -38,7 +38,7 @@ httpRoute({
3838
},
3939
}),
4040
response: {
41-
ok: t.string,
41+
200: t.string,
4242
},
4343
});
4444
```
@@ -64,7 +64,7 @@ const Route = httpRoute({
6464
},
6565
}),
6666
response: {
67-
ok: t.string,
67+
200: t.string,
6868
},
6969
});
7070

@@ -86,22 +86,22 @@ const response: string = await routeApiClient({ id: 1337 });
8686
### `response`
8787

8888
Declares the potential responses that a route may return along with the codec associated
89-
to each response. The possible response keys can be found in the `io-ts-response`
90-
package. Incoming responses are assumed to be JSON.
89+
to each response. Response keys correspond to HTTP status codes. Incoming responses are
90+
assumed to be JSON.
9191

9292
```typescript
9393
const Route = httpRoute({
9494
path: '/example',
9595
method: 'GET',
9696
request: httpRequest({}),
9797
response: {
98-
ok: t.type({
98+
200: t.type({
9999
foo: t.string,
100100
}),
101-
notFound: t.type({
101+
404: t.type({
102102
message: t.string,
103103
}),
104-
invalidRequest: t.type({
104+
400: t.type({
105105
message: t.string,
106106
}),
107107
},
@@ -154,7 +154,7 @@ const StringBodyRoute = httpRoute({
154154
}),
155155
]),
156156
response: {
157-
ok: t.string,
157+
200: t.string,
158158
},
159159
});
160160

@@ -191,7 +191,7 @@ const UnionRoute = httpRoute({
191191
}),
192192
]),
193193
response: {
194-
ok: string,
194+
200: string,
195195
},
196196
});
197197

0 commit comments

Comments
 (0)