Skip to content

Commit c056132

Browse files
feat: add response validation to the client (#373)
* feat: automatic validating the schema on the client * feat: add a flag to enable/disable the client validation behaviour * chore: add minor changeset to the ts-rest/core package * Update afraid-eagles-report.md * docs: add guidelines for the `validateResponseOnClient` option * docs: add notes for those using zod as the schema validator * docs: move the caution for schema validation to the correct spot --------- Co-authored-by: Michael Angelo Rivera <55844504+michaelangeloio@users.noreply.github.com>
1 parent c00987d commit c056132

4 files changed

Lines changed: 120 additions & 32 deletions

File tree

.changeset/afraid-eagles-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/core': minor
3+
---
4+
5+
feat: add support for client-side response validation against contract schemas for `@ts-rest/core`

apps/docs/docs/core/core.md

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,16 @@ This will force the client to always pass
9090

9191
```typescript
9292
const c = initContract();
93-
export const contract = c.router({
94-
// ...endpoints
95-
}, {
96-
baseHeaders: z.object({
97-
authorization: z.string(),
98-
}),
99-
});
93+
export const contract = c.router(
94+
{
95+
// ...endpoints
96+
},
97+
{
98+
baseHeaders: z.object({
99+
authorization: z.string(),
100+
}),
101+
}
102+
);
100103
```
101104

102105
## Responses
@@ -165,6 +168,55 @@ export const contract = c.router({
165168
});
166169
```
167170

171+
### Schema validation on the client
172+
173+
By default, all responses are inferred at the type-level by the client using the contract, and are not validated at runtime.
174+
175+
However, you can use the `validateResponseOnClient` option to validate the response at runtime by checking it against the defined schema associated with the status code in the contract. By default, this option is set to `false`.
176+
177+
If you would like to enable this functionality for all routes in the contract, you can set the `validateResponseOnClient` option to `true` when initializing the contract.
178+
179+
```typescript
180+
const c = initContract();
181+
export const contract = c.router({
182+
{
183+
// ...endpoints
184+
},
185+
{
186+
validateResponseOnClient: true,
187+
}
188+
});
189+
```
190+
191+
You can also control this option on a per-route basis which will also override the globally set option.
192+
193+
```typescript
194+
const c = initContract();
195+
export const contract = c.router({
196+
getPosts: {
197+
...,
198+
validateResponseOnClient: true,
199+
}
200+
});
201+
```
202+
203+
:::caution
204+
When using `zod` as the schema, should the validation fail, the error will be thrown as a `ZodError`.
205+
206+
You can catch this error and handle it however you like.
207+
208+
```typescript
209+
try {
210+
const posts = await client.getPosts();
211+
} catch (error) {
212+
if (error instanceof ZodError) {
213+
// handle error
214+
}
215+
}
216+
```
217+
218+
:::
219+
168220
## Combining Contracts
169221

170222
You can combine contracts to create a single contract, helpful if you want many sub-contracts, especially if they are huge.
@@ -254,13 +306,16 @@ You can assign `baseHeaders` which will be merged with the contract `headers`. H
254306

255307
```typescript
256308
const c = initContract();
257-
export const contract = c.router({
258-
// ...endpoints
259-
}, {
260-
baseHeaders: z.object({
309+
export const contract = c.router(
310+
{
311+
// ...endpoints
312+
},
313+
{
314+
baseHeaders: z.object({
261315
authorization: z.string(),
262316
}),
263-
});
317+
}
318+
);
264319
```
265320

266321
### Path Prefix

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

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ import {
88
ClientInferResponses,
99
PartialClientInferRequest,
1010
NextClientArgs,
11-
Frameworks
11+
Frameworks,
1212
} from './infer-types';
1313

1414
type RecursiveProxyObj<T extends AppRouter, TClientArgs extends ClientArgs> = {
1515
[TKey in keyof T]: T[TKey] extends AppRoute
16-
? AppRouteFunction<T[TKey], TClientArgs>
17-
: T[TKey] extends AppRouter
18-
? RecursiveProxyObj<T[TKey], TClientArgs>
19-
: never;
16+
? AppRouteFunction<T[TKey], TClientArgs>
17+
: T[TKey] extends AppRouter
18+
? RecursiveProxyObj<T[TKey], TClientArgs>
19+
: never;
2020
};
2121

2222
/**
@@ -90,7 +90,8 @@ export const tsRestFetchApi: ApiFetcher = async ({
9090
credentials,
9191
signal,
9292
cache,
93-
next
93+
next,
94+
route,
9495
}) => {
9596
const result = await fetch(path, {
9697
method,
@@ -99,16 +100,31 @@ export const tsRestFetchApi: ApiFetcher = async ({
99100
credentials,
100101
signal,
101102
cache,
102-
next
103+
next,
103104
// we must type cast here because the typescript types for RequestInit
104105
// do not include properties like "next" for Frameworks (like Nextjs)
105106
} as RequestInit);
106107
const contentType = result.headers.get('content-type');
107108

108-
if (contentType?.includes("application/") && contentType?.includes('json')) {
109+
if (contentType?.includes('application/') && contentType?.includes('json')) {
110+
if (!route.validateResponseOnClient) {
111+
return {
112+
status: result.status,
113+
body: await result.json(),
114+
headers: result.headers,
115+
};
116+
}
117+
118+
const jsonData = await result.json();
119+
const statusCode = result.status;
120+
const response = route.responses[statusCode];
121+
109122
return {
110-
status: result.status,
111-
body: await result.json(),
123+
status: statusCode,
124+
body:
125+
response && typeof response !== 'symbol' && 'parse' in response
126+
? response?.parse(jsonData)
127+
: jsonData,
112128
headers: result.headers,
113129
};
114130
}
@@ -157,7 +173,7 @@ export const fetchApi = ({
157173
extraInputArgs,
158174
headers,
159175
signal,
160-
next
176+
next,
161177
}: {
162178
path: string;
163179
clientArgs: ClientArgs;
@@ -243,21 +259,31 @@ export const getCompleteUrl = (
243259
return `${baseUrl}${path}${queryComponent}`;
244260
};
245261

246-
export const getRouteQuery = <TAppRoute extends AppRoute, Framework extends Frameworks = 'none'>(
262+
export const getRouteQuery = <
263+
TAppRoute extends AppRoute,
264+
Framework extends Frameworks = 'none'
265+
>(
247266
route: TAppRoute,
248-
clientArgs: InitClientArgs,
267+
clientArgs: InitClientArgs
249268
) => {
250269
const knownResponseStatuses = Object.keys(route.responses);
251270
return async (
252-
inputArgs?: Framework extends 'nextjs' ?
253-
ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'>
271+
inputArgs?: Framework extends 'nextjs'
272+
? ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'>
254273
: ClientInferRequest<AppRouteMutation, ClientArgs>
255274
) => {
256-
const { query, params, body, headers, extraHeaders, next, ...extraInputArgs } =
275+
const {
276+
query,
277+
params,
278+
body,
279+
headers,
280+
extraHeaders,
281+
next,
282+
...extraInputArgs
283+
} =
257284
// ---- Merge all framework Request infer types ----
258-
inputArgs as ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'> &
259-
ClientInferRequest<AppRouteMutation, ClientArgs>
260-
|| {};
285+
(inputArgs as ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'> &
286+
ClientInferRequest<AppRouteMutation, ClientArgs>) || {};
261287

262288
const completeUrl = getCompleteUrl(
263289
query,

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ declare const NullSymbol: unique symbol;
1515
export type ContractPlainType<T> = Opaque<T, 'ContractPlainType'>;
1616
export type ContractNullType = Opaque<typeof NullSymbol, 'ContractNullType'>;
1717
export type ContractAnyType =
18-
| z.ZodTypeAny
18+
| z.ZodSchema
1919
| ContractPlainType<unknown>
2020
| ContractNullType
2121
| null;
@@ -38,6 +38,7 @@ type AppRouteCommon = {
3838
>;
3939
strictStatusCodes?: boolean;
4040
metadata?: unknown;
41+
validateResponseOnClient?: boolean;
4142
};
4243

4344
/**
@@ -152,6 +153,7 @@ export type RouterOptions<TPrefix extends string = string> = {
152153
baseHeaders?: unknown;
153154
strictStatusCodes?: boolean;
154155
pathPrefix?: TPrefix;
156+
validateResponseOnClient?: boolean;
155157
};
156158

157159
/**

0 commit comments

Comments
 (0)