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 并为数据库播种。

现在,您应该能够访问 http://localhost: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 模型具有一对多关系。这意味着一个用户可以拥有多篇文章,但一篇文章只能有一个作者。为简单起见,author 关系是可选的,因此仍然可以创建没有作者的文章。

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

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

更新您的种子脚本

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

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

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

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

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

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

ArticleEntity 添加 authorId 字段

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

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

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

为用户实现 CRUD 端点

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

注意:本节的内容与本系列第一章中的 为文章模型实现 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 添加为导入。将以下 imports 添加到 UsersModule

现在,您可以在 UsersService 内部注入 PrismaService,并使用它来访问数据库。为此,请像这样向 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。为了获取 author,您必须向 GET /users/:id 端点发出额外的请求。如果您既需要文章又需要其作者,则这是不理想的,因为您需要发出两个 API 请求。您可以通过返回 author 以及 Article 对象来改进这一点。

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

如果您测试 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 返回的 author 对象不包含 password 字段

GET /articles/:id does not reveal password

总结和最终想法

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

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

不要错过下一篇文章!

注册 Prisma 新闻通讯