From e1e58adfcbafb0a38575c84144c8373ca29bd07a Mon Sep 17 00:00:00 2001 From: Patrick McLaughlin Date: Wed, 10 Aug 2022 18:03:34 -0400 Subject: [PATCH] feat: add typed-express-router package --- package-lock.json | 60 +++ packages/express-wrapper/package.json | 1 + packages/express-wrapper/src/index.ts | 19 +- packages/express-wrapper/src/request.ts | 56 +- packages/express-wrapper/src/response.ts | 2 - packages/express-wrapper/tsconfig.json | 3 + packages/typed-express-router/LICENSE | 13 + packages/typed-express-router/README.md | 111 ++++ packages/typed-express-router/package.json | 48 ++ packages/typed-express-router/src/errors.ts | 22 + packages/typed-express-router/src/index.ts | 161 ++++++ .../src/path.ts | 0 packages/typed-express-router/src/types.ts | 132 +++++ .../test/path.test.ts | 0 .../typed-express-router/test/server.test.ts | 502 ++++++++++++++++++ packages/typed-express-router/tsconfig.json | 15 + 16 files changed, 1103 insertions(+), 42 deletions(-) create mode 100644 packages/typed-express-router/LICENSE create mode 100644 packages/typed-express-router/README.md create mode 100644 packages/typed-express-router/package.json create mode 100644 packages/typed-express-router/src/errors.ts create mode 100644 packages/typed-express-router/src/index.ts rename packages/{express-wrapper => typed-express-router}/src/path.ts (100%) create mode 100644 packages/typed-express-router/src/types.ts rename packages/{express-wrapper => typed-express-router}/test/path.test.ts (100%) create mode 100644 packages/typed-express-router/test/server.test.ts create mode 100644 packages/typed-express-router/tsconfig.json diff --git a/package-lock.json b/package-lock.json index 98ed5b06..0f00043e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,10 @@ "resolved": "packages/superagent-wrapper", "link": true }, + "node_modules/@api-ts/typed-express-router": { + "resolved": "packages/typed-express-router", + "link": true + }, "node_modules/@ava/typescript": { "version": "3.0.1", "dev": true, @@ -14225,6 +14229,38 @@ "engines": { "node": ">=10" } + }, + "packages/typed-express-router": { + "version": "0.0.0-semantically-released", + "license": "Apache-2.0", + "dependencies": { + "@api-ts/io-ts-http": "0.0.0-semantically-released", + "express": "4.17.2", + "fp-ts": "2.12.2", + "io-ts": "2.1.3" + }, + "devDependencies": { + "@api-ts/superagent-wrapper": "0.0.0-semantically-released", + "@ava/typescript": "3.0.1", + "@types/express": "4.17.13", + "ava": "4.3.1", + "c8": "7.12.0", + "ts-node": "10.9.1", + "typescript": "4.7.4" + } + }, + "packages/typed-express-router/node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } } }, "dependencies": { @@ -14751,6 +14787,30 @@ } } }, + "@api-ts/typed-express-router": { + "version": "file:packages/typed-express-router", + "requires": { + "@api-ts/io-ts-http": "0.0.0-semantically-released", + "@api-ts/superagent-wrapper": "0.0.0-semantically-released", + "@ava/typescript": "3.0.1", + "@types/express": "4.17.13", + "ava": "4.3.1", + "c8": "7.12.0", + "express": "4.17.2", + "fp-ts": "2.12.2", + "io-ts": "2.1.3", + "ts-node": "10.9.1", + "typescript": "4.7.4" + }, + "dependencies": { + "typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true + } + } + }, "@ava/typescript": { "version": "3.0.1", "dev": true, diff --git a/packages/express-wrapper/package.json b/packages/express-wrapper/package.json index e6b795df..a68e4ae8 100644 --- a/packages/express-wrapper/package.json +++ b/packages/express-wrapper/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@api-ts/superagent-wrapper": "0.0.0-semantically-released", + "@api-ts/typed-express-router": "0.0.0-semantically-released", "@ava/typescript": "3.0.1", "@types/express": "4.17.13", "ava": "4.3.1", diff --git a/packages/express-wrapper/src/index.ts b/packages/express-wrapper/src/index.ts index 8c996d5f..94059d3a 100644 --- a/packages/express-wrapper/src/index.ts +++ b/packages/express-wrapper/src/index.ts @@ -6,9 +6,9 @@ import express from 'express'; import { ApiSpec, HttpRoute } from '@api-ts/io-ts-http'; +import { createRouter } from '@api-ts/typed-express-router'; -import { apiTsPathToExpress } from './path'; -import { decodeRequestAndEncodeResponse, RouteHandler } from './request'; +import { handleRequest, onDecodeError, onEncodeError, RouteHandler } from './request'; import { defaultResponseEncoder, ResponseEncoder } from './response'; export { middlewareFn, MiddlewareChain, MiddlewareChainOutput } from './middleware'; @@ -33,7 +33,10 @@ export function routerForApiSpec({ routeHandlers, encoder = defaultResponseEncoder, }: CreateRouterProps) { - const router = express.Router(); + const router = createRouter(spec, { + onDecodeError, + onEncodeError, + }); for (const apiName of Object.keys(spec)) { const resource = spec[apiName] as Spec[string]; for (const method of Object.keys(resource)) { @@ -42,17 +45,15 @@ export function routerForApiSpec({ } const httpRoute: HttpRoute = resource[method]!; const routeHandler = routeHandlers[apiName]![method]!; - const expressRouteHandler = decodeRequestAndEncodeResponse( + const expressRouteHandler = handleRequest( 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. - routeHandler as RouteHandler, + routeHandler as RouteHandler, encoder, ); - const expressPath = apiTsPathToExpress(httpRoute.path); - router[method](expressPath, expressRouteHandler); + // FIXME: Can't prove to TS here that `apiName` is valid to pass to the generalized `router[method]` + (router[method] as any)(apiName, [expressRouteHandler]); } } diff --git a/packages/express-wrapper/src/request.ts b/packages/express-wrapper/src/request.ts index 14689ef4..7bfd16ef 100644 --- a/packages/express-wrapper/src/request.ts +++ b/packages/express-wrapper/src/request.ts @@ -4,10 +4,14 @@ */ import express from 'express'; -import * as E from 'fp-ts/Either'; import * as PathReporter from 'io-ts/lib/PathReporter'; -import { HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http'; +import { ApiSpec, HttpRoute, RequestType, ResponseType } from '@api-ts/io-ts-http'; +import { + OnDecodeErrorFn, + OnEncodeErrorFn, + TypedRequestHandler, +} from '@api-ts/typed-express-router'; import { runMiddlewareChain, @@ -90,58 +94,48 @@ const createNamedFunction = void>( fn: F, ): F => Object.defineProperty(fn, 'name', { value: name }); -export const decodeRequestAndEncodeResponse = ( +export const onDecodeError: OnDecodeErrorFn = (errs, _req, res) => { + const validationErrors = PathReporter.failure(errs); + const validationErrorMessage = validationErrors.join('\n'); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.write(JSON.stringify({ error: validationErrorMessage })); + res.end(); +}; + +export const onEncodeError: OnEncodeErrorFn = (err, _req, res) => { + console.warn('Error in route handler:', err); + res.status(500).end(); +}; + +export const handleRequest = ( apiName: string, httpRoute: HttpRoute, handler: RouteHandler, responseEncoder: ResponseEncoder, -): express.RequestHandler => { +): TypedRequestHandler => { return createNamedFunction( 'decodeRequestAndEncodeResponse' + httpRoute.method + apiName, async (req, res, next) => { - const maybeRequest = httpRoute.request.decode(req); - if (E.isLeft(maybeRequest)) { - 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 - | KeyedResponseType - | undefined; try { const handlerParams = MiddlewareBrand in handler - ? await runMiddlewareChain( - maybeRequest.right, - getMiddleware(handler), - req, - res, - ) + ? await runMiddlewareChain(req.decoded, getMiddleware(handler), req, res) : await runMiddlewareChainIgnoringResults( - E.getOrElseW(() => { - throw Error('Request failed to decode'); - })(maybeRequest), + req.decoded, getMiddleware(handler), req, res, ); const serviceFn = getServiceFunction(handler); - rawResponse = await serviceFn(handlerParams); + const response = await serviceFn(handlerParams); + responseEncoder(httpRoute, response)(req, res, next); } catch (err) { console.warn('Error in route handler:', err); res.status(500).end(); next(); return; } - - const expressHandler = responseEncoder(httpRoute, rawResponse); - expressHandler(req, res, next); }, ); }; diff --git a/packages/express-wrapper/src/response.ts b/packages/express-wrapper/src/response.ts index 75fc648b..263e0d11 100644 --- a/packages/express-wrapper/src/response.ts +++ b/packages/express-wrapper/src/response.ts @@ -15,8 +15,6 @@ export type KeyedResponseType = { }; }[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: ResponseType, diff --git a/packages/express-wrapper/tsconfig.json b/packages/express-wrapper/tsconfig.json index 4433521c..23eec22e 100644 --- a/packages/express-wrapper/tsconfig.json +++ b/packages/express-wrapper/tsconfig.json @@ -10,6 +10,9 @@ }, { "path": "../superagent-wrapper" + }, + { + "path": "../typed-express-router" } ] } diff --git a/packages/typed-express-router/LICENSE b/packages/typed-express-router/LICENSE new file mode 100644 index 00000000..b99f7843 --- /dev/null +++ b/packages/typed-express-router/LICENSE @@ -0,0 +1,13 @@ +Copyright 2022 BitGo Inc + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/typed-express-router/README.md b/packages/typed-express-router/README.md new file mode 100644 index 00000000..61b85245 --- /dev/null +++ b/packages/typed-express-router/README.md @@ -0,0 +1,111 @@ +# @api-ts/typed-express-router + +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 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) +- Follow the express router api as closely as possible otherwise + +## Non-Goals + +- Enforce that all routes listed in an `apiSpec` have an associated route handler +- Layer anything on top of the `express.RequestHandler[]` chain beyond the additional + properties described in `Goals` (projects and other libraries can do this) + +## Usage + +### Creating a router + +Two very similar functions are provided by this library that respectively create or wrap +an Express router: + +```ts +import { createRouter, wrapRouter } from '@api-ts/typed-express-router'; +import express from 'express'; + +import { MyApi } from 'my-api-package'; + +const app = express(); + +const typedRouter = createRouter(MyApi); +app.use(typedRouter); +``` + +### Adding routes + +Once you have the `typedRouter`, you can start adding routes by the api-ts api name: + +```ts +typedRouter.get('hello.world', [HelloWorldHandler]); +``` + +Here, `HelloWorldHandler` is a almost like an Express request handler, but `req` and +`res` have an extra property. `req.decoded` contains the validated and decoded request. +On the response side, there is an extra `res.sendEncoded(status, payload)` function that +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 + +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: + +```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(); +}); +``` + +### Hooks and error handlers + +The `createRouter`, `wrapRouter`, and individual route methods all take an optional last +parameter where a post-response and error handling function may be provided. Ones +specified for a specific route take precedence over the top-level ones. These may be +used to customize error responses and perform other actions like metrics collection or +logging. + +```ts +const typedRouter = createRouter(MyApi, { + onDecodeError: (errs, req, res) => { + // Format `errs` however you want + res.send(400).json({ message: 'Bad request' }).end(); + }, + onEncodeError: (err, req, res) => { + // Ideally won't happen unless type safety is violated, so it's a 500 + res.send(500).json({ message: 'Internal server error' }).end(); + }, + afterEncodedResponseSent: (status, payload, req, res) => { + // Perform side effects or other things, `res` should be ended by this point + endRequestMetricsCollection(req); + }, +}); + +// Override the decode error handler on one route +typedRouter.get('hello.world', [HelloWorldHandler], { + onDecodeError: customHelloDecodeErrorHandler, +}); +``` + +### Other usage + +Other than what is documented above, a wrapped router should behave like a regular +Express one, so things like `typedRouter.use()` should behave the same. diff --git a/packages/typed-express-router/package.json b/packages/typed-express-router/package.json new file mode 100644 index 00000000..5403e403 --- /dev/null +++ b/packages/typed-express-router/package.json @@ -0,0 +1,48 @@ +{ + "name": "@api-ts/typed-express-router", + "version": "0.0.0-semantically-released", + "description": "Implement an HTTP specification with Express", + "author": "Patrick McLaughlin ", + "license": "Apache-2.0", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src/" + ], + "scripts": { + "build": "tsc --build --incremental --verbose .", + "clean": "rm -rf -- dist", + "format": "prettier --check .", + "format:fix": "prettier --write .", + "test": "c8 --all ava" + }, + "dependencies": { + "@api-ts/io-ts-http": "0.0.0-semantically-released", + "express": "4.17.2", + "fp-ts": "2.12.2", + "io-ts": "2.1.3" + }, + "devDependencies": { + "@api-ts/superagent-wrapper": "0.0.0-semantically-released", + "@ava/typescript": "3.0.1", + "@types/express": "4.17.13", + "ava": "4.3.1", + "c8": "7.12.0", + "ts-node": "10.9.1", + "typescript": "4.7.4" + }, + "ava": { + "typescript": { + "compile": false, + "extensions": [ + "ts" + ], + "rewritePaths": { + "test/": "dist/test/" + } + } + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/typed-express-router/src/errors.ts b/packages/typed-express-router/src/errors.ts new file mode 100644 index 00000000..226f84ac --- /dev/null +++ b/packages/typed-express-router/src/errors.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import { Errors } from 'io-ts'; +import * as PathReporter from 'io-ts/lib/PathReporter'; + +export function defaultOnDecodeError( + errs: Errors, + _req: express.Request, + res: express.Response, +) { + const validationErrors = PathReporter.failure(errs); + const validationErrorMessage = validationErrors.join('\n'); + res.status(400).json({ error: validationErrorMessage }).end(); +} + +export function defaultOnEncodeError( + err: unknown, + _req: express.Request, + res: express.Response, +) { + res.status(500).end(); + console.warn(`Error in route handler: ${err}`); +} diff --git a/packages/typed-express-router/src/index.ts b/packages/typed-express-router/src/index.ts new file mode 100644 index 00000000..40bc976d --- /dev/null +++ b/packages/typed-express-router/src/index.ts @@ -0,0 +1,161 @@ +import { ApiSpec, HttpRoute, KeyToHttpStatus } from '@api-ts/io-ts-http'; +import express from 'express'; +import * as E from 'fp-ts/Either'; +import { pipe } from 'fp-ts/function'; +import { defaultOnDecodeError, defaultOnEncodeError } from './errors'; +import { apiTsPathToExpress } from './path'; +import { + AddAliasRouteHandler, + AddRouteHandler, + Methods, + WrappedRouteOptions, + WrappedRouter, + WrappedRouterOptions, +} from './types'; + +export type { + AfterEncodedResponseSentFn, + OnDecodeErrorFn, + OnEncodeErrorFn, + TypedRequestHandler, + WrappedRouter, + WrappedRouteOptions, + WrappedRouterOptions, + WrappedRequest, + WrappedResponse, +} from './types'; + +/** + * Creates a new Express router and wraps it with the specified api-ts spec + * + * @param spec {ApiSpec} the api-ts spec to associate with the router + * @param options {WrappedRouterOptions} Express router options as well as default error handlers and hooks to use for routes + * @returns {WrappedRouter} the wrapped Express router + */ +export function createRouter( + spec: Spec, + { + onDecodeError, + onEncodeError, + afterEncodedResponseSent, + ...options + }: WrappedRouterOptions = {}, +): WrappedRouter { + const router = express.Router(options); + return wrapRouter(router, spec, { + onDecodeError, + onEncodeError, + afterEncodedResponseSent, + }); +} + +/** + * Wraps an existing Express router + * + * @param router {express.Router} the Express router to wrap + * @param spec {ApiSpec} the api-ts spec to associate with the router + * @param options {WrappedRouteOptions} default error handlers and hooks to use for routes + * @returns {WrappedRouter} the wrapped Express router + */ +export function wrapRouter( + router: express.Router, + spec: Spec, + { + onDecodeError = defaultOnDecodeError, + onEncodeError = defaultOnEncodeError, + afterEncodedResponseSent = () => {}, + }: WrappedRouteOptions, +): WrappedRouter { + function makeAddAliasRoute( + method: Method, + ): AddAliasRouteHandler { + return (path, 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(); + }, + ), + ); + }; + + router[method](path, [wrapReqAndRes, ...(handlers as express.RequestHandler[])]); + }; + } + + function makeAddRoute( + method: Method, + ): AddRouteHandler { + return (apiName, handlers, options) => { + const path = spec[apiName as keyof typeof spec]![method]!.path; + return makeAddAliasRoute(method)( + apiTsPathToExpress(path), + apiName, + handlers, + options, + ); + }; + } + + const result: WrappedRouter = Object.assign( + (req: express.Request, res: express.Response, next: express.NextFunction) => + router.call(router, req, res, next), + { + ...router, + get: makeAddRoute('get'), + 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, + }, + ); + + Object.setPrototypeOf(result, Object.getPrototypeOf(router)); + + return result; +} diff --git a/packages/express-wrapper/src/path.ts b/packages/typed-express-router/src/path.ts similarity index 100% rename from packages/express-wrapper/src/path.ts rename to packages/typed-express-router/src/path.ts diff --git a/packages/typed-express-router/src/types.ts b/packages/typed-express-router/src/types.ts new file mode 100644 index 00000000..f070c8da --- /dev/null +++ b/packages/typed-express-router/src/types.ts @@ -0,0 +1,132 @@ +import { ApiSpec, HttpRoute, RequestType } from '@api-ts/io-ts-http'; +import express from 'express'; +import * as t from 'io-ts'; + +export type Methods = 'get' | 'post' | 'put' | 'delete'; + +export type RouteAt< + Spec extends ApiSpec, + ApiName extends keyof Spec, + 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 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 OnDecodeErrorFn = ( + errs: t.Errors, + req: express.Request, + res: express.Response, +) => void; + +export type OnEncodeErrorFn = ( + err: unknown, + req: express.Request, + res: express.Response, +) => void; + +export type AfterEncodedResponseSentFn = < + Status extends keyof Route['response'], +>( + status: Status, + payload: Route['response'][Status], + req: express.Request, + res: express.Response, +) => void; + +export type WrappedRouteOptions = { + onDecodeError?: OnDecodeErrorFn; + onEncodeError?: OnEncodeErrorFn; + afterEncodedResponseSent?: AfterEncodedResponseSentFn; +}; + +export type WrappedRouterOptions = express.RouterOptions & + WrappedRouteOptions; + +export type TypedRequestHandler< + Spec extends ApiSpec, + ApiName extends keyof Spec, + Method extends keyof Spec[ApiName], +> = ( + req: WrappedRequest, + res: WrappedResponse, + next: express.NextFunction, +) => void; + +type ApiNamesWithMethod = { + [K in keyof Spec]: Method extends keyof Spec[K] ? K : never; +}[keyof Spec]; + +/** + * 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 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 + */ +export type AddRouteHandler = < + ApiName extends ApiNamesWithMethod, +>( + apiName: ApiName, + handlers: TypedRequestHandler[], + 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. + * + * @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 + */ +export type AddAliasRouteHandler = < + ApiName extends ApiNamesWithMethod, +>( + path: string, + apiName: ApiName, + handlers: TypedRequestHandler[], + options?: WrappedRouteOptions, +) => void; + +/** + * An Express router that is wrapped and associated with an api-ts `apiSpec`. + */ +export type WrappedRouter = Omit< + express.Router, + 'get' | 'post' | 'put' | 'delete' +> & + express.RequestHandler & { + 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']; + }; diff --git a/packages/express-wrapper/test/path.test.ts b/packages/typed-express-router/test/path.test.ts similarity index 100% rename from packages/express-wrapper/test/path.test.ts rename to packages/typed-express-router/test/path.test.ts diff --git a/packages/typed-express-router/test/server.test.ts b/packages/typed-express-router/test/server.test.ts new file mode 100644 index 00000000..5075e274 --- /dev/null +++ b/packages/typed-express-router/test/server.test.ts @@ -0,0 +1,502 @@ +import test from 'ava'; + +import * as t from 'io-ts'; +import express from 'express'; +import supertest from 'supertest'; + +import { apiSpec, httpRequest, httpRoute, optional } from '@api-ts/io-ts-http'; +import { buildApiClient, supertestRequestFactory } from '@api-ts/superagent-wrapper'; + +import { createRouter } from '../src'; +import { TypedRequestHandler } from '../src/types'; + +const PutHello = httpRoute({ + path: '/hello', + method: 'PUT', + // DISCUSS: what about req.user? + // and more generally, things that aren't in headers/body/query/route + request: httpRequest({ + body: { + secretCode: t.number, + appMiddlewareRan: optional(t.boolean), + }, + }), + response: { + // TODO: create prettier names for these codecs at the io-ts-http level + 200: t.type({ + message: t.string, + appMiddlewareRan: t.boolean, + routeMiddlewareRan: t.boolean, + }), + 400: t.type({ + errors: t.string, + }), + 404: t.unknown, + // DISCUSS: what if a response isn't listed here but shows up? + 500: t.unknown, + }, +}); +type PutHello = typeof PutHello; + +const GetHello = httpRoute({ + path: '/hello/{id}', + method: 'GET', + request: httpRequest({ + params: { + id: t.string, + }, + }), + response: { + 200: t.type({ + id: t.string, + }), + }, +}); + +const GetHelloOverlap = httpRoute({ + path: '/hello/world', + method: 'GET', + request: httpRequest({}), + response: { + 200: t.literal('Hello World!'), + }, +}); + +const TestApiSpec = apiSpec({ + 'hello.world': { + put: PutHello, + get: GetHello, + }, + 'hello.world.overlap': { + get: GetHelloOverlap, + }, +}); + +type TestApiSpec = typeof TestApiSpec; + +const appMiddleware: express.RequestHandler = (req, _res, next) => { + req.body.appMiddlewareRan = true; + next(); +}; + +const routeMiddleware: express.RequestHandler = (req, _res, next) => { + (req as any).routeMiddlewareRan = true; + next(); +}; + +const CreateHelloWorld: TypedRequestHandler = ( + req, + res, +) => { + const { secretCode, appMiddlewareRan } = req.decoded; + if (secretCode === 0) { + res.sendEncoded(400, { + errors: 'Please do not tell me zero! I will now explode', + }); + } else { + res.sendEncoded(200, { + message: + secretCode === 42 ? 'Everything you see from here is yours' : "Who's there?", + appMiddlewareRan: appMiddlewareRan ?? false, + routeMiddlewareRan: (req as any).routeMiddlewareRan ?? false, + }); + } +}; + +const GetHelloWorld: TypedRequestHandler = ( + { decoded: { id } }, + res, +) => res.sendEncoded(200, { id }); + +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(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?" }); +}); + +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]); + + const app = express(); + app.use(router); + + const apiClient = supertest(app); + + const response = await apiClient + .get('/alternateHello/1234') + .expect(200) + .then((res) => res.body); + + t.like(response, { id: '1234' }); +}); + +test('should invoke post-response hook', async (t) => { + const router = createRouter(TestApiSpec); + + let hookRun = false; + + router.use(express.json()); + router.use(appMiddleware); + router.put('hello.world', [routeMiddleware, CreateHelloWorld], { + afterEncodedResponseSent: () => { + hookRun = true; + }, + }); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + await apiClient['hello.world'].put({ secretCode: 1000 }).expect(200); + + t.true(hookRun); +}); + +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 + router.get('hello.world.overlap', [ + (_req, res) => { + res.sendEncoded(200, 'Hello World!'); + }, + ]); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + const response = await apiClient['hello.world.overlap'].get({}).decode(); + + // Defined the wider route first, so that should be matched and cause a decode error + t.is(response.status, 'decodeError'); + t.like(response.body, { id: 'world' }); +}); + +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(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + const response = await apiClient['hello.world'] + .get({ id: '1337' }) + .decodeExpecting(200) + .then((res) => res.body); + + t.like(response, { id: '1337' }); +}); + +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(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: true }); +}); + +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(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?", routeMiddlewareRan: true }); +}); + +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(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + + const response = await apiClient['hello.world'] + .put({ secretCode: 0 }) + .decodeExpecting(400) + .then((res) => res.body); + + t.like(response, { errors: 'Please do not tell me zero! I will now explode' }); +}); + +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(router); + + t.notThrows(async () => { + await supertest(app) + .put('/hello') + .set('Content-Type', 'application/json') + .expect(400); + }); +}); + +test('should invoke custom decode error function', async (t) => { + const router = createRouter(TestApiSpec, { + onDecodeError: (_errs, _req, res) => { + res.status(400).json('Custom decode error').end(); + }, + }); + + router.use(express.json()); + router.getAlias('/helloNoPathParams', 'hello.world', [ + (_req, res) => { + res.sendEncoded(200, { id: '1234' }); + }, + ]); + + const app = express(); + app.use(router); + + const apiClient = supertest(app); + const response = await apiClient.get('/helloNoPathParams').expect(400); + + t.is(response.body, 'Custom decode error'); +}); + +test('should invoke per-route custom decode error function', async (t) => { + const router = createRouter(TestApiSpec, { + onDecodeError: (_errs, _req, res) => { + res.status(400).json('Top-level decode error').end(); + }, + }); + + router.use(express.json()); + router.getAlias( + '/helloNoPathParams', + 'hello.world', + [ + (_req, res) => { + res.sendEncoded(200, { id: '1234' }); + }, + ], + { + onDecodeError: (_errs, _req, res) => { + res.status(400).json('Route decode error').end(); + }, + }, + ); + + const app = express(); + app.use(router); + + const apiClient = supertest(app); + const response = await apiClient.get('/helloNoPathParams').expect(400); + + t.is(response.body, 'Route decode error'); +}); + +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); + }, + ]); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + const response = await apiClient['hello.world'].get({ id: '1234' }).decode(); + + t.is(response.original.status, 500); +}); + +test('should invoke custom encode error function when response type does not match', async (t) => { + const router = createRouter(TestApiSpec, { + onEncodeError: (_err, _req, res) => { + res.status(500).json('Custom encode error').end(); + }, + }); + + router.use(express.json()); + router.get('hello.world', [ + (_req, res) => { + res.sendEncoded(200, { what: 'is this parameter?' } as any); + }, + ]); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + const response = await apiClient['hello.world'].get({ id: '1234' }).decode(); + + t.is(response.original.status, 500); + t.is(response.body, 'Custom encode error'); +}); + +test('should invoke per-route custom encode error function when response type does not match', async (t) => { + const router = createRouter(TestApiSpec, { + onEncodeError: (_err, _req, res) => { + res.status(500).json('Top-level encode error').end(); + }, + }); + + router.use(express.json()); + router.get( + 'hello.world', + [ + (_req, res) => { + res.sendEncoded(200, { what: 'is this parameter?' } as any); + }, + ], + { + onEncodeError: (_err, _req, res) => { + res.status(500).json('Route encode error').end(); + }, + }, + ); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + const response = await apiClient['hello.world'].get({ id: '1234' }).decode(); + + t.is(response.original.status, 500); + t.is(response.body, 'Route encode error'); +}); + +test('should invoke custom encode error function when an unknown HTTP status is passed to `sendEncoded`', async (t) => { + const router = createRouter(TestApiSpec, { + onEncodeError: (_err, _req, res) => { + res.status(500).json('Custom encode error').end(); + }, + }); + + router.use(express.json()); + router.get('hello.world', [ + (_req, res) => { + res.sendEncoded(202 as any, {} as any); + }, + ]); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), TestApiSpec); + const response = await apiClient['hello.world'].get({ id: '1234' }).decode(); + + t.is(response.original.status, 500); + t.is(response.body, 'Custom encode error'); +}); + +test('should invoke custom encode error function when an unknown keyed status is passed to `sendEncoded`', async (test) => { + const WeirdApi = apiSpec({ + foo: { + get: httpRoute({ + path: '/foo', + method: 'GET', + request: httpRequest({}), + response: { + wat: t.type({}), + }, + }), + }, + }); + + const router = createRouter(WeirdApi, { + onEncodeError: (_err, _req, res) => { + res.status(500).json('Custom encode error').end(); + }, + }); + + router.use(express.json()); + router.get('foo', [ + (_req, res) => { + res.sendEncoded('wat', {}); + }, + ]); + + const app = express(); + app.use(router); + + const server = supertest(app); + const apiClient = buildApiClient(supertestRequestFactory(server), WeirdApi); + const response = await apiClient['foo'].get({}).decode(); + + test.is(response.original.status, 500); + test.is(response.body, 'Custom encode error'); +}); diff --git a/packages/typed-express-router/tsconfig.json b/packages/typed-express-router/tsconfig.json new file mode 100644 index 00000000..4433521c --- /dev/null +++ b/packages/typed-express-router/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "outDir": "./dist" + }, + "references": [ + { + "path": "../io-ts-http" + }, + { + "path": "../superagent-wrapper" + } + ] +}