diff --git a/packages/typed-express-router/README.md b/packages/typed-express-router/README.md index 61b85245..cb026bbf 100644 --- a/packages/typed-express-router/README.md +++ b/packages/typed-express-router/README.md @@ -5,12 +5,13 @@ A thin wrapper around Express's `Router` ## Goals - Define Express routes that are associated with routes in an api-ts `apiSpec` -- Augment the existing Express request with the decoded request object +- Augment the existing Express request with the decoded request object and api-ts route + metadata - Augment the existing Express response with a type-checked `encode` function - Allow customization of what to do on decode/encode errors, per-route if desired - Allow action to be performed after an encoded response is sent, per-route if desired -- Allow routes to be defined with path that is different than the one specified in the - `httpRoute` (e.g. for aliases) +- Allow routes to define alias routes with path that is different than the one specified + in the `httpRoute` - Follow the express router api as closely as possible otherwise ## Non-Goals @@ -53,25 +54,15 @@ will enforce types on the payload and encode types appropriately (e.g. `BigIntFromString` will be converted to a string). The exported `TypedRequestHandler` type may be used to infer the parameter types for these functions. -### Aliased routes +### Route aliases -If more flexibility is needed in the route path, the `getAlias`-style route functions -may be used. They take a path that is directly interpreted by Express, but otherwise -work like the regular route methods: +If more flexibility is needed in the route path, a `routeAliases` function may be +provided to match multiple paths. These paths may use the full Express matching syntax, +but take care to preserve any path parameters or else you will likely get decode errors. ```ts -typedRouter.getAlias('/oldDeprecatedHelloWorld', 'hello.world', [HelloWorldHandler]); -``` - -### Unchecked routes - -For convenience, the original router's `get`/`post`/`put`/`delete` methods can still be -used via `getUnchecked` (or similar): - -```ts -// Just a normal express route -typedRouter.getUnchecked('/api/foo/bar', (req, res) => { - res.send(200).end(); +typedRouter.get('hello.world', [HelloWorldHandler], { + routeAliases: ['/oldDeprecatedHelloWorld'], }); ``` @@ -105,6 +96,31 @@ typedRouter.get('hello.world', [HelloWorldHandler], { }); ``` +### Unchecked routes + +If you need custom behavior on decode errors that is more involved than just sending an +error response, then the unchecked variant of the router functions can be used. They do +not fail and call `onDecodeError` when a request is invalid. Instead, they will still +populate `req.decoded`, except this time it'll contain the +`Either` type for route handlers to inspect. + +```ts +// Just a normal express route +typedRouter.getUnchecked('hello.world', (req, res) => { + if (E.isLeft(req.decoded)) { + console.warn('Route failed to decode! Continuing anyway'); + }) + + res.send(200).end(); +}); +``` + +### Router middleware + +Middleware added with `typedRouter.use()` is ran just after the request is decoded but +before it is validated, even on checked routes. It'll have access to `req.decoded` in +the same way that unchecked routes do. + ### Other usage Other than what is documented above, a wrapped router should behave like a regular diff --git a/packages/typed-express-router/src/index.ts b/packages/typed-express-router/src/index.ts index 40bc976d..61b9eb39 100644 --- a/packages/typed-express-router/src/index.ts +++ b/packages/typed-express-router/src/index.ts @@ -5,9 +5,12 @@ import { pipe } from 'fp-ts/function'; import { defaultOnDecodeError, defaultOnEncodeError } from './errors'; import { apiTsPathToExpress } from './path'; import { - AddAliasRouteHandler, AddRouteHandler, + AddUncheckedRouteHandler, Methods, + UncheckedRequestHandler, + WrappedRequest, + WrappedResponse, WrappedRouteOptions, WrappedRouter, WrappedRouterOptions, @@ -18,6 +21,7 @@ export type { OnDecodeErrorFn, OnEncodeErrorFn, TypedRequestHandler, + UncheckedRequestHandler, WrappedRouter, WrappedRouteOptions, WrappedRouterOptions, @@ -64,60 +68,69 @@ export function wrapRouter( onDecodeError = defaultOnDecodeError, onEncodeError = defaultOnEncodeError, afterEncodedResponseSent = () => {}, - }: WrappedRouteOptions, + }: WrappedRouteOptions, ): WrappedRouter { - function makeAddAliasRoute( + const routerMiddleware: UncheckedRequestHandler[] = []; + + function makeAddUncheckedRoute( method: Method, - ): AddAliasRouteHandler { - return (path, apiName, handlers, options) => { + ): AddUncheckedRouteHandler { + return (apiName, handlers, options) => { const route: HttpRoute = spec[apiName as keyof Spec]![method]!; - const wrapReqAndRes: express.RequestHandler = (req, res, next) => { - pipe( - route.request.decode(req), - E.matchW( - (errs) => (options?.onDecodeError ?? onDecodeError)(errs, req, res), - (decoded) => { - // Gotta cast to mutate this in place - (req as any).decoded = decoded; - (res as any).sendEncoded = ( - status: keyof typeof route['response'], - payload: any, - ) => { - try { - const codec = route.response[status]; - if (!codec) { - throw new Error(`no codec defined for response status ${status}`); - } - const statusCode = - typeof status === 'number' - ? status - : KeyToHttpStatus[status as keyof KeyToHttpStatus]; - if (statusCode === undefined) { - throw new Error(`unknown HTTP status code for key ${status}`); - } else if (!codec.is(payload)) { - throw new Error( - `response does not match expected type ${codec.name}`, - ); - } - const encoded = codec.encode(payload); - res.status(statusCode).json(encoded).end(); - (options?.afterEncodedResponseSent ?? afterEncodedResponseSent)( - status, - payload, - req, - res, - ); - } catch (err) { - (options?.onEncodeError ?? onEncodeError)(err, req, res); - } - }; - next(); - }, - ), - ); + const wrapReqAndRes: UncheckedRequestHandler = (req, res, next) => { + const decoded = route.request.decode(req); + req.decoded = decoded; + req.apiName = apiName; + req.httpRoute = route; + res.sendEncoded = ( + status: keyof typeof route['response'], + payload: unknown, + ) => { + try { + const codec = route.response[status]; + if (!codec) { + throw new Error(`no codec defined for response status ${status}`); + } + const statusCode = + typeof status === 'number' + ? status + : KeyToHttpStatus[status as keyof KeyToHttpStatus]; + if (statusCode === undefined) { + throw new Error(`unknown HTTP status code for key ${status}`); + } else if (!codec.is(payload)) { + throw new Error(`response does not match expected type ${codec.name}`); + } + const encoded = codec.encode(payload); + res.status(statusCode).json(encoded).end(); + (options?.afterEncodedResponseSent ?? afterEncodedResponseSent)( + statusCode, + payload, + req as WrappedRequest, + res as WrappedResponse, + ); + } catch (err) { + (options?.onEncodeError ?? onEncodeError)( + err, + req as WrappedRequest, + res as WrappedResponse, + ); + } + }; + next(); }; - router[method](path, [wrapReqAndRes, ...(handlers as express.RequestHandler[])]); + const middlewareChain = [ + wrapReqAndRes, + ...routerMiddleware, + ...handlers, + ] as express.RequestHandler[]; + + const path = spec[apiName as keyof typeof spec]![method]!.path; + router[method](apiTsPathToExpress(path), middlewareChain); + + options?.routeAliases?.forEach((alias) => { + router[method](alias, middlewareChain); + }); }; } @@ -125,11 +138,24 @@ export function wrapRouter( method: Method, ): AddRouteHandler { return (apiName, handlers, options) => { - const path = spec[apiName as keyof typeof spec]![method]!.path; - return makeAddAliasRoute(method)( - apiTsPathToExpress(path), + const validateMiddleware: UncheckedRequestHandler = (req, res, next) => { + pipe( + req.decoded, + E.matchW( + (errs) => { + (options?.onDecodeError ?? onDecodeError)(errs, req, res); + }, + (value) => { + req.decoded = value; + next(); + }, + ), + ); + }; + + return makeAddUncheckedRoute(method)( apiName, - handlers, + [validateMiddleware, ...handlers], options, ); }; @@ -144,14 +170,13 @@ export function wrapRouter( post: makeAddRoute('post'), put: makeAddRoute('put'), delete: makeAddRoute('delete'), - getAlias: makeAddAliasRoute('get'), - postAlias: makeAddAliasRoute('post'), - putAlias: makeAddAliasRoute('put'), - deleteAlias: makeAddAliasRoute('delete'), - getUnchecked: router.get, - postUnchecked: router.post, - putUnchecked: router.put, - deleteUnchecked: router.delete, + getUnchecked: makeAddUncheckedRoute('get'), + postUnchecked: makeAddUncheckedRoute('post'), + putUnchecked: makeAddUncheckedRoute('put'), + deleteUnchecked: makeAddUncheckedRoute('delete'), + use: (middleware: UncheckedRequestHandler) => { + routerMiddleware.push(middleware); + }, }, ); diff --git a/packages/typed-express-router/src/types.ts b/packages/typed-express-router/src/types.ts index 22592e8d..b7c126a7 100644 --- a/packages/typed-express-router/src/types.ts +++ b/packages/typed-express-router/src/types.ts @@ -1,9 +1,4 @@ -import { - ApiSpec, - HttpRoute, - Method as HttpMethod, - RequestType, -} from '@api-ts/io-ts-http'; +import { ApiSpec, HttpRoute, Method as HttpMethod } from '@api-ts/io-ts-http'; import express from 'express'; import * as t from 'io-ts'; @@ -15,24 +10,19 @@ export type RouteAt< Method extends keyof Spec[ApiName], > = Spec[ApiName][Method] extends HttpRoute ? Spec[ApiName][Method] : never; -export type WrappedRequest< - Spec extends ApiSpec, - ApiName extends keyof Spec, - Method extends keyof Spec[ApiName], -> = express.Request & { - decoded: RequestType>; +export type WrappedRequest = express.Request & { + decoded: Decoded; + apiName: string; + httpRoute: HttpRoute; }; -export type WrappedResponse< - Spec extends ApiSpec, - ApiName extends keyof Spec, - Method extends keyof Spec[ApiName], -> = express.Response & { - sendEncoded: ['response']>( - status: Status, - payload: t.TypeOf['response'][Status]>, - ) => void; -}; +export type WrappedResponse> = + express.Response & { + sendEncoded: ( + status: Status, + payload: Responses[Status], + ) => void; + }; export type OnDecodeErrorFn = ( errs: t.Errors, @@ -42,41 +32,52 @@ export type OnDecodeErrorFn = ( export type OnEncodeErrorFn = ( err: unknown, - req: express.Request, - res: express.Response, + req: WrappedRequest, + res: WrappedResponse, ) => void; -export type AfterEncodedResponseSentFn = < - Status extends keyof Route['response'], ->( - status: Status, - payload: Route['response'][Status], - req: express.Request, - res: express.Response, +export type AfterEncodedResponseSentFn = ( + status: number, + payload: unknown, + req: WrappedRequest, + res: WrappedResponse, ) => void; -export type WrappedRouteOptions = { - onDecodeError?: OnDecodeErrorFn; +export type UncheckedWrappedRouteOptions = { onEncodeError?: OnEncodeErrorFn; - afterEncodedResponseSent?: AfterEncodedResponseSentFn; + afterEncodedResponseSent?: AfterEncodedResponseSentFn; + routeAliases?: string[]; }; -export type WrappedRouterOptions = express.RouterOptions & - WrappedRouteOptions; +export type WrappedRouteOptions = UncheckedWrappedRouteOptions & { + onDecodeError?: OnDecodeErrorFn; +}; + +export type WrappedRouterOptions = express.RouterOptions & WrappedRouteOptions; export type TypedRequestHandler< - Spec extends ApiSpec, - ApiName extends keyof Spec, - Method extends keyof Spec[ApiName], + Spec extends ApiSpec = ApiSpec, + ApiName extends keyof Spec = string, + Method extends keyof Spec[ApiName] = string, +> = ( + req: WrappedRequest['request']>>, + res: WrappedResponse['response']>>, + next: express.NextFunction, +) => void; + +export type UncheckedRequestHandler< + Spec extends ApiSpec = ApiSpec, + ApiName extends keyof Spec = string, + Method extends keyof Spec[ApiName] = string, > = ( - req: WrappedRequest, - res: WrappedResponse, + req: WrappedRequest['request']['decode']>>, + res: WrappedResponse['response']>>, next: express.NextFunction, ) => void; type ApiNamesWithMethod = { - [K in keyof Spec]: Method extends keyof Spec[K] ? K : never; -}[keyof Spec]; + [K in keyof Spec & string]: Method extends keyof Spec[K] ? K : never; +}[keyof Spec & string]; /** * Defines a route from one listed in an `apiSpec`. The request object will contain @@ -92,25 +93,24 @@ export type AddRouteHandler = < >( apiName: ApiName, handlers: TypedRequestHandler[], - options?: WrappedRouteOptions, + options?: WrappedRouteOptions, ) => void; /** - * Defines a route from one listed in an `apiSpec`, except matching an arbitrary path. Ensure that any path parameters match - * with what is expected from the `httpRoute` or else you will get decode errors. + * Defines a route from one listed in an `apiSpec`. The request object will contain + * a `decoded` request property, and the response object will have a type-checked + * `sendEncoded` function with the correct types. * - * @param path {string} the path to match, can use the full Express router syntax * @param apiName {string} the api name defined in the `apiSpec` assoiated with this router * @param handlers {TypedRequestHandler[]} a series of Express request handlers with extra properties - * @param options {WrappedRouteOptions} error and response hooks for this route that override the top-level ones if provided + * @param options {UncheckedWrappedRouteOptions} error and response hooks for this route that override the top-level ones if provided */ -export type AddAliasRouteHandler = < +export type AddUncheckedRouteHandler = < ApiName extends ApiNamesWithMethod, >( - path: string, apiName: ApiName, - handlers: TypedRequestHandler[], - options?: WrappedRouteOptions, + handlers: UncheckedRequestHandler[], + options?: UncheckedWrappedRouteOptions, ) => void; /** @@ -118,20 +118,16 @@ export type AddAliasRouteHandler = */ export type WrappedRouter = Omit< express.Router, - 'get' | 'post' | 'put' | 'delete' + 'get' | 'post' | 'put' | 'delete' | 'use' > & express.RequestHandler & { + use: (middleware: UncheckedRequestHandler) => void; get: AddRouteHandler; post: AddRouteHandler; put: AddRouteHandler; delete: AddRouteHandler; - getAlias: AddAliasRouteHandler; - postAlias: AddAliasRouteHandler; - putAlias: AddAliasRouteHandler; - deleteAlias: AddAliasRouteHandler; - // Expose the original express router methods as an escape hatch - getUnchecked: express.Router['get']; - postUnchecked: express.Router['post']; - putUnchecked: express.Router['put']; - deleteUnchecked: express.Router['delete']; + getUnchecked: AddUncheckedRouteHandler; + postUnchecked: AddUncheckedRouteHandler; + putUnchecked: AddUncheckedRouteHandler; + deleteUnchecked: AddUncheckedRouteHandler; }; diff --git a/packages/typed-express-router/test/server.test.ts b/packages/typed-express-router/test/server.test.ts index 5075e274..67da60af 100644 --- a/packages/typed-express-router/test/server.test.ts +++ b/packages/typed-express-router/test/server.test.ts @@ -111,12 +111,12 @@ const GetHelloWorld: TypedRequestHandler = ( test('should match basic routes', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); - router.use(appMiddleware); router.put('hello.world', [routeMiddleware, CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); + app.use(appMiddleware); app.use(router); const server = supertest(app); @@ -133,12 +133,11 @@ test('should match basic routes', async (t) => { test('should match aliased routes', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); - router.use(appMiddleware); - router.get('hello.world', [GetHelloWorld]); - router.getAlias('/alternateHello/:id', 'hello.world', [GetHelloWorld]); + router.get('hello.world', [GetHelloWorld], { routeAliases: ['/alternateHello/:id'] }); const app = express(); + app.use(express.json()); + app.use(appMiddleware); app.use(router); const apiClient = supertest(app); @@ -156,8 +155,6 @@ test('should invoke post-response hook', async (t) => { let hookRun = false; - router.use(express.json()); - router.use(appMiddleware); router.put('hello.world', [routeMiddleware, CreateHelloWorld], { afterEncodedResponseSent: () => { hookRun = true; @@ -165,6 +162,8 @@ test('should invoke post-response hook', async (t) => { }); const app = express(); + app.use(express.json()); + app.use(appMiddleware); app.use(router); const server = supertest(app); @@ -178,7 +177,6 @@ test('should invoke post-response hook', async (t) => { test('should match first defined route when there is an overlap', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); router.get('hello.world', [GetHelloWorld]); // This won't be matched because of definition order @@ -190,6 +188,7 @@ test('should match first defined route when there is an overlap', async (t) => { const app = express(); app.use(router); + app.use(express.json()); const server = supertest(app); const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); @@ -204,12 +203,12 @@ test('should match first defined route when there is an overlap', async (t) => { test('should handle io-ts-http formatted path parameters', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); - router.use(appMiddleware); router.put('hello.world', [routeMiddleware, CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); + app.use(appMiddleware); app.use(router); const server = supertest(app); @@ -226,12 +225,12 @@ test('should handle io-ts-http formatted path parameters', async (t) => { test('should invoke app-level middleware', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); - router.use(appMiddleware); router.put('hello.world', [CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); + app.use(appMiddleware); app.use(router); const server = supertest(app); @@ -245,14 +244,65 @@ test('should invoke app-level middleware', async (t) => { t.like(response, { message: "Who's there?", appMiddlewareRan: true }); }); +test('should invoke router-level middleware', async (t) => { + const router = createRouter(TestApiSpec); + + let routerMiddlewareRan: string = ''; + router.use((req, _res, next) => { + routerMiddlewareRan = req.apiName; + next(); + }); + + router.put('hello.world', [CreateHelloWorld]); + router.get('hello.world', [GetHelloWorld]); + + const app = express(); + app.use(express.json()); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + const response = await apiClient['hello.world'] + .put({ secretCode: 1000 }) + .decodeExpecting(200) + .then((res) => res.body); + + t.like(response, { message: "Who's there?", appMiddlewareRan: false }); + t.is(routerMiddlewareRan, 'hello.world'); +}); + +test('router-level middleware should run before request validation on checked routes', async (t) => { + const router = createRouter(TestApiSpec); + + let routerMiddlewareRan: string = ''; + router.use((req, _res, next) => { + routerMiddlewareRan = req.apiName; + next(); + }); + + router.put('hello.world', [CreateHelloWorld]); + + const app = express(); + app.use(express.json()); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + await apiClient['hello.world'].put({} as any).expect(400); + + t.is(routerMiddlewareRan, 'hello.world'); +}); + test('should invoke route-level middleware', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); router.put('hello.world', [routeMiddleware, CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -269,11 +319,11 @@ test('should invoke route-level middleware', async (t) => { test('should infer status code from response type', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); router.put('hello.world', [routeMiddleware, CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -290,11 +340,11 @@ test('should infer status code from response type', async (t) => { test('should return a 400 when request fails to decode', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); router.put('hello.world', [routeMiddleware, CreateHelloWorld]); router.get('hello.world', [GetHelloWorld]); const app = express(); + app.use(express.json()); app.use(router); t.notThrows(async () => { @@ -312,14 +362,20 @@ test('should invoke custom decode error function', async (t) => { }, }); - router.use(express.json()); - router.getAlias('/helloNoPathParams', 'hello.world', [ - (_req, res) => { - res.sendEncoded(200, { id: '1234' }); + router.get( + 'hello.world', + [ + (_req, res) => { + res.sendEncoded(200, { id: '1234' }); + }, + ], + { + routeAliases: ['/helloNoPathParams'], }, - ]); + ); const app = express(); + app.use(express.json()); app.use(router); const apiClient = supertest(app); @@ -335,9 +391,7 @@ test('should invoke per-route custom decode error function', async (t) => { }, }); - router.use(express.json()); - router.getAlias( - '/helloNoPathParams', + router.get( 'hello.world', [ (_req, res) => { @@ -348,10 +402,12 @@ test('should invoke per-route custom decode error function', async (t) => { onDecodeError: (_errs, _req, res) => { res.status(400).json('Route decode error').end(); }, + routeAliases: ['/helloNoPathParams'], }, ); const app = express(); + app.use(express.json()); app.use(router); const apiClient = supertest(app); @@ -363,7 +419,6 @@ test('should invoke per-route custom decode error function', async (t) => { test('should send a 500 when response type does not match', async (t) => { const router = createRouter(TestApiSpec); - router.use(express.json()); router.get('hello.world', [ (_req, res) => { res.sendEncoded(200, { what: 'is this parameter?' } as any); @@ -371,6 +426,7 @@ test('should send a 500 when response type does not match', async (t) => { ]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -387,7 +443,6 @@ test('should invoke custom encode error function when response type does not mat }, }); - router.use(express.json()); router.get('hello.world', [ (_req, res) => { res.sendEncoded(200, { what: 'is this parameter?' } as any); @@ -395,6 +450,7 @@ test('should invoke custom encode error function when response type does not mat ]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -412,7 +468,6 @@ test('should invoke per-route custom encode error function when response type do }, }); - router.use(express.json()); router.get( 'hello.world', [ @@ -428,6 +483,7 @@ test('should invoke per-route custom encode error function when response type do ); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -445,7 +501,6 @@ test('should invoke custom encode error function when an unknown HTTP status is }, }); - router.use(express.json()); router.get('hello.world', [ (_req, res) => { res.sendEncoded(202 as any, {} as any); @@ -453,6 +508,7 @@ test('should invoke custom encode error function when an unknown HTTP status is ]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app); @@ -483,7 +539,6 @@ test('should invoke custom encode error function when an unknown keyed status is }, }); - router.use(express.json()); router.get('foo', [ (_req, res) => { res.sendEncoded('wat', {}); @@ -491,6 +546,7 @@ test('should invoke custom encode error function when an unknown keyed status is ]); const app = express(); + app.use(express.json()); app.use(router); const server = supertest(app);