Skip to content

Commit 964f7c6

Browse files
mgnskoliverbutler
andauthored
fix(core): Replace optional path params in request URL (#704)
Co-authored-by: Oliver Butler <dev@oliverbutler.uk>
1 parent 23567fa commit 964f7c6

5 files changed

Lines changed: 244 additions & 5 deletions

File tree

.changeset/sixty-horses-drum.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ts-rest/express': minor
3+
'@ts-rest/core': minor
4+
'@ts-rest/nest': minor
5+
---
6+
7+
Support optional path params in the type system, and ensure multiple levels of query params are dealt with in nest

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,46 @@ describe('insertParamsIntoPath', () => {
9292

9393
expect(result).toBe('/1');
9494
});
95+
96+
it('should insert optional params into path with many params', () => {
97+
const path = '/post/:id?/comments/:commentId?/:commentId2?';
98+
99+
const params = {
100+
commentId: '2',
101+
commentId2: '3',
102+
id: '1',
103+
};
104+
105+
const result = insertParamsIntoPath({ path, params });
106+
107+
expect(result).toBe('/post/1/comments/2/3');
108+
});
109+
110+
it('should insert optional params into path with no params', () => {
111+
const path = '/post/:id?/comments/:commentId?/:commentId2?';
112+
113+
const result = insertParamsIntoPath({ path, params: {} });
114+
115+
expect(result).toBe('/post/comments');
116+
});
117+
118+
it('should insert not have trailing slashes', () => {
119+
const path = '/post/:id?/comments/:commentId?/:commentId2?/:commentId3?';
120+
121+
const result = insertParamsIntoPath({ path, params: {} });
122+
123+
expect(result).toBe('/post/comments');
124+
});
125+
126+
it('should insert optional params into paths with only one param', () => {
127+
const path = '/:id?';
128+
129+
const params = {
130+
id: '1',
131+
};
132+
133+
const result = insertParamsIntoPath({ path, params });
134+
135+
expect(result).toBe('/1');
136+
});
95137
});

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

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export type ParamsFromUrl<T extends string> = RecursivelyExtractPathParams<
4848
}
4949
: never;
5050

51+
const PARAM_REGEX = /:([^/?]+)\??/g;
52+
const DOUBLE_SLASH_REGEX = /\/\//g;
53+
5154
/**
5255
* @param path - The URL e.g. /posts/:id
5356
* @param params - The params e.g. `{ id: string }`
@@ -60,9 +63,13 @@ export const insertParamsIntoPath = <T extends string>({
6063
path: T;
6164
params: ParamsFromUrl<T>;
6265
}) => {
63-
return path
64-
.replace(/:([^/]+)/g, (_, p) => {
65-
return (params as any)[p] || '';
66-
})
67-
.replace(/\/\//g, '/');
66+
let result = path
67+
.replace(PARAM_REGEX, (_, p) => (params as Record<string, string>)[p] || '')
68+
.replace(DOUBLE_SLASH_REGEX, '/');
69+
70+
while (result.length > 1 && result.endsWith('/')) {
71+
result = result.slice(0, -1);
72+
}
73+
74+
return result;
6875
};

libs/ts-rest/express/src/lib/ts-rest-express.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,59 @@ describe('ts-rest-express', () => {
286286
});
287287
});
288288

289+
it('should handle two levels of optional url params', async () => {
290+
const contract = c.router({
291+
getPosts: {
292+
method: 'GET',
293+
path: '/posts/:year?/:month?',
294+
responses: {
295+
200: z.object({
296+
id: z.string().optional(),
297+
}),
298+
},
299+
},
300+
});
301+
302+
const router = s.router(contract, {
303+
getPosts: async ({ params }) => {
304+
return {
305+
status: 200,
306+
body: {
307+
id: `${params.year}-${params.month}`,
308+
},
309+
};
310+
},
311+
});
312+
313+
const app = express();
314+
app.use(express.json());
315+
app.use(express.urlencoded({ extended: true }));
316+
createExpressEndpoints(contract, router, app);
317+
318+
await supertest(app)
319+
.get('/posts')
320+
.expect((res) => {
321+
expect(res.status).toEqual(200);
322+
expect(res.body).toEqual({
323+
id: `undefined-undefined`,
324+
});
325+
});
326+
327+
await supertest(app)
328+
.get('/posts/2025')
329+
.expect((res) => {
330+
expect(res.status).toEqual(200);
331+
expect(res.body).toEqual({ id: '2025-undefined' });
332+
});
333+
334+
await supertest(app)
335+
.get('/posts/2025/01')
336+
.expect((res) => {
337+
expect(res.status).toEqual(200);
338+
expect(res.body).toEqual({ id: '2025-01' });
339+
});
340+
});
341+
289342
it('should handle multipart/form-data', async () => {
290343
const contract = c.router({
291344
uploadFiles: {

libs/ts-rest/nest/src/lib/ts-rest-nest-handler.spec.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,72 @@ describe('ts-rest-nest-handler', () => {
765765
},
766766
});
767767
});
768+
769+
it('should route correctly for optional params', async () => {
770+
const c = initContract();
771+
772+
const contract = c.router({
773+
getPosts: {
774+
path: '/posts/:year?/:month?',
775+
method: 'GET',
776+
responses: {
777+
200: z.object({
778+
rangeSearched: z.string(),
779+
}),
780+
},
781+
},
782+
});
783+
784+
@Controller()
785+
class TestController {
786+
@TsRestHandler(contract)
787+
async handler() {
788+
return tsRestHandler(contract, {
789+
getPosts: async ({ params }) => ({
790+
status: 200,
791+
body: { rangeSearched: `${params.year}-${params.month}` },
792+
}),
793+
});
794+
}
795+
}
796+
797+
const moduleRef = await Test.createTestingModule({
798+
controllers: [TestController],
799+
}).compile();
800+
801+
const app = moduleRef.createNestApplication();
802+
await app.init();
803+
804+
await supertest(app.getHttpServer())
805+
.get('/posts')
806+
.send()
807+
.expect(200)
808+
.then((res) =>
809+
expect(res.body).toStrictEqual({
810+
rangeSearched: 'undefined-undefined',
811+
}),
812+
);
813+
814+
await supertest(app.getHttpServer())
815+
.get('/posts/yyyy')
816+
.send()
817+
.expect(200)
818+
.then((res) =>
819+
expect(res.body).toStrictEqual({
820+
rangeSearched: 'yyyy-undefined',
821+
}),
822+
);
823+
824+
await supertest(app.getHttpServer())
825+
.get('/posts/yyyy/mm')
826+
.send()
827+
.expect(200)
828+
.then((res) =>
829+
expect(res.body).toStrictEqual({
830+
rangeSearched: 'yyyy-mm',
831+
}),
832+
);
833+
});
768834
});
769835

770836
describe('single-handler api', () => {
@@ -1584,6 +1650,70 @@ describe('ts-rest-nest-handler', () => {
15841650
expect(res.header.location).toBe('/redirected');
15851651
});
15861652
});
1653+
1654+
it('should route correctly for optional params', async () => {
1655+
const c = initContract();
1656+
1657+
const contract = c.router({
1658+
getPosts: {
1659+
path: '/posts/:year?/:month?',
1660+
method: 'GET',
1661+
responses: {
1662+
200: z.object({
1663+
rangeSearched: z.string(),
1664+
}),
1665+
},
1666+
},
1667+
});
1668+
1669+
@Controller()
1670+
class TestController {
1671+
@TsRestHandler(contract.getPosts)
1672+
async handler() {
1673+
return tsRestHandler(contract.getPosts, async ({ params }) => ({
1674+
status: 200,
1675+
body: { rangeSearched: `${params.year}-${params.month}` },
1676+
}));
1677+
}
1678+
}
1679+
1680+
const moduleRef = await Test.createTestingModule({
1681+
controllers: [TestController],
1682+
}).compile();
1683+
1684+
const app = moduleRef.createNestApplication();
1685+
await app.init();
1686+
1687+
await supertest(app.getHttpServer())
1688+
.get('/posts')
1689+
.send()
1690+
.expect(200)
1691+
.then((res) =>
1692+
expect(res.body).toStrictEqual({
1693+
rangeSearched: 'undefined-undefined',
1694+
}),
1695+
);
1696+
1697+
await supertest(app.getHttpServer())
1698+
.get('/posts/yyyy')
1699+
.send()
1700+
.expect(200)
1701+
.then((res) =>
1702+
expect(res.body).toStrictEqual({
1703+
rangeSearched: 'yyyy-undefined',
1704+
}),
1705+
);
1706+
1707+
await supertest(app.getHttpServer())
1708+
.get('/posts/yyyy/mm')
1709+
.send()
1710+
.expect(200)
1711+
.then((res) =>
1712+
expect(res.body).toStrictEqual({
1713+
rangeSearched: 'yyyy-mm',
1714+
}),
1715+
);
1716+
});
15871717
});
15881718

15891719
it('should be able to combine single-handler, multi-handler and vanilla nest controllers', async () => {

0 commit comments

Comments
 (0)