Skip to content

Commit d4d9be5

Browse files
committed
feat: Add pathParams for Zod verification
1 parent ff4a029 commit d4d9be5

15 files changed

Lines changed: 239 additions & 91 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/express': minor
4+
---
5+
6+
Add support for pathParams Zod verification
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"dev": {
3+
"host": "http://localhost:3333"
4+
}
5+
}

apps/example-express/src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ const completedRouter = s.router(apiBlog, {
8585
body: { message: 'Post deleted' },
8686
};
8787
},
88+
testPathParams: async ({ params }) => {
89+
return {
90+
status: 200,
91+
body: params,
92+
};
93+
},
8894
});
8995

9096
const openapi = generateOpenApi(apiBlog, {
@@ -105,3 +111,5 @@ const server = app.listen(port, () => {
105111
console.log(`Listening at http://localhost:${port}`);
106112
});
107113
server.on('error', console.error);
114+
115+
export default app;

apps/example-express/src/test.http

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
@host = http://localhost:3333
21
@contentType = application/json
32

43
### Create Post
54
POST {{host}}/posts HTTP/1.1
65
content-type: application/json
7-
6+
87
{
98
"title": "Post Title",
109
"content": "Post Content"
@@ -13,15 +12,15 @@ content-type: application/json
1312
### Create Posts (Bad Body)
1413
POST {{host}}/posts HTTP/1.1
1514
content-type: application/json
16-
15+
1716
{
1817
"title": "Post Title"
1918
}
2019

21-
20+
2221
### Get Posts
2322
GET {{host}}/posts HTTP/1.1
2423

2524
### Search Posts
26-
GET {{host}}/posts?search=REST HTTP/1.1
25+
GET {{host}}/posts?search=great&take=10&skip=0 HTTP/1.1
2726

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import * as supertest from 'supertest';
2+
import app from '../main';
3+
4+
const superTestApp = supertest(app);
5+
6+
describe('Posts Endpoints', () => {
7+
it('GET /posts should return an array of posts', async () => {
8+
const res = await superTestApp.get('/posts?skip=0&take=10');
9+
10+
expect(res.status).toStrictEqual(200);
11+
});
12+
13+
it('should transform skip and take into numbers', async () => {
14+
const res = await superTestApp.get('/posts?skip=0&take=10');
15+
16+
expect(res.status).toStrictEqual(200);
17+
expect(res.body.skip).toStrictEqual(0);
18+
expect(res.body.take).toStrictEqual(10);
19+
});
20+
21+
it('should error if a required query param is missing', async () => {
22+
const res = await superTestApp.get('/posts?skip=0');
23+
24+
expect(res.status).toStrictEqual(400);
25+
expect(res.body).toStrictEqual({
26+
issues: [
27+
{
28+
code: 'invalid_type',
29+
expected: 'string',
30+
message: 'Required',
31+
path: ['take'],
32+
received: 'undefined',
33+
},
34+
],
35+
name: 'ZodError',
36+
});
37+
});
38+
39+
it('should error if body is incorrect', async () => {
40+
const res = await superTestApp.post('/posts').send({
41+
title: 'Good title',
42+
content: 123,
43+
});
44+
45+
expect(res.status).toStrictEqual(400);
46+
expect(res.body).toStrictEqual({
47+
issues: [
48+
{
49+
code: 'invalid_type',
50+
expected: 'string',
51+
message: 'Expected string, received number',
52+
path: ['content'],
53+
received: 'number',
54+
},
55+
],
56+
name: 'ZodError',
57+
});
58+
});
59+
60+
it('should transform body correctly', async () => {
61+
const res = await superTestApp.post('/posts').send({
62+
title: 'Title with extra spaces ',
63+
content: 'content',
64+
});
65+
66+
expect(res.status).toStrictEqual(201);
67+
expect(res.body.title).toStrictEqual('Title with extra spaces');
68+
});
69+
70+
it('should format params using pathParams correctly', async () => {
71+
const res = await superTestApp.get('/test/123/name');
72+
73+
expect(res.status).toStrictEqual(200);
74+
expect(res.body).toStrictEqual({
75+
id: 123,
76+
name: 'name',
77+
});
78+
});
79+
});

libs/example-contracts/src/lib/contract-blog.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const apiBlog = c.router({
2929
201: PostSchema,
3030
},
3131
body: z.object({
32-
title: z.string(),
32+
title: z.string().transform((v) => v.trim()),
3333
content: z.string(),
3434
published: z.boolean().optional(),
3535
description: z.string().optional(),
@@ -86,4 +86,18 @@ export const apiBlog = c.router({
8686
}),
8787
summary: 'Get all posts',
8888
},
89+
testPathParams: {
90+
method: 'GET',
91+
path: '/test/:id/:name',
92+
pathParams: z.object({
93+
id: z.string().transform(Number),
94+
name: z.string(),
95+
}),
96+
responses: {
97+
200: z.object({
98+
id: z.number(),
99+
name: z.string(),
100+
}),
101+
},
102+
},
89103
});

libs/example-microservice/util-posts-api/src/lib/example-microservice-util-posts-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const postsApi = c.router({
3333
}),
3434
},
3535
},
36+
3637
updatePostThumbnail: {
3738
method: 'POST',
3839
path: '/posts/:id/thumbnail',

libs/ts-rest/core/src/lib/dsl.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type AppRouteQuery = {
1010
* e.g. "/posts/:id".
1111
*/
1212
path: string;
13+
pathParams?: unknown;
1314
query?: unknown;
1415
summary?: string;
1516
description?: string;
@@ -28,6 +29,7 @@ export type AppRouteMutation = {
2829
* e.g. "/posts/:id".
2930
*/
3031
path: string;
32+
pathParams?: unknown;
3133
contentType?: 'application/json' | 'multipart/form-data';
3234
body: unknown;
3335
query?: unknown;

libs/ts-rest/core/src/lib/paths.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,5 @@ export const convertQueryParamsToUrlString = (
8282
.join('&')
8383
: '';
8484

85-
return queryString.length > 0 &&
86-
queryString !== null &&
87-
queryString !== undefined
88-
? '?' + queryString
89-
: '';
85+
return queryString?.length > 0 ? '?' + queryString : '';
9086
};

libs/ts-rest/core/src/lib/type-utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ export type With<T, V> = Pick<T, ExcludeKeysWithoutTypeOf<T, V>>;
7474

7575
export type ZodInferOrType<T> = T extends ZodTypeAny ? z.infer<T> : T;
7676

77+
export type Merge<T, U> = Omit<T, keyof U> & U;
78+
7779
type Try<A, B, C> = A extends B ? A : C;
7880

7981
type NarrowRaw<T> =

0 commit comments

Comments
 (0)