Skip to content

Commit bfec125

Browse files
barylaGabrola
andauthored
feat(fastify): add hooks support (#265)
Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent 72d356c commit bfec125

6 files changed

Lines changed: 425 additions & 31 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/fastify': minor
3+
---
4+
5+
Add option to define hooks on app level or route level

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ export type AppRouter = {
166166
[key: string]: AppRouter | AppRoute;
167167
};
168168

169+
export type FlattenAppRouter<T extends AppRouter | AppRoute> =
170+
T extends AppRoute
171+
? T
172+
: {
173+
[TKey in keyof T]: T[TKey] extends AppRoute
174+
? T[TKey]
175+
: T[TKey] extends AppRouter
176+
? FlattenAppRouter<T[TKey]>
177+
: never;
178+
}[keyof T];
179+
169180
export type RouterOptions<TPrefix extends string = string> = {
170181
baseHeaders?: unknown;
171182
strictStatusCodes?: boolean;

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

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
AppRouteMutation,
44
AppRouteQuery,
55
AppRouter,
6+
FlattenAppRouter,
67
ServerInferRequest,
78
ServerInferResponseBody,
89
ServerInferResponses,
@@ -102,13 +103,3 @@ export type TsRestExpressOptions<T extends AppRouter> = {
102103
next: NextFunction,
103104
) => void);
104105
};
105-
106-
type FlattenAppRouter<T extends AppRouter | AppRoute> = T extends AppRoute
107-
? T
108-
: {
109-
[TKey in keyof T]: T[TKey] extends AppRoute
110-
? T[TKey]
111-
: T[TKey] extends AppRouter
112-
? FlattenAppRouter<T[TKey]>
113-
: never;
114-
}[keyof T];

libs/ts-rest/fastify/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { exclude: _, ...swcJestConfig } = JSON.parse(
77
readFileSync(`${__dirname}/.lib.swcrc`, 'utf-8')
88
);
99
export default {
10+
testEnvironment: 'node',
1011
displayName: 'ts-rest-fastify',
1112
preset: '../../../jest.preset.js',
1213
transform: {

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

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { z } from 'zod';
44
import fastify from 'fastify';
55
import * as supertest from 'supertest';
66

7+
declare module 'fastify' {
8+
interface FastifyReply {
9+
errorMessage?: string;
10+
}
11+
}
12+
713
const c = initContract();
814

915
const contract = c.router({
@@ -793,4 +799,262 @@ describe('ts-rest-fastify', () => {
793799
expect(res.body).toEqual({ message: 'Not found' });
794800
});
795801
});
802+
803+
it('should be able to use a hook on a single endpoint', async () => {
804+
const contract = c.router({
805+
getMe: {
806+
method: 'GET',
807+
path: '/me',
808+
responses: { 200: z.boolean() },
809+
},
810+
});
811+
812+
const router = s.router(contract, {
813+
getMe: {
814+
hooks: {
815+
preValidation: async (request, reply) => {
816+
reply.status(401).send({ message: 'Unauthorized' });
817+
},
818+
},
819+
async handler() {
820+
return { status: 200, body: true };
821+
},
822+
},
823+
});
824+
825+
const app = fastify();
826+
app.register(s.plugin(router));
827+
828+
await app.ready();
829+
830+
const response = await supertest(app.server).get('/me');
831+
832+
expect(response.statusCode).toEqual(401);
833+
expect(response.body).toEqual({ message: 'Unauthorized' });
834+
});
835+
836+
it('should be able to use array of hooks on a single endpoint', async () => {
837+
const contract = c.router({
838+
getMe: {
839+
method: 'GET',
840+
path: '/me',
841+
responses: { 200: z.boolean() },
842+
},
843+
});
844+
845+
const router = s.router(contract, {
846+
getMe: {
847+
hooks: {
848+
preValidation: [
849+
async (request, reply) => {
850+
reply.errorMessage = 'Unauthorized';
851+
},
852+
async (request, reply) => {
853+
reply.status(401).send({ message: reply.errorMessage });
854+
},
855+
],
856+
},
857+
async handler() {
858+
return { status: 200, body: true };
859+
},
860+
},
861+
});
862+
863+
const app = fastify();
864+
app.register(s.plugin(router));
865+
866+
await app.ready();
867+
868+
const response = await supertest(app.server).get('/me');
869+
870+
expect(response.statusCode).toEqual(401);
871+
expect(response.body).toEqual({ message: 'Unauthorized' });
872+
});
873+
874+
it('should be able to use multiple hooks on a single endpoint', async () => {
875+
let calledTimes = 0;
876+
const contract = c.router({
877+
getMe: {
878+
method: 'GET',
879+
path: '/me',
880+
responses: { 200: z.boolean() },
881+
},
882+
});
883+
884+
const router = s.router(contract, {
885+
getMe: {
886+
hooks: {
887+
preValidation: async () => {
888+
calledTimes += 1;
889+
},
890+
onRequest: [
891+
async () => {
892+
calledTimes += 1;
893+
},
894+
(_, __, done) => {
895+
calledTimes += 1;
896+
done();
897+
},
898+
],
899+
},
900+
async handler() {
901+
return { status: 200, body: true };
902+
},
903+
},
904+
});
905+
906+
const app = fastify();
907+
app.register(s.plugin(router));
908+
909+
await app.ready();
910+
911+
const response = await supertest(app.server).get('/me');
912+
913+
expect(response.statusCode).toEqual(200);
914+
expect(response.body).toBeTruthy();
915+
expect(calledTimes).toEqual(3);
916+
});
917+
918+
it('should be able to use a global hook', async () => {
919+
const contract = c.router({
920+
getMe: {
921+
method: 'GET',
922+
path: '/me',
923+
responses: { 200: z.boolean() },
924+
},
925+
});
926+
927+
const router = s.router(contract, {
928+
getMe: {
929+
async handler() {
930+
return { status: 200, body: true };
931+
},
932+
},
933+
});
934+
935+
const fn = jest.fn();
936+
937+
const app = fastify();
938+
app.register(s.plugin(router), {
939+
hooks: {
940+
onRoute: async (routeOptions) => {
941+
fn({
942+
method: routeOptions.method,
943+
path: routeOptions.config?.tsRestRoute.path,
944+
});
945+
},
946+
onRequest: async (request, reply) => {
947+
reply.status(401).send({ message: 'Unauthorized' });
948+
},
949+
},
950+
});
951+
952+
await app.ready();
953+
954+
expect(fn.mock.calls).toEqual([
955+
[{ method: 'GET', path: '/me' }],
956+
[{ method: 'HEAD', path: '/me' }],
957+
]);
958+
959+
const response = await supertest(app.server).get('/me');
960+
961+
expect(response.statusCode).toEqual(401);
962+
expect(response.body).toEqual({ message: 'Unauthorized' });
963+
});
964+
965+
it('should be able to use a global hook array', async () => {
966+
const contract = c.router({
967+
getMe: {
968+
method: 'GET',
969+
path: '/me',
970+
responses: { 200: z.boolean() },
971+
},
972+
});
973+
974+
const router = s.router(contract, {
975+
getMe: {
976+
async handler() {
977+
return { status: 200, body: true };
978+
},
979+
},
980+
});
981+
982+
const fn = jest.fn();
983+
984+
const app = fastify();
985+
app.decorateReply('errorMessage', undefined);
986+
app.register(s.plugin(router), {
987+
hooks: {
988+
onRoute: [
989+
async (routeOptions) => {
990+
fn(routeOptions.method);
991+
},
992+
async (routeOptions) => {
993+
fn(routeOptions.config?.tsRestRoute.path);
994+
},
995+
],
996+
onRequest: [
997+
async (request, reply) => {
998+
reply.errorMessage = 'Unauthorized';
999+
},
1000+
async (request, reply) => {
1001+
reply.status(401).send({ message: reply.errorMessage });
1002+
},
1003+
],
1004+
},
1005+
});
1006+
1007+
await app.ready();
1008+
1009+
expect(fn.mock.calls).toEqual([['GET'], ['/me'], ['HEAD'], ['/me']]);
1010+
1011+
const response = await supertest(app.server).get('/me');
1012+
1013+
expect(response.statusCode).toEqual(401);
1014+
expect(response.body).toEqual({ message: 'Unauthorized' });
1015+
});
1016+
1017+
it('should be able to combine global hooks and route hooks', async () => {
1018+
let calledTimes = 0;
1019+
const contract = c.router({
1020+
getMe: {
1021+
method: 'GET',
1022+
path: '/me',
1023+
responses: { 200: z.boolean() },
1024+
},
1025+
});
1026+
1027+
const router = s.router(contract, {
1028+
getMe: {
1029+
hooks: {
1030+
preValidation: async () => {
1031+
calledTimes += 1;
1032+
},
1033+
},
1034+
async handler() {
1035+
return { status: 200, body: true };
1036+
},
1037+
},
1038+
});
1039+
1040+
const app = fastify();
1041+
app.register(s.plugin(router), {
1042+
hooks: {
1043+
onRequest: async () => {
1044+
calledTimes += 1;
1045+
},
1046+
preValidation: async () => {
1047+
calledTimes += 1;
1048+
},
1049+
},
1050+
});
1051+
1052+
await app.ready();
1053+
1054+
const response = await supertest(app.server).get('/me');
1055+
1056+
expect(response.statusCode).toEqual(200);
1057+
expect(response.body).toBeTruthy();
1058+
expect(calledTimes).toEqual(3);
1059+
});
7961060
});

0 commit comments

Comments
 (0)