NestJS + Prisma 构建 REST API 系列教程(四):关系型数据处理
欢迎来到 NestJS、Prisma 和 PostgresQL 构建 REST API 系列的第四篇教程。在本篇教程中,你将学习如何在 NestJS 程序中处理关系型数据。
简介
在本系列的第一章中,你已经创建了一个新的NestJS项目并且集成了Prisma、PostgreSQL 和 Swagger。接下来,你为一个博客程序的服务端构建了一个简陋的 REST API。在第二章中,你学习了如何进行输入验证和类型转换。
那么在本章中,你将学习如何在数据层和 API 层处理关系型数据。
- 首先,你将在数据库 schema 文件中添加一个
User
模型,它和Articles
记录存在一对多关系(例如:一个用户可以有多篇文章)。 - 接下来,你将实现
User
端点的 API 路由,以在User
记录上执行 CRUD(新增、查询、更改和删除)操作。 - 最后,你将学习如何在 API 层对
User-Article
进行建模。
在本教程中,你将使用第二章构建的 REST API。
给数据库添加一个 User
模型
目前,你的数据库 schema 里仅有一个模型:Article
。文章是被注册用户写的。所以,你需要在数据库 schema 中添加一个 User
模型来反应这个关系。
我们从更新 Prisma schema 开始:
// prisma/schema.prisma
model Article {
id Int @id @default(autoincrement())
title String @unique
description String?
body String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ author User? @relation(fields: [authorId], references: [id])
+ authorId Int?
}
+model User {
+ id Int @id @default(autoincrement())
+ name String?
+ email String @unique
+ password String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ articles Article[]
+}
User
模型有一些你可能期望的字段,例如:id
、email
、password
等等。它还与 Article
模型有一个一对多关系。这意味着一个用户可以拥有多篇文章,但是一篇文章只能拥有一个作者。为简单起见,author
关系被设置为可选,所以我们也可以直接创建一篇文章而没有作者。
现在,要将变更应用到你的数据库中,运行此迁移命令:
npx prisma migrate dev --name "add-user-model"
如果该迁移命令运行成功了,你会看到以下输出:
...
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20230318100533_add_user_model/
└─ migration.sql
Your database is now in sync with your schema.
...
更新种子脚本
该种子脚本负责用模拟数据填充数据库。你将更新此种子脚本,以便在数据库中创建一些用户。
打开 prisma/seed.ts 文件并用如下代码更新:
async function main() {
// create two dummy users
+ const user1 = await prisma.user.upsert({
+ where: { email: 'sabin@adams.com' },
+ update: {},
+ create: {
+ email: 'sabin@adams.com',
+ name: 'Sabin Adams',
+ password: 'password-sabin',
+ },
+ });
+ const user2 = await prisma.user.upsert({
+ where: { email: 'alex@ruheni.com' },
+ update: {},
+ create: {
+ email: 'alex@ruheni.com',
+ name: 'Alex Ruheni',
+ password: 'password-alex',
+ },
+ });
// create three dummy articles
const post1 = await prisma.article.upsert({
where: { title: 'Prisma Adds Support for MongoDB' },
update: {
+ authorId: user1.id,
},
create: {
title: 'Prisma Adds Support for MongoDB',
body: 'Support for MongoDB has been one of the most requested features since the initial release of...',
description: "We are excited to share that today's Prisma ORM release adds stable support for MongoDB!",
published: false,
+ authorId: user1.id,
},
});
const post2 = await prisma.article.upsert({
where: { title: "What's new in Prisma? (Q1/22)" },
update: {
+ authorId: user2.id,
},
create: {
title: "What's new in Prisma? (Q1/22)",
body: 'Our engineers have been working hard, issuing new releases with many improvements...',
description: 'Learn about everything in the Prisma ecosystem and community from January to March 2022.',
published: true,
+ authorId: user2.id,
},
});
const post3 = await prisma.article.upsert({
where: { title: 'Prisma Client Just Became a Lot More Flexible' },
update: {},
create: {
title: 'Prisma Client Just Became a Lot More Flexible',
body: 'Prisma Client extensions provide a powerful new way to add functionality to Prisma in a type-safe manner...',
description: 'This article will explore various ways you can use Prisma Client extensions to add custom functionality to Prisma Client..',
published: true,
},
});
console.log({ user1, user2, post1, post2, post3 });
}
现在该种子脚本创建了两个用户和三篇文章。其中第一篇文章是第一个用户写的,第二篇文章是第二个用户写的,第三篇文章无作者。
注意:此时,你正在以纯文本的形式储存密码。但在真实应用中千万别这样做。在下一章中,你将了解更多关于加盐密码和散列密码的知识。
要执行种子脚本,运行以下命令:
npx prisma db seed
如果种子脚本运行成功,则你会看到以下输出:
...
🌱 The seed command has been executed.
给 ArticleEntity
添加一个 aurhorId
字段
在运行完迁移命令之后,你可能注意到产生了一个新的 TypeScript 错误。ArticleEntity
类实现了 Prisma 生成的 Article
类型。该 Article
类型有一个新的 authorId
字段,但这个 ArticleEntity
类却没有该字段的定义。TypeScript 识别出这种类型不匹配并引发错误。你可以通过给 ArticleEntity
类添加 authorId
字段来修复这个问题。
在 ArticleEntity
中添加一个新的 authorId
字段:
// src/articles/entities/article.entity.ts
import { Article } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
export class ArticleEntity implements Article {
@ApiProperty()
id: number;
@ApiProperty()
title: string;
@ApiProperty({ required: false, nullable: true })
description: string | null;
@ApiProperty()
body: string;
@ApiProperty()
published: boolean;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
+ @ApiProperty({ required: false, nullable: true })
+ authorId: number | null;
}
在像 JavaScript 这样的弱类型语言中,你必须自己识别和修复此类问题。拥有像 TypeScript 这样的强类型语言的一大优势就是它可以快速帮你捕获与类型相关的问题。
为用户实现 CRUD 端点
在这一小节中,你将在 REST API 中实现 /users
资源。这将使你可以在数据库中对用户表执行 CRUD 操作。
注意:本节内容与本系列第一章中为文章模型实现 CRUD 操作一节相似。那一节更深入地介绍了该主题,因此你可以通过阅读它来获得更好的概念理解。
生成新的 users
REST 资源
要为 users
生成新的 REST 资源,可以运行以下命令:
npx nest generate resource
你将得到一些命令行提示。相应地回答这些问题:
What name would you like to use for this resource (plural, e.g., "users")?
usersWhat transport layer do you use?
REST APIWould you like to generate CRUD entry points?
Yes
现在你可以在 src/users
文件夹中找到一个新的 users
模块,里面包含 REST 端点的所有代码模版。
在 src/users/users.controller.ts
文件中,你将看到不同路由(也被叫作路由处理器)的定义。处理每个请求的业务逻辑则被封装在 src/users/users.service.ts
文件中。
如果你打开 Swagger 生成的 API 页面,你会看到下面这样:
把 PrismaClient
添加到 Users
模块
要在 Users
模块内部访问 PrismaClient
,你必须导入 PrismaMudule
。把以下 imports
添加到 UsersModule
中:
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
+import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
controllers: [UsersController],
+ providers: [UsersService],
+ imports: [PrismaModule],
})
export class UsersModule {}
现在你可以在 UsersService
中注入 PrismaService
并用它来访问数据库。要如此做,需要给 users.service.ts
添加一个构造器,就像这样:
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
+import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class UsersService {
+ constructor(private prisma: PrismaService) {}
// CRUD operations
}
定义 User
实体和 DTO 类
就像 ArticleEntity
一样,你还要定义一个 UserEntity
类,此类将会在 API 层表示 User
实体。在 user.entity.ts
文件中定义 UserEntity
类,如下所示:
// src/users/entities/user.entity.ts
import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';
export class UserEntity implements User {
@ApiProperty()
id: number;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
password: string;
}
@ApiProperty
装饰器用于让该属性在 Swagger 中展示。注意我们并没有把 @ApiProperty
装饰器添加到 password
字段上。这是因为此字段是敏感信息,你并不想把它暴露给 API。
注意:省略
@ApiProperty
装饰器将只会在 Swagger 文档中隐藏password
属性。该属性依然会被展示在响应体中。在后面的章节中会处理这个问题。
DTO(数据传输对象)是定义数据如何通过网络发送的对象。你还需要实现 CreateUserDto
和 UpdateUserDto
这两个类来分别定义在创建和更新用户时将发送到 API 的数据。在 create-user.dto.ts
文件中定义 CreateUserDto
,如下所示:
// src/users/dto/create-user.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsNotEmpty()
@ApiProperty()
name: string;
@IsString()
@IsNotEmpty()
@ApiProperty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(6)
@ApiProperty()
password: string;
}
@IsString
,@MinLength
和 @IsNotEmpty
都是验证装饰器,用来验证发送到 API 的数据格式。在本系列教程的第二章中有对验证做更详细的讲解。
UpdateUserDto
的定义是从 CreateUserDto
的定义中自动推断出来的,因此不需要显示的定义。
定义 UsersService
类
UsesService
负责使用 Prisma 客户端从数据库中修改和获取数据并将这些数据提供给 UsersController
。你将在此类中实现 create()
、findAll()
、findOne()
、update()
和 remove()
方法。
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
create(createUserDto: CreateUserDto) {
+ return this.prisma.user.create({ data: createUserDto });
}
findAll() {
+ return this.prisma.user.findMany();
}
findOne(id: number) {
+ return this.prisma.user.findUnique({ where: { id } });
}
update(id: number, updateUserDto: UpdateUserDto) {
+ return this.prisma.user.update({ where: { id }, data: updateUserDto });
}
remove(id: number) {
+ return this.prisma.user.delete({ where: { id } });
}
}
定义 UsersController 类
UsersController
负责处理 users
端点的请求和响应。它将利用 UsersService
访问数据库,UserEntity
定义响应主体,CreateUserDto
和 UpdateUserDto
定义请求主体。
该控制器包含了几个不同的路由处理器。你将在这个类中实现五个路由处理器,分别对应五个端点:
create()
-POST /users
findAll()
-GET /users
findOne()
-GET /users/:id
update()
-PATCH /users/:id
remove()
-DELETE /uses/:id
在 users.controller.ts
中更新这些路由处理的实现方法,如下所示:
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
+ ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
+import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
+import { UserEntity } from './entities/user.entity';
@Controller('users')
+@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
+ @ApiCreatedResponse({ type: UserEntity })
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
@Get()
+ @ApiOkResponse({ type: UserEntity, isArray: true })
findAll() {
return this.usersService.findAll();
}
@Get(':id')
+ @ApiOkResponse({ type: UserEntity })
+ findOne(@Param('id', ParseIntPipe) id: number) {
+ return this.usersService.findOne(id);
}
@Patch(':id')
+ @ApiCreatedResponse({ type: UserEntity })
+ update(
+ @Param('id', ParseIntPipe) id: number,
+ @Body() updateUserDto: UpdateUserDto,
) {
+ return this.usersService.update(id, updateUserDto);
}
@Delete(':id')
+ @ApiOkResponse({ type: UserEntity })
+ remove(@Param('id', ParseIntPipe) id: number) {
+ return this.usersService.remove(id);
}
}
更新后的控制器使用 @ApiTags
装饰器将端点分到 users
标签组下。它还使用 @ApiCreateResponse
和 @ApiOkResponse
装饰器为每个端点定义响应体。
更新后的 Swagger API 文档页是这样的:
你可以随意测试不同的端点以验证它们的行为是否符合你的预期。
从响应体中排除 password
字段
虽然 users
API 按预期工作,但它有一个重大的安全漏洞。password
字段在不同端点响应体中被返回了。
你有两个选择来修复此问题:
- 在控制器的路由处理器中,把密码从响应体中手动删除。
- 使用一个拦截器自动地从响应体中删除密码。
使用 ClassSerializerInterceptor
从响应中删除一个字段
NestJS 中的拦截器允许你挂接到请求-响应周期,并允许你在执行路由处理器之前和之后执行额外的逻辑。在本例中,你将用它把 password
字段从响应体中删除
NestJS 又一个内置的 ClassSerializerInterceptor
,它可以被用来转换对象。你将用这个拦截器把 password
字段从响应体中删除。
首先,通过更改 main.ts
在全局启用 ClassSerializerInterceptor
:
// src/main.ts
+import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
+import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
+ app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}
bootstrap();
注意:除了全局,你也可以将拦截器绑定到一个方法或控制器。要了解更多信息请参阅此NestJS 文档。
ClassSerializerInterceptor
使用 class-transformer
包来定义如何转换对象。在 UserEntity
类中使用 @Exclude()
装饰器来排除 password
字段:
// src/users/entities/user.entity.ts
import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';
+import { Exclude } from 'class-transformer';
export class UserEntity implements User {
@ApiProperty()
id: number;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
+ @Exclude()
password: string;
}
如果你再次尝试请求 GET /users/:id
端点,你会发现 password
字段依然被暴露出来。这是因为,当前在控制器中的路由处理器返回的 User
类型是 Prisma 客户端生成的。ClassSerializerInterceptor
仅适用于使用 @Exclude()
装饰器装饰的类。在本例中即 UserEntity
类。所以,你需要更新路由处理器以返回 UserEntity
类型。
// src/users/entities/user.entity.ts
import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';
import { Exclude } from 'class-transformer';
export class UserEntity implements User {
+ constructor(partial: Partial<UserEntity>) {
+ Object.assign(this, partial);
+ }
@ApiProperty()
id: number;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
@ApiProperty()
name: string;
@ApiProperty()
email: string;
@Exclude()
password: string;
}
此构造器接收一个对象并使用 Object.assign()
方法把属性从 partial
对象复制到 UserEntity
实例。partial
的类型是 Partial<UserEntity>
。这意味着该 partial
对象可以包含在 UserEntity
类中定义的任何属性的子集。
接下来,更新 UsersController 路由器处理器的返回值,使用 UserEntity
替代 Prisma.User
对象:
// src/users/users.controller.ts
@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@ApiCreatedResponse({ type: UserEntity })
+ async create(@Body() createUserDto: CreateUserDto) {
+ return new UserEntity(await this.usersService.create(createUserDto));
}
@Get()
@ApiOkResponse({ type: UserEntity, isArray: true })
+ async findAll() {
+ const users = await this.usersService.findAll();
+ return users.map((user) => new UserEntity(user));
}
@Get(':id')
@ApiOkResponse({ type: UserEntity })
+ async findOne(@Param('id', ParseIntPipe) id: number) {
+ return new UserEntity(await this.usersService.findOne(id));
}
@Patch(':id')
@ApiCreatedResponse({ type: UserEntity })
+ async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
+ return new UserEntity(await this.usersService.update(id, updateUserDto));
}
@Delete(':id')
@ApiOkResponse({ type: UserEntity })
+ async remove(@Param('id', ParseIntPipe) id: number) {
+ return new UserEntity(await this.usersService.remove(id));
}
}
现在,密码字段应该被响应对象忽略了。
同时返回文章及其作者
在第一章中你已经实现了 GET /articles/:id
端点来获取一篇文章。目前,此端点还不能返回文章的 author
,只有 authorId
。为了获取 author
你不得不再发起另外一个请求到 GET /users/:id
端点。如果你同时需要文章及其作者,这并不理想,因为你需要发送两个 API 请求。你可以通过同时返回 Article
对象和 author
来改善这种情况。
数据访问逻辑是在 ArticlesService
中被实现的。更新 findOne()
方法以同时返回 Article
对象和 author
:
// src/articles/articles.service.ts
findOne(id: number) {
+ return this.prisma.article.findUnique({
+ where: { id },
+ include: {
+ author: true,
+ },
+ });
}
如果你测试 GET /articles/:id
端点,你会发现如果文章存在作者,那么此时作者就会被包含在响应对象中。但是,有个问题,password
字段又一次被暴露出来了。
这个问题的原因和上一次的极其相似。现在,ArticlesController
返回了 Prisma 生成类型的实例,而 ClassSerializerInterceptor
只对 UserEntity
类起作用。要解决此问题,你需要修改 ArticleEntity
类的实现方法并确保它使用 UserEntity
的实例初始化 author
属性。
// src/articles/entities/article.entity.ts
import { Article } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
+import { UserEntity } from 'src/users/entities/user.entity';
export class ArticleEntity implements Article {
@ApiProperty()
id: number;
@ApiProperty()
title: string;
@ApiProperty({ required: false, nullable: true })
description: string | null;
@ApiProperty()
body: string;
@ApiProperty()
published: boolean;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
@ApiProperty({ required: false, nullable: true })
authorId: number | null;
+ @ApiProperty({ required: false, type: UserEntity })
+ author?: UserEntity;
+ constructor({ author, ...data }: Partial<ArticleEntity>) {
+ Object.assign(this, data);
+ if (author) {
+ this.author = new UserEntity(author);
+ }
+ }
}
你再次使用 Object.assign()
方法将属性从数据对象复制到 ArticleEntity
实例。这个 author
属性,如果存在的话,会被 UserEntity
的实例初始化。
现在修改 ArticlesController
以返回 ArticleEntity
对象的实例:
// src/articles/articles.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ArticleEntity } from './entities/article.entity';
@Controller('articles')
@ApiTags('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Post()
@ApiCreatedResponse({ type: ArticleEntity })
+ async create(@Body() createArticleDto: CreateArticleDto) {
+ return new ArticleEntity(
+ await this.articlesService.create(createArticleDto),
+ );
}
@Get()
@ApiOkResponse({ type: ArticleEntity, isArray: true })
+ async findAll() {
+ const articles = await this.articlesService.findAll();
+ return articles.map((article) => new ArticleEntity(article));
}
@Get('drafts')
@ApiOkResponse({ type: ArticleEntity, isArray: true })
+ async findDrafts() {
+ const drafts = await this.articlesService.findDrafts();
+ return drafts.map((draft) => new ArticleEntity(draft));
}
@Get(':id')
@ApiOkResponse({ type: ArticleEntity })
+ async findOne(@Param('id', ParseIntPipe) id: number) {
+ return new ArticleEntity(await this.articlesService.findOne(id));
}
@Patch(':id')
@ApiCreatedResponse({ type: ArticleEntity })
+ async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateArticleDto: UpdateArticleDto,
) {
+ return new ArticleEntity(
+ await this.articlesService.update(id, updateArticleDto),
+ );
}
@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
+ async remove(@Param('id', ParseIntPipe) id: number) {
+ return new ArticleEntity(await this.articlesService.remove(id));
}
}
现在,GET /articles/:id
返回的 author
对象不会再包含 password
字段了:
总结
在本章中,你学习了如何在 NestJS 应用中使用 Prisma 对关系型数据进行建模。另外你还学习了 ClassSerializerInterceptor
的有关知识,以及如何使用实体类来控制返回到客户端的数据。
你可以在 GitHub 代码库的 end-relational-data
分支找到教程中的完整代码。
【全文完】
原文作者:Tasin Ishmam - Backend web developer
原文地址:www.prisma.io/blog/nestjs…
原文发表于:2023年3月23日
转载自:https://juejin.cn/post/7236951503796371513