Skip to content

Commit 1c53ac9

Browse files
Implement throwOnUnknownStatus to throw on unexpected responses (#228)
1 parent e48db26 commit 1c53ac9

5 files changed

Lines changed: 120 additions & 6 deletions

File tree

‎.changeset/wise-pumas-kick.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+
Add `throwOnUnknownStatus` to `initClient` configuration. When set to `true` the client will throw errors for all status codes returned by the server which are not defined in the contract.

‎libs/ts-rest/core/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export * from './lib/zod-utils';
88
export * from './lib/server';
99
export * from './lib/response-validation-error';
1010
export * from './lib/infer-types';
11+
export * from './lib/unknown-status-error';

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,4 +691,38 @@ describe('custom api', () => {
691691
})
692692
);
693693
});
694+
695+
it('has correct types when throwOnUnknownStatus is configured', async () => {
696+
const client = initClient(router, {
697+
baseUrl: 'https://api.com',
698+
baseHeaders: {
699+
'X-Api-Key': 'foo',
700+
},
701+
throwOnUnknownStatus: true,
702+
});
703+
704+
fetchMock.getOnce({ url: 'https://api.com/posts' }, { status: 200 });
705+
706+
const result = await client.posts.getPosts({});
707+
708+
type ClientGetPostsResponseStatusType = Expect<
709+
Equal<typeof result.status, 200>
710+
>;
711+
});
712+
713+
it('throws an error when throwOnUnknownStatus is configured and response is unknown', async () => {
714+
const client = initClient(router, {
715+
baseUrl: 'https://isolated.com',
716+
baseHeaders: {
717+
'X-Api-Key': 'foo',
718+
},
719+
throwOnUnknownStatus: true,
720+
});
721+
722+
fetchMock.getOnce({ url: 'https://isolated.com/posts' }, { status: 419 });
723+
724+
await expect(client.posts.getPosts({})).rejects.toThrowError(
725+
'Server returned unexpected response. Expected one of: 200 got: 419'
726+
);
727+
});
694728
});

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

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ZodInferOrType,
1414
ZodInputOrType,
1515
} from './type-utils';
16+
import { UnknownStatusError } from './unknown-status-error';
1617

1718
type RecursiveProxyObj<T extends AppRouter, TClientArgs extends ClientArgs> = {
1819
[TKey in keyof T]: T[TKey] extends AppRoute
@@ -22,6 +23,17 @@ type RecursiveProxyObj<T extends AppRouter, TClientArgs extends ClientArgs> = {
2223
: never;
2324
};
2425

26+
type RecursiveProxyObjNoUnknownStatus<
27+
T extends AppRouter,
28+
TClientArgs extends ClientArgs & { throwOnUnknownStatus: true }
29+
> = {
30+
[TKey in keyof T]: T[TKey] extends AppRoute
31+
? AppRouteFunctionNoUnknownStatus<T[TKey], TClientArgs>
32+
: T[TKey] extends AppRouter
33+
? RecursiveProxyObjNoUnknownStatus<T[TKey], TClientArgs>
34+
: never;
35+
};
36+
2537
type AppRouteMutationType<T> = ZodInputOrType<T>;
2638

2739
/**
@@ -96,13 +108,16 @@ type DataReturnArgs<
96108
Without<DataReturnArgsBase<TRoute, TClientArgs>, never>
97109
>;
98110

99-
export type ApiRouteResponse<T> =
111+
export type ApiRouteResponseNoUnknownStatus<T> =
100112
| {
101113
[K in keyof T]: {
102114
status: K;
103115
body: ZodInferOrType<T[K]>;
104116
};
105-
}[keyof T]
117+
}[keyof T];
118+
119+
export type ApiRouteResponse<T> =
120+
| ApiRouteResponseNoUnknownStatus<T>
106121
| {
107122
status: Exclude<HTTPStatusCode, keyof T>;
108123
body: unknown;
@@ -140,6 +155,22 @@ export type AppRouteFunction<
140155
args: Prettify<DataReturnArgs<TRoute, TClientArgs>>
141156
) => Promise<Prettify<ApiRouteResponse<TRoute['responses']>>>;
142157

158+
/**
159+
* Returned from a mutation or query call when NoUnknownStatus mode is enabled
160+
*/
161+
export type AppRouteFunctionNoUnknownStatus<
162+
TRoute extends AppRoute,
163+
TClientArgs extends ClientArgs
164+
> = AreAllPropertiesOptional<DataReturnArgs<TRoute, TClientArgs>> extends true
165+
? (
166+
args?: Prettify<DataReturnArgs<TRoute, TClientArgs>>
167+
) => Promise<Prettify<ApiRouteResponseNoUnknownStatus<TRoute['responses']>>>
168+
: (
169+
args: Prettify<DataReturnArgs<TRoute, TClientArgs>>
170+
) => Promise<
171+
Prettify<ApiRouteResponseNoUnknownStatus<TRoute['responses']>>
172+
>;
173+
143174
export interface ClientArgs {
144175
baseUrl: string;
145176
baseHeaders: Record<string, string>;
@@ -293,8 +324,9 @@ export const getCompleteUrl = (
293324

294325
export const getRouteQuery = <TAppRoute extends AppRoute>(
295326
route: TAppRoute,
296-
clientArgs: ClientArgs
327+
clientArgs: InitClientArgs
297328
) => {
329+
const knownResponseStatuses = Object.keys(route.responses);
298330
return async (inputArgs?: DataReturnArgsBase<any, ClientArgs>) => {
299331
const { query, params, body, headers, extraHeaders, ...extraInputArgs } =
300332
inputArgs || {};
@@ -307,7 +339,7 @@ export const getRouteQuery = <TAppRoute extends AppRoute>(
307339
!!clientArgs.jsonQuery
308340
);
309341

310-
return await fetchApi({
342+
const response = await fetchApi({
311343
path: completeUrl,
312344
clientArgs,
313345
route,
@@ -319,6 +351,15 @@ export const getRouteQuery = <TAppRoute extends AppRoute>(
319351
...headers,
320352
},
321353
});
354+
355+
if (!clientArgs.throwOnUnknownStatus) {
356+
return response;
357+
}
358+
359+
if (knownResponseStatuses.includes(response.status.toString())) {
360+
return response;
361+
}
362+
throw new UnknownStatusError(response, knownResponseStatuses);
322363
};
323364
};
324365

@@ -327,10 +368,28 @@ export type InitClientReturn<
327368
TClientArgs extends ClientArgs
328369
> = RecursiveProxyObj<T, TClientArgs>;
329370

330-
export const initClient = <T extends AppRouter, TClientArgs extends ClientArgs>(
371+
export type InitClientReturnNoUnknownStatus<
372+
T extends AppRouter,
373+
TClientArgs extends ClientArgs & { throwOnUnknownStatus: true }
374+
> = RecursiveProxyObjNoUnknownStatus<T, TClientArgs>;
375+
376+
export type InitClientArgs = ClientArgs & {
377+
/**
378+
* Ensures that the responses from the server match those defined in the
379+
* contract.
380+
*/
381+
throwOnUnknownStatus?: boolean;
382+
};
383+
384+
export const initClient = <
385+
T extends AppRouter,
386+
TClientArgs extends InitClientArgs
387+
>(
331388
router: T,
332389
args: TClientArgs
333-
): InitClientReturn<T, TClientArgs> => {
390+
): TClientArgs extends { throwOnUnknownStatus: true }
391+
? InitClientReturnNoUnknownStatus<T, TClientArgs>
392+
: InitClientReturn<T, TClientArgs> => {
334393
return Object.fromEntries(
335394
Object.entries(router).map(([key, subRouter]) => {
336395
if (isAppRoute(subRouter)) {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export class UnknownStatusError extends Error {
2+
response: { status: number; body: unknown };
3+
4+
constructor(
5+
response: { status: number; body: unknown },
6+
knownResponseStatuses: string[]
7+
) {
8+
const expectedStatuses = knownResponseStatuses.join(',');
9+
super(
10+
`Server returned unexpected response. Expected one of: ${expectedStatuses} got: ${response.status}`
11+
);
12+
13+
this.response = response;
14+
}
15+
}

0 commit comments

Comments
 (0)