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 Client 并为数据库填充数据。

现在,您应该能够通过 https://:3000/api/ 访问 API 文档。

项目结构和文件

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

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

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

  • src 目录包含应用程序的源代码。有三个模块:
    • app 模块位于 src 目录的根目录,是应用程序的入口点。它负责启动 Web 服务器。
    • prisma 模块包含 Prisma Client,它是您与数据库的接口。
    • articles 模块定义了 /articles 路由的端点和相应的业务逻辑。
  • prisma 文件夹包含以下内容
    • schema.prisma 文件定义了数据库 schema。
    • 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 识别出这种类型不匹配并引发错误。您将通过向 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 作为导入添加。将以下 imports 添加到 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 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 生成类型的实例,而 ClassSerializerInterceptor 使用 UserEntity 类。为了解决这个问题,您将更新 ArticleEntity 类的实现,并确保它使用 UserEntity 的实例初始化 author 属性。

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

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

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

GET /articles/:id does not reveal password

总结和最后说明

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

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

不要错过下一篇文章!

订阅 Prisma 新闻通讯

© . This site is unofficial and not affiliated with Prisma Data, Inc.