Skip to content

Commit 83f6675

Browse files
authored
feat(core): do not require body for DELETE endpoints in contracts (#672)
1 parent 6b5c36e commit 83f6675

8 files changed

Lines changed: 49 additions & 12 deletions

File tree

.changeset/late-bugs-destroy.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+
Do not require `body` to be defined for DELETE endpoints in contracts

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ const postsRouter = c.router({
126126
204: c.noBody(),
127127
},
128128
},
129+
deletePostUndefinedBody: {
130+
method: 'DELETE',
131+
path: `/posts/:id`,
132+
responses: {
133+
204: c.noBody(),
134+
},
135+
},
129136
});
130137

131138
// Three endpoints, two for posts, and one for health
@@ -610,6 +617,24 @@ describe('client', () => {
610617
expect(result.headers.has('Content-Length')).toBe(false);
611618
expect(result.headers.has('Content-Type')).toBe(false);
612619
});
620+
621+
it('w/ undefined body', async () => {
622+
fetchMock.deleteOnce(
623+
{
624+
url: 'https://api.com/posts/1',
625+
},
626+
{ status: 204 },
627+
);
628+
629+
const result = await client.posts.deletePostUndefinedBody({
630+
params: { id: '1' },
631+
});
632+
633+
expect((result.body as Blob).size).toStrictEqual(0);
634+
expect(result.status).toBe(204);
635+
expect(result.headers.has('Content-Length')).toBe(false);
636+
expect(result.headers.has('Content-Type')).toBe(false);
637+
});
613638
});
614639

615640
describe('multipart/form-data', () => {

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,16 @@ export const fetchApi = (options: FetchApiOptions) => {
263263
};
264264

265265
if (route.method !== 'GET') {
266-
if (route.contentType === 'multipart/form-data') {
266+
if ('contentType' in route && route.contentType === 'multipart/form-data') {
267267
fetcherArgs = {
268268
...fetcherArgs,
269269
contentType: 'multipart/form-data',
270270
body: body instanceof FormData ? body : createFormData(body),
271271
};
272-
} else if (route.contentType === 'application/x-www-form-urlencoded') {
272+
} else if (
273+
'contentType' in route &&
274+
route.contentType === 'application/x-www-form-urlencoded'
275+
) {
273276
fetcherArgs = {
274277
...fetcherArgs,
275278
contentType: 'application/x-www-form-urlencoded',

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export type AppRouteMutation = AppRouteCommon & {
6969
body: ContractAnyType | ContractNoBodyType;
7070
};
7171

72+
/**
73+
* A mutation endpoint. In REST terms, one using POST, PUT,
74+
* PATCH, or DELETE.
75+
*/
76+
export type AppRouteDeleteNoBody = AppRouteCommon & {
77+
method: 'DELETE';
78+
};
79+
7280
type ValidatedHeaders<
7381
T extends AppRoute,
7482
TOptions extends RouterOptions,
@@ -158,7 +166,7 @@ type ApplyOptions<
158166
/**
159167
* A union of all possible endpoint types.
160168
*/
161-
export type AppRoute = AppRouteQuery | AppRouteMutation;
169+
export type AppRoute = AppRouteQuery | AppRouteMutation | AppRouteDeleteNoBody;
162170
export type AppRouteStrictStatusCodes = Omit<AppRoute, 'strictStatusCodes'> & {
163171
strictStatusCodes: true;
164172
};

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import {
22
AppRoute,
3-
AppRouteMutation,
4-
AppRouteQuery,
53
AppRouter,
64
checkZodSchema,
75
HTTPStatusCode,
@@ -88,7 +86,7 @@ const recursivelyApplyExpressRouter = ({
8886
const validateRequest = (
8987
req: Request,
9088
res: Response,
91-
schema: AppRouteQuery | AppRouteMutation,
89+
schema: AppRoute,
9290
options: TsRestExpressOptions<AppRouter>,
9391
) => {
9492
const paramsResult = checkZodSchema(req.params, schema.pathParams, {

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

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import {
22
AppRoute,
3-
AppRouteMutation,
4-
AppRouteQuery,
53
AppRouter,
64
checkZodSchema,
75
FlattenAppRouter,
@@ -143,7 +141,7 @@ const isAppRouteImplementation = <TRoute extends AppRoute>(
143141
const validateRequest = (
144142
request: fastify.FastifyRequest,
145143
reply: fastify.FastifyReply,
146-
schema: AppRouteQuery | AppRouteMutation,
144+
schema: AppRoute,
147145
options: BaseRegisterRouterOptions,
148146
) => {
149147
const paramsResult = checkZodSchema(request.params, schema.pathParams, {

libs/ts-rest/open-api/src/lib/ts-rest-open-api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export const generateOpenApi = (
236236
);
237237

238238
const bodySchema =
239-
path.route?.method !== 'GET'
239+
path.route?.method !== 'GET' && 'body' in path.route
240240
? getOpenApiSchemaFromZod(path.route.body)
241241
: null;
242242

@@ -268,7 +268,7 @@ export const generateOpenApi = (
268268
);
269269

270270
const contentType =
271-
path.route?.method !== 'GET'
271+
path.route?.method !== 'GET' && 'contentType' in path.route
272272
? path.route?.contentType ?? 'application/json'
273273
: 'application/json';
274274

libs/ts-rest/serverless/src/lib/router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ const recursivelyProcessContract = ({
7171

7272
const validateRequest = <TPlatformArgs, TRequestExtension>(
7373
req: TsRestRequest,
74-
schema: AppRouteQuery | AppRouteMutation,
74+
schema: AppRoute,
7575
options: ServerlessHandlerOptions<TPlatformArgs, TRequestExtension>,
7676
) => {
7777
const paramsResult = checkZodSchema(req.params, schema.pathParams, {

0 commit comments

Comments
 (0)