Skip to content

Commit 8c2b2f9

Browse files
authored
Merge pull request #55 from bitgopatmcl/response-in-express-wrapper
change the way `response` is used
2 parents 94b374d + 7dd2fff commit 8c2b2f9

25 files changed

+238
-259
lines changed

packages/express-wrapper/src/index.ts

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

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

9-
import {
10-
ApiSpec,
11-
HttpResponseCodes,
12-
HttpRoute,
13-
RequestType,
14-
ResponseType,
15-
} from '@api-ts/io-ts-http';
8+
import { ApiSpec, HttpRoute } from '@api-ts/io-ts-http';
169

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

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

11821
export function createServer<Spec extends ApiSpec>(
11922
spec: Spec,
12023
configureExpressApplication: (app: express.Application) => {
12124
[ApiName in keyof Spec]: {
122-
[Method in keyof Spec[ApiName]]: RouteStack<Spec[ApiName][Method]>;
25+
[Method in keyof Spec[ApiName]]: RouteHandler<Spec[ApiName][Method]>;
12326
};
12427
},
12528
) {
@@ -134,14 +37,13 @@ export function createServer<Spec extends ApiSpec>(
13437
continue;
13538
}
13639
const httpRoute: HttpRoute = resource[method]!;
137-
const stack = routes[apiName]![method]!;
138-
// Note: `stack` is guaranteed to be non-empty thanks to our function's type signature
139-
const handler = decodeRequestAndEncodeResponse(
40+
const routeHandler = routes[apiName]![method]!;
41+
const expressRouteHandler = decodeRequestAndEncodeResponse(
14042
apiName,
141-
httpRoute,
142-
stack[stack.length - 1] as Function<HttpRoute>,
43+
httpRoute as any, // TODO: wat
44+
getServiceFunction(routeHandler),
14345
);
144-
const handlers = [...stack.slice(0, stack.length - 1), handler];
46+
const handlers = [...getMiddleware(routeHandler), expressRouteHandler];
14547

14648
const expressPath = apiTsPathToExpress(httpRoute.path);
14749
router[method](expressPath, handlers);
Lines changed: 103 additions & 0 deletions
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+
};

0 commit comments

Comments
 (0)