Skip to content

Commit 74e41dc

Browse files
authored
feat(express): add middleware composition helpers (#232)
1 parent 8a3b2d0 commit 74e41dc

22 files changed

Lines changed: 982 additions & 316 deletions

File tree

.changeset/serious-hotels-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/express': minor
3+
---
4+
5+
Add middleware directly through ts-rest with type-safe injected route object
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': minor
3+
---
4+
5+
Add 'metadata' property to routes

.github/workflows/prerelease.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Release
1+
name: Pre-Release
22
on:
33
push:
44
branches:

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ const contract = c.contract({
5353
responses: {
5454
200: c.response<Post[]>(), // <-- OR normal TS types
5555
},
56+
headers: z.object({
57+
'x-pagination-page': z.coerce.number().optional(),
58+
}),
5659
},
5760
});
5861
```
@@ -74,6 +77,7 @@ Consume the api on the client with a RPC-like interface:
7477

7578
```typescript
7679
const result = await client.getPosts({
80+
headers: { 'x-pagination-page': 1 },
7781
query: { skip: 0, take: 10 },
7882
// ^-- Fully typed!
7983
});
@@ -95,8 +99,8 @@ yarn add @ts-rest/open-api
9599

96100
Create a contract, implement it on your server then consume it in your client. Incrementally adopt, trial it with your team, then get shipping faster.
97101

98-
<div align="center" style={{margin: "50px"}}>
99-
<h2>👉 Read more on the official <a href="https://ts-rest.com/docs/quickstart?utm_source=github&utm_medium=documentation&utm_campaign=readme">Quickstart Guide</a>👈</h2>
102+
<div align="center">
103+
<h3>👉 Read more on the official <a href="https://ts-rest.com/docs/quickstart?utm_source=github&utm_medium=documentation&utm_campaign=readme">Quickstart Guide</a> 👈</h3>
100104
</div>
101105

102106
## Star History

apps/docs/docs/core/core.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export const contract = c.router({
2222
description: z.string().optional(),
2323
}),
2424
summary: 'Create a post',
25+
metadata: { role: 'user' } as const,
2526
},
2627
getPosts: {
2728
method: 'GET',
@@ -38,6 +39,7 @@ export const contract = c.router({
3839
search: z.string().optional(),
3940
}),
4041
summary: 'Get all posts',
42+
metadata: { role: 'guest' } as const,
4143
},
4244
});
4345
```
@@ -123,6 +125,28 @@ export const contract = c.router({
123125
});
124126
```
125127

128+
## Metadata
129+
130+
You can attach metadata with any type to your contract routes that can be accessed anywhere throughout ts-rest where
131+
you have access to the contract route object.
132+
133+
```typescript
134+
const c = initContract();
135+
export const contract = c.router({
136+
getPosts: {
137+
...,
138+
metadata: { role: 'guest' } as const,
139+
}
140+
});
141+
```
142+
143+
:::caution
144+
145+
As the contract is not only used on the server, but on the client as well, it will also be part of your client-side bundle.
146+
You should not put any sensitive information in the metadata.
147+
148+
:::
149+
126150
## Intellisense
127151

128152
For intellisense on your contract types, you can use [JSDoc Reference](https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#type).

apps/docs/docs/express.mdx

Lines changed: 0 additions & 50 deletions
This file was deleted.

apps/docs/docs/express/express.mdx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { InstallTabs } from '@site/src/components/InstallTabs';
2+
3+
# Express Server
4+
5+
## Installation
6+
7+
<InstallTabs packageName="@ts-rest/express" />
8+
9+
## Usage
10+
11+
```typescript
12+
import { initServer } from '@ts-rest/express';
13+
import { contract } from './contract';
14+
15+
const app = express();
16+
app.use(bodyParser.urlencoded({ extended: false }));
17+
app.use(bodyParser.json());
18+
19+
const s = initServer();
20+
const router = s.router(contract, {
21+
getPost: async ({ params: { id } }) => {
22+
const post = prisma.post.findUnique({ where: { id } });
23+
24+
return {
25+
status: 200,
26+
body: post ?? null,
27+
};
28+
},
29+
});
30+
31+
createExpressEndpoints(contract, router, app);
32+
```
33+
34+
`createExpressEndpoints` is a function that takes a contract, a corresponding router with implementations and middleware for each endpoint, and an express app, and it will
35+
create the corresponding express routes for each endpoint with the correct method, paths and middleware and attach them to your express app.
36+
37+
## Options
38+
39+
You can pass an optional options object as the last argument for `createExpressEndpoints`.
40+
41+
```typescript
42+
type Options = {
43+
logInitialization?: boolean; // print route initialization logs to console
44+
jsonQuery?: boolean;
45+
responseValidation?: boolean;
46+
globalMiddleware?: ((req, res, next) => void)[];
47+
requestValidationErrorHandler?:
48+
| 'default'
49+
| 'combined'
50+
| ((err: RequestValidationError, req, res, next) => void);
51+
};
52+
```
53+
54+
### Response Validation
55+
56+
To enable response parsing and validation, you can use the `validateResponses` option.
57+
If there is a corresponding response Zod schema defined in the contract for the returned status code, the response will be parsed and validated.
58+
If validation fails a `ResponseValidationError` will be thrown causing a 500 response to be returned.
59+
60+
```typescript
61+
createExpressEndpoints(contract, router, app, {
62+
validateResponses: true,
63+
});
64+
```
65+
66+
### Request Validation Error Handling
67+
68+
The default functionality of handling request validation errors is to return a 400 response with the first validation error that the validator comes across.
69+
70+
You can pass `combined` to the `requestValidationErrorHandler` option to return a 400 response with all validation errors in the body in this form.
71+
72+
```typescript
73+
{
74+
pathParameterErrors: z.ZodError | null;
75+
headerErrors: z.ZodError | null;
76+
queryParameterErrors: z.ZodError | null;
77+
bodyErrors: z.ZodError | null;
78+
}
79+
```
80+
81+
You can also pass a custom error handler function to the `requestValidationErrorHandler` option.
82+
83+
```typescript
84+
createExpressEndpoints(contract, router, app, {
85+
requestValidationErrorHandler: (err, req, res, next) => {
86+
// err is typed as ^ RequestValidationError
87+
return res.status(400).json({
88+
message: 'Validation failed'
89+
});
90+
},
91+
});
92+
```
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Middleware
2+
3+
You can add middleware to your routes through ts-rest directly instead of having to attach them to your app directly.
4+
They are regular Express.js request handlers, but with the added benefit of having
5+
a typed contract route attached to the Request object at `req.tsRestRoute`.
6+
7+
This allows you to pass metadata from your contract to your middleware to be used for authorization, logging, etc.
8+
9+
## Route Middleware
10+
11+
In the below example we show how you can add middleware that only runs for a specific route.
12+
13+
```typescript
14+
import { initServer } from '@ts-rest/express';
15+
import { contract } from './contract';
16+
17+
const s = initServer();
18+
19+
const router = s.router(contract, {
20+
getPost: {
21+
middleware: [
22+
(req, res, next) => {
23+
// req.tsRestRoute is typed as the contract route
24+
console.log('Called: ', req.tsRestRoute.method, req.tsRestRoute.path);
25+
// prints: Called: GET /posts/:id
26+
next();
27+
}
28+
],
29+
handler: async ({ params: { id } }) => {
30+
const post = prisma.post.findUnique({ where: { id } });
31+
32+
return {
33+
status: 200,
34+
body: post ?? null,
35+
};
36+
}
37+
},
38+
});
39+
40+
createExpressEndpoints(contract, router, app);
41+
```
42+
43+
## Global Middleware
44+
45+
You can also add middleware that runs for all routes in a contract. These run before any route-specific middleware.
46+
47+
```typescript
48+
import { initServer } from '@ts-rest/express';
49+
import { contract } from './contract';
50+
51+
const s = initServer();
52+
53+
const router = s.router(contract, {
54+
getPost: {
55+
middleware: [
56+
(req, res, next) => {
57+
// req.tsRestRoute is typed as the contract route
58+
console.log('Called: ', req.tsRestRoute.method, req.tsRestRoute.path);
59+
// 'GET' ^ '/posts/:id' ^
60+
next();
61+
}
62+
],
63+
handler: async ({ params: { id } }) => {
64+
const post = prisma.post.findUnique({ where: { id } });
65+
66+
return {
67+
status: 200,
68+
body: post ?? null,
69+
};
70+
}
71+
},
72+
});
73+
74+
createExpressEndpoints(contract, router, app, {
75+
globalMiddleware: [passport.authenticate('jwt', { session: false })]
76+
});
77+
```

apps/docs/docs/intro.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ const contract = c.contract({
2828
responses: {
2929
200: c.response<Post[]>(), // <-- OR normal TS types
3030
},
31+
headers: z.object({
32+
'x-pagination-page': z.coerce.number().optional(),
33+
}),
3134
},
3235
});
3336
```
@@ -49,6 +52,7 @@ Consume the api on the client with a RPC-like interface:
4952

5053
```typescript
5154
const result = await client.getPosts({
55+
headers: { 'x-pagination-page': 1 },
5256
query: { skip: 0, take: 10 },
5357
// ^-- Fully typed!
5458
});

apps/docs/sidebars.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,13 @@ const sidebars = {
8787
id: 'next',
8888
},
8989
{
90-
type: 'doc',
90+
type: 'category',
9191
label: '@ts-rest/express',
92-
id: 'express',
92+
collapsed: false,
93+
items: [
94+
{ type: 'doc', id: 'express/express' },
95+
{ type: 'doc', id: 'express/middleware' },
96+
],
9397
},
9498
{
9599
type: 'doc',

0 commit comments

Comments
 (0)