Как стать автором
Обновить
2091.35

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

Время на прочтение12 мин
Количество просмотров13K



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


В данном туториале мы разработаем простой сервер на 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!




Теги:
Хабы:
Всего голосов 10: ↑10 и ↓0+10
Комментарии9

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud