
Привет, друзья!
В данном туториале мы разработаем простой сервер на NestJS, взаимодействующий с SQLite с помощью Prisma, с административной панелью, автоматически генерируемой с помощью AdminJS, и описанием интерфейса, автоматически генерируемым с помощью Swagger. Все это будет приготовлено под соусом TypeScript.
Если вам это интересно, прошу под кат.
NestJS — это фреймворк для разработки эффективных и масштабируемых серверных приложений на Node.js. Данный фреймворк использует прогрессивный (что означает текущую версию ECMAScript) JavaScript с полной поддержкой TypeScript (использование TypeScript является опциональным) и сочетает в себе элементы объектно-ориентированного, функционального и реактивного функционального программирования.
Под капотом NestJS использует Express (по умолчанию), но также позволяет переключиться на Fastify.
- Руководство по NestJS
- Шпаргалка по Express API
- Карманная книга по TypeScript
- Шпаргалка по TypeScript
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!