NestJS + Prisma 构建 REST API 系列教程(四):关系型数据处理

1,192 阅读13分钟

欢迎来到 NestJS、Prisma 和 PostgresQL 构建 REST API 系列的第四篇教程。在本篇教程中,你将学习如何在 NestJS 程序中处理关系型数据。

简介

在本系列的第一章中,你已经创建了一个新的NestJS项目并且集成了Prisma、PostgreSQL 和 Swagger。接下来,你为一个博客程序的服务端构建了一个简陋的 REST API。在第二章中,你学习了如何进行输入验证和类型转换。

那么在本章中,你将学习如何在数据层和 API 层处理关系型数据。

  1. 首先,你将在数据库 schema 文件中添加一个 User 模型,它和 Articles 记录存在一对多关系(例如:一个用户可以有多篇文章)。
  2. 接下来,你将实现 User 端点的 API 路由,以在 User 记录上执行 CRUD(新增、查询、更改和删除)操作。
  3. 最后,你将学习如何在 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 模型有一些你可能期望的字段,例如:idemailpassword 等等。它还与 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

你将得到一些命令行提示。相应地回答这些问题:

  1. What name would you like to use for this resource (plural, e.g., "users")? users
  2. What transport layer do you use? REST API
  3. Would you like to generate CRUD entry points? Yes

现在你可以在 src/users 文件夹中找到一个新的 users 模块,里面包含 REST 端点的所有代码模版。

src/users/users.controller.ts 文件中,你将看到不同路由(也被叫作路由处理器)的定义。处理每个请求的业务逻辑则被封装在 src/users/users.service.ts 文件中。

如果你打开 Swagger 生成的 API 页面,你会看到下面这样:

2.jpeg

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(数据传输对象)是定义数据如何通过网络发送的对象。你还需要实现 CreateUserDtoUpdateUserDto 这两个类来分别定义在创建和更新用户时将发送到 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 定义响应主体,CreateUserDtoUpdateUserDto 定义请求主体。

该控制器包含了几个不同的路由处理器。你将在这个类中实现五个路由处理器,分别对应五个端点:

  • 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 文档页是这样的:

3.jpeg

你可以随意测试不同的端点以验证它们的行为是否符合你的预期。

从响应体中排除 password 字段

虽然 users API 按预期工作,但它有一个重大的安全漏洞。password 字段在不同端点响应体中被返回了。

4.jpeg

你有两个选择来修复此问题:

  1. 在控制器的路由处理器中,把密码从响应体中手动删除。
  2. 使用一个拦截器自动地从响应体中删除密码。

使用 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));
  }
}

现在,密码字段应该被响应对象忽略了。

5.jpeg

同时返回文章及其作者

在第一章中你已经实现了 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 字段又一次被暴露出来了。

6.jpeg

这个问题的原因和上一次的极其相似。现在,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 字段了:

7.jpeg

总结

在本章中,你学习了如何在 NestJS 应用中使用 Prisma 对关系型数据进行建模。另外你还学习了 ClassSerializerInterceptor 的有关知识,以及如何使用实体类来控制返回到客户端的数据。

你可以在 GitHub 代码库的 end-relational-data 分支找到教程中的完整代码。

【全文完】

原文作者:Tasin Ishmam - Backend web developer

原文地址:www.prisma.io/blog/nestjs…

原文发表于:2023年3月23日