Введение
Друзья, всем привет!
Меня зовут Алексей и вот уже некоторое время я занимаюсь 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/ отобразится следующая страница:
Уже что-то, но на полноценную документацию еще не похоже.
Во-первых, перенесем все методы работы с заметками в отдельную секцию 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 будет выглядеть следующим образом:
В заключении также рекомендую заглянуть в официальный репозиторий с примерами кода от разработчиков фреймворка.