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 Extension。Prisma VS Code Extension 为 Prisma 添加了非常好的智能感知(IntelliSense)和语法高亮功能。
  • ... *可选* 使用 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 并为数据库填充种子数据。

现在,您应该能够通过 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 schema

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

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

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

更新种子脚本

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

打开 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 入口点吗? Yes

您现在应该能在 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 可见。请注意,您没有将 @ApiProperty 装饰器添加到 password 字段。这是因为此字段是敏感信息,您不希望在 API 中暴露它。

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

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

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

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. 使用 拦截器(interceptor) 自动从响应体中移除密码

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

使用 ClassSerializerInterceptor 从响应中移除字段

NestJS 中的 拦截器(Interceptor) 允许您介入请求-响应生命周期,并在路由处理程序执行之前和之后执行额外逻辑。在本例中,您将使用它从响应体中移除 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(作者 ID)。为了获取 author 信息,您必须向 GET /users/:id 端点发出额外的请求。如果您需要文章及其作者信息,这并不理想,因为您需要发出两个 API 请求。您可以通过与 Article 对象一起返回 author 信息来改进这一点。

数据访问逻辑在 ArticlesService 内部实现。更新 findOne() 方法,使其与 Article 对象一起返回 author 信息

如果您测试 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 分支找到本教程的完成代码。如果您发现问题,请随时在仓库中提出 issue 或提交 PR。您也可以直接在 Twitter 上与我联系。

不要错过下一篇文章!

订阅 Prisma 新闻通讯