Введение

Друзья, всем привет! 

Меня зовут Алексей и вот уже некоторое время я занимаюсь frontend-разработкой.

В этой статье я опишу один из способов реализации приложения, предоставляющего RESTfull API. Вкратце расскажу о том, как я писал подобное приложение на Typescript, а также приведу примеры кода. Существование такой статьи сильно облегчило бы мне жизнь при работе над проектом, надеюсь моя статья поможет и вам!

Немного предыстории

Для тестирования гипотез при развитии продукта требуется в короткие сроки реализовать прототип какого-нибудь приложения. В рамках рабочих задач мне довелось поработать над подобным прототипом. Это было backend-приложение предоставляющее RESTfull API и реализованное с применением технологий Nest.js и Swagger.

Выбор технологий

При выборе стека ключевым требованием стало использование Node.js, так как задача быстрой реализации RESTfull API легла на плечи команды frontend-разработки. При этом в качестве основного инструмента команда обычно использует фреймворк Angular.

Поэтому Nest.js оказался идеальным кандидатом, так как создатели этого фреймворка вдохновлялись подходами, используемыми в Angular. Здесь и привычный нам Dependency Injection, RxJS, Typescript, система модулей и мощный CLI. Полученный API решили задокументировать с помощью Swagger.

Вкратце о технологиях

Nest (NestJS) — фреймворк для разработки эффективных и масштабируемых серверных приложений на Node.js. Данный фреймворк использует прогрессивный (что означает текущую версию ECMAScript) JavaScript с полной поддержкой TypeScript (использование TypeScript является опциональным) и сочетает в себе элементы объектно-ориентированного, функционального и реактивного функционального программирования.

Под капотом Nest использует Express (по умолчанию), но также позволяет использовать Fastify.

Более подробно про Nest рекомендую почитать здесь.

Swagger — это набор инструментов, которые помогают описывать API. Благодаря ему пользователи и машины лучше понимают возможности REST API без доступа к коду. С помощью Swagger можно быстро создать документацию и отправить ее другим разработчикам или клиентам.

Более подробно про Swagger рекомендую почитать здесь.

Подготовка и настройка проекта

Как я уже отметил выше, Nest.js содержит в комплекте довольно мощный CLI. Начнем с его установки, убедившись при этом в том, что у нас на машине установлен Node.js и npm. Для установки выполним команду:

$ npm i -g @nestjs/cli

После того как CLI установлен создадим с его помощью шаблон нашего приложения с именем rest-api-with-swagger:

$ nest new rest-api-with-swagger

В Nest.js сущности, которые отвечают за обработку входящих HTTP-запросов и формирование ответов, называются контроллерами. Ниже приведен пример кода из контроллера (app.controller.ts), созданного по умолчанию при генерации шаблонного проекта:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }
}

В свою очередь, сущности, которые реализуют бизнес-логику приложения, называются сервисами:

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }
}

Сервисы и контроллеры (а также пайпы (Pipes), гарды (Guards) и другие сущности) объединяются в модули (Modules) - строительные блоки, из которых и формируется конечное приложение.

REST API

Пусть нашим ресурсом, доступ к которому мы хотим обеспечить посредством разрабатываемого API, будут заметки (notes). Для того, чтобы не тратить время на создание необходимых сущностей, воспользуемся следующей командой:

$ nest g resource notes # или nest generate resource notes

Данная команда создаст модуль приложения, посвященный работе с заметками, и автоматически подключит его к нашему приложению. Структура файлов этого модуля будет иметь следующий вид:

src
	notes
		|-- dto 
  			-- create-note.dto.ts
				-- update-note.dto.ts
		|-- entities
  			-- note.entity.ts
		-- notes.controller.spec.ts 
  	-- notes.controller.ts
  	-- notes.service.spec.ts
  	-- notes.service.ts
  	-- notes.module.ts
...

Код контроллера, который будет обрабатывать запросы на выполнение операций над заметками, при этом выглядит примерно следующим образом:

import { 
  Controller, Get, Post, 
  Body, Patch, Param, Delete, Query 
} from '@nestjs/common';
import { NotesService } from './notes.service';
import { CreateNoteDto } from './dto/create-note.dto';
import { UpdateNoteDto } from './dto/update-note.dto';

// Все запросы, содержащие в пути /notes, будут перенаправлены в этот контроллер
@Controller('notes')
export class NotesController {
  constructor(private readonly notesService: NotesService) {}

  @Post() // обработает POST http://localhost/notes?userId={userId}
  create(
  	@Query('userId') userId: number, // <--- достанет userId из query строки  
   	@Body() createNoteDto: CreateNoteDto
	) {
    return this.notesService.create(userId, createNoteDto);
  }

  @Get() // обработает GET http://localhost/notes?userId={userId}
  findAll(@Query('userId') userId: number) {
    return this.notesService.findAll(userId);
  }

  @Get(':noteId') // обработает GET http://localhost/notes/{noteId}
  findOne(@Param('noteId') noteId: number) {
    return this.notesService.findOne(noteId);
  }

  @Patch(':noteId') // обработает PATCH http://localhost/notes/{noteId}
  update(@Param('noteId') noteId: number, @Body() updateNoteDto: UpdateNoteDto) {
    return this.notesService.update(noteId, updateNoteDto);
  }

  @Delete(':noteId') // обработает DELETE http://localhost/notes/{noteId}
  remove(@Param('noteId') noteId: number) {
    return this.notesService.remove(noteId);
  }
}

Вот так вот просто мы получили готовый контроллер, работа которого соответствует всем необходимым правилам построения REST API. Далее, реализуем логику работы с нашими заметками в NotesService. Для упрощения, заметки будем хранить в массиве. В случае реального приложения, в данном сервисе потребовалось бы реализовать логику обращения к сервису работы с хранилищем заметок (например, БД), но это тема другой статьи. Подробнее можно почитать тут.

В первую очередь наполним модели (CreateNoteDto, UpdateNoteDto и Note), описывающие сами заметки и действия над ними. В результате код сервиса будет выглядеть следующим образом:

import { Injectable } from '@nestjs/common';
import { CreateNoteDto } from './dto/create-note.dto';
import { UpdateNoteDto } from './dto/update-note.dto';
import { Note } from './entities/note.entity';

@Injectable()
export class NotesService {

  private _notes: Note[] = [];

  create(userId: number, dto: CreateNoteDto) {
    const id = this._getRandomInt();
    const note = new Note(id, userId, dto.title, dto.content);
    this._notes.push(note);
    return note;
  }

  findAll(userId: number) {
    return this._notes.filter(note => note.userId == userId);
  }

  findOne(noteId: number) {
    return this._notes.filter(note => note.id == noteId);
  }

  update(noteId: number, dto: UpdateNoteDto) {
    const index = this._notes.findIndex(note => note.id == noteId)
    
    if (index === -1) {
      return;
    }

    const { id, userId } = this._notes[index];
    this._notes[index] = new Note(id, userId, dto.title, dto.content);
    return this._notes[index];
  }

  remove(noteId: number) {
    this._notes = this._notes.filter(note => note.id != noteId)
  }

  private _getRandomInt() {
    return Math.floor(Math.random() * 100);
  }
}

Базовое приложение, реализующее CRUD-операции над заметками (ресурсом), получено. Теперь, перейдем к документированию API. Для этого установим Swagger-модуль для Nest.js:

$ npm install --save @nestjs/swagger swagger-ui-express

и подключим его к нашему приложению в файле main.ts:

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('Notes API')
    .setDescription('The notes API description')
    .setVersion('1.0')
    .build();
    
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  await app.listen(3000);
}

bootstrap();

Для того чтобы убедится в работоспособности нашего приложения, запустим его командой:

$ npm run start:dev

После запуска, по адресу http://localhost:3000/api/ отобразится следующая страница:

Страница со Swagger представлением полученного API
Страница со Swagger представлением полученного API

Уже что-то, но на полноценную документацию еще не похоже.

Во-первых, перенесем все методы работы с заметками в отдельную секцию Notes. Для этого повесим очередной декоратор на NotesController:

@ApiTags('Notes')  // <---- Отдельная секция в Swagger для всех методов контроллера
@Controller('notes')
export class NotesController {
  ...
}

Также, уберем из нашей документации метод работы с корневым маршрутом (/), повесив на него декоратор ApiExcludeEndpoint:

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  @ApiExcludeEndpoint() // <----- Скрыть метод контроллера в Swagger описании
  getHello(): string {
    ...
  }
}

При этом, результат будет выглядеть следующим образом:

Во-вторых, добавим нашим эндоинтам описание, а также валидацию принимаемых параметров. В результате методы нашего контроллера приобретут следующий вид:

@ApiTags('Notes')
@Controller('notes') 
export class NotesController {

  ...

  @Patch(':noteId') // обработает PATCH http://localhost/notes/{noteId}
  @ApiOperation({ summary: "Updates a note with specified id" })
  @ApiParam({ name: "noteId", required: true, description: "Note identifier" })
  @ApiResponse({ status: HttpStatus.OK, description: "Success", type: Note })
  @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: "Bad Request" })
  update(
    @Param('noteId', new ParseIntPipe()) noteId: number, 
    @Body() updateNoteDto: UpdateNoteDto
	) {
    return this.notesService.update(noteId, updateNoteDto);
  }

	...
}

Полный код приложения можно найти в репозитории по ссылке. Список декораторов, позволяющих описать методы API, можно найти по тут. В примере выше для валидации входных параметров метода update используется пайп ParseIntPipe, более подробно с ним и другими встроенными пайпами можно ознакомится по ссылке.

Чтобы корректно сформировать Swagger описание, необходимо модифицировать наши dto, а также класс Note:

import { ApiProperty } from "@nestjs/swagger";

export class Note {
    @ApiProperty({ description: "Note identifier", nullable: false })
    id: number;

    @ApiProperty({ description: "User identifier", nullable: true })
    userId: number;
    
    @ApiProperty({ description: "Note title", nullable: true })
    title: string;
    
    @ApiProperty({ description: "Note content", nullable: true })
    content: string;
  
	...
}

В результате, наше Swagger описание будет выглядеть следующим образом:

API эндпоинты готовы, теперь защитим наш ресурс от несанкционированного доступа. В моем случае, согласно ТЗ требовалось использовать способ доступа с помощью API ключа (подробнее можно почитать здесь). Примеры авторизации с использованием JWT можно посмотреть тут, тут или тут.

Реализовывать авторизацию будем с помощью популярной библиотеки passport, для этого воспользуемся официальным модулем (оберткой) для Nest.js - @nestjs/passport. В первую очередь установим требуемый модуль:

$ npm install --save @nestjs/passport passport passport-headerapikey

Далее, создадим в нашем приложении отдельный модуль, отвечающий за авторизацию:

$ nest g mo authorization # nest generate module authorization

Работа с библиотекой passport основывается на использовании так называемых стратегий авторизации. Реализуем одну из них (api-key.strategy.ts):

import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import Strategy from "passport-headerapikey";

@Injectable()
export class ApiKeyStrategy extends PassportStrategy(Strategy, "api-key") {
  
  constructor(private readonly _configService: ConfigService) {
    super({ header: "X-API-KEY", prefix: "" }, 
          true, 
          async (apiKey, done) => this.validate(apiKey, done)
         );
  }

  public validate = (incomingApiKey: string, done: (error: Error, data) => Record<string, unknown>) => {
    const configApiKey = this._configService.get("apiKey");

    if (configApiKey === incomingApiKey) {
      done(null, true);
    }

    done(new UnauthorizedException(), null);
  };
}

В примере выше в конструктор ApiKeyStrategy инжектируется сервис ConfigService. Данный сервис является частью пакета @nestjs/config и позволяет упростить работу с файлами, в которых содержатся переменные окружения (т.е. файлы вида .env). В нашем приложении ключ доступа к API является конфигурационным параметром и прописан в файле .env (см. код проекта). Более подробно о работе с модулем конфигурации можно ознакомится тут.

Теперь соберем воедино наш модуль авторизации (authorization.module.ts):

import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PassportModule } from "@nestjs/passport";
import { ApiKeyStrategy } from "./api-key.strategy";

@Module({
  imports: [PassportModule, ConfigModule],
  providers: [ApiKeyStrategy],
})
export class AuthorizationModule {}

Модуль авторизации готов, но на данный момент методы нашего NotesController продолжат обрабатывать запросы, которые не содержат API ключа в заголовках HTTP-запросов. Для защиты API добавим еще несколько декораторов в контроллер:

@ApiTags('Notes')
@ApiSecurity("X-API-KEY", ["X-API-KEY"]) // <----- Авторизация через Swagger 
@Controller('notes')
export class NotesController {
  constructor(private readonly notesService: NotesService) {}

  @Post() // обработает POST http://localhost/notes?userId={userId}
  @UseGuards(AuthGuard("api-key")) // <---- Вернет 401 (unauthorized)
  																 // при попытке доступа без корректного API ключа
	...
  create(
  	@Query('userId', new ParseIntPipe()) userId: number,
   	@Body() createNoteDto: CreateNoteDto
	) {
    return this.notesService.create(userId, createNoteDto);
  }
}

и модифицируем файл main.ts:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('Notes API')
    .setDescription('The notes API description')
    .setVersion('1.0')
    .addApiKey({       // <--- Покажет опцию X-API-KEY (apiKey)
      type: "apiKey",  // в окне 'Available authorizations' в Swagger
      name: "X-API-KEY", 
      in: "header", 
      description: "Enter your API key" 
		}, "X-API-KEY")
    .build();
  
  ...
}

Конечное Swagger описание нашего API будет выглядеть следующим образом:

В заключении также рекомендую заглянуть в официальный репозиторий с примерами кода от разработчиков фреймворка.

Комментарии (1)


  1. Babayka_od
    28.05.2022 17:25
    +8

    В чем смысл переписывать документацию?