Skip to content

Commit 8a19717

Browse files
committed
feat: add pathParams transformations
1 parent d4d9be5 commit 8a19717

15 files changed

Lines changed: 91 additions & 100 deletions

File tree

.changeset/large-gifts-eat.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/nest': minor
4+
'@ts-rest/next': minor
5+
---
6+
7+
Add pathParams transformations

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

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@ import {
66
ParamsFromUrl,
77
} from './paths';
88
import { HTTPStatusCode } from './status-codes';
9-
import { Without, ZodInferOrType } from './type-utils';
10-
11-
9+
import { Merge, Without, ZodInferOrType } from './type-utils';
1210

1311
type RecursiveProxyObj<T extends AppRouter> = {
1412
[TKey in keyof T]: T[TKey] extends AppRoute
@@ -23,12 +21,20 @@ type AppRouteMutationType<T> = T extends ZodTypeAny ? z.infer<T> : T;
2321
/**
2422
* Extract the path params from the path in the contract
2523
*/
26-
export type PathParams<T extends AppRoute> = ParamsFromUrl<
24+
export type PathParamsFromUrl<T extends AppRoute> = ParamsFromUrl<
2725
T['path']
2826
> extends infer U
2927
? U
3028
: never;
3129

30+
/**
31+
* Merge PathParamsFromUrl<T> with pathParams schema if it exists
32+
*/
33+
export type PathParamsWithCustomValidators<T extends AppRoute> =
34+
T['pathParams'] extends undefined
35+
? PathParamsFromUrl<T>
36+
: Merge<PathParamsFromUrl<T>, ZodInferOrType<T['pathParams']>>;
37+
3238
// Allow FormData if the contentType is multipart/form-data
3339
type AppRouteBodyOrFormData<T extends AppRouteMutation> =
3440
T['contentType'] extends 'multipart/form-data'
@@ -39,7 +45,7 @@ interface DataReturnArgs<TRoute extends AppRoute> {
3945
body: TRoute extends AppRouteMutation
4046
? AppRouteBodyOrFormData<TRoute>
4147
: never;
42-
params: PathParams<TRoute>;
48+
params: PathParamsFromUrl<TRoute>;
4349
query: TRoute['query'] extends ZodTypeAny
4450
? AppRouteMutationType<TRoute['query']>
4551
: never;
@@ -144,16 +150,13 @@ export const fetchApi = (
144150
};
145151

146152
export const getCompleteUrl = (
147-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
148153
query: any,
149154
baseUrl: string,
150-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
151155
params: any,
152156
route: AppRoute
153157
) => {
154158
const path = insertParamsIntoPath({
155159
path: route.path,
156-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
157160
params: params as any,
158161
});
159162
const queryComponent = convertQueryParamsToUrlString(query);
@@ -164,7 +167,6 @@ export const getRouteQuery = <TAppRoute extends AppRoute>(
164167
route: TAppRoute,
165168
clientArgs: ClientArgs
166169
) => {
167-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
168170
return async (inputArgs: DataReturnArgs<any>) => {
169171
const completeUrl = getCompleteUrl(
170172
inputArgs.query,
@@ -181,7 +183,6 @@ const createNewProxy = (router: AppRouter, args: ClientArgs) => {
181183
return new Proxy(
182184
{},
183185
{
184-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
185186
get: (target, propKey): any => {
186187
if (typeof propKey === 'string' && propKey in router) {
187188
const subRouter = router[propKey];

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

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { Narrow } from './type-utils';
22

3+
/**
4+
* The path with colon-prefixed parameters
5+
* e.g. "/posts/:id".
6+
*/
7+
type Path = string;
8+
39
/**
410
* A query endpoint. In REST terms, one using GET.
511
*/
612
export type AppRouteQuery = {
713
method: 'GET';
8-
/**
9-
* The path with colon-prefixed parameters
10-
* e.g. "/posts/:id".
11-
*/
12-
path: string;
14+
path: Path;
1315
pathParams?: unknown;
1416
query?: unknown;
1517
summary?: string;
@@ -24,11 +26,7 @@ export type AppRouteQuery = {
2426
*/
2527
export type AppRouteMutation = {
2628
method: 'POST' | 'DELETE' | 'PUT' | 'PATCH';
27-
/**
28-
* The path with colon-prefixed parameters
29-
* e.g. "/posts/:id".
30-
*/
31-
path: string;
29+
path: Path;
3230
pathParams?: unknown;
3331
contentType?: 'application/json' | 'multipart/form-data';
3432
body: unknown;

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

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,12 @@ export const getPathParamsFromArray = (
3535
});
3636

3737
// remove pathParams where key doesn't start with :
38-
const pathParamsWithoutColons = Object.entries(pathParams).reduce(
39-
(acc, [key, value]) => {
40-
if (key.startsWith(':')) {
41-
const keyWithoutColon = key.slice(1);
42-
acc[keyWithoutColon] = value;
43-
}
44-
45-
return acc;
46-
},
47-
{} as Record<string, string>
48-
);
49-
50-
return pathParamsWithoutColons;
38+
return Object.entries(pathParams).reduce((acc, [key, value]) => {
39+
if (key.startsWith(':')) {
40+
const keyWithoutColon = key.slice(1);
41+
acc[keyWithoutColon] = value;
42+
}
43+
44+
return acc;
45+
}, {} as Record<string, string>);
5146
};

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

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,7 @@ export function getValue<
5050
.split(/[.[\]]/)
5151
.filter(Boolean)
5252
.reduce<GetFieldType<TData, TPath>>(
53-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5453
(value, key) => (value as any)?.[key],
55-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5654
data as any
5755
);
5856

@@ -79,7 +77,6 @@ export type Merge<T, U> = Omit<T, keyof U> & U;
7977
type Try<A, B, C> = A extends B ? A : C;
8078

8179
type NarrowRaw<T> =
82-
// eslint-disable-next-line @typescript-eslint/ban-types
8380
| (T extends Function ? T : never)
8481
| (T extends string | number | bigint | boolean ? T : never)
8582
| (T extends [] ? [] : never)

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
import { z, ZodTypeAny } from 'zod';
2-
import { AppRoute } from './dsl';
1+
import { z } from 'zod';
32

4-
const isZodObject = (
3+
export const isZodObject = (
54
body: unknown
6-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
75
): body is z.ZodObject<any, any, any, any> => {
8-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
96
return (body as z.ZodObject<any, any, any, any>)?.safeParse !== undefined;
107
};
118

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

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@ import {
55
AppRouteMutation,
66
AppRouteQuery,
77
AppRouter,
8-
isAppRoute,
8+
checkZodSchema,
99
getValue,
10+
isAppRoute,
11+
PathParamsWithCustomValidators,
1012
Without,
1113
ZodInferOrType,
12-
PathParams,
13-
checkZodSchema,
14-
Merge,
1514
} from '@ts-rest/core';
1615

1716
export type ApiRouteResponse<T> = {
@@ -24,7 +23,7 @@ export type ApiRouteResponse<T> = {
2423
type AppRouteQueryImplementation<T extends AppRouteQuery> = (
2524
input: Without<
2625
{
27-
params: PathParamsWithZod<T>;
26+
params: PathParamsWithCustomValidators<T>;
2827
query: ZodInferOrType<T['query']>;
2928
headers: IncomingHttpHeaders;
3029
req: Request;
@@ -38,17 +37,10 @@ type WithoutFileIfMultiPart<T extends AppRouteMutation> =
3837
? Without<ZodInferOrType<T['body']>, File>
3938
: ZodInferOrType<T['body']>;
4039

41-
/**
42-
* Merge PathParams<T> with pathParams schema if it exists
43-
*/
44-
type PathParamsWithZod<T extends AppRoute> = T['pathParams'] extends undefined
45-
? PathParams<T>
46-
: Merge<PathParams<T>, ZodInferOrType<T['pathParams']>>;
47-
4840
type AppRouteMutationImplementation<T extends AppRouteMutation> = (
4941
input: Without<
5042
{
51-
params: PathParamsWithZod<T>;
43+
params: PathParamsWithCustomValidators<T>;
5244
query: ZodInferOrType<T['query']>;
5345
body: WithoutFileIfMultiPart<T>;
5446
headers: IncomingHttpHeaders;
@@ -82,10 +74,8 @@ export const initServer = () => {
8274
};
8375

8476
const recursivelyApplyExpressRouter = (
85-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8677
router: RecursiveRouterObj<any> | AppRouteImplementation<any>,
8778
path: string[],
88-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8979
routeTransformer: (route: AppRouteImplementation<any>, path: string[]) => void
9080
): void => {
9181
if (typeof router === 'object') {
@@ -102,7 +92,6 @@ const recursivelyApplyExpressRouter = (
10292
};
10393

10494
const transformAppRouteQueryImplementation = (
105-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
10695
route: AppRouteQueryImplementation<any>,
10796
schema: AppRouteQuery,
10897
app: IRouter
@@ -124,9 +113,7 @@ const transformAppRouteQueryImplementation = (
124113
return res.status(400).send(paramsResult.error);
125114
}
126115

127-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
128116
const result = await route({
129-
// @ts-ignore
130117
params: paramsResult.data,
131118
query: queryResult.data,
132119
headers: req.headers,
@@ -138,7 +125,6 @@ const transformAppRouteQueryImplementation = (
138125
};
139126

140127
const transformAppRouteMutationImplementation = (
141-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
142128
route: AppRouteMutationImplementation<any>,
143129
schema: AppRouteMutation,
144130
app: IRouter
@@ -161,18 +147,22 @@ const transformAppRouteMutationImplementation = (
161147
return res.status(400).send(bodyResult.error);
162148
}
163149

150+
const paramsResult = checkZodSchema(req.params, schema.pathParams, {
151+
passThroughExtraKeys: true,
152+
});
153+
154+
if (!paramsResult.success) {
155+
return res.status(400).send(paramsResult.error);
156+
}
157+
164158
const result = await route({
165-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
166-
// @ts-ignore
167-
params: req.params,
159+
params: paramsResult.data,
168160
body: bodyResult.data,
169161
query: queryResult.data,
170162
headers: req.headers,
171-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
172-
// @ts-ignore
163+
// @ts-expect-error
173164
files: req.files,
174-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
175-
// @ts-ignore
165+
// @ts-expect-error
176166
file: req.file,
177167
req: req,
178168
});
@@ -220,7 +210,6 @@ export const createExpressEndpoints = <
220210
transformAppRouteMutationImplementation(route, routerViaPath, app);
221211
} else {
222212
transformAppRouteQueryImplementation(
223-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
224213
route as AppRouteQueryImplementation<any>,
225214
routerViaPath,
226215
app

libs/ts-rest/nest/src/lib/api.decorator.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
ExecutionContext,
88
Get,
99
HttpException,
10-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
1110
Injectable,
1211
NestInterceptor,
1312
Patch,
@@ -20,7 +19,7 @@ import {
2019
AppRouteMutation,
2120
checkZodSchema,
2221
getPathParamsFromUrl,
23-
PathParams,
22+
PathParamsWithCustomValidators,
2423
Without,
2524
ZodInferOrType,
2625
} from '@ts-rest/core';
@@ -33,7 +32,7 @@ type BodyWithoutFileIfMultiPart<T extends AppRouteMutation> =
3332

3433
export type ApiDecoratorShape<TRoute extends AppRoute> = Without<
3534
{
36-
params: PathParams<TRoute>;
35+
params: PathParamsWithCustomValidators<TRoute>;
3736
body: TRoute extends AppRouteMutation
3837
? BodyWithoutFileIfMultiPart<TRoute>
3938
: never;
@@ -54,7 +53,6 @@ const getQueryParams = (url: string): Record<string, string> => {
5453
};
5554

5655
export const ApiDecorator = createParamDecorator(
57-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
5856
(_: unknown, ctx: ExecutionContext): ApiDecoratorShape<any> => {
5957
const req = ctx.switchToHttp().getRequest();
6058

@@ -84,10 +82,17 @@ export const ApiDecorator = createParamDecorator(
8482
throw new BadRequestException(bodyResult.error);
8583
}
8684

85+
const pathParamsResult = checkZodSchema(pathParams, appRoute.pathParams, {
86+
passThroughExtraKeys: true,
87+
});
88+
89+
if (!pathParamsResult.success) {
90+
throw new BadRequestException(pathParamsResult.error);
91+
}
92+
8793
return {
8894
query: queryResult.data,
89-
// @ts-expect-error because the decorator shape is any
90-
params: pathParams,
95+
params: pathParamsResult.data,
9196
body: bodyResult.data,
9297
};
9398
}
@@ -112,7 +117,6 @@ const getMethodDecorator = (appRoute: AppRoute) => {
112117
export class ApiRouteInterceptor implements NestInterceptor {
113118
constructor(private readonly appRoute: AppRoute) {}
114119

115-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
116120
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
117121
const req = context.switchToHttp().getRequest();
118122

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { AppRoute, AppRouter, ApiRouteResponse, Without } from '@ts-rest/core';
22
import { ApiDecoratorShape } from './api.decorator';
33

44
type AppRouterMethodShape<T extends AppRoute> = (
5-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
65
...args: any[]
76
) => Promise<ApiRouteResponse<T['responses']>>;
87

0 commit comments

Comments
 (0)