Skip to content

Commit

Permalink
Merge pull request #55 from bitgopatmcl/response-in-express-wrapper
Browse files Browse the repository at this point in the history
change the way `response` is used
  • Loading branch information
bitgopatmcl authored Apr 29, 2022
2 parents 94b374d + 7dd2fff commit 8c2b2f9
Show file tree
Hide file tree
Showing 25 changed files with 238 additions and 259 deletions.
126 changes: 14 additions & 112 deletions packages/express-wrapper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,122 +4,25 @@
*/

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

import {
ApiSpec,
HttpResponseCodes,
HttpRoute,
RequestType,
ResponseType,
} from '@api-ts/io-ts-http';
import { ApiSpec, HttpRoute } from '@api-ts/io-ts-http';

import { apiTsPathToExpress } from './path';

export type Function<R extends HttpRoute> = (
input: RequestType<R>,
) => ResponseType<R> | Promise<ResponseType<R>>;
export type RouteStack<R extends HttpRoute> = [
...express.RequestHandler[],
Function<R>,
];

/**
* Dynamically assign a function name to avoid anonymous functions in stack traces
* https://stackoverflow.com/a/69465672
*/
const createNamedFunction = <F extends (...args: any) => void>(
name: string,
fn: F,
): F => Object.defineProperty(fn, 'name', { value: name });

const isKnownStatusCode = (code: string): code is keyof typeof HttpResponseCodes =>
HttpResponseCodes.hasOwnProperty(code);

const decodeRequestAndEncodeResponse = <Route extends HttpRoute>(
apiName: string,
httpRoute: Route,
handler: Function<Route>,
): express.RequestHandler => {
return createNamedFunction(
'decodeRequestAndEncodeResponse' + httpRoute.method + apiName,
async (req, res) => {
const maybeRequest = httpRoute.request.decode(req);
if (maybeRequest._tag === 'Left') {
console.log('Request failed to decode');
const validationErrors = PathReporter.failure(maybeRequest.left);
const validationErrorMessage = validationErrors.join('\n');
res.writeHead(400, { 'Content-Type': 'application/json' });
res.write(JSON.stringify({ error: validationErrorMessage }));
res.end();
return;
}

let rawResponse: ResponseType<Route> | undefined;
try {
rawResponse = await handler(maybeRequest.right);
} catch (err) {
console.warn('Error in route handler:', err);
res.statusCode = 500;
res.end();
return;
}

// Take the first match -- the implication is that the ordering of declared response
// codecs is significant!
for (const [statusCode, responseCodec] of Object.entries(httpRoute.response)) {
if (rawResponse.type !== statusCode) {
continue;
}

if (!isKnownStatusCode(statusCode)) {
console.warn(
`Got unrecognized status code ${statusCode} for ${apiName} ${httpRoute.method}`,
);
res.status(500);
res.end();
return;
}

// We expect that some route implementations may "beat the type
// system away with a stick" and return some unexpected values
// that fail to encode, so we catch errors here just in case
let response: unknown;
try {
response = responseCodec.encode(rawResponse.payload);
} catch (err) {
console.warn(
"Unable to encode route's return value, did you return the expected type?",
err,
);
res.statusCode = 500;
res.end();
return;
}
// DISCUSS: safer ways to handle this cast
res.writeHead(HttpResponseCodes[statusCode], {
'Content-Type': 'application/json',
});
res.write(JSON.stringify(response));
res.end();
return;
}

// If we got here then we got an unexpected response
res.status(500);
res.end();
},
);
};
import {
decodeRequestAndEncodeResponse,
getMiddleware,
getServiceFunction,
RouteHandler,
} from './request';

const isHttpVerb = (verb: string): verb is 'get' | 'put' | 'post' | 'delete' =>
({ get: 1, put: 1, post: 1, delete: 1 }.hasOwnProperty(verb));
verb === 'get' || verb === 'put' || verb === 'post' || verb === 'delete';

export function createServer<Spec extends ApiSpec>(
spec: Spec,
configureExpressApplication: (app: express.Application) => {
[ApiName in keyof Spec]: {
[Method in keyof Spec[ApiName]]: RouteStack<Spec[ApiName][Method]>;
[Method in keyof Spec[ApiName]]: RouteHandler<Spec[ApiName][Method]>;
};
},
) {
Expand All @@ -134,14 +37,13 @@ export function createServer<Spec extends ApiSpec>(
continue;
}
const httpRoute: HttpRoute = resource[method]!;
const stack = routes[apiName]![method]!;
// Note: `stack` is guaranteed to be non-empty thanks to our function's type signature
const handler = decodeRequestAndEncodeResponse(
const routeHandler = routes[apiName]![method]!;
const expressRouteHandler = decodeRequestAndEncodeResponse(
apiName,
httpRoute,
stack[stack.length - 1] as Function<HttpRoute>,
httpRoute as any, // TODO: wat
getServiceFunction(routeHandler),
);
const handlers = [...stack.slice(0, stack.length - 1), handler];
const handlers = [...getMiddleware(routeHandler), expressRouteHandler];

const expressPath = apiTsPathToExpress(httpRoute.path);
router[method](expressPath, handlers);
Expand Down
103 changes: 103 additions & 0 deletions packages/express-wrapper/src/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* express-wrapper
* A simple, type-safe web server
*/

import express from 'express';
import * as t from 'io-ts';
import * as PathReporter from 'io-ts/lib/PathReporter';

import {
HttpRoute,
HttpToKeyStatus,
KeyToHttpStatus,
RequestType,
ResponseType,
} from '@api-ts/io-ts-http';

type NumericOrKeyedResponseType<R extends HttpRoute> =
| ResponseType<R>
| {
[S in keyof R['response']]: S extends keyof HttpToKeyStatus
? {
type: HttpToKeyStatus[S];
payload: t.TypeOf<R['response'][S]>;
}
: never;
}[keyof R['response']];

export type ServiceFunction<R extends HttpRoute> = (
input: RequestType<R>,
) => NumericOrKeyedResponseType<R> | Promise<NumericOrKeyedResponseType<R>>;

export type RouteHandler<R extends HttpRoute> =
| ServiceFunction<R>
| { middleware: express.RequestHandler[]; handler: ServiceFunction<R> };

export const getServiceFunction = <R extends HttpRoute>(
routeHandler: RouteHandler<R>,
): ServiceFunction<R> =>
'handler' in routeHandler ? routeHandler.handler : routeHandler;

export const getMiddleware = <R extends HttpRoute>(
routeHandler: RouteHandler<R>,
): express.RequestHandler[] =>
'middleware' in routeHandler ? routeHandler.middleware : [];

/**
* Dynamically assign a function name to avoid anonymous functions in stack traces
* https://stackoverflow.com/a/69465672
*/
const createNamedFunction = <F extends (...args: any) => void>(
name: string,
fn: F,
): F => Object.defineProperty(fn, 'name', { value: name });

export const decodeRequestAndEncodeResponse = <Route extends HttpRoute>(
apiName: string,
httpRoute: Route,
handler: ServiceFunction<Route>,
): express.RequestHandler => {
return createNamedFunction(
'decodeRequestAndEncodeResponse' + httpRoute.method + apiName,
async (req, res) => {
const maybeRequest = httpRoute.request.decode(req);
if (maybeRequest._tag === 'Left') {
console.log('Request failed to decode');
const validationErrors = PathReporter.failure(maybeRequest.left);
const validationErrorMessage = validationErrors.join('\n');
res.writeHead(400, { 'Content-Type': 'application/json' });
res.write(JSON.stringify({ error: validationErrorMessage }));
res.end();
return;
}

let rawResponse: NumericOrKeyedResponseType<Route> | undefined;
try {
rawResponse = await handler(maybeRequest.right);
} catch (err) {
console.warn('Error in route handler:', err);
res.status(500).end();
return;
}

const { type, payload } = rawResponse;
const status = typeof type === 'number' ? type : (KeyToHttpStatus as any)[type];
if (status === undefined) {
console.warn('Unknown status code returned');
res.status(500).end();
return;
}
const responseCodec = httpRoute.response[status];
if (responseCodec === undefined || !responseCodec.is(payload)) {
console.warn(
"Unable to encode route's return value, did you return the expected type?",
);
res.status(500).end();
return;
}

res.status(status).json(responseCodec.encode(payload)).end();
},
);
};
Loading

0 comments on commit 8c2b2f9

Please sign in to comment.