Skip to content

Commit 23741d2

Browse files
authored
feat: Allow overriding status codes in response (#12)
1 parent 853f751 commit 23741d2

File tree

3 files changed

+131
-91
lines changed

3 files changed

+131
-91
lines changed

__tests__/router.test.ts

+107-85
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Crypto from 'crypto';
22

3-
import { createApp } from '../src';
3+
import { createApp, HttpStatusCode } from '../src';
44
import { unseal } from '../src/utils/encryptCookies';
55

66
declare module '../src' {
@@ -10,107 +10,129 @@ declare module '../src' {
1010
}
1111

1212
describe('router', () => {
13-
it('should set cookies', async () => {
14-
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
15-
path: '/users',
16-
method: 'get',
17-
validation: {},
18-
handler: async (_request, t) => {
19-
await t.setCookie('test', 'testowa', { encrypted: false });
20-
return null;
21-
},
22-
});
13+
describe('cookies', () => {
14+
it('should set cookies', async () => {
15+
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
16+
path: '/users',
17+
method: 'get',
18+
validation: {},
19+
handler: async (_request, t) => {
20+
await t.setCookie('test', 'testowa', { encrypted: false });
21+
return null;
22+
},
23+
});
2324

24-
const result = await app.inject({
25-
method: 'get',
26-
path: '/users',
25+
const result = await app.inject({
26+
method: 'get',
27+
path: '/users',
28+
});
29+
30+
expect(result.headers['set-cookie']).toEqual([`test=testowa; Path=/; HttpOnly; Secure; SameSite=Lax`]);
2731
});
2832

29-
expect(result.headers['set-cookie']).toEqual([`test=testowa; Path=/; HttpOnly; Secure; SameSite=Lax`]);
30-
});
33+
it('should set encrypted cookies', async () => {
34+
const secret = Crypto.randomBytes(22).toString('base64');
35+
const app = createApp({ cookies: { secret } }).route({
36+
path: '/users',
37+
method: 'get',
38+
validation: {},
39+
handler: async (_request, t) => {
40+
await t.setCookie('test', 'testowa', { encrypted: true });
41+
return null;
42+
},
43+
});
3144

32-
it('should set encrypted cookies', async () => {
33-
const secret = Crypto.randomBytes(22).toString('base64');
34-
const app = createApp({ cookies: { secret } }).route({
35-
path: '/users',
36-
method: 'get',
37-
validation: {},
38-
handler: async (_request, t) => {
39-
await t.setCookie('test', 'testowa', { encrypted: true });
40-
return null;
41-
},
42-
});
45+
const result = await app.inject({
46+
method: 'get',
47+
path: '/users',
48+
});
4349

44-
const result = await app.inject({
45-
method: 'get',
46-
path: '/users',
50+
expect(result.headers['set-cookie']).toHaveLength(1);
51+
const [key, val] = (result.headers['set-cookie'] as readonly [string])[0].split(';')[0]!.split('=');
52+
expect(key).toEqual('test');
53+
expect(await unseal({ sealed: val!, secret })).toEqual('testowa');
4754
});
4855

49-
expect(result.headers['set-cookie']).toHaveLength(1);
50-
const [key, val] = (result.headers['set-cookie'] as readonly [string])[0].split(';')[0]!.split('=');
51-
expect(key).toEqual('test');
52-
expect(await unseal({ sealed: val!, secret })).toEqual('testowa');
53-
});
56+
it('should read all cookies', async () => {
57+
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
58+
path: '/users',
59+
method: 'get',
60+
validation: {},
61+
handler: (request) => {
62+
expect(request.cookies['test']).toEqual('testowa');
63+
expect(request.cookies['secret']).toEqual('testowa');
64+
return null;
65+
},
66+
});
5467

55-
it('should read all cookies', async () => {
56-
const app = createApp({ cookies: { secret: 'a'.repeat(32) } }).route({
57-
path: '/users',
58-
method: 'get',
59-
validation: {},
60-
handler: (request) => {
61-
expect(request.cookies['test']).toEqual('testowa');
62-
expect(request.cookies['secret']).toEqual('testowa');
63-
return null;
64-
},
68+
await app.inject({
69+
method: 'get',
70+
path: '/users',
71+
cookies: [
72+
'test=testowa',
73+
'secret=Fe26.2**n0Jf_bWyQEP8IGbXE2USt82PE9W_dGIvOSlLTeKvwnA*a_fJJWNvbhjvvy0yBg2APw*PzP5xlZ63c6EImBsgDaZ7A**LF1tHWLrtR1pckVC9moT-V2b8LVmE_NYWfsgYqYhZwk*i40hiIKMSNNpwQUmd2HalR9KEvODTfDRPLah2H_q2JqdPg3ecHW6PvnJTC2YAHGUiwvIERWqE5LvaSjCyULvbQ',
74+
],
75+
});
76+
77+
expect.assertions(2);
6578
});
6679

67-
await app.inject({
68-
method: 'get',
69-
path: '/users',
70-
cookies: [
71-
'test=testowa',
72-
'secret=Fe26.2**n0Jf_bWyQEP8IGbXE2USt82PE9W_dGIvOSlLTeKvwnA*a_fJJWNvbhjvvy0yBg2APw*PzP5xlZ63c6EImBsgDaZ7A**LF1tHWLrtR1pckVC9moT-V2b8LVmE_NYWfsgYqYhZwk*i40hiIKMSNNpwQUmd2HalR9KEvODTfDRPLah2H_q2JqdPg3ecHW6PvnJTC2YAHGUiwvIERWqE5LvaSjCyULvbQ',
73-
],
80+
it('should not accept multiple params in path segment', () => {
81+
const app = createApp({});
82+
83+
return expect(() =>
84+
app.route({
85+
path: '/currencies/:from-:to',
86+
method: 'get',
87+
validation: {
88+
params: {} as any,
89+
},
90+
91+
handler: () => {
92+
return { message: 'OKEJ' };
93+
},
94+
}),
95+
).toThrowErrorMatchingInlineSnapshot(`"RouteValidationError: Each path segment can contain at most one param."`);
7496
});
7597

76-
expect.assertions(2);
98+
it('should not accept regexes', () => {
99+
const app = createApp({});
100+
101+
return expect(() =>
102+
app.route({
103+
path: '/currencies/:from(\\d+)',
104+
method: 'get',
105+
validation: {
106+
params: {} as any,
107+
},
108+
109+
handler: () => {
110+
return { message: 'OKEJ' };
111+
},
112+
}),
113+
).toThrowErrorMatchingInlineSnapshot(
114+
`"RouteValidationError: Don't use regular expressions in routes. Use validators instead."`,
115+
);
116+
});
77117
});
78118

79-
it('should not accept multiple params in path segment', () => {
80-
const app = createApp({});
81-
82-
return expect(() =>
83-
app.route({
84-
path: '/currencies/:from-:to',
119+
describe('status code', () => {
120+
it('should allow overriding status codes', async () => {
121+
const app = createApp({}).route({
122+
path: '/users',
85123
method: 'get',
86-
validation: {
87-
params: {} as any,
88-
},
89-
90-
handler: () => {
91-
return { message: 'OKEJ' };
124+
validation: {},
125+
handler: async (req, t) => {
126+
await t.setStatus(HttpStatusCode.Accepted);
127+
return null;
92128
},
93-
}),
94-
).toThrowErrorMatchingInlineSnapshot(`"RouteValidationError: Each path segment can contain at most one param."`);
95-
});
96-
97-
it('should not accept regexes', () => {
98-
const app = createApp({});
129+
});
99130

100-
return expect(() =>
101-
app.route({
102-
path: '/currencies/:from(\\d+)',
131+
const result = await app.inject({
103132
method: 'get',
104-
validation: {
105-
params: {} as any,
106-
},
107-
108-
handler: () => {
109-
return { message: 'OKEJ' };
110-
},
111-
}),
112-
).toThrowErrorMatchingInlineSnapshot(
113-
`"RouteValidationError: Don't use regular expressions in routes. Use validators instead."`,
114-
);
133+
path: '/users',
134+
});
135+
expect(result.statusCode).toEqual(202);
136+
});
115137
});
116138
});

src/modules/router.ts

+22-5
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ export const errorMiddleware =
9393
};
9494

9595
type AsyncHandler = (
96-
...args: Parameters<Express.Handler>
96+
req: Express.Request,
97+
res: Express.Response<any, ExpressResponseLocals>,
98+
next: Express.NextFunction,
9799
) => Promise<ReturnType<Express.Handler>> | ReturnType<Express.Handler>;
98100

99101
const finalErrorGuard = (h: AsyncHandler): AsyncHandler => {
@@ -241,28 +243,40 @@ export const routeToExpressHandler = <
241243
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- response is always Json or null
242244
server.events.emit(':response', result.value as Json | null);
243245

246+
const fallbackStatusCode =
247+
result.value === null || result.value === undefined ? HttpStatusCode.NoContent : HttpStatusCode.OK;
248+
const statusCode = res.locals[CUSTOM_STATUS_CODE] ?? fallbackStatusCode;
249+
244250
if (result.value === null) {
245-
res.status(204).end();
251+
res.status(statusCode).end();
246252
return;
247253
} else if (result.value === undefined) {
248254
console.warn(
249255
'Handler returned `undefined` which usually means you forgot to `await` something. If you want an empty response, return `null` instead.',
250256
);
251-
res.status(204).end();
257+
res.status(statusCode).end();
252258
return;
253259
} else {
254-
res.status(200).json(result.value);
260+
res.status(statusCode).json(result.value);
255261
return;
256262
}
257263
};
258264
};
259265

266+
export const CUSTOM_STATUS_CODE = Symbol('CUSTOM_STATUS_CODE');
267+
export interface ExpressResponseLocals {
268+
/* eslint-disable functional/prefer-readonly-type -- these are writable */
269+
[key: string]: any;
270+
[CUSTOM_STATUS_CODE]?: HttpStatusCode;
271+
/* eslint-enable functional/prefer-readonly-type */
272+
}
273+
260274
function createRequestToolkitFor({
261275
appOptions,
262276
res,
263277
}: {
264278
readonly req: Express.Request;
265-
readonly res: Express.Response;
279+
readonly res: Express.Response<any, ExpressResponseLocals>;
266280
readonly appOptions: AppOptions;
267281
}): TypeOfWebRequestToolkit {
268282
const toolkit: TypeOfWebRequestToolkit = {
@@ -280,6 +294,9 @@ function createRequestToolkitFor({
280294
const cookieOptions = deepMerge(options, appOptions.cookies);
281295
res.clearCookie(name, cookieOptions);
282296
},
297+
setStatus(statusCode: HttpStatusCode) {
298+
res.locals[CUSTOM_STATUS_CODE] = statusCode;
299+
},
283300
};
284301

285302
return toolkit;

src/modules/shared.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { TypeOfWebRequestMeta, TypeOfWebServerMeta, TypeOfWebEvents } from '..';
22
import type { Callback, Json, MaybeAsync } from '../utils/types';
33
import type { RequestId, ServerId } from '../utils/uniqueId';
4-
import type { HttpMethod } from './httpStatusCodes';
4+
import type { HttpMethod, HttpStatusCode } from './httpStatusCodes';
55
import type { TypeOfWebPlugin } from './plugins';
66
import type { ParseRouteParams } from './router';
77
import type { SchemaRecord, TypeOfRecord } from './validation';
@@ -40,6 +40,7 @@ interface AppOptionsCookies extends SetCookieOptions {
4040
export interface TypeOfWebRequestToolkit {
4141
setCookie(name: string, value: string, options?: SetCookieOptions): MaybeAsync<void>;
4242
removeCookie(name: string, options?: SetCookieOptions): MaybeAsync<void>;
43+
setStatus(statusCode: HttpStatusCode): MaybeAsync<void>;
4344
}
4445

4546
export interface SetCookieOptions {

0 commit comments

Comments
 (0)