Skip to content

Commit 84473e7

Browse files
committed
feat: add path parameter validation using Zod schema
1 parent aa6c4bb commit 84473e7

File tree

7 files changed

+128
-17
lines changed

7 files changed

+128
-17
lines changed

src/lib/openapi-validation.interceptor.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import {
44
Injectable,
55
NestInterceptor,
66
} from '@nestjs/common';
7+
import { mapEntries } from 'radash';
78
import { Observable, map } from 'rxjs';
9+
import z from 'zod';
810
import { OpenApiValidator } from './openapi-validator';
911

1012
@Injectable()
@@ -15,22 +17,32 @@ export class OpenApiValidationInterceptor
1517
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
1618
const req = context.switchToHttp().getRequest();
1719
const res = context.switchToHttp().getResponse();
18-
const url = req.url.split('?')[0];
20+
const url = req.route.path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
21+
const endpoint = this.openapi.paths[url][req.method.toLowerCase()];
1922
const schema =
20-
this.openapi.paths[url][req.method.toLowerCase()].responses[
21-
res.statusCode ?? 200
22-
]?.content?.[
23+
endpoint.responses[res.statusCode ?? 200]?.content?.[
2324
res.getHeader('content-type') ??
2425
req.headers['content-type'] ??
2526
'application/json'
2627
]?.schema;
28+
const dto: string = schema?.$ref?.split('/').pop();
2729

28-
const dtoName = schema?.$ref?.split('/').pop();
29-
if (!dtoName) return next.handle();
30+
const inPath = endpoint.parameters?.filter((p) => p.in === 'path');
31+
if (inPath?.length) {
32+
const zReqParams = z.object(
33+
mapEntries(inPath, (_, v: any) => [
34+
v.name,
35+
this.openapiPropToZod({ required: v.required, ...v.schema }, {}),
36+
]),
37+
);
38+
req.params = this.validate(req.params, zReqParams, 'param');
39+
}
40+
41+
if (!dto) return next.handle();
3042

3143
return next.handle().pipe(
3244
map((data) => {
33-
return this.validate(data, dtoName, 'response');
45+
return this.validate(data, { dto }, 'response');
3446
}),
3547
);
3648
}

src/lib/openapi-validation.pipe.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,16 @@ export class OpenApiValidationPipe
77
implements PipeTransform
88
{
99
transform(value: any, metadata: ArgumentMetadata) {
10-
const dtoName = metadata.metatype?.name;
11-
if (!dtoName || !this.schemata[dtoName]) return value;
10+
const dto = metadata.metatype?.name;
1211

13-
if (!this.openapi.components?.schemas[dtoName]) {
12+
if (!dto || !this.schemata[dto]) return value;
13+
14+
if (!this.openapi.components?.schemas[dto]) {
1415
throw new Error(
15-
`${dtoName} is not registered in your OpenAPI document. Use \`@OpenApiRegister()\` on your DTO to register it. More info: https://github.com/Akronae/nestjs-openapi-validation?tab=readme-ov-file#registering-models.`,
16+
`${dto} is not registered in your OpenAPI document. Use \`@OpenApiRegister()\` on your DTO to register it. More info: https://github.com/Akronae/nestjs-openapi-validation?tab=readme-ov-file#registering-models.`,
1617
);
1718
}
1819

19-
return this.validate(value, dtoName, metadata.type);
20+
return this.validate(value, { dto }, metadata.type);
2021
}
2122
}

src/lib/openapi-validator.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { BadRequestException, Paramtype } from '@nestjs/common';
22
import { OpenAPIObject } from '@nestjs/swagger';
33
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
44
import { mapEntries } from 'radash';
5-
import z, { ZodType } from 'zod';
5+
import z, { ZodSchema, ZodType } from 'zod';
66

77
type OpenApiProp = {
88
type?: string;
@@ -51,8 +51,13 @@ export class OpenApiValidator {
5151
});
5252
}
5353

54-
validate(value: any, dtoName: string, type: Paramtype | 'response') {
55-
const zodSchema = this.getZodSchema(dtoName);
54+
validate(
55+
value: any,
56+
schema: { dto: string } | ZodSchema,
57+
type: Paramtype | 'response',
58+
) {
59+
const zodSchema =
60+
schema instanceof ZodSchema ? schema : this.getZodSchema(schema.dto);
5661
const res = zodSchema.safeParse(value);
5762

5863
if (!res.success) {

src/metadata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ export default async () => {
44
["./modules/app/app.dto"]: await import("./modules/app/app.dto"),
55
["./modules/users/users.dto"]: await import("./modules/users/users.dto")
66
};
7-
return { "@nestjs/swagger": { "models": [[import("./modules/app/app.dto"), { "Query1": { str1: { required: true, type: () => String }, str2: { required: false, type: () => String }, date: { required: true, type: () => Date }, nbr1: { required: true, type: () => Number }, nbr2: { required: false, type: () => Number } }, "Query2": { str1: { required: true, type: () => String }, nbr1: { required: true, type: () => Number }, enum1: { required: true, type: () => Object } }, "Query3": { enum1: { required: true, type: () => Object }, enum2: { required: true, type: () => Object } }, "Query4": { query1: { required: true, type: () => t["./modules/app/app.dto"].Query1 }, field2: { required: true, type: () => Object } }, "Query5": { query4: { required: true, type: () => t["./modules/app/app.dto"].Query4 }, force: { required: true, type: () => Boolean }, query1: { required: false, type: () => t["./modules/app/app.dto"].Query1 } }, "Query6": { arr: { required: true, type: () => [t["./modules/app/app.dto"].Query4] } }, "Query7": { str: { required: true, type: () => String }, nbr: { required: true, type: () => Number }, email: { required: true, type: () => String }, url: { required: true, type: () => String }, phone: { required: true, type: () => String } }, "Query8": { nested: { required: true, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) } }) } }, "Query9": { required: { required: true, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) }, opt: { required: false, type: () => ({ prop: { required: true, type: () => Number } }) }, semi: { required: false, type: () => ({ opt: { required: false, type: () => Number } }) } }) }, opt: { required: false, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) }, opt: { required: false, type: () => ({ prop: { required: true, type: () => Number } }) }, semi: { required: false, type: () => ({ opt: { required: false, type: () => Number } }) } }) } }, "Query10": { arr2d: { required: true, type: () => [[String]] }, arr2d2: { required: false, type: () => [[Number]] } } }], [import("./modules/users/users.dto"), { "UserQuery1": { name: { required: true, type: () => String }, age: { required: false, type: () => Number } }, "UserQuery2Options": { all: { required: true, type: () => String } }, "UserQuery2": { name: { required: true, type: () => String }, options: { required: false, type: () => t["./modules/users/users.dto"].UserQuery2Options } }, "UserQuery3": { a: { required: true, type: () => String }, b: { required: false, type: () => Number } }, "UserQuery4": { required: { required: true, type: () => String } } }]], "controllers": [[import("./modules/app/app.controller"), { "AppController": { "getStatus": {}, "getStatusV2": { type: String }, "getQuery1": { type: t["./modules/app/app.dto"].Query1 }, "getQuery2": { type: t["./modules/app/app.dto"].Query2 }, "getQuery3": { type: t["./modules/app/app.dto"].Query3 }, "getQuery4": { type: t["./modules/app/app.dto"].Query4 }, "getQuery5": { type: t["./modules/app/app.dto"].Query5 }, "getQuery6": { type: t["./modules/app/app.dto"].Query6 }, "getQuery7": { type: t["./modules/app/app.dto"].Query7 }, "getQuery8": { type: t["./modules/app/app.dto"].Query8 }, "getQuery9": { type: t["./modules/app/app.dto"].Query9 }, "getQuery10": { type: t["./modules/app/app.dto"].Query10 }, "getResponse1": { type: t["./modules/app/app.dto"].Query1 }, "getResponse2": { type: t["./modules/app/app.dto"].Query2 }, "getResponse3": { type: t["./modules/app/app.dto"].Query3 }, "getResponse4": { type: t["./modules/app/app.dto"].Query4 }, "getResponse5": { type: t["./modules/app/app.dto"].Query5 }, "getResponse6": { type: t["./modules/app/app.dto"].Query6 }, "getResponse7": { type: t["./modules/app/app.dto"].Query7 }, "getResponse8": { type: t["./modules/app/app.dto"].Query8 }, "getResponse9": { type: t["./modules/app/app.dto"].Query9 }, "getResponse10": { type: t["./modules/app/app.dto"].Query10 } } }], [import("./modules/users/users.controller"), { "UsersController": { "getQuery1": { type: t["./modules/users/users.dto"].UserQuery1 }, "getResponse1": { type: t["./modules/users/users.dto"].UserQuery1 }, "getQuery2": { type: t["./modules/users/users.dto"].UserQuery2 }, "getResponse2": { type: t["./modules/users/users.dto"].UserQuery2 }, "getQuery3": { type: String }, "getQuery4": { type: String } } }]] } };
7+
return { "@nestjs/swagger": { "models": [[import("./modules/app/app.dto"), { "Query1": { str1: { required: true, type: () => String }, str2: { required: false, type: () => String }, date: { required: true, type: () => Date }, nbr1: { required: true, type: () => Number }, nbr2: { required: false, type: () => Number } }, "Query2": { str1: { required: true, type: () => String }, nbr1: { required: true, type: () => Number }, enum1: { required: true, type: () => Object } }, "Query3": { enum1: { required: true, type: () => Object }, enum2: { required: true, type: () => Object } }, "Query4": { query1: { required: true, type: () => t["./modules/app/app.dto"].Query1 }, field2: { required: true, type: () => Object } }, "Query5": { query4: { required: true, type: () => t["./modules/app/app.dto"].Query4 }, force: { required: true, type: () => Boolean }, query1: { required: false, type: () => t["./modules/app/app.dto"].Query1 } }, "Query6": { arr: { required: true, type: () => [t["./modules/app/app.dto"].Query4] } }, "Query7": { str: { required: true, type: () => String }, nbr: { required: true, type: () => Number }, email: { required: true, type: () => String }, url: { required: true, type: () => String }, phone: { required: true, type: () => String } }, "Query8": { nested: { required: true, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) } }) } }, "Query9": { required: { required: true, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) }, opt: { required: false, type: () => ({ prop: { required: true, type: () => Number } }) }, semi: { required: false, type: () => ({ opt: { required: false, type: () => Number } }) } }) }, opt: { required: false, type: () => ({ long: { required: true, type: () => ({ prop: { required: true, type: () => Number } }) }, opt: { required: false, type: () => ({ prop: { required: true, type: () => Number } }) }, semi: { required: false, type: () => ({ opt: { required: false, type: () => Number } }) } }) } }, "Query10": { arr2d: { required: true, type: () => [[String]] }, arr2d2: { required: false, type: () => [[Number]] } } }], [import("./modules/users/users.dto"), { "UserQuery1": { name: { required: true, type: () => String }, age: { required: false, type: () => Number } }, "UserQuery2Options": { all: { required: true, type: () => String } }, "UserQuery2": { name: { required: true, type: () => String }, options: { required: false, type: () => t["./modules/users/users.dto"].UserQuery2Options } }, "UserQuery3": { a: { required: true, type: () => String }, b: { required: false, type: () => Number } }, "UserQuery4": { required: { required: true, type: () => String } } }]], "controllers": [[import("./modules/app/app.controller"), { "AppController": { "getStatus": {}, "getStatusV2": { type: String }, "getQuery1": { type: t["./modules/app/app.dto"].Query1 }, "getQuery2": { type: t["./modules/app/app.dto"].Query2 }, "getQuery3": { type: t["./modules/app/app.dto"].Query3 }, "getQuery4": { type: t["./modules/app/app.dto"].Query4 }, "getQuery5": { type: t["./modules/app/app.dto"].Query5 }, "getQuery6": { type: t["./modules/app/app.dto"].Query6 }, "getQuery7": { type: t["./modules/app/app.dto"].Query7 }, "getQuery8": { type: t["./modules/app/app.dto"].Query8 }, "getQuery9": { type: t["./modules/app/app.dto"].Query9 }, "getQuery10": { type: t["./modules/app/app.dto"].Query10 }, "getResponse1": { type: t["./modules/app/app.dto"].Query1 }, "getResponse2": { type: t["./modules/app/app.dto"].Query2 }, "getResponse3": { type: t["./modules/app/app.dto"].Query3 }, "getResponse4": { type: t["./modules/app/app.dto"].Query4 }, "getResponse5": { type: t["./modules/app/app.dto"].Query5 }, "getResponse6": { type: t["./modules/app/app.dto"].Query6 }, "getResponse7": { type: t["./modules/app/app.dto"].Query7 }, "getResponse8": { type: t["./modules/app/app.dto"].Query8 }, "getResponse9": { type: t["./modules/app/app.dto"].Query9 }, "getResponse10": { type: t["./modules/app/app.dto"].Query10 } } }], [import("./modules/users/users.controller"), { "UsersController": { "getQuery1": { type: t["./modules/users/users.dto"].UserQuery1 }, "getResponse1": { type: t["./modules/users/users.dto"].UserQuery1 }, "getQuery2": { type: t["./modules/users/users.dto"].UserQuery2 }, "getResponse2": { type: t["./modules/users/users.dto"].UserQuery2 }, "getQuery3": { type: String }, "getQuery4": { type: String }, "getQuery5": {} } }]] } };
88
};

src/modules/app/__snapshots__/app.module.spec.ts.snap

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1964,6 +1964,44 @@ exports[`AppController (e2e) /users/query_4 success (GET) 1`] = `{}`;
19641964

19651965
exports[`AppController (e2e) /users/query_4 success (GET) 2`] = `{}`;
19661966

1967+
exports[`AppController (e2e) /users/query_5 fail (GET) 1`] = `
1968+
{
1969+
"error": {
1970+
"param": {
1971+
"issues": [
1972+
{
1973+
"code": "invalid_type",
1974+
"expected": "number",
1975+
"message": "Expected number, received nan",
1976+
"path": [
1977+
"id",
1978+
],
1979+
"received": "nan",
1980+
},
1981+
],
1982+
"name": "ZodError",
1983+
},
1984+
},
1985+
}
1986+
`;
1987+
1988+
exports[`AppController (e2e) /users/query_5 success (GET) 1`] = `
1989+
{
1990+
"id": 453,
1991+
"lol": 1,
1992+
"required": "hello",
1993+
}
1994+
`;
1995+
1996+
exports[`AppController (e2e) /users/query_5 success (GET) 2`] = `
1997+
{
1998+
"id": 23,
1999+
"lol": 1,
2000+
"optional": "world",
2001+
"required": "hi",
2002+
}
2003+
`;
2004+
19672005
exports[`AppController (e2e) /users/response_1 fail (GET) 1`] = `
19682006
{
19692007
"error": {

src/modules/app/app.module.spec.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,4 +885,45 @@ describe('AppController (e2e)', () => {
885885
expect(res.status).toBe(200);
886886
expect(res.body).toMatchSnapshot();
887887
});
888+
889+
it('/users/query_5 fail (GET)', async () => {
890+
const res = await request(app.getHttpServer())
891+
.get('/v1/users/query_5/hi')
892+
.query({
893+
required: 'lala',
894+
});
895+
expect(res.status).toBe(400);
896+
expect(res.body).toMatchSnapshot();
897+
});
898+
899+
it('/users/query_5 success (GET)', async () => {
900+
const res = await request(app.getHttpServer())
901+
.get('/v1/users/query_5/453')
902+
.query({
903+
required: 'hello',
904+
});
905+
expect(res.status).toBe(200);
906+
expect(res.body).toEqual({
907+
required: 'hello',
908+
id: 453,
909+
lol: 1,
910+
});
911+
expect(res.body).toMatchSnapshot();
912+
});
913+
it('/users/query_5 success (GET)', async () => {
914+
const res = await request(app.getHttpServer())
915+
.get('/v1/users/query_5/00023')
916+
.query({
917+
required: 'hi',
918+
optional: 'world',
919+
});
920+
expect(res.status).toBe(200);
921+
expect(res.body).toEqual({
922+
required: 'hi',
923+
optional: 'world',
924+
id: 23,
925+
lol: 1,
926+
});
927+
expect(res.body).toMatchSnapshot();
928+
});
888929
});

src/modules/users/users.controller.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
1+
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
22
import { UserQuery1, UserQuery2, UserQuery3, UserQuery4 } from './users.dto';
33

44
@Controller({
@@ -33,4 +33,18 @@ export class UsersController {
3333
getQuery4(@Query() _query: UserQuery4) {
3434
return 'ok!';
3535
}
36+
37+
@Get('query_5/:id')
38+
getQuery5(
39+
@Param('id') id: number,
40+
@Query('required') required: string,
41+
@Query('optional') optional?: string,
42+
) {
43+
return {
44+
required,
45+
optional,
46+
id,
47+
lol: 1,
48+
};
49+
}
3650
}

0 commit comments

Comments
 (0)