NestJS: модули и документирование через Swagger
Reading time: 6 minutes
NestJS, в отличие от других Node.js-фреймворков, предоставляет богатый набор инструментов, библиотек и возможностей для написания чистого, масштабируемого кода. В этой статье разберём, как использовать Swagger для документирования приложения и какие практики применять при создании модулей.
Установка зависимостей
Глобальная установка NestJS CLI для создания проектов:
npm i -g @nestjs/cli
Инициализация нового проекта:
nest new app</code>
Установка зависимостей Swagger:
npm install --save @nestjs/swagger swagger-ui-express
Создание простого модуля
NestJS продвигает модульную архитектуру по образцу Angular. Каждый модуль должен быть независимым и спроектированным так, чтобы его можно было использовать в любом приложении.
В качестве примера создадим типовой модуль cats. Архитектурно он включает Entity, Service, Repository, Controller и DTO.
Entity
Опишем сущность. Entity — это представление реального объекта. Если в мире есть кот, в программе он описывается через сущность.
// src/module/cat/entity/Cat.ts
export class Cat {
id: number;
name: string;
weight: number;
constructor (id: number, name: string, weight: number) {
this.id = id;
this.name = name;
this.weight = weight;
}
}
Service
Бизнес-логика — куда добавляется кот, как получить список, как получить одного — описывается в сервисах.
// src/module/cat/service/CatService.ts
import { Cat } from "../entity/Cat";
import { CreateCatData } from "../dto/CreateCatData";
import { Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class CatService {
private readonly cats: Cat[] = [];
public async add(data: CreateCatData): Promise<Cat> {
const cat = new Cat(this.cats.length, data.name, data.weight);
this.cats.push(cat);
return cat;
}
public async list(): Promise<Cat[]> {
return this.cats;
}
public async get(id: number): Promise<Cat> {
const cat = this.cats.find(cat => cat.id === id);
if(!cat) {
throw new NotFoundException("Cat not found");
}
return cat;
}
}
На этом этапе разработчики нередко допускают ошибку — я сам через неё прошёл.
Пока кода немного и мы не работаем с базой данных, используем массив. Мы описали его прямо в сервисе. В реальном проекте здесь инжектируется подключение к БД. И это нарушает первый принцип SOLID — Single Responsibility. Сервис отвечает за бизнес-логику, а не за работу с данными. Для последнего — свой паттерн: Repository.
Repository
// src/module/cat/repository/CatRepository.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { Cat } from "../entity/Cat";
@Injectable()
export class CatRepository {
private readonly cats: Cat[] = [];
public async save(cat: Cat): Promise<void> {
this.cats.push(cat);
}
public async findAll(): Promise<Cat[]> {
return this.cats;
}
public async get(id: number): Promise<Cat> {
const cat = this.cats.find(cat => cat.id === id);
if(!cat) {
throw new NotFoundException("Cat not found");
}
return cat;
}
}
Теперь сервис работает через репозиторий. Обновим код сервиса:
// src/module/cat/service/CatService.ts
import { Cat } from "../entity/Cat";
import { CreateCatData } from "../dto/CreateCatData";
import { Injectable } from "@nestjs/common";
import { CatRepository } from "../repository/CatRepository";
@Injectable()
export class CatService {
constructor(
private readonly catRepository: CatRepository
) {}
public async add(data: CreateCatData): Promise<Cat> {
const id = this.catRepository.count;
const cat = new Cat(id, data.name, data.weight);
await this.catRepository.save(cat);
return cat;
}
public async list(): Promise<Cat[]> {
return this.catRepository.findAll();
}
public async get(id: number): Promise<Cat> {
return this.catRepository.get(id);
}
}
Data Transfer Object (DTO)
Сервисные методы часто принимают много аргументов. Паттерн Data Transfer Object решает эту проблему: объект DTO объединяет все нужные аргументы в своих свойствах. В нашем случае — для метода add.
Класс DTO прост:
// src/module/cat/dto/CreateCatData.ts
export class CreateCatData {
name: string;
weight: number;
}
Controller
Контроллер — паттерн, принимающий входные данные и передающий их в систему. Классы контроллера содержат только приём пользовательского ввода, валидацию, вызов нужного метода сервиса и формирование ответа.
В нашем случае контроллер выглядит так:
// src/module/cat/controller/CatController.ts
import { Body, Controller, Get, Param, ParseIntPipe, Post } from "@nestjs/common";
import { CatService } from "../service/CatService";
import { Cat } from "../entity/Cat";
import { CreateCatData } from "../dto/CreateCatData";
@Controller()
export class CatController {
constructor(
private readonly catService: CatService
) {}
@Post("/cat")
public async create(@Body() data: CreateCatData): Promise<Cat> {
return this.catService.add(data);
}
@Get("/cats")
public async list(): Promise<Cat[]> {
return this.catService.list();
}
@Get("/cat/:id")
public async get(@Param("id", ParseIntPipe) id: number): Promise<Cat> {
return this.catService.get(id);
}
}
Module
Теперь соберём всё описанное в модуль:
// src/module/cat/CatModule.ts
import { Module } from "@nestjs/common";
import { CatController } from "./controller/CatController";
import { CatService } from "./service/CatService";
import { CatRepository } from "./repository/CatRepository";
@Module({
controllers: [
CatController
],
providers: [
CatService,
CatRepository
],
exports: [
CatService,
CatRepository
]
})
export class CatModule {}
Controllers указываются в секции controllers — на этапе компиляции NestJS создаст HTTP-интерфейс для этих классов.
providers — массив классов, используемых в модуле. На этапе компиляции NestJS автоматически создаёт объекты с разрешением зависимостей конструктора.
exports — провайдеры, которые можно использовать в других модулях.
Импорт модуля: добавьте его в секцию imports в AppModule и запустите приложение командой npm run start:dev. В логах будет видно, что модуль подключён и маршруты зарегистрированы.
Работает!
Swagger
Добавим документацию для модуля и приложения в целом.
Подключите Swagger-модуль в main.ts:
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
(async function bootstrap() {
const app = await NestFactory.create(AppModule);
const options = new DocumentBuilder()
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup('/docs', app, document);
await app.listen(3000);
})()
Откройте localhost:3000/docs — документация контроллера уже видна. Но API пока непонятен. Добавим ясности.
Декораторы на свойства DTO:
// src/module/cat/dto/CreateCatData.ts
import { ApiProperty } from "@nestjs/swagger";
export class CreateCatData {
@ApiProperty({
required: true,
type: 'string',
name: 'name',
description: 'name of your cat'
})
name: string;
@ApiProperty({
required: true,
type: 'number',
name: 'weight',
description: 'weight of you cat'
})
weight: number;
}
Теперь для POST /cat видна схема запроса.
Документируем контроллер:
// src/module/cat/controller/CatController.ts
@Controller()
@ApiTags("Cats")
export class CatController {
constructor(
private readonly catService: CatService
) {}
@Post("/cat")
@ApiResponse({type: Cat, status: 201})
public async create(@Body() data: CreateCatData): Promise<Cat> {
return this.catService.add(data);
}
@Get("/cats")
@ApiResponse({type: Cat, isArray: true, status: 200})
public async list(): Promise<Cat[]> {
return this.catService.list();
}
@Get("/cat/:id")
@ApiResponse({type: Cat, status: 200})
@HttpCode(200)
public async get(@Param("id", ParseIntPipe) id: number): Promise<Cat> {
return this.catService.get(id);
}
}
Благодаря ApiTags все методы контроллера сгруппированы под отдельным заголовком. Чтобы убрать пустую схему ответа, добавим описание свойств сущности:
// src/module/cat/entity/Cat.ts
import { ApiProperty } from "@nestjs/swagger";
export class Cat {
@ApiProperty({
type: 'number',
name: 'id',
description: 'id of cat'
})
id: number;
@ApiProperty({
type: 'string',
name: 'name',
description: 'name of cat'
})
name: string;
@ApiProperty({
type: 'number',
name: 'weight',
description: 'weight of cat'
})
weight: number;
constructor(id: number, name: string, weight: number) {
this.id = id;
this.name = name;
this.weight = weight;
}
}
Теперь каждый маршрут показывает полную картину: как он работает, какой запрос нужно отправить и что придёт в ответ. Запросы можно отправлять прямо из Swagger — кнопка «try it out» в помощь.
Мы разобрали создание модулей и их документирование. Удачи в разработке!