Skip to main content

Разрабатываем TypeScript + NestJS + Prisma + AdminJS + Swagger REST API

· 13 min read

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

В данном туториале мы разработаем простой сервер на 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 с переменной среды окружения DATABASE_URL, значением которой является строка, используемая 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/20220506124711_init/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!