Skip to content

Commit fc0adc6

Browse files
authored
feat(core): add contract option to define common responses (#558)
1 parent a36c3b0 commit fc0adc6

6 files changed

Lines changed: 356 additions & 22 deletions

File tree

.changeset/unlucky-crabs-fix.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 contract option to define common responses

apps/docs/docs/core/core.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,28 @@ export const contract = c.router({
165165
});
166166
```
167167

168+
### Common Responses
169+
170+
APIs often have shared common response schemas, specifically for error responses. You can define these common responses in the contract options.
171+
172+
```typescript
173+
const c = initContract();
174+
export const contract = c.router(
175+
{
176+
// ...endpoints
177+
},
178+
{
179+
commonResponses: {
180+
404: c.type<{ message: 'Not Found'; reason: string }>(),
181+
500: c.otherResponse({
182+
contentType: 'text/plain',
183+
body: z.literal('Server Error'),
184+
}),
185+
},
186+
}
187+
);
188+
```
189+
168190
### Strict Response Status Codes
169191

170192
To help with incremental adoption, ts-rest, by default, will allow any response status code to be returned from the server

apps/docs/docs/next.mdx

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ export default createNextRouter(contract, router);
4040

4141
`createNextRouter` is a function that takes a contract and a complete router, and creates endpoints, with the correct methods, paths and callbacks.
4242

43-
## Single Route Handler
44-
45-
It's also possible to create a handler for a single contract route
43+
It's also possible to create a handler for a single contract route.
4644

4745
```typescript
4846
// pages/api/posts/index.tsx
@@ -130,9 +128,3 @@ export class RequestValidationError extends Error {
130128
}
131129
}
132130
```
133-
134-
## Future Work
135-
136-
As this pattern doesn't support a lambda per endpoint, it is planned to provide a helper utility to allow individual endpoints to be created.
137-
138-
The downside of this is that it will not be possible to give a Typescript warning if the path is incorrect.

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

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,4 +789,157 @@ describe('contract', () => {
789789
Equal<(typeof contract.get.responses)['204'], ContractNoBodyType>
790790
>;
791791
});
792+
793+
it('should be typed correctly with merged common responses', () => {
794+
const contract = c.router(
795+
{
796+
posts: {
797+
getPost: {
798+
method: 'GET',
799+
path: '/posts/:id',
800+
responses: {
801+
200: z.object({
802+
id: z.number(),
803+
}),
804+
},
805+
},
806+
},
807+
},
808+
{
809+
commonResponses: {
810+
404: z.object({
811+
message: z.string(),
812+
}),
813+
},
814+
},
815+
);
816+
817+
type ContractShape = Expect<
818+
Equal<
819+
typeof contract,
820+
{
821+
posts: {
822+
getPost: {
823+
method: 'GET';
824+
path: '/posts/:id';
825+
responses: {
826+
200: z.ZodObject<
827+
{
828+
id: z.ZodNumber;
829+
},
830+
'strip',
831+
z.ZodTypeAny,
832+
{
833+
id: number;
834+
},
835+
{
836+
id: number;
837+
}
838+
>;
839+
404: z.ZodObject<
840+
{
841+
message: z.ZodString;
842+
},
843+
'strip',
844+
z.ZodTypeAny,
845+
{
846+
message: string;
847+
},
848+
{
849+
message: string;
850+
}
851+
>;
852+
};
853+
};
854+
};
855+
}
856+
>
857+
>;
858+
});
859+
860+
it('should be typed correctly with merged common responses and overriding common response', () => {
861+
const contract = c.router(
862+
{
863+
posts: {
864+
getPost: {
865+
method: 'GET',
866+
path: '/posts/:id',
867+
responses: {
868+
200: z.object({
869+
id: z.number(),
870+
}),
871+
400: z.object({
872+
overrideReason: z.string(),
873+
}),
874+
},
875+
},
876+
},
877+
},
878+
{
879+
commonResponses: {
880+
400: z.object({
881+
reason: z.string(),
882+
}),
883+
404: z.object({
884+
message: z.string(),
885+
}),
886+
},
887+
},
888+
);
889+
890+
type ContractShape = Expect<
891+
Equal<
892+
typeof contract,
893+
{
894+
posts: {
895+
getPost: {
896+
method: 'GET';
897+
path: '/posts/:id';
898+
responses: {
899+
200: z.ZodObject<
900+
{
901+
id: z.ZodNumber;
902+
},
903+
'strip',
904+
z.ZodTypeAny,
905+
{
906+
id: number;
907+
},
908+
{
909+
id: number;
910+
}
911+
>;
912+
400: z.ZodObject<
913+
{
914+
overrideReason: z.ZodString;
915+
},
916+
'strip',
917+
z.ZodTypeAny,
918+
{
919+
overrideReason: string;
920+
},
921+
{
922+
overrideReason: string;
923+
}
924+
>;
925+
404: z.ZodObject<
926+
{
927+
message: z.ZodString;
928+
},
929+
'strip',
930+
z.ZodTypeAny,
931+
{
932+
message: string;
933+
},
934+
{
935+
message: string;
936+
}
937+
>;
938+
};
939+
};
940+
};
941+
}
942+
>
943+
>;
944+
});
792945
});

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

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export type ContractOtherResponse<T extends ContractAnyType> = Opaque<
2626
'ContractOtherResponse'
2727
>;
2828

29+
export type AppRouteResponse =
30+
| ContractAnyType
31+
| ContractNoBodyType
32+
| ContractOtherResponse<ContractAnyType>;
33+
2934
type AppRouteCommon = {
3035
path: Path;
3136
pathParams?: ContractAnyType;
@@ -34,12 +39,7 @@ type AppRouteCommon = {
3439
summary?: string;
3540
description?: string;
3641
deprecated?: boolean;
37-
responses: Record<
38-
number,
39-
| ContractAnyType
40-
| ContractNoBodyType
41-
| ContractOtherResponse<ContractAnyType>
42-
>;
42+
responses: Record<number, AppRouteResponse>;
4343
strictStatusCodes?: boolean;
4444
metadata?: unknown;
4545

@@ -131,7 +131,7 @@ type UniversalMerge<A, B> = A extends z.AnyZodObject
131131
type ApplyOptions<
132132
TRoute extends AppRoute,
133133
TOptions extends RouterOptions,
134-
> = Omit<TRoute, 'headers' | 'path'> &
134+
> = Omit<TRoute, 'headers' | 'path' | 'responses'> &
135135
WithoutUnknown<{
136136
path: TOptions['pathPrefix'] extends string
137137
? `${TOptions['pathPrefix']}${TRoute['path']}`
@@ -142,6 +142,9 @@ type ApplyOptions<
142142
: TOptions['strictStatusCodes'] extends boolean
143143
? TOptions['strictStatusCodes']
144144
: unknown;
145+
responses: 'commonResponses' extends keyof TOptions
146+
? Prettify<Merge<TOptions['commonResponses'], TRoute['responses']>>
147+
: TRoute['responses'];
145148
}>;
146149

147150
/**
@@ -164,6 +167,7 @@ export type RouterOptions<TPrefix extends string = string> = {
164167
baseHeaders?: unknown;
165168
strictStatusCodes?: boolean;
166169
pathPrefix?: TPrefix;
170+
commonResponses?: Record<number, AppRouteResponse>;
167171

168172
/**
169173
* @deprecated Use `validateResponse` on the client options
@@ -210,12 +214,7 @@ type ContractInstance = {
210214
* a {@link AppRouter}
211215
*/
212216
mutation: <T extends AppRouteMutation>(mutation: NarrowObject<T>) => T;
213-
responses: <
214-
TResponses extends Record<
215-
number,
216-
ContractAnyType | ContractOtherResponse<ContractAnyType>
217-
>,
218-
>(
217+
responses: <TResponses extends Record<number, AppRouteResponse>>(
219218
responses: TResponses,
220219
) => TResponses;
221220
/**
@@ -270,6 +269,10 @@ const recursivelyApplyOptions = <T extends AppRouter>(
270269
validateResponseOnClient:
271270
value.validateResponseOnClient ??
272271
options?.validateResponseOnClient,
272+
responses: {
273+
...options?.commonResponses,
274+
...value.responses,
275+
},
273276
},
274277
];
275278
} else {

0 commit comments

Comments
 (0)