Skip to content

Commit 860e402

Browse files
authored
feat: allow server to send no response body (#547)
1 parent 487b2b6 commit 860e402

19 files changed

Lines changed: 451 additions & 19 deletions

.changeset/cuddly-experts-press.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@ts-rest/serverless': minor
3+
'@ts-rest/express': minor
4+
'@ts-rest/fastify': minor
5+
'@ts-rest/core': minor
6+
'@ts-rest/nest': minor
7+
'@ts-rest/next': minor
8+
---
9+
10+
Add contract definition for an absent body and handle accordingly on the server

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

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,10 @@ const postsRouter = c.router({
121121
deletePost: {
122122
method: 'DELETE',
123123
path: `/posts/:id`,
124+
body: c.noBody(),
124125
responses: {
125-
200: c.type<boolean>(),
126+
204: c.noBody(),
126127
},
127-
body: null,
128128
},
129129
});
130130

@@ -552,23 +552,22 @@ describe('client', () => {
552552
});
553553

554554
describe('delete', () => {
555-
it('w/ body', async () => {
556-
const value = { key: 'value' };
555+
it('w/ no body', async () => {
557556
fetchMock.deleteOnce(
558557
{
559558
url: 'https://api.com/posts/1',
560559
},
561-
{ body: value, status: 200 },
560+
{ status: 204 },
562561
);
563562

564563
const result = await client.posts.deletePost({
565564
params: { id: '1' },
566565
});
567566

568-
expect(result.body).toStrictEqual(value);
569-
expect(result.status).toBe(200);
570-
expect(result.headers.get('Content-Length')).toBe('15');
571-
expect(result.headers.get('Content-Type')).toBe('application/json');
567+
expect((result.body as Blob).size).toStrictEqual(0);
568+
expect(result.status).toBe(204);
569+
expect(result.headers.has('Content-Length')).toBe(false);
570+
expect(result.headers.has('Content-Type')).toBe(false);
572571
});
573572
});
574573

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ContractOtherResponse,
66
ContractPlainType,
77
ContractPlainTypeRuntimeSymbol,
8+
ContractNoBodyType,
89
} from './dsl';
910
import type { Equal, Expect } from './test-helpers';
1011

@@ -739,4 +740,20 @@ describe('contract', () => {
739740
>
740741
>;
741742
});
743+
744+
it('should set type correctly for no body', () => {
745+
const contract = c.router({
746+
get: {
747+
method: 'GET',
748+
path: '/',
749+
responses: {
750+
204: c.noBody(),
751+
},
752+
},
753+
});
754+
755+
type ResponseType = Expect<
756+
Equal<(typeof contract.get.responses)['204'], ContractNoBodyType>
757+
>;
758+
});
742759
});

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ type MixedZodError<A, B> = Opaque<{ a: A; b: B }, 'MixedZodError'>;
1111
type Path = string;
1212

1313
declare const NullSymbol: unique symbol;
14+
export const ContractNoBody = Symbol('ContractNoBody');
1415

1516
export type ContractPlainType<T> = Opaque<T, 'ContractPlainType'>;
1617
export type ContractNullType = Opaque<typeof NullSymbol, 'ContractNullType'>;
18+
export type ContractNoBodyType = typeof ContractNoBody;
1719
export type ContractAnyType =
1820
| z.ZodSchema
1921
| ContractPlainType<unknown>
@@ -34,7 +36,9 @@ type AppRouteCommon = {
3436
deprecated?: boolean;
3537
responses: Record<
3638
number,
37-
ContractAnyType | ContractOtherResponse<ContractAnyType>
39+
| ContractAnyType
40+
| ContractNoBodyType
41+
| ContractOtherResponse<ContractAnyType>
3842
>;
3943
strictStatusCodes?: boolean;
4044
metadata?: unknown;
@@ -62,7 +66,7 @@ export type AppRouteMutation = AppRouteCommon & {
6266
| 'application/json'
6367
| 'multipart/form-data'
6468
| 'application/x-www-form-urlencoded';
65-
body: ContractAnyType;
69+
body: ContractAnyType | ContractNoBodyType;
6670
};
6771

6872
type ValidatedHeaders<
@@ -236,6 +240,8 @@ type ContractInstance = {
236240
contentType: string;
237241
body: T;
238242
}) => ContractOtherResponse<T>;
243+
/** Use to indicate that a route takes no body or responds with no body */
244+
noBody: () => ContractNoBodyType;
239245
};
240246

241247
/**
@@ -303,5 +309,6 @@ export const initContract = (): ContractInstance => {
303309
contentType,
304310
body,
305311
}) as ContractOtherResponse<T>,
312+
noBody: () => ContractNoBody,
306313
};
307314
};

libs/ts-rest/core/src/lib/infer-types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
AppRouter,
55
AppRouteStrictStatusCodes,
66
ContractAnyType,
7+
ContractNoBodyType,
78
ContractOtherResponse,
89
} from './dsl';
910
import { HTTPStatusCode } from './status-codes';
@@ -60,7 +61,10 @@ type PathParamsWithCustomValidators<
6061
>;
6162

6263
export type ResolveResponseType<
63-
T extends ContractAnyType | ContractOtherResponse<ContractAnyType>,
64+
T extends
65+
| ContractAnyType
66+
| ContractNoBodyType
67+
| ContractOtherResponse<ContractAnyType>,
6468
> = T extends ContractOtherResponse<infer U> ? U : T;
6569

6670
type AppRouteResponses<

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { HTTPStatusCode } from './status-codes';
22
import { checkZodSchema } from './zod-utils';
33
import { ResponseValidationError } from './response-validation-error';
4-
import { AppRoute, ContractAnyType, ContractOtherResponse } from './dsl';
4+
import {
5+
AppRoute,
6+
ContractAnyType,
7+
ContractNoBody,
8+
ContractNoBodyType,
9+
ContractOtherResponse,
10+
} from './dsl';
511

612
export const isAppRouteResponse = (
713
value: unknown,
@@ -15,7 +21,10 @@ export const isAppRouteResponse = (
1521
};
1622

1723
export const isAppRouteOtherResponse = (
18-
response: ContractAnyType | ContractOtherResponse<ContractAnyType>,
24+
response:
25+
| ContractAnyType
26+
| ContractNoBodyType
27+
| ContractOtherResponse<ContractAnyType>,
1928
): response is ContractOtherResponse<ContractAnyType> => {
2029
return (
2130
response != null &&
@@ -24,6 +33,15 @@ export const isAppRouteOtherResponse = (
2433
);
2534
};
2635

36+
export const isAppRouteNoBody = (
37+
response:
38+
| ContractAnyType
39+
| ContractNoBodyType
40+
| ContractOtherResponse<ContractAnyType>,
41+
): response is ContractNoBodyType => {
42+
return response === ContractNoBody;
43+
};
44+
2745
export const validateResponse = ({
2846
appRoute,
2947
response,

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod';
2-
import { ContractNullType, ContractPlainType } from './dsl';
2+
import { ContractNoBodyType, ContractNullType, ContractPlainType } from './dsl';
33

44
type GetIndexedField<T, K> = K extends keyof T
55
? T[K]
@@ -53,6 +53,8 @@ export type With<T, V> = Pick<T, ExcludeKeysWithoutTypeOf<T, V>>;
5353

5454
export type ZodInferOrType<T> = T extends ContractNullType
5555
? null
56+
: T extends ContractNoBodyType
57+
? undefined
5658
: T extends ContractPlainType<infer U>
5759
? U
5860
: T extends z.ZodTypeAny
@@ -61,6 +63,8 @@ export type ZodInferOrType<T> = T extends ContractNullType
6163

6264
export type ZodInputOrType<T> = T extends ContractNullType
6365
? null
66+
: T extends ContractNoBodyType
67+
? undefined
6468
: T extends ContractPlainType<infer U>
6569
? U
6670
: T extends z.ZodTypeAny

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,60 @@ describe('ts-rest-express', () => {
180180
'text/css; charset=utf-8',
181181
);
182182
});
183+
184+
it('should handle no content body', async () => {
185+
const c = initContract();
186+
187+
const contract = c.router({
188+
noContent: {
189+
method: 'POST',
190+
path: '/:status',
191+
pathParams: z.object({
192+
status: z.coerce
193+
.number()
194+
.pipe(z.union([z.literal(200), z.literal(204)])),
195+
}),
196+
body: c.noBody(),
197+
responses: {
198+
200: c.noBody(),
199+
204: c.noBody(),
200+
},
201+
},
202+
});
203+
204+
const server = initServer();
205+
const router = server.router(contract, {
206+
noContent: async ({ params }) => {
207+
return {
208+
status: params.status,
209+
body: undefined,
210+
};
211+
},
212+
});
213+
214+
const app = express();
215+
app.use(express.json());
216+
app.use(express.urlencoded({ extended: true }));
217+
createExpressEndpoints(contract, router, app);
218+
219+
await supertest(app)
220+
.post('/200')
221+
.expect((res) => {
222+
expect(res.status).toEqual(200);
223+
expect(res.text).toEqual('');
224+
expect(res.header['content-type']).toBeUndefined();
225+
expect(res.header['content-length']).toStrictEqual('0');
226+
});
227+
228+
await supertest(app)
229+
.post('/204')
230+
.expect((res) => {
231+
expect(res.status).toEqual(204);
232+
expect(res.text).toEqual('');
233+
expect(res.header['content-type']).toBeUndefined();
234+
expect(res.header['content-length']).toBeUndefined();
235+
});
236+
});
183237
});
184238

185239
describe('download', () => {

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
AppRouter,
66
checkZodSchema,
77
isAppRoute,
8+
isAppRouteNoBody,
89
isAppRouteOtherResponse,
910
parseJsonQueryObject,
1011
validateResponse,
@@ -179,6 +180,11 @@ const initializeExpressRoute = ({
179180
}
180181

181182
const responseType = schema.responses[statusCode];
183+
184+
if (isAppRouteNoBody(responseType)) {
185+
return res.status(statusCode).end();
186+
}
187+
182188
if (isAppRouteOtherResponse(responseType)) {
183189
res.setHeader('content-type', responseType.contentType);
184190
return res.status(statusCode).send(validatedResponseBody);

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ const contract = c.router({
2828
}),
2929
},
3030
},
31+
noContent: {
32+
method: 'POST',
33+
path: '/no-content',
34+
body: c.noBody(),
35+
responses: {
36+
204: c.noBody(),
37+
},
38+
},
3139
testPathParams: {
3240
method: 'GET',
3341
path: '/test/:id',
@@ -73,6 +81,12 @@ describe('ts-rest-fastify', () => {
7381
},
7482
};
7583
}),
84+
noContent: async () => {
85+
return {
86+
status: 204,
87+
body: undefined,
88+
};
89+
},
7690
testPathParams: async ({ params }) => {
7791
return {
7892
status: 200,
@@ -183,6 +197,23 @@ describe('ts-rest-fastify', () => {
183197
});
184198
});
185199

200+
it('should handle no content response', async () => {
201+
const app = fastify({ logger: false });
202+
203+
s.registerRouter(contract, router, app, {
204+
logInitialization: false,
205+
});
206+
207+
await app.ready();
208+
209+
const response = await supertest(app.server).post('/no-content');
210+
211+
expect(response.statusCode).toEqual(204);
212+
expect(response.text).toEqual('');
213+
expect(response.header['content-type']).toBeUndefined();
214+
expect(response.header['content-length']).toBeUndefined();
215+
});
216+
186217
it("should allow for custom error handler if body doesn't match", async () => {
187218
const app = fastify({ logger: false });
188219

@@ -433,6 +464,9 @@ describe('ts-rest-fastify', () => {
433464
ping: async () => {
434465
throw new Error('not implemented');
435466
},
467+
noContent: async () => {
468+
throw new Error('not implemented');
469+
},
436470
testPathParams: async () => {
437471
throw new Error('not implemented');
438472
},

0 commit comments

Comments
 (0)