Skip to content

Commit 5005793

Browse files
authored
fix(core): preserve trailing slashes in client requests (#765)
1 parent c6e675c commit 5005793

3 files changed

Lines changed: 91 additions & 99 deletions

File tree

.changeset/olive-deers-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': patch
3+
---
4+
5+
Fix trailing slashes not being preserved by the client

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

Lines changed: 64 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -47,91 +47,92 @@ expectType<{
4747

4848
describe('insertParamsIntoPath', () => {
4949
it('should insert params into path', () => {
50-
const path = '/post/:id/comments/:commentId';
51-
52-
const params = {
53-
commentId: '2',
54-
id: '1',
55-
};
56-
57-
const result = insertParamsIntoPath({ path, params });
58-
59-
expect(result).toBe('/post/1/comments/2');
50+
expect(
51+
insertParamsIntoPath({
52+
path: '/post/:id/comments/:commentId',
53+
params: { commentId: '2', id: '1' },
54+
}),
55+
).toBe('/post/1/comments/2');
6056
});
6157

6258
it('should insert params into path with no params', () => {
63-
const path = '/posts';
64-
65-
const result = insertParamsIntoPath({ path, params: {} });
66-
67-
expect(result).toBe('/posts');
59+
expect(
60+
insertParamsIntoPath({
61+
path: '/posts',
62+
params: { a: '1' },
63+
}),
64+
).toBe('/posts');
6865
});
6966

7067
it('should insert params into path with many params', () => {
71-
const path = '/post/:id/comments/:commentId/:commentId2';
72-
73-
const params = {
74-
commentId: '2',
75-
commentId2: '3',
76-
id: '1',
77-
};
78-
79-
const result = insertParamsIntoPath({ path, params });
80-
81-
expect(result).toBe('/post/1/comments/2/3');
68+
expect(
69+
insertParamsIntoPath({
70+
path: '/post/:id/comments/:commentId/:commentId2',
71+
params: { commentId: '2', commentId2: '3', id: '1' },
72+
}),
73+
).toBe('/post/1/comments/2/3');
8274
});
8375

8476
it('should insert into paths with only one param', () => {
85-
const path = '/:id';
86-
87-
const params = {
88-
id: '1',
89-
};
90-
91-
const result = insertParamsIntoPath({ path, params });
92-
93-
expect(result).toBe('/1');
77+
expect(
78+
insertParamsIntoPath({
79+
path: '/:id',
80+
params: { id: '1' },
81+
}),
82+
).toBe('/1');
9483
});
9584

9685
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');
86+
expect(
87+
insertParamsIntoPath({
88+
path: '/post/:id?/comments/:commentId?/:commentId2?',
89+
params: { commentId: '2', commentId2: '3', id: '1' },
90+
}),
91+
).toBe('/post/1/comments/2/3');
10892
});
10993

11094
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');
95+
expect(
96+
insertParamsIntoPath({
97+
path: '/post/:id?/comments/:commentId?/:commentId2?',
98+
params: {},
99+
}),
100+
).toBe('/post/comments');
116101
});
117102

118103
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');
104+
expect(
105+
insertParamsIntoPath({
106+
path: '/post/:id?/comments/:commentId?/:commentId2?/:commentId3?',
107+
params: {},
108+
}),
109+
).toBe('/post/comments');
124110
});
125111

126112
it('should insert optional params into paths with only one param', () => {
127-
const path = '/:id?';
128-
129-
const params = {
130-
id: '1',
131-
};
113+
expect(
114+
insertParamsIntoPath({
115+
path: '/:id?',
116+
params: { id: '1' },
117+
}),
118+
).toBe('/1');
119+
});
132120

133-
const result = insertParamsIntoPath({ path, params });
121+
it('should preserve trailing slashes in path', () => {
122+
expect(
123+
insertParamsIntoPath({
124+
path: '/:id/',
125+
params: { id: '1' },
126+
}),
127+
).toBe('/1/');
128+
});
134129

135-
expect(result).toBe('/1');
130+
it('should preserve trailing slashes in path with optional params', () => {
131+
expect(
132+
insertParamsIntoPath({
133+
path: '/post/:id?/comments/:commentId?/:commentId2?/:commentId3?/',
134+
params: {},
135+
}),
136+
).toBe('/post/comments/');
136137
});
137138
});

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

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,19 @@ type ResolveOptionalPathParam<T extends string> =
1111
* @params T - The URL e.g. /posts/:id
1212
* @params TAcc - Accumulator object
1313
*/
14-
type RecursivelyExtractPathParams<
15-
T extends string,
16-
TAcc extends null | Record<string, string>,
17-
> = T extends `/:${infer PathParam}/${infer Right}`
14+
type RecursivelyExtractPathParams<T extends string> = T extends ''
15+
? Record<never, never>
16+
: T extends `${infer Left}/:${infer PathParam}/${infer Right}`
1817
? ResolveOptionalPathParam<PathParam> &
19-
RecursivelyExtractPathParams<Right, TAcc>
20-
: T extends `/:${infer PathParam}`
21-
? ResolveOptionalPathParam<PathParam>
22-
: T extends `/${string}/${infer Right}`
23-
? RecursivelyExtractPathParams<Right, TAcc>
24-
: T extends `/${string}`
25-
? TAcc
18+
RecursivelyExtractPathParams<Left> &
19+
RecursivelyExtractPathParams<Right>
2620
: T extends `:${infer PathParam}/${infer Right}`
27-
? ResolveOptionalPathParam<PathParam> &
28-
RecursivelyExtractPathParams<Right, TAcc>
21+
? ResolveOptionalPathParam<PathParam> & RecursivelyExtractPathParams<Right>
22+
: T extends `${infer Left}/:${infer PathParam}`
23+
? ResolveOptionalPathParam<PathParam> & RecursivelyExtractPathParams<Left>
2924
: T extends `:${infer PathParam}`
30-
? TAcc & ResolveOptionalPathParam<PathParam>
31-
: T extends `${string}/${infer Right}`
32-
? RecursivelyExtractPathParams<Right, TAcc>
33-
: TAcc;
25+
? ResolveOptionalPathParam<PathParam>
26+
: Record<never, never>;
3427

3528
/**
3629
* Extract path params from path function
@@ -39,17 +32,12 @@ type RecursivelyExtractPathParams<
3932
*
4033
* @params T - The URL e.g. /posts/:id
4134
*/
42-
export type ParamsFromUrl<T extends string> = RecursivelyExtractPathParams<
43-
T,
44-
{}
45-
> extends infer U
46-
? {
47-
[key in keyof U]: U[key];
48-
}
49-
: never;
50-
51-
const PARAM_REGEX = /:([^/?]+)\??/g;
52-
const DOUBLE_SLASH_REGEX = /\/\//g;
35+
export type ParamsFromUrl<T extends string> =
36+
RecursivelyExtractPathParams<T> extends infer U
37+
? {
38+
[key in keyof U]: U[key];
39+
}
40+
: never;
5341

5442
/**
5543
* @param path - The URL e.g. /posts/:id
@@ -63,13 +51,11 @@ export const insertParamsIntoPath = <T extends string>({
6351
path: T;
6452
params: ParamsFromUrl<T>;
6553
}) => {
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-
}
54+
const pathParams = params as Record<string, string>;
7355

74-
return result;
56+
return path.replace(/\/?:([^/?]+)\??/g, (matched, p) =>
57+
pathParams[p]
58+
? `${matched.startsWith('/') ? '/' : ''}${pathParams[p]}`
59+
: '',
60+
);
7561
};

0 commit comments

Comments
 (0)