Разрабатываем REST API с помощью TypeScript, NestJS, Prisma, AdminJS и Swagger

от автора

Привет, друзья!

В данном туториале мы разработаем простой сервер на NestJS, взаимодействующий с SQLite с помощью Prisma, с административной панелью, автоматически генерируемой с помощью AdminJS, и описанием интерфейса, автоматически генерируемым с помощью Swagger. Все это будет приготовлено под соусом TypeScript.

Репозиторий с кодом проекта.

Если вам это интересно, прошу под кат.

NestJS — это фреймворк для разработки эффективных и масштабируемых серверных приложений на Node.js. Данный фреймворк использует прогрессивный (что означает текущую версию ECMAScript) JavaScript с полной поддержкой TypeScript (использование TypeScript является опциональным) и сочетает в себе элементы объектно-ориентированного, функционального и реактивного функционального программирования.

Под капотом NestJS использует Express (по умолчанию), но также позволяет переключиться на Fastify.

Prisma — это современное объектно-реляционное отображение (Object Relational Mapping, ORM) для Node.js и TypeScript. Проще говоря, Prisma — это инструмент, позволяющий работать с реляционными (PostgreSQL, MySQL, SQL Server, SQLite) и нереляционной (MongoDB) базами данных с помощью JavaScript или TypeScript без использования SQL, хотя такая возможность все же имеется.

AdminJS — это инструмент, позволяющий внедрять в приложение автоматически генерируемый интерфейс админки на React. Интерфейс генерируется на основе моделей БД и позволяет управлять ее содержимым.

Swagger — это инструмент, позволяющий внедрять в приложение автоматически генерируемое описание интерфейса. Интерфейс генерируется на основании маршрутов (роутов) приложения. Специальные комментарии позволяют формировать дополнительную информацию о конечных точках.

Подготовка и настройка проекта

Глобально устанавливаем NestJS CLI и создаем NestJS-проект,:

yarn global add @nestjs/cli # or npm i -g @nestjs/cli  # nestjs-prisma - название проекта/директории nest new nestjs-prisma

Переходим в созданную директорию и устанавливаем Prisma в качестве зависимости для разработки:

cd nestjs-prisma  yarn add -D prisma # or npm i -D prisma

Инициализируем Prisma-проект:

yarn prisma init # or npx prisma init

Выполнение данной команды приводит к генерации файла prisma/schema.prisma, определяющего подключение к БД, генератор, используемый для генерации клиента Prisma, и схему БД, а также файла .env с переменной среды окружения _DATABASEURL, значением которой является строка, используемая Prisma для подключения к БД.

Редактируем файл schema.prisma — изменяем дефолтный провайдер postgresql на sqlite:

datasource db {   provider = "sqlite"   url      = env("DATABASE_URL") }

Обратите внимание: для работы со схемой Prisma удобно пользоваться этим расширением для VSCode.

Определяем строку подключения к БД в файле .env:

DATABASE_URL="file:./dev.db"

Обратите внимание: БД SQLite — это просто файл, для работы с ним не требуется отдельный сервер.

Наша БД будет содержать 2 таблицы: для пользователей и постов.

Определяем соответствующие модели в файле schema.prisma:

model User {   id    Int     @id @default(autoincrement())   email String  @unique   name  String?   posts Post[] }  model Post {   id        Int     @id @default(autoincrement())   title     String   content   String?   published Boolean @default(false)   authorId  Int   author    User    @relation(fields: [authorId], references: [id]) }

Обратите внимание: между таблицами существуют отношения один-ко-многим (one-to-many), т.е. одному пользователю может принадлежать несколько постов (у каждого поста должен быть автор). Также обратите внимание, что данные пользователя должны содержать, как минимум, адрес электронной почты, а данные поста, как минимум — заголовок и автора.

Выполняем миграцию:

# init - название миграции yarn prisma migrate dev --name init # or npx prisma ...

Выполнение данной команды приводит к генерации файла prisma/dev.db, содержащего БД, и файла _prisma/migrations/20220506124711init/migration.sql (у вас название директории с файлом migration.sql будет другим) с миграцией на SQL. Также запускается установка клиента Prisma. Если по какой-то причине этого не произошло, клиента необходимо установить вручную:

yarn add @prisma/client # or npm i @prisma/client

Обратите внимание: клиент Prisma устанавливается в качестве производственной зависимости.

Также обратите внимание, что установка клиента Prisma приводит к автоматическому выполнению команды prisma generate для генерации типов TypeScript для всевозможных вариаций моделей БД. При внесении каких-либо изменений в существующие модели, добавлении новых моделей и т.п. может потребоваться выполнить эту команду вручную для обновления клиента (приведения его в соответствие с БД).

На этом подготовка и настройка проекта завершены и можно приступать к разработке REST API.

Разработка REST API

При разработке REST API, подключении AdminJS и Swagger мы будем работать с файлами, находящими в директории src.

Начнем с создания PrismaService, отвечающего за инстанцирование (создание экземпляра) PrismaClient и подключение к БД (а также отключение от нее). Создаем файл prisma.service.ts следующего содержания:

import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@prisma/client';  @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit {   async onModuleInit() {     // подключаемся к БД при инициализации модуля     await this.$connect();   }    async enableShutdownHooks(app: INestApplication) {     this.$on('beforeExit', async () => {       // закрываем приложение при отключении от БД       await app.close();     });   } }

Теперь займемся сервисами для обращения к БД с помощью моделей User и Post из схемы Prisma.

Создаем файл user.service.ts следующего содержания:

import { Injectable } from '@nestjs/common'; // преимущество использования `Prisma` в `TypeScript-проекте` состоит в том, // что `Prisma` автоматически генерирует типы для моделей и их вариаций import { User, Prisma } from '@prisma/client'; import { PrismaService } from './prisma.service';  @Injectable() export class UserService {   // внедряем зависимость   constructor(private prisma: PrismaService) {}    // получение пользователя по email   async user(where: Prisma.UserWhereUniqueInput): Promise<User | null> {     return this.prisma.user.findUnique({       where,     });   }    // получение всех пользователей   async users(params: {     skip?: number;     take?: number;     cursor?: Prisma.UserWhereUniqueInput;     where?: Prisma.UserWhereInput;     orderBy?: Prisma.UserOrderByWithRelationInput;   }): Promise<User[]> {     const { skip, take, cursor, where, orderBy } = params;     return this.prisma.user.findMany({       skip,       take,       cursor,       where,       orderBy,     });   }    // создание пользователя   async createUser(data: Prisma.UserCreateInput): Promise<User> {     return this.prisma.user.create({ data });   }    // обновление пользователя   async updateUser(params: {     where: Prisma.UserWhereUniqueInput;     data: Prisma.UserUpdateInput;   }): Promise<User> {     const { where, data } = params;     return this.prisma.user.update({       data,       where,     });   }    // удаление пользователя   async removeUser(where: Prisma.UserWhereUniqueInput): Promise<User> {     return this.prisma.user.delete({ where });   } }

Создаем файл post.service.ts следующего содержания:

import { Injectable } from '@nestjs/common'; import { PrismaService } from './prisma.service'; import { Post, Prisma } from '@prisma/client';  type GetPostsParams = {   skip?: number;   take?: number;   cursor?: Prisma.PostWhereUniqueInput;   where?: Prisma.PostWhereInput;   orderBy?: Prisma.PostOrderByWithRelationInput; };  @Injectable() export class PostService {   constructor(private prisma: PrismaService) {}    // получение поста по id   async post(where: Prisma.PostWhereUniqueInput): Promise<Post | null> {     return this.prisma.post.findUnique({ where });   }    // получение всех постов   async posts(params: GetPostsParams) {     return this.prisma.post.findMany(params);   }    // создание поста   async createPost(data: Prisma.PostCreateInput): Promise<Post> {     return this.prisma.post.create({ data });   }    // обновление поста   async updatePost(params: {     where: Prisma.PostWhereUniqueInput;     data: Prisma.PostUpdateInput;   }): Promise<Post> {     return this.prisma.post.update(params);   }    // удаление поста   async removePost(where: Prisma.PostWhereUniqueInput): Promise<Post> {     return this.prisma.post.delete({ where });   } }

Определим несколько роутов в основном контроллере приложения. Редактируем файл app.controller.ts:

import {   Controller,   Get,   Param,   Post,   Body,   Put,   Delete, } from '@nestjs/common'; import { UserService } from './user.service'; import { PostService } from './post.service'; import { User as UserModel, Post as PostModel } from '@prisma/client';  type UserData = { email: string; name?: string };  type PostData = {   title: string;   content?: string;   authorEmail: string; };  // добавляем префикс пути @Controller('api') export class AppController {   constructor(     // внедряем зависимости     private readonly userService: UserService,     private readonly postService: PostService,   ) {}    @Get('post/:id')   async getPostById(@Param('id') id: string): Promise<PostModel> {     return this.postService.post({ id: Number(id) });   }    @Get('feed')   async getPublishedPosts(): Promise<PostModel[]> {     return this.postService.posts({       where: {         published: true,       },     });   }    @Get('filtered-posts/:searchString')   async getFilteredPosts(     @Param('searchString') searchString: string,   ): Promise<PostModel[]> {     return this.postService.posts({       where: {         OR: [           {             title: { contains: searchString },           },           {             content: { contains: searchString },           },         ],       },     });   }    @Post('post')   async createDraft(@Body() postData: PostData): Promise<PostModel> {     const { title, content, authorEmail } = postData;      return this.postService.createPost({       title,       content,       author: {         connect: { email: authorEmail },       },     });   }    @Put('publish/:id')   async publishPost(@Param('id') id: string): Promise<PostModel> {     return this.postService.updatePost({       where: { id: Number(id) },       data: { published: true },     });   }    @Delete('post/:id')   async removePost(@Param('id') id: string): Promise<PostModel> {     return this.postService.removePost({ id: Number(id) });   }    @Post('user')   async registerUser(@Body() userData: UserData): Promise<UserModel> {     return this.userService.createUser(userData);   } }

Контроллер реализует следующие роуты:

  • GET:
    • /post/:id: получение поста по id;
    • /feed: получение всех опубликованных постов;
    • filtered-posts/:searchString: получение постов, отфильтрованных по заголовку или содержимому;
  • POST:
    • /post: создание поста:
    • тело запроса:
      • title: String (обязательно): заголовок;
      • content: String (опционально): содержимое;
      • authorEmail: String (обязательно): email автора;
    • /user: создание пользователя:
    • тело запроса:
      • email: String (обязательно): адрес электронной почты;
      • name: String (опционально): имя;
  • PUT:
    • /publish/:id: публикация поста по id;
  • DELETE:
    • /post/:id: удаление поста по id.

Обратите внимание: ко всем роутам будет автоматически добавлен префикс пути, определенный в контроллере (api). Также обратите внимание, что в реальном приложении большинство (если не все) роуты, связанные с постами, будут защищенными (private), т.е. доступными только зарегистрированным и авторизованным пользователям (выполняющим запрос с токеном доступа — access token).

Внедряем провайдеры в основной модуль приложения (app.module.ts):

import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; // ! import { PrismaService } from './prisma.service'; import { UserService } from './user.service'; import { PostService } from './post.service';  @Module({   imports: [],   controllers: [AppController],   // !   providers: [PrismaService, UserService, PostService], }) export class AppModule {}

Для корректной работы Prisma с enableShutdownHooks требуется немного отредактировать файл main.ts:

import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { PrismaService } from './prisma.service';  async function bootstrap() {   const app = await NestFactory.create(AppModule);   // !   const prismaService = app.get(PrismaService);   await prismaService.enableShutdownHooks(app);   await app.listen(3000); } bootstrap();

На этом разработка REST API завершена. Давайте убедимся в работоспособности сервера. Для этого я буду использовать Insomnia.

Запускаем сервер в режиме для разработки:

yarn start:dev # or npm run start:dev

Сам сервер доступен по адресу http://localhost:3000, а определенный нами REST API по адресу http://localhost:3000/api.

Регистрируем нового пользователя с именем Bob и адресом электронной почты bob@email.com:

Создаем от имени Bob 3 поста:



Получаем пост с id, равным 4:

Публикуем посты с id, равными 5 и 6:


Получаем опубликованные посты:

Получаем посты, в заголовке или содержимом которых встречается слово title2 (независимо от регистра):

Удаляем пост с id, равным 5:

Получаем посты, в которых встречается слово title (в нашем случае, все посты):

Отлично, сервер работает, как ожидается.

Приступим к внедрению в приложение админки.

Внедрение админки

Обратите внимание: модули AdminJS для работы с NestJS и Prisma являются экспериментальными, т.е. находятся в стадии активной разработки. Это означает, что способ их подключения и использования в будущем может измениться.

Устанавливаем зависимости:

yarn add adminjs @adminjs/nestjs express @adminjs/express express-formidable express-session # or npm i ...

Обратите внимание: несмотря на то, что Express является зависимостью NestJS (поскольку используется в качестве дефолтной нижележащей платформы — underlying platform), для корректной работы AdminJS он должен быть установлен в качестве производственной зависимости приложения. Также обратите внимание, что согласно документации AdminJS, установка пакета express-session является опциональной, но на сегодняшний день это не так: без него @adminjs/express категорически отказывается от сотрудничества, а без @adminjs/express не работает @adminjs/nestjs.

Оформим код AdminJS в виде отдельного модуля. Создаем файл admin.module.ts следующего содержания:

import AdminJS from 'adminjs'; // без этого `@adminjs/nestjs` по какой-то причине "не видит" `@aminjs/express`, необходимый ему для работы import '@adminjs/express'; import { AdminModule } from '@adminjs/nestjs'; import { Database, Resource } from '@adminjs/prisma'; // мы не можем использовать `User` и `Post` из `@prisma/client`, // поскольку нам нужны модели, а не типы, // поэтому приходится делать так import { PrismaClient } from '@prisma/client'; import { DMMFClass } from '@prisma/client/runtime'; 1;  const prisma = new PrismaClient(); const dmmf = (prisma as any)._dmmf as DMMFClass;  AdminJS.registerAdapter({ Database, Resource });  export default AdminModule.createAdmin({   adminJsOptions: {     // путь к админке     rootPath: '/admin',     // в этом списке должны быть указаны все модели/таблицы БД,     // доступные для редактирования     resources: [       {         resource: { model: dmmf.modelMap.User, client: prisma },       },       {         resource: { model: dmmf.modelMap.Post, client: prisma },       },     ],   }, });

Подключаем (импортируем) этот модуль в AppModule:

import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { PrismaService } from './prisma.service'; import { UserService } from './user.service'; import { PostService } from './post.service'; // ! import AdminModule from './admin.module';  @Module({   // !   imports: [AdminModule],   controllers: [AppController],   providers: [PrismaService, UserService, PostService], }) export class AppModule {}

Перезапускаем сервер и переходим по адресу http://localhost:3000/admin:

Изучим содержимое таблицы постов. Для этого нажимаем на Post на панели навигации слева:

Стандартный интерфейс админки позволяет создавать новые записи, редактировать и удалять существующие, а также фильтровать записи по полям.

Редактируем запись с id, равным 4: изменяем заголовок на Title2, содержимое на Content2 и публикуем пост. Удаляем запись с id === 6 и создаем запись с заголовком Title4 и содержимым Content4:

Возвращаемся в Insomnia и получаем все посты (в которых встречается слово title):

Как видим, выполненные в админке операции привели к обновлению данных в базе.

Обратите внимание: если функционал вашей админки будет ограничен редактированием записей в БД, лучше воспользоваться решением, предоставляемым Prisma, что называется, из коробки. Речь идет о Prisma Studio.

Запускаем Prisma Studio с помощью следующей команды:

yarn prisma studio # or npx prisma studio

Переходим по адресу http://localhost:5555:

Prisma Studio предназначен исключительно для редактирования записей в БД. AdminJS предоставляет более широкие возможности по работе с данными и не только.

На этом разработка админки завершена.

Приступим к внедрению в приложение документации.

Внедрение документации

С внедрением в приложение документации все гораздо проще, поскольку NestJS поддерживает Swagger (Open API) из коробки.

Устанавливаем зависимости:

yarn add @nestjs/swagger swagger-ui-express # or npm i ...

Подключаем Swagger в основном файле приложения (main.ts):

import { NestFactory } from '@nestjs/core'; // swagger import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';  import { AppModule } from './app.module'; import { PrismaService } from './prisma.service';  async function bootstrap() {   const app = await NestFactory.create(AppModule);   // prisma   const prismaService = app.get(PrismaService);   await prismaService.enableShutdownHooks(app);   // swagger   const config = new DocumentBuilder()     // заголовок     .setTitle('Title')     // описание     .setDescription('Description')     // версия     .setVersion('1.0')     .build();   const document = SwaggerModule.createDocument(app, config);   // первый параметр - префикс пути, по которому будет доступна документация   SwaggerModule.setup('swagger', app, document);    await app.listen(3000); } bootstrap();

Перезапускаем сервер и переходим по адресу http://localhost:3000/swagger:

Как видим, Swagger успешно разрешил (обнаружил и проанализировал) все роуты нашего приложения. Форму (shape) ответов и другую дополнительную информацию о маршрутах можно определить вручную с помощью специальных комментариев.

Для того, чтобы получить сгенерированные Swagger данные в виде JSON-объекта следует перейти по адресу http://localhost:3000/swagger-json:

Подробнее о поддержке NestJS спецификации Open API можно почитать здесь.

Таким образом, нам удалось минимальными усилиями реализовать относительно полноценный и полностью типизированный («типобезопасный» — type safe) REST API с автоматически генерируемой админкой и документацией.

Пожалуй, это все, чем я хотел поделиться с вами в этой статье. Надеюсь, вы нашли для себя что-то интересное и не зря потратили время.

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/665794/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *