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 添加了一些非常好的 IntelliSense 和语法高亮。
  • ... (可选)可以访问 Unix shell(例如 Linux 和 macOS 中的终端/shell)来运行本系列中提供的命令。

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

克隆存储库

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

本教程的起点可在 end-validation 分支的 GitHub 存储库中找到。要开始使用,请克隆存储库并检出 end-validation 分支

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

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

注意:步骤 4 也会生成 Prisma Client 并播种数据库。

现在,您应该可以通过 https://127.0.0.1:3000/api/ 访问 API 文档。

项目结构和文件

您克隆的存储库应具有以下结构

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

此存储库中值得注意的文件和目录是

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

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

向数据库添加 User 模型

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

首先更新您的 Prisma 模式

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

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

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

更新你的种子脚本

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

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

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

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

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

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

ArticleEntity 添加 authorId 字段

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

ArticleEntity 内部添加一个新的 authorId 字段

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

为 Users 实现 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

向 Users 模块添加 PrismaClient

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

您现在可以将 PrismaService 注入 UsersService 内部,并使用它来访问数据库。为此,请像这样向 users.service.ts 添加一个构造函数

定义 User 实体和 DTO 类

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

@ApiProperty 装饰器用于使属性对 Swagger 可见。请注意,您没有向 password 字段添加 @ApiProperty 装饰器。这是因为此字段很敏感,您不想在 API 中公开它。

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

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

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

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

定义 UsersService 类

UsersService 负责使用 Prisma Client 修改和从数据库中获取数据,并将其提供给 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 Client 生成的 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。为了获取作者,您必须向 GET /users/:id 端点发出额外的请求。如果您同时需要文章及其作者,这并不理想,因为您需要发出两个 API 请求。您可以通过返回 Article 对象的同时返回 author 来改进这一点。

数据访问逻辑在 ArticlesService 内部实现。更新 findOne() 方法以返回 Article 对象的同时返回 author

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

GET /articles/:id reveals password

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

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

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

现在,GET /articles/:id 返回不带 password 字段的作者对象

GET /articles/:id does not reveal password

总结和最终说明

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

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

不要错过下一篇文章!

注册 Prisma 新闻通讯