Привет, Хабр! Представляю вашему вниманию перевод статьи "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
¦ 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)
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 модели?lucky_libora
14.10.2019 11:01Тоже натолкнулся на такую проблему. В итоге пришлось отказаться от shared каталога, все модели респонсов/реквестов храню в backend и импортирую их во фронт. Решение не очень красивое, но рабочее
Desprit
14.10.2019 11:15Ну, кстати, да. Вполне приемлемо для меня.
Еще заработало когда я в nest-cli.json указал следующее:
{
"collection": "@nestjs/schematics",
"sourceRoot": "backend/src"
}
Тогдаnest start --watch
находил файлы в dist директории правильно. Но затем я понял, что это привело к неправильной работе комманд видаnest g ...
. Эти команды начали создавать файлы не там, где мне было нужно.
gibson_dev
Это точно статья? Больше похоже на перепечатку QuickStart из доки
RokeAlvo Автор
это начало, выкладываю, по мере оформления
gibson_dev
Все же стоило проработать статью до какого законченного варианта — то что вышло сейчас никакой смысловой нагрузки не несет
RokeAlvo Автор
Первая публикация — при публикации, не было даже варианта оформления с markdown, только html. Не было ни какой уверенности, что все правильно оформил.