Привет, Хабр! Представляю вашему вниманию перевод статьи "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. Например, обе платформы используют guards для разрешения или предотвращения доступа к некоторым частям ваших приложений и обе платформы предоставляют интерфейс CanActivate для реализации этих guards. Тем не менее, важно отметить, что, несмотря на некоторые сходные концепции, обе структуры независимы друг от друга. То есть, в этой статье, мы создадим независимый API для нашего front-end, который можно будет использовать с любым другим фреймворком (React, Vue.JS и так далее).


Веб-приложение для он-лайн заказов


В этом руководстве мы создадим простое приложение, в котором пользователи смогут делать заказы в ресторане. Оно будет реализовывать такую логику:


  • любой пользователь может просматривать меню;
  • только авторизованный пользователь может добавлять товар в корзину (делать заказ)
  • только администратор может добавлять новые пункты меню.

Для простоты мы не будем взаимодействовать с внешней базой данных и не реализуем функциональность корзины нашего магазина.


Создание файловой структуры проекта 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 — хороший источник с ответами