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


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




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


  1. Suvitruf
    16.05.2022 13:02
    +2

    Как по мне, призму и иные внутренние штуки не очень выносить в контроллер. Я бы это всё оставлял внутри сервисов, с которыми контроллер работает, и сами сервисы возвращают преобразованные объекты из базы.


    1. Kuch
      16.05.2022 23:37
      +1

      Согласен. Более того, я бы вынес именно запросы к БД в *.repository.ts, в сервисе оставил логику + обращение к базе через репозитории, а в контроллер только для того, чтобы связать эндпоинт и нужный метод из сервиса


  1. DonAlPAtino
    16.05.2022 20:28

    Можно вопрос немного "в сторону". Мне тут (совсем не программисту) надо сделать прототип REST приложения. Можно сказать MVP для демонстрации партнеру. Вроде не сложно, куча примеров НО! Во всех найденных примерах всегда добавляется один объект. Один пост, одна статья, один юзер. В том же nestjs под это вся валидация из коробки. Понятно что это под web-frontend. У меня же скорее интерфейс к другой системе и партнер хочет сразу пачкой объекты загружать. А таких примеров реализации в лоб я не нахожу. Это плохой тон? Или есть объективные причины (типа сложной валидации) почему так не делают? Или еще что-то?


    1. aio350 Автор
      16.05.2022 21:48

      Для создания, обновления и удаления нескольких записей в одной транзакции Prisma предоставляет такие методы, как createMany, updateMany и deleteMany, соответственно. Особенность этих методов (транзакции) состоит в том, что если хотя бы один объект в массиве окажется невалидным, операция провалится (ни один объект не будет записан etc.). Но если позаботиться о валидации заранее, то все получится) Совсем не программисту я бы не советовал начинать с NestJS, Prisma и TypeScript. Простой REST API, пожалуй, легче всего реализовать с помощью Express + Mongoose (для демонстрации хватит кластера в MongoDB Atlas).


      1. DonAlPAtino
        16.05.2022 22:06

        Prisma же уже в БД пишет. Мне бы пример как через REST не один объект загрузить, а пачку.


        1. Kuch
          16.05.2022 23:39
          +2

          В post запросе в body передать массив объектов


          1. DonAlPAtino
            17.05.2022 11:49

            Это понятно. Вопрос почему никто таких примеров в статьях не дает. Чтобы просто скопипастить проект и допилить под свою схему данных :-) Т.е. никаких подводных камней в этом нет и просто все считают не нужным показывать такие примеры?


            1. Kuch
              17.05.2022 11:52
              +1

              Никаких подводных камней. В боди в примерах обычно передается JSON с одним объектом. Массив тоже является легальной структурой, которую можно перевести в JSON на равне с объектом (не зря же Array "наследник" от Object). Так что разницы никакой нет и всё у тебя пройдет как по маслу


            1. caesarisme
              18.05.2022 17:39

              Если подводный камень при использовании Prisma + Swagger. Если хотите Code-first документировать модели в сваггере, то у вас ничего не получится, так как призма внутри себя генерит типы в виде `type` (typescript). Тут либо танцы с тайпскриптом, либо танцы со сваггером. Но в целом, если нужно сделать нечто простое, то призма - более чем хороша.