Website development
Node.JS
TypeScript
14 October 2019

Full-stack TypeScript Apps

From Sandbox Tutorial

Привет, Хабр! Представляю вашему вниманию перевод статьи "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 — хороший источник с ответами


+7
5.6k 77
Comments 11
Top of the day