Introduction
I encountered NestJS for the first time about five years ago.
I was making a transition from .net to node js, so I was looking for a strongly-typed, well-documented server-side library.
I found NestJS.
The class validator decorators have always bothered me, and I even wrote a lint that forced programmers to add the validation.
We recently decided at my work to move to NestJS, so this issue has popped up again and I found a great solution!
Since we can use third-party libraries to do validation using OpenAPI documents, all we need to do is to “compile” Nest controllers to OpenAPI documents. Here we go!
Let's start
There are two critical things to consider when writing APIs:
Documentation - Your API must be understood by your consumers. There is a standard interface for that - https://swagger.io/specification/
Validation - You don’t want to query the database or do something with some wrong input data.
First, we'll do it in the old primitive way, and then I'll show you my way.
We will create a simple route that queries events by time, type, and location.
The Old Ugly Way
Create a new nest application -
nest new the-old-way
Create Events controlller -
nest g resource Events
(Do not generate CRUD entry points that will add a lot of code that we do not need.)
Let's add the events route:
import { Controller, Get, Param, Query } from "@nestjs/common";
interface Event {
timestamp: number;
type: EventType;
locationId: number;
id: number;
}
enum EventType {
SYSTEM = "system",
USER = "user",
}
@Controller("events")
export class EventsController {
@Get(":locationId")
getEvents(@Param("locationId") locationId: number,
@Query("ids") ids?: number[],
@Query("type") type?: EventType): Promise<Event[]> {
return new Promise((res) => res([]));
}
}
Location path parm is a number and it is required.
ids query param is an array of numbers (optional).
type query param can be only "system" or "user"(optional).
The response is an array of events.
Let's add a validation pipe (official doc)
npm i --save class-validator class-transformer
In the main.ts file, add app.useGlobalPipes(new ValidationPipe());
Let's add an openAPI generator by following the official article.
When you open http://localhost:3000/api/ you can see the schema:
There are some problems here -
Query parameters are mandatory,but they should be optional.
The type parameter is a string, but it should be an enum.
There is no response schema.
There is no validation - you can test this by sending a string in the locationId param - http://localhost:3000/events/some-string
When we compile to javascript, all the types disappear. NestJS solves this problem by using decorators.
So, let's try to fix this with ApiProperty and class validator decorators.
Let's try to fix that with ApiProperty and class validator decorators.
To use decorators, we first need to move our parameters to some class:
class GetEventsPathParams {
@IsNumber() locationId: number;
}
class GetEventsQueryParams {
@IsArray() @IsOptional() ids?: number[];
@IsEnum(EventType) @IsOptional() type?: EventType;
}
@Controller("events")
export class EventsController {
@Get(":locationId")
getEvents(@Param() params: GetEventsPathParams, @Query() query: GetEventsQueryParams): Promise<Event[]> {
return new Promise((res) => res([]));
}
}
Success! Now we get a validation error -
{"statusCode":400,"message":["locationId must be a number conforming to the specified constraints"],"error":"Bad Request"}
Let's see what happened with the opanAPI doc -
Whoops! All our parameters were gone!
Let's try to fix this by adding ApiResponse decorators -
class Event {
@ApiProperty() timestamp: number;
@ApiProperty() type: EventType;
@ApiProperty() locationId: number;
@ApiProperty() id: number;
}
enum EventType {
SYSTEM = "system",
USER = "user",
}
class GetEventsPathParams {
@ApiProperty({ required: true }) @IsNumber() locationId: number;
}
class GetEventsQueryParams {
@ApiProperty({ required: false, type: ["number"] }) @IsArray() @IsOptional() ids?: number[];
@ApiProperty({ required: false, enum: EventType }) @IsEnum(EventType) @IsOptional() type?: EventType;
}
@Controller("events")
export class EventsController {
@Get(":locationId")
@ApiResponse({ type: [Event] })
getEvents(@Param() params: GetEventsPathParams, @Query() query: GetEventsQueryParams): Promise<Event[]> {
return new Promise((res) => res([]));
}
}
And voila - it looks much better:
The point is clear:
- You need to declare each type 3 times!
- You need to learn class validator and @nestjs/swagger!
- You have to create classes that will never be instantiated!
- To allow the declaration of a class property without initialization, Nest sets the strict-flag to false. In other words, it is less type-safe.
The solution for all this is to create a package that can “compile” NestJS controllers to OpenAPI documents.
Feel free to look at my code on Github, any contributions or suggestions are welcome!
Let's start again from the beginning-
Create a new nest application -
nest new the-new-way
Create Events controlller -
nest g resource Events
Do not generate CRUD entry points that will add a lot of code that we do not need.
Let's add the events route:
interface Event {
timestamp: number;
type: EventType;
locationId: number;
id: number;
}
enum EventType {
SYSTEM = "system",
USER = "user",
}
@Controller("events")
export class EventsController {
@Get(":locationId")
getEvents(@Param("locationId") locationId: number,
@Query("ids") ids?: number[],
@Query("type") type?: EventType): Promise<Event[]> {
return new Promise(resolve => resolve([]));
}
}
Generate openapidoc with nest-openapi-gen package -
Run npm i -D nest-openapi-gen
Add openapi.generator.ts file in the root -
import { generate } from "nest-openapi-gen";
generate({ filePath: "./openapi.json" });
Change the build script to generate the openAPI document -
"build": "ts-node openapi.generator.ts && nest build",
Run npm run build
You can see the file generated in the folder.
The last step is to add some openAPI UI to show our document.
we can use swagger-ui-express for that
Run npm i @nestjs/swagger swagger-ui-express
Add this code in main.ts file
const document = JSON.parse(readFileSync("./openapi.json", { encoding: "utf-8" }));
SwaggerModule.setup("api", app, document);
Open http://localhost:3000/api and see the magic.
But wait, we aren’t finished yet, we still need to take care of the validation.
Since we have the API documentation, we can use it for validation.
Let's use express-openapi-validator for that:
Run npm i express-openapi-validator
Add middleware:
app.use(OpenApiValidator.middleware({
apiSpec: require('../openapi.schema.json'),
validateResponses: true
}));
Add a global exception filter -
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
catch(exception: Error & { context?: any }, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status =
exception instanceof HttpException
? exception.getStatus()
: exception instanceof BadRequest
? exception.status
: HttpStatus.INTERNAL_SERVER_ERROR;
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception.message,
errors: (exception as BadRequest).errors,
});
}
}
And that's it! Now we have validation and openapi documents for our code, without adding unnecessary classes and decorators.
One last thing, we need to set the strict-flag to true to avoid using properties without initializing.
Just add "strict": true
, and remove "strictNullChecks": false,from your tsconfg file.
You can find the source code here
Top comments (3)
"should not use" is a hot take
You can use the
@nestjs/swagger
cli plugin to infer types from ts, don't need the Api decoratordocs.nestjs.com/openapi/cli-plugin...
It would solve the "type 3 times" and "no response type" issue
as for the "You have to create classes that will never be instantiated", it's too opinioned that based on developer taste, from a Java spring background, I think it's intuitive and easy to follow, on the other hand, I understand it crosses the boundary that makes types have effects in run time
I know someone would hate class, but why are you using Nest if you hate it? Have you ever think how the Nest DI works, with just a typescript and it knows what to inject? It uses emitDecoratorMetadata to achieve it, it also makes typescript type part of your runtime code already.
I guess you did read the documentation very well.
The solution is already there this could just solve your problem:
Thanks @mamged , i fixed it. but the point is clear also without it.