2023年3月23日

使用 NestJS 和 Prisma 构建 REST API:处理关系型数据

8 分钟阅读

欢迎阅读本系列关于使用 NestJS、Prisma 和 PostgreSQL 构建 REST API 的第四篇教程!在本教程中,您将学习如何在 NestJS REST API 中处理关系型数据。

Building a REST API with NestJS and Prisma: Handling Relational Data

目录

引言

在本系列的第一章中,您创建了一个新的 NestJS 项目并将其与 Prisma、PostgreSQL 和 Swagger 集成。然后,您为博客应用程序的后端构建了一个基本的 REST API。在第二章中,您学习了如何进行输入验证和转换。

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

  1. 首先,您将向数据库模式添加一个 User 模型,该模型将与 Article 记录建立一对多关系(即一个用户可以拥有多篇文章)。
  2. 接下来,您将为 User 端点实现 API 路由,以对 User 记录执行 CRUD(创建、读取、更新和删除)操作。
  3. 最后,您将学习如何在 API 层中建模 User-Article 关系。

在本教程中,您将使用第二章中构建的 REST API。

开发环境

要跟随本教程,您需要满足以下条件:

  • ... 已安装 Node.js
  • ... 已安装 DockerDocker Compose。如果您使用的是 Linux,请确保您的 Docker 版本为 20.10.0 或更高。您可以通过在终端中运行 docker version 来检查您的 Docker 版本。
  • ... 可选地,已安装 Prisma VS Code 扩展。Prisma VS Code 扩展为 Prisma 提供了非常好的智能感知和语法高亮功能。
  • ... 可选地,可访问 Unix shell(例如 Linux 和 macOS 中的终端/shell)以运行本系列中提供的命令。

如果您没有 Unix shell(例如,您使用的是 Windows 机器),您仍然可以继续学习,但可能需要根据您的机器修改 shell 命令。

克隆仓库

本教程的起点是本系列第二章的结尾。它包含一个使用 NestJS 构建的基本 REST API。

本教程的起点可在 GitHub 仓库end-validation 分支中找到。要开始,请克隆仓库并切换到 end-validation 分支

现在,执行以下操作以开始:

  1. 导航到克隆的目录
  1. 安装依赖
  1. 使用 Docker 启动 PostgreSQL 数据库
  1. 应用数据库迁移
  1. 启动项目

注意:步骤 4 还会生成 Prisma 客户端并为数据库填充种子数据。

现在,您应该能够访问 http://localhost:3000/api/ 上的 API 文档。

项目结构和文件

您克隆的仓库应具有以下结构:

注意:您可能会注意到此文件夹也带有一个 test 目录。本教程不会涵盖测试。但是,如果您想了解使用 Prisma 测试应用程序的最佳实践,请务必查看本教程系列:《使用 Prisma 进行测试的终极指南》

此仓库中值得注意的文件和目录有:

  • src 目录包含应用程序的源代码。有三个模块:
    • app 模块位于 src 目录的根部,是应用程序的入口点。它负责启动 Web 服务器。
    • prisma 模块包含 Prisma 客户端,它是您与数据库的接口。
    • articles 模块定义了 /articles 路由的端点及相关的业务逻辑。
  • prisma 文件夹包含以下内容:
    • schema.prisma 文件定义了数据库模式。
    • migrations 目录包含数据库迁移历史记录。
    • seed.ts 文件包含一个用于向开发数据库填充虚拟数据的脚本。
  • docker-compose.yml 文件定义了您的 PostgreSQL 数据库的 Docker 镜像。
  • .env 文件包含您的 PostgreSQL 数据库的连接字符串。

注意:有关这些组件的更多信息,请参阅本教程系列的第一章。

向数据库添加一个 User 模型

目前,您的数据库模式只有一个模型:Article。一篇文章可以由一个注册用户撰写。因此,您将向数据库模式添加一个 User 模型以反映这种关系。

首先更新您的 Prisma 模式

User 模型有一些您可能期望的字段,例如 idemailpassword 等。它还与 Article 模型建立了一对多关系。这意味着一个用户可以拥有多篇文章,但一篇文章只能有一个作者。为了简化,author 关系被设置为可选,因此仍然可以在没有作者的情况下创建文章。

现在,要将更改应用到数据库,请运行迁移命令

如果迁移成功运行,您应该看到以下输出:

更新您的种子脚本

种子脚本负责用虚拟数据填充您的数据库。您将更新种子脚本以在数据库中创建一些用户。

打开 prisma/seed.ts 文件并按如下方式更新它:

种子脚本现在创建了两个用户和三篇文章。第一篇文章由第一个用户撰写,第二篇文章由第二个用户撰写,第三篇文章没有作者。

注意:目前,您正在以纯文本形式存储密码。在实际应用程序中绝不应该这样做。您将在下一章中了解更多关于加盐密码和哈希处理的信息。

要执行种子脚本,请运行以下命令:

如果种子脚本成功运行,您应该看到以下输出:

ArticleEntity 添加 authorId 字段

运行迁移后,您可能已经注意到一个新的 TypeScript 错误。ArticleEntity 类实现了由 Prisma 生成的 Article 类型。Article 类型有一个新的 authorId 字段,但 ArticleEntity 类没有定义该字段。TypeScript 识别到这种类型不匹配并引发了错误。您将通过向 ArticleEntity 类添加 authorId 字段来修复此错误。

ArticleEntity 中添加一个新的 authorId 字段

在像 JavaScript 这样的弱类型语言中,您必须自己识别和修复此类问题。拥有像 TypeScript 这样的强类型语言的一大优势是它可以快速帮助您捕获与类型相关的问题。

为用户实现 CRUD 端点

在本节中,您将在 REST API 中实现 /users 资源。这将允许您对数据库中的用户执行 CRUD 操作。

注意:本节内容将与本系列第一章中“为 Article 模型实现 CRUD 操作”部分的内容相似。该部分更深入地涵盖了该主题,因此您可以阅读它以获得更好的概念理解。

生成新的 users REST 资源

要为 users 生成新的 REST 资源,请运行以下命令:

您将收到一些 CLI 提示。请相应地回答问题:

  1. 您希望为此资源使用什么名称(复数,例如“users”)? users
  2. 您使用什么传输层? REST API
  3. 您想生成 CRUD 入口点吗?

现在,您应该在 src/users 目录中找到一个新的 users 模块,其中包含所有 REST 端点的样板代码。

src/users/users.controller.ts 文件中,您将看到不同路由(也称为路由处理程序)的定义。处理每个请求的业务逻辑封装在 src/users/users.service.ts 文件中。

如果您打开 Swagger 生成的 API 页面,您应该会看到类似这样的内容:

Auto-generated "users" endpoints

PrismaClient 添加到 Users 模块

要在 Users 模块中访问 PrismaClient,您必须将 PrismaModule 作为导入项添加。将以下导入添加到 UsersModule

现在,您可以将 PrismaService 注入到 UsersService 中并使用它来访问数据库。为此,请向 users.service.ts 添加一个构造函数,如下所示:

定义 User 实体和 DTO 类

就像 ArticleEntity 一样,您将定义一个 UserEntity 类,它将用于在 API 层中表示 User 实体。在 user.entity.ts 文件中按如下方式定义 UserEntity 类:

@ApiProperty 装饰器用于使属性对 Swagger 可见。请注意,您没有将 @ApiProperty 装饰器添加到 password 字段。这是因为此字段是敏感的,您不希望在 API 中暴露它。

注意:省略 @ApiProperty 装饰器只会将 password 属性从 Swagger 文档中隐藏。该属性仍将在响应体中可见。您将在后面的部分中处理此问题。

DTO(数据传输对象)是定义数据如何通过网络发送的对象。您将需要实现 CreateUserDtoUpdateUserDto 类,以分别定义在创建和更新用户时将发送到 API 的数据。在 create-user.dto.ts 文件中按如下方式定义 CreateUserDto 类:

@IsString@MinLength@IsNotEmpty 是验证装饰器,将用于验证发送到 API 的数据。验证在本系列的第二章中有更详细的介绍。

UpdateUserDto 的定义是从 CreateUserDto 定义中自动推断出来的,因此不需要显式定义。

定义 UsersService

UsersService 负责使用 Prisma 客户端修改和获取数据库中的数据,并将其提供给 UsersController。您将在此类中实现 create()findAll()findOne()update()remove() 方法。

定义 UsersController

UsersController 负责处理对 users 端点的请求和响应。它将利用 UsersService 访问数据库,使用 UserEntity 定义响应体,并使用 CreateUserDtoUpdateUserDto 定义请求体。

控制器由不同的路由处理程序组成。您将在此类中实现五个路由处理程序,它们对应于五个端点:

  • create() - POST /users
  • findAll() - GET /users
  • findOne() - GET /users/:id
  • update() - PATCH /users/:id
  • remove() - DELETE /users/:id

按如下方式更新 users.controller.ts 中这些路由处理程序的实现:

更新后的控制器使用 @ApiTags 装饰器将端点分组到 users 标签下。它还使用 @ApiCreatedResponse@ApiOkResponse 装饰器为每个端点定义响应体。

更新后的 Swagger API 页面应如下所示:

Updated swagger page

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

从响应体中排除 password 字段

尽管 users API 运行正常,但它存在一个主要安全缺陷。password 字段在不同端点的响应体中返回。

GET /users/:id reveals password

您有两种方法可以解决此问题:

  1. 在控制器路由处理程序中手动从响应体中移除密码
  2. 使用拦截器自动从响应体中移除密码

第一种方法容易出错并导致不必要的代码重复。因此,您将使用第二种方法。

使用 ClassSerializerInterceptor 从响应中移除字段

NestJS 中的拦截器允许您介入请求-响应周期,并在路由处理程序执行前后执行额外逻辑。在这种情况下,您将使用它从响应体中移除 password 字段。

NestJS 有一个内置的ClassSerializerInterceptor,可用于转换对象。您将使用此拦截器从响应对象中移除 password 字段。

首先,通过更新 main.ts 全局启用 ClassSerializerInterceptor

注意: 也可以将拦截器绑定到方法或控制器,而不是全局绑定。您可以在 NestJS 文档中了解更多信息。

ClassSerializerInterceptor 使用 class-transformer 包来定义如何转换对象。使用 @Exclude() 装饰器在 UserEntity 类中排除 password 字段

如果您再次尝试使用 GET /users/:id 端点,您会注意到 password 字段仍然被暴露 🤔。这是因为,目前控制器中的路由处理程序返回的是由 Prisma 客户端生成的 User 类型。ClassSerializerInterceptor 仅适用于使用 @Exclude() 装饰器装饰的类。在这种情况下,它是 UserEntity 类。因此,您需要更新路由处理程序以返回 UserEntity 类型。

首先,您需要创建一个构造函数,它将实例化一个 UserEntity 对象。

构造函数接受一个对象,并使用 Object.assign() 方法将 partial 对象的属性复制到 UserEntity 实例。partial 的类型是 Partial<UserEntity>。这意味着 partial 对象可以包含 UserEntity 类中定义的任何属性子集。

接下来,更新 UsersController 路由处理程序以返回 UserEntity 而不是 Prisma.User 对象

现在,密码应该从响应对象中省略。

GET /users/:id does not reveal password

返回作者和文章

第一章中,您实现了 GET /articles/:id 端点以检索单篇文章。目前,此端点不返回文章的 author,只返回 authorId。为了获取 author,您必须向 GET /users/:id 端点发出额外的请求。如果您需要文章及其作者,这并不理想,因为您需要发出两次 API 请求。您可以通过将 authorArticle 对象一起返回来改进这一点。

数据访问逻辑在 ArticlesService 中实现。更新 findOne() 方法以返回 author 以及 Article 对象

如果您测试 GET /articles/:id 端点,您会注意到文章的作者(如果存在)包含在响应对象中。但是,有一个问题。password 字段再次暴露了 🤦。

GET /articles/:id reveals password

这个问题的原因与上次非常相似。目前,ArticlesController 返回 Prisma 生成类型的实例,而 ClassSerializerInterceptor 适用于 UserEntity 类。要解决此问题,您将更新 ArticleEntity 类的实现,并确保它使用 UserEntity 的实例初始化 author 属性。

您再次使用 Object.assign() 方法将 data 对象的属性复制到 ArticleEntity 实例。author 属性(如果存在)被初始化为 UserEntity 的实例。

现在,更新 ArticlesController 以返回 ArticleEntity 对象的实例

现在,GET /articles/:id 返回的 author 对象不包含 password 字段

GET /articles/:id does not reveal password

总结和最终说明

在本章中,您学习了如何使用 Prisma 在 NestJS 应用程序中建模关系型数据。您还了解了 ClassSerializerInterceptor 以及如何使用实体类来控制返回给客户端的数据。

您可以在 GitHub 仓库的 end-relational-data 分支中找到本教程的完整代码。如果您发现问题,请随时在仓库中提出问题或提交 PR。您也可以直接在 Twitter 上联系我。

不要错过下一篇文章!

订阅 Prisma 邮件通讯

© . All rights reserved.