Привет, Хабр! Представляю вашему вниманию перевод статьи "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
¦   L-- main.ts
+-- test
¦   +-- app.e2e-spec.ts
¦   L-- jest-e2e.json
+-- .gitignore
+-- .prettierrc
+-- nest-cli.json
+-- package.json
+-- package-lock.json
+-- README.md
+-- tsconfig.build.json
+-- tsconfig.json
L-- 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"}

Идентификация пользователей с Auth0


в процессе...

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


  1. gibson_dev
    14.10.2019 09:23

    Это точно статья? Больше похоже на перепечатку QuickStart из доки


    1. RokeAlvo Автор
      14.10.2019 09:47

      это начало, выкладываю, по мере оформления


      1. gibson_dev
        14.10.2019 09:49

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


        1. RokeAlvo Автор
          14.10.2019 09:56

          Первая публикация — при публикации, не было даже варианта оформления с markdown, только html. Не было ни какой уверенности, что все правильно оформил.


  1. Desprit
    14.10.2019 09:41

    Я столкнулся вот с какой проблемой. У меня angular приложение и nest к нему. Также, на первом уровне, есть папка shared с общими моделями данных.

    backend/
    frontend/
    shared/

    Когда я запускаю nest через start:dev, то в папка backend/dist преобретает вместо правильной структуры backend/src/ следующую:

    backend/dist/backend/src/
    backend/dist/shared/

    что затем приводит к ошибкам импортов. Как вы храните общие между angular и nest модели?


    1. RokeAlvo Автор
      14.10.2019 10:03

      я бы разделил бек и фронт по разным проектам


      1. Desprit
        14.10.2019 10:53

        Да, но ведь вопрос по общим моделям остается. Дублировать их для каждого проекта тогда?


    1. lucky_libora
      14.10.2019 11:01

      Тоже натолкнулся на такую проблему. В итоге пришлось отказаться от shared каталога, все модели респонсов/реквестов храню в backend и импортирую их во фронт. Решение не очень красивое, но рабочее


      1. Desprit
        14.10.2019 11:15

        Ну, кстати, да. Вполне приемлемо для меня.
        Еще заработало когда я в nest-cli.json указал следующее:

        {
        "collection": "@nestjs/schematics",
        "sourceRoot": "backend/src"
        }

        Тогда nest start --watch находил файлы в dist директории правильно. Но затем я понял, что это привело к неправильной работе комманд вида nest g .... Эти команды начали создавать файлы не там, где мне было нужно.


    1. myemuk
      15.10.2019 20:10

      Посмотрите в сторону nrwl с их продуктом nx. Использую их подход монорепозитория для проектов angular + nestjs.


  1. AlexanderMarginal
    14.10.2019 09:47

    Когда продолжение