NestJS: модули и документирование через Swagger

Reading time: 6 minutes

NestJS, в отличие от других Node.js-фреймворков, предоставляет богатый набор инструментов, библиотек и возможностей для написания чистого, масштабируемого кода. В этой статье разберём, как использовать Swagger для документирования приложения и какие практики применять при создании модулей. NestJs

Установка зависимостей

Глобальная установка 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» в помощь.

Мы разобрали создание модулей и их документирование. Удачи в разработке!

Table of Contents