Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/express-wrapper/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 10 additions & 9 deletions packages/express-wrapper/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +33,10 @@ export function routerForApiSpec<Spec extends ApiSpec>({
routeHandlers,
encoder = defaultResponseEncoder,
}: CreateRouterProps<Spec>) {
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)) {
Expand All @@ -42,17 +45,15 @@ export function routerForApiSpec<Spec extends ApiSpec>({
}
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<any>,
routeHandler as RouteHandler<HttpRoute>,
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]);
}
}

Expand Down
56 changes: 25 additions & 31 deletions packages/express-wrapper/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -90,58 +94,48 @@ const createNamedFunction = <F extends (...args: any) => 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<HttpRoute>,
responseEncoder: ResponseEncoder,
): express.RequestHandler => {
): TypedRequestHandler<ApiSpec, string, string> => {
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<HttpRoute>
| KeyedResponseType<HttpRoute>
| 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);
},
);
};
2 changes: 0 additions & 2 deletions packages/express-wrapper/src/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export type KeyedResponseType<R extends HttpRoute> = {
};
}[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<HttpRoute>,
Expand Down
3 changes: 3 additions & 0 deletions packages/express-wrapper/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
},
{
"path": "../superagent-wrapper"
},
{
"path": "../typed-express-router"
}
]
}
13 changes: 13 additions & 0 deletions packages/typed-express-router/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 111 additions & 0 deletions packages/typed-express-router/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading