Skip to content

Commit 046e498

Browse files
committed
feat: Nest improvements
1 parent 3778574 commit 046e498

8 files changed

Lines changed: 184 additions & 94 deletions

File tree

.changeset/tricky-zebras-sing.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/nest': minor
4+
---
5+
6+
Use Nest.js req.query and req.params instead of parsing from URL
Lines changed: 120 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,120 @@
1-
// import { PostService } from './post.service';
2-
// import { Test } from '@nestjs/testing';
3-
// import { INestApplication } from '@nestjs/common';
4-
// import * as request from 'supertest';
5-
6-
// describe('PostController', () => {
7-
// let app: INestApplication;
8-
// let postService: PostService;
9-
10-
// beforeEach(async () => {
11-
// const moduleRef = await Test.createTestingModule({
12-
// providers: [
13-
// {
14-
// provide: PostService,
15-
// useValue: {
16-
// getPosts: jest.fn().mockResolvedValue([]),
17-
// },
18-
// },
19-
// ],
20-
// }).compile();
21-
22-
// postService = moduleRef.get<PostService>(PostService);
23-
24-
// app = moduleRef.createNestApplication();
25-
// await app.init();
26-
// });
27-
28-
// it(`/GET posts`, () => {
29-
// return request(app.getHttpServer())
30-
// .get('/posts')
31-
// .expect(200)
32-
// .expect({
33-
// body: postService.getPosts({}),
34-
// });
35-
// });
36-
37-
// afterAll(async () => {
38-
// await app.close();
39-
// });
40-
// });
41-
42-
it.todo('fix Nest Jest, weird error being throw');
1+
import { PostService } from './post.service';
2+
import { Test } from '@nestjs/testing';
3+
import { INestApplication } from '@nestjs/common';
4+
import * as request from 'supertest';
5+
import { PostController } from './post.controller';
6+
7+
describe('PostController', () => {
8+
let app: INestApplication;
9+
let postService: PostService;
10+
11+
beforeEach(async () => {
12+
const moduleRef = await Test.createTestingModule({
13+
controllers: [PostController],
14+
providers: [
15+
{
16+
provide: PostService,
17+
useValue: {
18+
getPosts: jest.fn(),
19+
createPost: jest.fn(),
20+
},
21+
},
22+
],
23+
}).compile();
24+
25+
postService = moduleRef.get<PostService>(PostService);
26+
27+
app = moduleRef.createNestApplication();
28+
await app.init();
29+
});
30+
31+
describe('GET /posts', () => {
32+
it('should transform skip and take into numbers', async () => {
33+
jest.spyOn(postService, 'getPosts').mockResolvedValue([] as any);
34+
35+
return request(app.getHttpServer())
36+
.get('/posts')
37+
.query('skip=0&take=10')
38+
.expect(200)
39+
.expect({
40+
skip: 0,
41+
take: 10,
42+
});
43+
});
44+
45+
it('should error if a required query param is missing', async () => {
46+
return request(app.getHttpServer())
47+
.get('/posts')
48+
.query('skip=0')
49+
.expect(400)
50+
.expect({
51+
issues: [
52+
{
53+
code: 'invalid_type',
54+
expected: 'string',
55+
message: 'Required',
56+
path: ['take'],
57+
received: 'undefined',
58+
},
59+
],
60+
name: 'ZodError',
61+
});
62+
});
63+
});
64+
65+
describe('POST /posts', () => {
66+
it('should error if body is incorrect', async () => {
67+
return request(app.getHttpServer())
68+
.post('/posts')
69+
.send({
70+
title: 'Good title',
71+
content: 123,
72+
})
73+
.expect(400)
74+
.expect({
75+
issues: [
76+
{
77+
code: 'invalid_type',
78+
expected: 'string',
79+
message: 'Expected string, received number',
80+
path: ['content'],
81+
received: 'number',
82+
},
83+
],
84+
name: 'ZodError',
85+
});
86+
});
87+
88+
it('should transform body correctly', async () => {
89+
jest
90+
.spyOn(postService, 'createPost')
91+
.mockImplementationOnce(async (post) => post as any);
92+
93+
return request(app.getHttpServer())
94+
.post('/posts')
95+
.send({
96+
title: 'Title with extra spaces ',
97+
content: 'content',
98+
})
99+
.expect(201)
100+
.expect({
101+
title: 'Title with extra spaces',
102+
content: 'content',
103+
});
104+
});
105+
});
106+
107+
it('should format params using pathParams correctly', async () => {
108+
return request(app.getHttpServer())
109+
.get('/test/123/name')
110+
.expect(200)
111+
.expect({
112+
id: 123,
113+
name: 'name',
114+
});
115+
});
116+
117+
afterAll(async () => {
118+
await app.close();
119+
});
120+
});

libs/ts-rest/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
}
2828
},
2929
"peerDependencies": {
30+
"qs": "6.x.x",
3031
"zod": "3.x.x"
3132
}
3233
}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,28 @@ describe('convertQueryParamsToUrlString', () => {
105105

106106
expect(result).toBe('?id=1&commentId=2&commentId2=3');
107107
});
108+
109+
it('should convert query params to url string with array params', () => {
110+
const query = {
111+
colors: ['blue', 'green'],
112+
};
113+
114+
const result = convertQueryParamsToUrlString(query);
115+
116+
expect(result).toBe('?colors=blue&colors=green');
117+
});
118+
119+
it('should convert query params to url string with nested object', () => {
120+
const query = {
121+
color: {
122+
r: 100,
123+
g: 150,
124+
b: 200,
125+
},
126+
};
127+
128+
const result = convertQueryParamsToUrlString(query);
129+
130+
expect(result).toBe(encodeURI(`?color[r]=100&color[g]=150&color[b]=200`));
131+
});
108132
});

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

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { stringify } from 'qs';
2+
13
/**
24
* @params T - The URL e.g. /posts/:id
35
* @params TAcc - Accumulator object
@@ -64,23 +66,6 @@ export const insertParamsIntoPath = <T extends string>({
6466
* @param query - The query e.g. { id: string }
6567
* @returns - The query url segment e.g. ?id=123
6668
*/
67-
export const convertQueryParamsToUrlString = (
68-
query: Record<string, string>
69-
) => {
70-
const queryString =
71-
typeof query === 'object'
72-
? Object.keys(query)
73-
.map((key) => {
74-
if (query[key] === undefined) {
75-
return null;
76-
}
77-
return (
78-
encodeURIComponent(key) + '=' + encodeURIComponent(query[key])
79-
);
80-
})
81-
.filter(Boolean)
82-
.join('&')
83-
: '';
84-
85-
return queryString?.length > 0 ? '?' + queryString : '';
69+
export const convertQueryParamsToUrlString = (query: any) => {
70+
return stringify(query, { indices: false, addQueryPrefix: true });
8671
};

libs/ts-rest/nest/src/lib/api.decorator.ts

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,26 @@ import {
66
Delete,
77
ExecutionContext,
88
Get,
9-
HttpException,
109
Injectable,
1110
NestInterceptor,
1211
Patch,
1312
Post,
1413
Put,
14+
SetMetadata,
1515
UseInterceptors,
1616
} from '@nestjs/common';
1717
import {
1818
AppRoute,
1919
AppRouteMutation,
2020
checkZodSchema,
21-
getPathParamsFromUrl,
2221
PathParamsWithCustomValidators,
2322
Without,
2423
ZodInferOrType,
2524
} from '@ts-rest/core';
2625
import { map, Observable } from 'rxjs';
26+
import type { Request, Response } from 'express-serve-static-core';
27+
28+
const tsRestAppRouteMetadataKey = Symbol('ts-rest-app-route');
2729

2830
type BodyWithoutFileIfMultiPart<T extends AppRouteMutation> =
2931
T['contentType'] extends 'multipart/form-data'
@@ -41,33 +43,20 @@ export type ApiDecoratorShape<TRoute extends AppRoute> = Without<
4143
never
4244
>;
4345

44-
const getQueryParams = (url: string): Record<string, string> => {
45-
const searchParams = new URLSearchParams(url.split('?')[1]);
46-
const queryParams: Record<string, string> = {};
47-
48-
for (const [key, value] of searchParams) {
49-
queryParams[key] = value;
50-
}
51-
52-
return queryParams;
53-
};
54-
5546
export const ApiDecorator = createParamDecorator(
5647
(_: unknown, ctx: ExecutionContext): ApiDecoratorShape<any> => {
57-
const req = ctx.switchToHttp().getRequest();
58-
59-
const appRoute = req.appRoute as AppRoute | undefined;
48+
const req: Request = ctx.switchToHttp().getRequest();
49+
const appRoute: AppRoute | undefined = Reflect.getMetadata(
50+
tsRestAppRouteMetadataKey,
51+
ctx.getHandler()
52+
);
6053

6154
if (!appRoute) {
62-
throw new BadRequestException(
63-
'Make sure your route is decorated with @Api()'
64-
);
55+
// this will respond with a 500 error without revealing this error message in the response body
56+
throw new Error('Make sure your route is decorated with @Api()');
6557
}
6658

67-
const pathParams = getPathParamsFromUrl(req.url, appRoute);
68-
const queryParams = getQueryParams(req.url);
69-
70-
const queryResult = checkZodSchema(queryParams, appRoute.query);
59+
const queryResult = checkZodSchema(req.query, appRoute.query);
7160

7261
if (!queryResult.success) {
7362
throw new BadRequestException(queryResult.error);
@@ -82,7 +71,7 @@ export const ApiDecorator = createParamDecorator(
8271
throw new BadRequestException(bodyResult.error);
8372
}
8473

85-
const pathParamsResult = checkZodSchema(pathParams, appRoute.pathParams, {
74+
const pathParamsResult = checkZodSchema(req.params, appRoute.pathParams, {
8675
passThroughExtraKeys: true,
8776
});
8877

@@ -115,12 +104,8 @@ const getMethodDecorator = (appRoute: AppRoute) => {
115104

116105
@Injectable()
117106
export class ApiRouteInterceptor implements NestInterceptor {
118-
constructor(private readonly appRoute: AppRoute) {}
119-
120107
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
121-
const req = context.switchToHttp().getRequest();
122-
123-
req.appRoute = this.appRoute;
108+
const res: Response = context.switchToHttp().getResponse();
124109

125110
return next.handle().pipe(
126111
map((value) => {
@@ -129,7 +114,8 @@ export class ApiRouteInterceptor implements NestInterceptor {
129114
typeof value.status === 'number' &&
130115
value.body !== undefined
131116
) {
132-
throw new HttpException(value?.body, value.status);
117+
res.status(value.status);
118+
return value.body;
133119
}
134120

135121
return value;
@@ -142,8 +128,8 @@ export const Api = (appRoute: AppRoute): MethodDecorator => {
142128
const methodDecorator = getMethodDecorator(appRoute);
143129

144130
return applyDecorators(
131+
SetMetadata(tsRestAppRouteMetadataKey, appRoute),
145132
methodDecorator,
146-
// Apply the interceptor to populate req.appRoute
147-
UseInterceptors(new ApiRouteInterceptor(appRoute))
133+
UseInterceptors(ApiRouteInterceptor)
148134
);
149135
};

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"openapi3-ts": "^2.0.2",
7171
"postcss": "^8.4.16",
7272
"prism-react-renderer": "^1.2.1",
73+
"qs": "^6.11.0",
7374
"react": "18.2.0",
7475
"react-dom": "18.2.0",
7576
"react-hook-form": "^7.34.2",
@@ -130,8 +131,10 @@
130131
"@testing-library/react-native": "11.2.0",
131132
"@types/cors": "^2.8.12",
132133
"@types/express": "4.17.13",
134+
"@types/express-serve-static-core": "4.17.31",
133135
"@types/jest": "28.1.8",
134136
"@types/node": "18.7.18",
137+
"@types/qs": "^6.9.7",
135138
"@types/react": "18.0.20",
136139
"@types/react-dom": "18.0.6",
137140
"@types/react-native": "0.69.8",

0 commit comments

Comments
 (0)