From 85d66a9f5e99533fcf4004516996d9628ac17a86 Mon Sep 17 00:00:00 2001 From: Patrick McLaughlin Date: Thu, 12 May 2022 18:10:16 -0400 Subject: [PATCH] feat(express-wrapper): allow custom response encoders --- packages/express-wrapper/src/index.ts | 74 ++++++++++++++---------- packages/express-wrapper/src/request.ts | 47 +++------------ packages/express-wrapper/src/response.ts | 50 ++++++++++++++++ 3 files changed, 100 insertions(+), 71 deletions(-) create mode 100644 packages/express-wrapper/src/response.ts diff --git a/packages/express-wrapper/src/index.ts b/packages/express-wrapper/src/index.ts index 8a830bfc..7d00fe5f 100644 --- a/packages/express-wrapper/src/index.ts +++ b/packages/express-wrapper/src/index.ts @@ -14,43 +14,53 @@ import { getServiceFunction, RouteHandler, } from './request'; +import { defaultResponseEncoder, ResponseEncoder } from './response'; + +export type { ResponseEncoder } from './response'; const isHttpVerb = (verb: string): verb is 'get' | 'put' | 'post' | 'delete' => verb === 'get' || verb === 'put' || verb === 'post' || verb === 'delete'; -export function createServer( - spec: Spec, - configureExpressApplication: (app: express.Application) => { - [ApiName in keyof Spec]: { - [Method in keyof Spec[ApiName]]: RouteHandler; - }; - }, -) { - const app: express.Application = express(); - const routes = configureExpressApplication(app); - - const router = express.Router(); - for (const apiName of Object.keys(spec)) { - const resource = spec[apiName] as Spec[string]; - for (const method of Object.keys(resource)) { - if (!isHttpVerb(method)) { - continue; +export const createServerWithResponseEncoder = + (encoder: ResponseEncoder) => + ( + spec: ApiSpec, + configureExpressApplication: (app: express.Application) => { + [ApiName in keyof Spec]: { + [Method in keyof Spec[ApiName]]: RouteHandler; + }; + }, + ) => { + const app: express.Application = express(); + const routes = configureExpressApplication(app); + + const router = express.Router(); + for (const apiName of Object.keys(spec)) { + const resource = spec[apiName] as Spec[string]; + for (const method of Object.keys(resource)) { + if (!isHttpVerb(method)) { + continue; + } + const httpRoute: HttpRoute = resource[method]!; + const routeHandler = routes[apiName]![method]!; + const expressRouteHandler = decodeRequestAndEncodeResponse( + apiName, + httpRoute, + // FIXME: TS is complaining that `routeHandler` is not necessarily guaranteed to be a + // `ServiceFunction`, because subtypes of Spec[string][string] can have arbitrary extra keys. + getServiceFunction(routeHandler as any), + encoder, + ); + const handlers = [...getMiddleware(routeHandler), expressRouteHandler]; + + const expressPath = apiTsPathToExpress(httpRoute.path); + router[method](expressPath, handlers); } - const httpRoute: HttpRoute = resource[method]!; - const routeHandler = routes[apiName]![method]!; - const expressRouteHandler = decodeRequestAndEncodeResponse( - apiName, - httpRoute as any, // TODO: wat - getServiceFunction(routeHandler), - ); - const handlers = [...getMiddleware(routeHandler), expressRouteHandler]; - - const expressPath = apiTsPathToExpress(httpRoute.path); - router[method](expressPath, handlers); } - } - app.use(router); + app.use(router); + + return app; + }; - return app; -} +export const createServer = createServerWithResponseEncoder(defaultResponseEncoder); diff --git a/packages/express-wrapper/src/request.ts b/packages/express-wrapper/src/request.ts index f297020b..61b93b8d 100644 --- a/packages/express-wrapper/src/request.ts +++ b/packages/express-wrapper/src/request.ts @@ -4,27 +4,11 @@ */ 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'; +import { HttpRoute, RequestType } from '@api-ts/io-ts-http'; -type NumericOrKeyedResponseType = - | ResponseType - | { - [S in keyof R['response']]: S extends keyof HttpToKeyStatus - ? { - type: HttpToKeyStatus[S]; - payload: t.TypeOf; - } - : never; - }[keyof R['response']]; +import type { NumericOrKeyedResponseType, ResponseEncoder } from './response'; export type ServiceFunction = ( input: RequestType, @@ -53,10 +37,11 @@ const createNamedFunction = void>( fn: F, ): F => Object.defineProperty(fn, 'name', { value: name }); -export const decodeRequestAndEncodeResponse = ( +export const decodeRequestAndEncodeResponse = ( apiName: string, - httpRoute: Route, - handler: ServiceFunction, + httpRoute: HttpRoute, + handler: ServiceFunction, + responseEncoder: ResponseEncoder, ): express.RequestHandler => { return createNamedFunction( 'decodeRequestAndEncodeResponse' + httpRoute.method + apiName, @@ -72,7 +57,7 @@ export const decodeRequestAndEncodeResponse = ( return; } - let rawResponse: NumericOrKeyedResponseType | undefined; + let rawResponse: NumericOrKeyedResponseType | undefined; try { rawResponse = await handler(maybeRequest.right); } catch (err) { @@ -81,23 +66,7 @@ export const decodeRequestAndEncodeResponse = ( 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(); + responseEncoder(httpRoute, rawResponse, res); }, ); }; diff --git a/packages/express-wrapper/src/response.ts b/packages/express-wrapper/src/response.ts new file mode 100644 index 00000000..14f9c0cb --- /dev/null +++ b/packages/express-wrapper/src/response.ts @@ -0,0 +1,50 @@ +import express from 'express'; +import * as t from 'io-ts'; + +import { + HttpRoute, + HttpToKeyStatus, + KeyToHttpStatus, + ResponseType, +} from '@api-ts/io-ts-http'; + +export type NumericOrKeyedResponseType = + | ResponseType + | { + [Key in keyof R['response'] & keyof HttpToKeyStatus]: { + type: HttpToKeyStatus[Key]; + payload: t.TypeOf; + }; + }[keyof R['response'] & keyof HttpToKeyStatus]; + +// TODO: Use HKT (using fp-ts or a similar workaround method, or who knows maybe they'll add +// official support) to allow for polymorphic ResponseType<_>. +export type ResponseEncoder = ( + route: HttpRoute, + serviceFnResponse: NumericOrKeyedResponseType, + expressRes: express.Response, +) => void; + +export const defaultResponseEncoder: ResponseEncoder = ( + route, + serviceFnResponse, + expressRes, +) => { + const { type, payload } = serviceFnResponse; + const status = typeof type === 'number' ? type : (KeyToHttpStatus as any)[type]; + if (status === undefined) { + console.warn('Unknown status code returned'); + expressRes.status(500).end(); + return; + } + const responseCodec = route.response[status]; + if (responseCodec === undefined || !responseCodec.is(payload)) { + console.warn( + "Unable to encode route's return value, did you return the expected type?", + ); + expressRes.status(500).end(); + return; + } + + expressRes.status(status).json(responseCodec.encode(payload)).end(); +};