Привет, Хабр! Представляю вашему вниманию перевод статьи "Full-Stack TypeScript Apps — Part 1: Developing Backend APIs with Nest.js" автора Ana Ribeiro.
Часть 1: Разработка серверного API с помощью Nest.JS
TL;DR: это серия статей о том, как создать веб-приложение TypeScript с использованием Angular и Nest.JS. В первой части мы напишем простой серверный API с помощью Nest.JS. Вторая часть этой серии посвящена интерфейсному приложению с использованием Angular. Вы можете найти окончательный код, разработанный в этой статье в этом репозитории GitHub
Что такое Nest.Js и почему именно Angular?
Nest.js это фреймворк для создания серверных веб-приложений Node.js.
Отличительной особенностью является то, что он решает проблему, которую не решает ни один другой фреймоворк: структура проекта node.js. Если вы когда-нибудь разрабатывали под node.js, вы знаете, что можно многое сделать с помомщью одного модуля (например, Express middleware может сделать все, от аутентификации до валидации), что, в конечном итоге, может привести к трудноподдерживаемой "каше". Как вы увидите ниже, nest.js поможет нам в этом, предоставляя классы, которые специализируются на различных проблемах.
Nest.js сильно вдохновлен Angular. Например,
Веб-приложение для он-лайн заказов
В этом руководстве мы создадим простое приложение, в котором пользователи смогут делать заказы в ресторане. Оно будет реализовывать такую логику:
- любой пользователь может просматривать меню;
- только авторизованный пользователь может добавлять товар в корзину (делать заказ)
- только администратор может добавлять новые пункты меню.
Для простоты мы не будем взаимодействовать с внешней базой данных и не реализуем функциональность корзины нашего магазина.
Создание файловой структуры проекта Nest.js
Для установки Nest.js нам потребуется установить Node.js (v.8.9.x или выше) и NPM. Node.js для вашей операционной системы скачиваем и устанавливаем с официального сайта (NPM идет в комплекте). Когда все установится проверим версии:
node -v # v12.11.1 npm -v # 6.11.3
Есть разные пути для создания проекта с Nest.js; с ними можно ознакомиться в документации. Мы же воспользуемся nest-cli. Установим его:
npm i -g @nestjs/cli
Далее создадим наш проект простой командой:
nest new nest-restaurant-api
в процессе работы nest попросит нас выбрать менеджер пакетов: npm или yarn
Если все прошло удачно, nest создаст следующую файловую структуру:
nest-restaurant-api ├── src │ ├── app.controller.spec.ts │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── .gitignore ├── .prettierrc ├── nest-cli.json ├── package.json ├── package-lock.json ├── README.md ├── tsconfig.build.json ├── tsconfig.json └── tslint.json
перейдем в созданный каталог и запустим сервер разработки:
# сменим рабочий каталог cd nest-restaurant-api # запустим сервер npm run start:dev
Откроем браузер и введем http://localhost:3000. На экране увидим:

В рамках этого руководства мы небудем заниматься тестированием нашго API (хотя вы должны писать тесты для любого готового к работе приложения). Таким образом, вы можете очистить каталог test и удалить файл src/app.controller.spec.ts (который является тестовым). В итоге наша папка с исходиками содержит следующие файлы:
src/app.controller.tsиsrc/app.module.ts: эти файлы отвечают за создание сообщенияHello worldпо маршруту/. Т.к. эта точка входа не важна для этого приложения мы их удаляем. Вскоре вы узнаете более подробно, что такое ��онтроллеры (controllers) и службы (services).src/app.module.ts: содержит описание класса типа модуль (module), который отвечает за объявление импорта, экспорта контроллеров и провайдеров в приложение nest.js. Каждое приложение имеет по крайней мере один модуль, но вы можете создать более одного модуля для более сложных приложений (подробнее в документации. Наше приложение будет содержать только один модульsrc/main.ts: это файл, ответственный за запуск сервера.
Примечание: после удаления src/app.controller.tsиsrc/app.module.tsвы не сможете запустить наше приложение. Не волнуйтесь, скоро мы это исправим.
Создание точек входа (endpoints)
Наше API будет доступно по маршруту /items. Через эту точку входа пользователи смогут получать данные, а администраторы управлять меню. Давайте создадим ее.
Для этого создадим каталог с именем items внутри src. Все файлы, связанные с маршрутом /items будут храниться в этом новом каталоге.
Создание контроллеров
в nest.js, как и во многих других фреймворках, контроллеры отвечают за сопоставление маршрутов с функциональными возможностями. Чтобы создать контроллер в nest.js используется декоратор @Controller следующим образом: @Controller(${ENDPOINT}). Далее для того, чтобы сопоставить различные методы HTTP, такие как GET и POST, используются декораторы @Get, @Post, @Delete и т. д.
В нашем случае нам нужно создать контроллер, который возвращает блюда доступные в ресторане, и который будут использовать администраторы для управления содержимым меню. Давайте создадим файл с именем items.controller.tc в каталоге src/items со следующим содержанием:
import { Get, Post, Controller } from '@nestjs/common'; @Controller('items') export class ItemsController { @Get() async findAll(): Promise<string[]> { return ['Pizza', 'Coke']; } @Post() async create() { return 'Not yet implemented'; } }
для того, что бы сделать наш новый контроллер доступным в нашем приложении зарегистрируем его в модуле:
import { Module } from '@nestjs/common'; import { ItemsController } from './items/items.controller'; @Module({ imports: [], controllers: [ItemsController], providers: [], }) export class AppModule {}
Запустим наше приложение: npm run start:dev и откроем в браузере http://localhost:3000/items, если вы все сделали правильно, то мы должны увидеть ответ на наш get запрос: ['Pizza', 'Coke'].
Примечание переводчика: для создания новых контроллеров, как и других элементов nest.js: сервисов, провайдеров и т.д., удобней использовать команду nest generate из пакета nest-cli. Например, для создания вышеописанного контроллера, можно использовать команду nest generate controller items, в результате которой nest создаст файлы src/items/items.controller.spec.tc и src/items/items.controller.tc следующего содержания:
import { Get, Post, Controller } from '@nestjs/common'; @Controller('items') export class ItemsController {}
и зарегистрирует его в app.molule.tc
Добавление сервиса (service)
Сейчас при обращении к /items наше приложение на каждый запрос возвращает один и тот же массив, который мы не можем изменить. Обработка и сохранение данных не дело контроллера, для этого в nest.js предназначены сервисы (services)
Сервисы в nest — это классы, задекорированные @Injectable
Имя декоратора говорит само за себя, добавление этого декоратора к классу делает его вводимым (Injectable) в другие компоненты, например контроллеры.
Давайте создадим наш сервис. Создадим файл items.service.ts папке ./src/items со следующим содержанием:
import { Injectable } from '@nestjs/common'; @Injectable() export class ItemsService { private readonly items: string[] = ['Pizza', 'Coke']; findAll(): string[] { return this.items; } create(item: string) { this.items.push(item); } }
и изменим контроллер ItemsController (объявленный в items.controller.ts), что бы он использовал наш сервис:
import { Get, Post, Body, Controller } from '@nestjs/common'; import { ItemsService } from './items.service'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() async findAll(): Promise<string[]> { return this.itemsService.findAll(); } @Post() async create(@Body() item: string) { this.itemsService.create(item); } }
в новой версии контроллера мы применили декоратор @Body к аргументу метода create. Этот аргумент используется для автоматического сопоставления данных, передаваемых через req.body ['item'] к самому аргументу (в данном случае item).
Так же наш контроллер получает экземпляр класса ItemsService, введенный (injected) через конструктор. Объявление ItemsService как private readonly делает экземпляр неизменяемым и видимым только внутри класса.
И не забудем зарегистрировать наш сервис в app.module.ts:
import { Module } from '@nestjs/common'; import { ItemsController } from './items/items.controller'; import { ItemsService } from './items/items.service'; @Module({ imports: [], controllers: [ItemsController], providers: [ItemsService], }) export class AppModule {}
После всех изменений давайте отправим HTTP POST запрос к меню:
curl -X POST -H 'content-type: application/json' -d '{"item": "Salad"}' localhost:3000/items
Затем проверим, появились ли новые блюда в нашем меню, сделав GET запрос (либо открыв http://localhost:3000/items в браузере)
curl localhost:3000/items
Создание маршрута для корзины покупок
Теперь, когда у нас есть первая версия точки входа /items нашего API, давайте реализуем функционал корзины покупок. Процесс создания этого фу��кционала мало отличается от уже созданного API. Поэтому, что бы не загромождать руководство, мы создадим компонент отвечающий со статусом ОК при обращении.
Сперва в папке ./src/shopping-cart/ создадим файл shoping-cart.controller.ts:
import { Post, Controller } from '@nestjs/common'; @Controller('shopping-cart') export class ShoppingCartController { @Post() async addItem() { return 'This is a fake service :D'; } }
Зарегистрируем этот контроллер в нашем модуле (app.module.ts):
import { Module } from '@nestjs/common'; import { ItemsController } from './items/items.controller'; import { ShoppingCartController } from './shopping-cart/shopping-cart.controller'; import { ItemsService } from './items/items.service'; @Module({ imports: [], controllers: [ItemsController, ShoppingCartController], providers: [ItemsService], }) export class AppModule {}
Для проверки этой точки входа выполните следующую команду, предварительно убедившись, что приложение запущено:
curl -X POST localhost:3000/shopping-cart
Добавление описания Interface Typescript для Items
Вернемся к нашему сервису items. Сейчас мы сохраняем только название блюда, но этого явно мало, и, наверняка, нам захочется иметь больше информации (например, стоимость блюда). Думаю, вы согласитесь, что хранение этих данных в виде массива строк не лучшая идея?
Для решения данной проблемы мы можем создать массив объектов. Но как сохранить структуру объектов? Здесь нам поможет интерфейс TypeScript, в котором мы определим структуру объекта items. Создадим новый файл с именем item.interface.ts в папке src/items:
export interface Items { readonly name: string; readonly price: number; }
Затем изменим файл items.service.ts:
import { Injectable } from '@nestjs/common'; import { Item } from './item.interface'; @Injectable() export class ItemsService { private readonly items: Item[] = []; findAll(): Item[] { return this.items; } create(item: Item) { this.items.push(item); } }
И так же в items.controller.ts:
import { Get, Post, Body, Controller } from '@nestjs/common'; import { ItemsService } from './items.service'; import { Item } from './item.interface'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() async findAll(): Promise<Item[]> { return this.itemsService.findAll(); } @Post() async create(@Body() item: Item) { this.itemsService.create(item); } }
Валидация входных данных в Nest.js
Не смотря на то, что мы определили структуру объекта item, наше приложение не будет возвращать ошибку, если мы отправим не валидный POST запрос (любой тип данных не определенных в интерфейсе). Например, на такой запрос:
curl -H 'Content-Type: application/json' -d '{ "name": 3, "price": "any" }' http://localhost:3000/items
сервер должен отвечать со статусом 400 (bad request), но вместо этого наше приложение ответит статусом 200(OK).
Для решения этой проблемы создадим DTO (Data Transfer Object) и компонент Pipe (канал).
DTO это объект, определяющий как данные должны передаваться между процессами. Опишем DTO в файле src/items/create-item.dto.ts:
import { IsString, IsInt } from 'class-validator'; export class CreateItemDto { @IsString() readonly name: string; @IsInt() readonly price: number; }
Каналы (Pipes) в Nest.js это компоненты, использующиеся для валидации. Для нашего API создадим канал, в котором проверяется, соответствуют ли DTO данные, отправленные в метод. Один канал может использоваться разными контроллерами, поэтому создадим директорию src/common/ с файлом validation.pipe.ts:
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform, } from '@nestjs/common'; import { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; @Injectable() export class ValidationPipe implements PipeTransform<any> { async transform(value, metadata: ArgumentMetadata) { const { metatype } = metadata; if (!metatype || !this.toValidate(metatype)) { return value; } const object = plainToClass(metatype, value); const errors = await validate(object); if (errors.length > 0) { throw new BadRequestException('Validation failed'); } return value; } private toValidate(metatype): boolean { const types = [String, Boolean, Number, Array, Object]; return !types.find(type => metatype === type); } }
Примечание: Нам потребуется установить два модуля:class-validatorиclass-transformer. Для это выполните в консолиnpm install class-validator class-transformerи перезапустите сервер.
Адаптируем items.controller.ts для использования с нашим новым каналом (pipe) и DTO:
import { Get, Post, Body, Controller, UsePipes } from '@nestjs/common'; import { CreateItemDto } from './create-item.dto'; import { ItemsService } from './items.service'; import { Item } from './item.interface'; import { ValidationPipe } from '../common/validation.pipe'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() async findAll(): Promise<Item[]> { return this.itemsService.findAll(); } @Post() @UsePipes(new ValidationPipe()) async create(@Body() createItemDto: CreateItemDto) { this.itemsService.create(createItemDto); } }
Проверим наш код снова, теперь точка входа /items принимает данные только, если они определены в DTO. Например:
curl -H 'Content-Type: application/json' -d '{ "name": "Salad", "price": 3 }' http://localhost:3000/items
Вставьте не валидные данные (данные, которые не смогут пройти проверку в ValidationPipe), в результате мы получим ответ:
{"statusCode":400,"error":"Bad Request","message":"Validation failed"}
Создание Middleware
Согласно странице руководства по быстрому запуску Auth0, рекомендуемый способ проверки токена JWT, выданного Auth0, — это использование Express middleware, предоставляемого express-jwt. Этот middleware автоматизирует огромную часть работы.
Давайте создадим файл authentication.middleware.ts внутри каталога src / common со следующим кодом:
import { NestMiddleware } from '@nestjs/common'; import * as jwt from 'express-jwt'; import { expressJwtSecret } from 'jwks-rsa'; export class AuthenticationMiddleware implements NestMiddleware { use(req, res, next) { jwt({ secret: expressJwtSecret({ cache: true, rateLimit: true, jwksRequestsPerMinute: 5, jwksUri: 'https://${DOMAIN}/.well-known/jwks.json', }), audience: 'http://localhost:3000', issuer: 'https://${DOMAIN}/', algorithm: 'RS256', })(req, res, err => { if (err) { const status = err.status || 500; const message = err.message || 'Sorry, we were unable to process your request.'; return res.status(status).send({ message, }); } next(); }); }; }
Замените ${DOMAIN} на значение domain из настроек приложения Auth0
Примечание переводчика: в реальном приложении вынесетеDOMAINв константу, и задавайте ее значение черезenv(виртуальное окружение)
Установите библиотеки express-jwt и jwks-rsa:
npm install express-jwt jwks-rsa
Надо подключить созданный middleware (обработчик) к нашему приложению. Для этого в файле ./src/app.module.ts:
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { AuthenticationMiddleware } from './common/authentication.middleware'; import { ItemsController } from './items/items.controller'; import { ShoppingCartController } from './shopping-cart/shopping-cart.controller'; import { ItemsService } from './items/items.service'; @Module({ imports: [], controllers: [ItemsController, ShoppingCartController], providers: [ItemsService], }) export class AppModule { public configure(consumer: MiddlewareConsumer) { consumer .apply(AuthenticationMiddleware) .forRoutes( { path: '/items', method: RequestMethod.POST }, { path: '/shopping-cart', method: RequestMethod.POST }, ); } }
Вышеприведенный код говорит о том, что POST запросы к маршрутам /items и /shopping-cart защищены Express middleware, который проверяет наличие токена доступа в запросе.
Перезапустите сервер разработки (npm run start:dev) и вызовите Nest.js API:
# это не будет работать curl -X POST http://localhost:3000/shopping-cart # для начала задайте токен доступа TOKEN="eyJ0eXAiO...Mh0dpeNpg" # and issue a POST request with it curl -X POST -H 'authorization: Bearer '$TOKEN http://localhost:3000/shopping-cart
Управление ролями с Auth0
На данный момент любой пользователь, имеющий проверенный токен, может опубликовать item в нашем API. Однако, нам бы хотелось, что бы это могли делать только пользователи с правами администратора. Для реализации этой функции используем правила (rules) Auth0.
Итак, перейдите на панель управления Auth0, в раздел Rules (Правила). Там нажмите кнопку + CREATE RULE и выберите "Set roles to a user" ("установить роли для пользователя") в качестве модели правил.

Проделав это, мы получим файл JavaScript с шаблоном правил, который добавляет роль администратора к любому пользователю, у которого есть электронная почта, принадлежащая к некоторому домену. Изменим несколько деталей в этом шаблоне, чтобы получить функциональный пример. Для нашего приложения предоставим администратору доступ только к нашему собственному адресу электронной почты. Нам также потребуется изменить место сохранения информации о статусе администратора.
На данный момент эта информация сохраняется в токене идентификации (используется для предоставления информации о пользователе), но для доступа к ресурсам в API следует использовать токен доступа. Код после изменений должен выглядеть следующим образом:
function (user, context, callback) { user.app_metadata = user.app_metadata || {}; if (user.email && user.email === '${YOUR_EMAIL}') { user.app_metadata.roles = ['admin']; } else { user.app_metadata.roles = ['user']; } auth0.users .updateAppMetadata(user.user_id, user.app_metadata) .then(function() { context.accessToken['http://localhost:3000/roles'] = user.app_metadata.roles; callback(null, user, context); }) .catch(function(err) { callback(err); }); }
Примечание: замените${YOUR_EMAIL}на свой адрес электронной почты. Важно отметить, что, как правило, когда вы имеете дело с электронной почтой в правилах Auth0, идеальным является принудительная проверка электронной почты. В этом случае это не требуется, потому что мы используем свой собственный адрес электронной почты.
Примечание переводчика: вышеприведенный фрагмент кода вводится в браузере на странице настройки правила Auth0
Для проверки является ли токен, переданный нашему API, токеном администратора, нам требуется создать защитника (guard) Nest.js. В папке src/common создадим файл admin.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; @Injectable() export class AdminGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const user = context.getArgs()[0].user['http://localhost:3000/roles'] || ''; return user.indexOf('admin') > -1; } }
Теперь, если повторить процесс входа в систему, описанный выше, и использовать адрес электронной почты, определенный в правиле, мы получим новый access_token. Чтобы проверить содержимое этого access_token, скопируйте и вставьте токен в поле Encoded сайта https://jwt.io/. Мы увидим, что раздел полезных данных этого токена содержит следующий массив:
"http://localhost:3000/roles": [ "admin" ]
Если наш токен действительно включает эту информацию, продолжим интеграцию с Auth0. Итак, откройте items.controller.ts и добавьте туда наш новый guard:
import { Get, Post, Body, Controller, UsePipes, UseGuards, } from '@nestjs/common'; import { CreateItemDto } from './create-item.dto'; import { ItemsService } from './items.service'; import { Item } from './item.interface'; import { ValidationPipe } from '../common/validation.pipe'; import { AdminGuard } from '../common/admin.guard'; @Controller('items') export class ItemsController { constructor(private readonly itemsService: ItemsService) {} @Get() async findAll(): Promise<Item[]> { return this.itemsService.findAll(); } @Post() @UseGuards(new AdminGuard()) @UsePipes(new ValidationPipe()) async create(@Body() createItemDto: CreateItemDto) { this.itemsService.create(createItemDto); } }
Теперь, с нашим новым токеном, мы сможем добавлять новые items через наш API:
# запустим наш сервер npm run start:dev # отправим POST запрос на добавление блюда в меню curl -X POST -H 'Content-Type: application/json' \ -H 'authorization: Bearer '$TOKEN -d '{ "name": "Salad", "price": 3 }' http://localhost:3000/items
Примечание переводчика: для проверки, можно посмотреть, что у нас находится в items:
curl -X GET http://localhost:3000/items
Итоги
Поздравляю! Мы только что закончил строить свой Nest.JS API и теперь можем сосредоточиться на разработке frontend части нашего приложения! Обязательно ознакомьтесь со второй частью этой серии: Full-Stack TypeScript Apps — Part 2: Developing Frontend Angular Apps.
Примечание переводчика: перевод второй части в процессе
Подводя итоги, в этой статье мы использовали различные возможности Nest.js и TypeScript: модули (module), контроллеры (controller), службы (service), интерфейсы (interface), каналы (pipes), промежуточные обработчики (middleware) и guard для создания API. Надеюсь, вы получили хороший опыт и готовы продолжать развивать наше приложение. Если вам что-либо не понятно, то официальная документация nest.js — хороший источник с ответами