Skip to content

Commit c1c1d31

Browse files
authored
feat: add type-safe header definitions to contracts (#182)
1 parent cfce85a commit c1c1d31

28 files changed

Lines changed: 1166 additions & 525 deletions

.changeset/twenty-tips-confess.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/nest': minor
5+
'@ts-rest/next': minor
6+
'@ts-rest/react-query': minor
7+
'@ts-rest/solid-query': minor
8+
---
9+
10+
Add type-safe header definitions to contracts

apps/docs/docs/core/core.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export const contract = c.router({
2929
responses: {
3030
200: c.response<{ posts: Post[]; total: number }>(),
3131
},
32+
headers: z.object({
33+
pagination: z.string().optional(),
34+
}),
3235
query: z.object({
3336
take: z.string().transform(Number).optional(),
3437
skip: z.string().transform(Number).optional(),
@@ -62,7 +65,7 @@ Check the relevant sections to see how to enable JSON query encoding/decoding on
6265

6366
## Combining Contracts
6467

65-
You can combine contracts to create a single contract, helpful if you want many sub contracts, especially if they are huge.
68+
You can combine contracts to create a single contract, helpful if you want many sub-contracts, especially if they are huge.
6669

6770
```typescript
6871
const c = initContract();
@@ -88,6 +91,38 @@ export const contract = c.router({
8891
});
8992
```
9093

94+
## Headers
95+
96+
You can define headers in your contract, however, they must have an input type of `string`, as they cannot be typed otherwise.
97+
You can use Zod transforms or coercion to transform any string values to different types if needed.
98+
99+
```typescript
100+
const c = initContract();
101+
export const contract = c.router({
102+
getPosts: {
103+
...,
104+
headers: z.object({
105+
authorization: z.string(),
106+
pagination: z.coerce.number().optional(),
107+
}),
108+
}
109+
});
110+
```
111+
112+
You can also define base headers for all routes in a contract and its sub-contracts, this is useful for things like authorization headers.
113+
This will force the client to always pass
114+
115+
```typescript
116+
const c = initContract();
117+
export const contract = c.router({
118+
// ...endpoints
119+
}, {
120+
baseHeaders: z.object({
121+
authorization: z.string(),
122+
}),
123+
});
124+
```
125+
91126
## Intellisense
92127

93128
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/core/fetch.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ Breaking down the arguments:
3232

3333
- `body` - The body of the request, only used for `POST`, `PUT`, `PATCH` requests.
3434
- `query` - The query parameters of the request.
35-
- `headers` - The headers of the request (merged and overridden with `baseHeaders` in the client)
35+
- `headers` - Request headers defined in the contract (merged and overridden with `baseHeaders` in the client)
36+
- `extraHeaders` - If you want to pass headers not defined in the contract
3637
- `params` - The path parameters of the request.
3738

3839
:::tip Customise the API 🎨

apps/example-express/src/tests/posts-response-validation.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ const superTestApp = supertest(app);
55

66
describe('Posts Endpoints w/ Response Validation', () => {
77
it('should include default value and removes extra field', async () => {
8-
const res = await superTestApp.get('/validate-response/123/name?field=foo');
8+
const res = await superTestApp
9+
.get('/validate-response/123/name?field=foo')
10+
.set('x-api-key', 'foo');
911

1012
expect(res.status).toStrictEqual(200);
1113
expect(res.body).toStrictEqual({
@@ -16,7 +18,9 @@ describe('Posts Endpoints w/ Response Validation', () => {
1618
});
1719

1820
it('fails with invalid field', async () => {
19-
const res = await superTestApp.get('/validate-response/2000/name');
21+
const res = await superTestApp
22+
.get('/validate-response/2000/name')
23+
.set('x-api-key', 'foo');
2024

2125
expect(res.status).toStrictEqual(500);
2226
expect(res.body).toStrictEqual({});

apps/example-express/src/tests/posts.spec.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,47 @@ const superTestApp = supertest(app);
55

66
describe('Posts Endpoints', () => {
77
it('GET /posts should return an array of posts', async () => {
8-
const res = await superTestApp.get('/posts?skip=0&take=10');
8+
const res = await superTestApp
9+
.get('/posts?skip=0&take=10')
10+
.set('x-api-key', 'foo')
11+
.set('x-pagination', '5');
912

1013
expect(res.status).toStrictEqual(200);
1114
});
1215

1316
it('should transform skip and take into numbers', async () => {
14-
const res = await superTestApp.get('/posts?skip=0&take=10');
17+
const res = await superTestApp
18+
.get('/posts?skip=0&take=10')
19+
.set('x-api-key', 'foo');
1520

1621
expect(res.status).toStrictEqual(200);
1722
expect(res.body.skip).toStrictEqual(0);
1823
expect(res.body.take).toStrictEqual(10);
1924
});
2025

26+
it('should error on invalid pagination header', async () => {
27+
const res = await superTestApp
28+
.get('/posts?skip=0&take=10')
29+
.set('x-api-key', 'foo')
30+
.set('x-pagination', 'not a number');
31+
32+
expect(res.status).toStrictEqual(400);
33+
expect(res.body).toStrictEqual({
34+
issues: [
35+
{
36+
code: 'invalid_type',
37+
expected: 'number',
38+
message: 'Expected number, received nan',
39+
path: ['x-pagination'],
40+
received: 'nan',
41+
},
42+
],
43+
name: 'ZodError',
44+
});
45+
});
46+
2147
it('should error if a required query param is missing', async () => {
22-
const res = await superTestApp.get('/posts?skip=0');
48+
const res = await superTestApp.get('/posts?skip=0').set('x-api-key', 'foo');
2349

2450
expect(res.status).toStrictEqual(400);
2551
expect(res.body).toStrictEqual({
@@ -37,7 +63,7 @@ describe('Posts Endpoints', () => {
3763
});
3864

3965
it('should error if body is incorrect', async () => {
40-
const res = await superTestApp.post('/posts').send({
66+
const res = await superTestApp.post('/posts').set('x-api-key', 'foo').send({
4167
title: 'Good title',
4268
content: 123,
4369
});
@@ -57,8 +83,26 @@ describe('Posts Endpoints', () => {
5783
});
5884
});
5985

86+
it('should error if api key header is missing', async () => {
87+
const res = await superTestApp.get('/posts');
88+
89+
expect(res.status).toStrictEqual(400);
90+
expect(res.body).toStrictEqual({
91+
issues: [
92+
{
93+
code: 'invalid_type',
94+
expected: 'string',
95+
message: 'Required',
96+
path: ['x-api-key'],
97+
received: 'undefined',
98+
},
99+
],
100+
name: 'ZodError',
101+
});
102+
});
103+
60104
it('should transform body correctly', async () => {
61-
const res = await superTestApp.post('/posts').send({
105+
const res = await superTestApp.post('/posts').set('x-api-key', 'foo').send({
62106
title: 'Title with extra spaces ',
63107
content: 'content',
64108
});
@@ -68,7 +112,9 @@ describe('Posts Endpoints', () => {
68112
});
69113

70114
it('should format params using pathParams correctly', async () => {
71-
const res = await superTestApp.get('/test/123/name');
115+
const res = await superTestApp
116+
.get('/test/123/name')
117+
.set('x-api-key', 'foo');
72118

73119
expect(res.status).toStrictEqual(200);
74120
expect(res.body).toStrictEqual({

apps/example-nest/src/app/post-json-query.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('PostJsonQueryController', () => {
4040

4141
return request(app.getHttpServer())
4242
.get('/posts-json-query')
43+
.set('x-api-key', 'foo')
4344
.query('skip=0&take=10&search="foo"')
4445
.expect(200)
4546
.expect({

apps/example-nest/src/app/post-validate-responses.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ describe('PostValidateResponsesController', () => {
5050

5151
return request(app.getHttpServer())
5252
.get('/posts')
53+
.set('x-api-key', 'foo')
5354
.expect(200)
5455
.expect({
5556
posts: [
@@ -77,6 +78,7 @@ describe('PostValidateResponsesController', () => {
7778

7879
return request(app.getHttpServer())
7980
.get('/posts')
81+
.set('x-api-key', 'foo')
8082
.expect(500)
8183
.expect({ statusCode: 500, message: 'Internal server error' });
8284
});

apps/example-nest/src/app/post.controller.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('PostController', () => {
3737

3838
return request(app.getHttpServer())
3939
.get('/posts')
40+
.set('x-api-key', 'foo')
4041
.query('skip=0&take=10')
4142
.expect(200)
4243
.expect({
@@ -47,9 +48,31 @@ describe('PostController', () => {
4748
});
4849
});
4950

51+
it('should fetch pagination header', async () => {
52+
jest.spyOn(postService, 'getPosts').mockResolvedValue({
53+
posts: [],
54+
totalPosts: 0,
55+
});
56+
57+
return request(app.getHttpServer())
58+
.get('/posts')
59+
.set('x-api-key', 'foo')
60+
.set('x-pagination', '123')
61+
.query('skip=0&take=10')
62+
.expect(200)
63+
.expect({
64+
posts: [],
65+
count: 0,
66+
skip: 0,
67+
take: 10,
68+
pagination: 123,
69+
});
70+
});
71+
5072
it('should error if a required query param is missing', async () => {
5173
return request(app.getHttpServer())
5274
.get('/posts')
75+
.set('x-api-key', 'foo')
5376
.query('skip=0')
5477
.expect(400)
5578
.expect({
@@ -71,6 +94,7 @@ describe('PostController', () => {
7194
it('should error if body is incorrect', async () => {
7295
return request(app.getHttpServer())
7396
.post('/posts')
97+
.set('x-api-key', 'foo')
7498
.send({
7599
title: 'Good title',
76100
content: 123,
@@ -97,6 +121,7 @@ describe('PostController', () => {
97121

98122
return request(app.getHttpServer())
99123
.post('/posts')
124+
.set('x-api-key', 'foo')
100125
.send({
101126
title: 'Title with extra spaces ',
102127
content: 'content',
@@ -112,6 +137,7 @@ describe('PostController', () => {
112137
it('should format params using pathParams correctly', async () => {
113138
return request(app.getHttpServer())
114139
.get('/test/123/name')
140+
.set('x-api-key', 'foo')
115141
.expect(200)
116142
.expect({
117143
id: 123,

apps/example-nest/src/app/post.controller.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ export class PostController implements NestControllerInterface<typeof c> {
2525
@TsRest(c.getPosts)
2626
async getPosts(
2727
@TsRestRequest()
28-
{ query: { take, skip, search } }: RequestShapes['getPosts']
28+
{
29+
query: { take, skip, search },
30+
headers: { 'x-pagination': pagination },
31+
}: RequestShapes['getPosts']
2932
) {
3033
const { posts, totalPosts } = await this.postService.getPosts({
3134
take,
@@ -35,7 +38,7 @@ export class PostController implements NestControllerInterface<typeof c> {
3538

3639
return {
3740
status: 200 as const,
38-
body: { posts, count: totalPosts, skip, take },
41+
body: { posts, count: totalPosts, skip, take, pagination },
3942
};
4043
}
4144

0 commit comments

Comments
 (0)