2020年8月20日

使用 TypeScript、PostgreSQL 和 Prisma 构建后端:REST、验证与测试

本文是一个关于使用 TypeScript、PostgreSQL 和 Prisma 构建后端的直播系列和文章的一部分。在本文中,我们将探讨如何构建 REST API、验证输入以及测试 API。

Backend with TypeScript, PostgreSQL & Prisma: REST, Validation & Tests

引言

本系列的目标是通过解决一个具体问题——在线课程评分系统——来探索和展示现代后端开发中不同的模式、问题和架构。这是一个很好的例子,因为它具有多种关系类型,并且足够复杂,可以代表真实的用例。

直播回放可在上方找到,内容与本文相同。

本系列将涵盖哪些内容

本系列将重点关注数据库在后端开发各个方面的作用,涵盖以下内容:

主题部分
数据建模第 1 部分
CRUD第 1 部分
聚合第 1 部分
REST API 层第 2 部分 (当前)
验证第 2 部分 (当前)
测试第 2 部分 (当前)
身份验证待完成
授权待完成
与外部 API 集成待完成
部署待完成

今天你将学到什么

在第一篇文章中,你为问题领域设计了数据模型,并编写了一个使用 Prisma Client 将数据保存到数据库的种子脚本。

在本系列的第二篇文章中,你将在第一篇文章中的数据模型和 Prisma schema 之上构建 REST API。你将使用 Hapi 来构建 REST API。通过 REST API,你将能够通过 HTTP 请求执行数据库操作。

作为 REST API 的一部分,你将开发以下方面:

  1. REST API:实现一个 HTTP 服务器,带有资源端点来处理不同模型的 CRUD 操作。你将集成 Prisma 与 Hapi,以便在 API 端点处理程序中访问 Prisma Client。
  2. 验证:添加请求体(payload)验证规则,以确保用户输入与 Prisma schema 中预期的类型匹配。
  3. 测试:使用 Jest 和 Hapi 的 server.inject 编写 REST 端点的测试,模拟 HTTP 请求来验证 REST 端点的验证和持久化逻辑。

在本文结束时,你将拥有一个包含 CRUD(创建、读取、更新和删除)操作端点及测试的 REST API。REST 资源将 HTTP 请求映射到 Prisma schema 中的模型,例如 GET /users 端点将处理与 User 模型相关的操作。

本系列的后续部分将详细介绍列表中的其他方面。

注意:在整个指南中,你会发现各种检查点,这些检查点使你能够验证是否正确执行了步骤。

先决条件

假定知识

本系列假定你具备 TypeScript、Node.js 和关系型数据库的基础知识。如果你有 JavaScript 经验但还没有尝试过 TypeScript,你仍然应该能够跟上。本系列将使用 PostgreSQL,但是大多数概念也适用于其他关系型数据库,如 MySQL。此外,熟悉 REST 概念也很有用。除此之外,不需要 Prisma 的先验知识,因为本系列将涵盖这些内容。

开发环境

你应该安装以下内容:

  • Node.js
  • Docker (将用于运行开发环境的 PostgreSQL 数据库)

如果你使用 Visual Studio Code,推荐安装 Prisma 扩展,它提供语法高亮、格式化及其他辅助功能。

注意:如果你不想使用 Docker,可以设置一个本地 PostgreSQL 数据库或在 Heroku 上托管 PostgreSQL 数据库

克隆仓库

本系列的源代码可在 GitHub 上找到。

首先,克隆仓库并安装依赖:

注意:通过检出 part-2 分支,你可以从相同的起点开始跟着文章操作。

启动 PostgreSQL

要启动 PostgreSQL,请在 real-world-grading-app 文件夹中运行以下命令:

注意:Docker 将使用 docker-compose.yml 文件启动 PostgreSQL 容器。

构建 REST API

在深入实现之前,我们将回顾一些与 REST API 相关的基本概念:

  • API:应用程序编程接口(Application programming interface)。一套允许程序相互通信的规则。通常由开发者在服务器端创建 API,并允许客户端与其通信。
  • REST:一套开发者遵循的约定,用于通过 HTTP 请求暴露与状态相关的操作(在本例中是存储在数据库中的状态)。例如,请查看 GitHub REST API
  • 端点:REST API 的入口点,具有以下属性(非详尽列表):
    • 路径,例如 /users/,用于访问 users 端点。路径决定了用于访问端点的 URL,例如 www.myapi.com/users/
    • HTTP 方法,例如 GETPOSTDELETE。HTTP 方法将确定端点暴露的操作类型,例如 GET /users 端点将允许获取用户,而 POST /users 端点将允许创建用户。
    • 处理程序:用于处理端点请求的代码(在本例中是 TypeScript)。
  • HTTP 状态码:响应的 HTTP 状态码将告知 API 消费者操作是否成功以及是否发生任何错误。查看此列表了解不同的 HTTP 状态码,例如资源成功创建时返回 201,消费者输入验证失败时返回 400

注意:REST 方法的一个关键目标是使用 HTTP 作为应用协议,通过遵循约定来避免重复造轮子。

API 端点

API 将包含以下端点(HTTP 方法后跟路径):

资源HTTP 方法路由描述
UserPOST/users创建用户(可选关联课程)
UserGET/users/{userId}获取单个用户
UserPUT/users/{userId}更新用户
UserDELETE/users/{userId}删除用户
UserGET/users获取用户列表
CourseEnrollmentGET/users/{userId}/courses获取用户在课程中的注册信息
CourseEnrollmentPOST/users/{userId}/courses将用户注册到课程(作为学生或教师)
CourseEnrollmentDELETE/users/{userId}/courses/{courseId}删除用户在课程中的注册
CoursePOST/courses创建课程
CourseGET/courses获取课程列表
CourseGET/courses/{courseId}获取单个课程
CoursePUT/courses/{courseId}更新课程
CourseDELETE/courses/{courseId}删除课程
TestPOST/courses/{courseId}/tests为课程创建测试
TestGET/courses/tests/{testId}获取单个测试
TestPUT/courses/tests/{testId}更新测试
TestDELETE/courses/tests/{testId}删除测试
Test ResultGET/users/{userId}/test-results获取用户的测试结果
Test ResultPOST/courses/tests/{testId}/test-results为与用户关联的测试创建测试结果
Test ResultGET/courses/tests/{testId}/test-results获取测试的多个测试结果
Test ResultPUT/courses/tests/test-results/{testResultId}更新测试结果(与用户和测试关联)
Test ResultDELETE/courses/tests/test-results/{testResultId}删除测试结果

注意:包含参数并用 {} 括起来的路径,例如 {userId},表示在 URL 中被内插(interpolated)的变量,例如在 www.myapi.com/users/13 中,userId 的值是 13

上述端点已根据其关联的主要模型/资源进行了分组。这种分类有助于将代码组织到独立的模块中,以提高可维护性。

在本文中,你将实现上述部分端点(前四个),以说明不同 CRUD 操作的不同模式。完整的 API 将在GitHub 仓库中提供。这些端点应提供大多数操作的接口。虽然某些资源没有用于删除资源的 DELETE 端点,但可以在以后添加。

注意:在整篇文章中,术语 endpointroute 将互换使用。虽然它们指的是同一事物,但 endpoint 是在 REST 上下文中使用,而 route 是在 HTTP 服务器上下文中使用。

Hapi

API 将使用 Hapi 构建 – 这是一个用于构建 HTTP 服务器的 Node.js 框架,它开箱即用地支持验证和测试。

Hapi 由一个名为 @hapi/hapi 的核心模块(即 HTTP 服务器)以及扩展核心功能的模块组成。在这个后端中,你还将使用以下内容:

  • @hapi/joi 用于声明式输入验证
  • @hapi/boom 用于创建 HTTP 友好型错误对象

为了让 Hapi 与 TypeScript 协同工作,你需要添加 Hapi 和 Joi 的类型定义。这是必要的,因为 Hapi 是用 JavaScript 编写的。通过添加类型定义,你将获得丰富的自动补全功能,并允许 TypeScript 编译器确保代码的类型安全。

安装以下软件包:

创建服务器

你需要做的第一件事是创建一个 Hapi 服务器,它将绑定到一个接口和端口。

将以下 Hapi 服务器添加到 src/server.ts 文件中:

首先,你导入 Hapi。然后你初始化一个新的 Hapi.server()(类型为 @types/hapi__hapi 包中定义的 Hapi.Server),其中包含连接详情,包括监听的端口号和主机信息。之后,你启动服务器并记录其正在运行。

要在开发期间本地运行服务器,请运行 npm dev 脚本,该脚本将使用 ts-node-dev 自动转译 TypeScript 代码并在你进行更改时重新启动服务器:npm run dev

检查点:如果在浏览器中打开 http://localhost:3000,你应该会看到以下内容:{"statusCode":404,"error":"Not Found","message":"Not Found"}

恭喜,你已成功创建了一个服务器。然而,该服务器没有定义任何路由。在下一步中,你将定义第一个路由。

定义路由

要添加路由,你将在上一步实例化 Hapi serverroute() 方法。在定义与业务逻辑相关的路由之前,添加一个返回 200 HTTP 状态码的 /status 端点是一个好习惯。这对于确保服务器正常运行很有用。

为此,更新 server.ts 文件中的 start 函数,在顶部添加以下内容:

在这里,你定义了 HTTP 方法、路径以及一个返回对象 { up: true } 的处理程序,最后将 HTTP 状态码设置为 200

检查点:如果在浏览器中打开 http://localhost:3000,你应该会看到以下内容:{"up":true}

将路由移至插件

在上一步中,你定义了一个 status 端点。由于 API 将暴露许多不同的端点,将它们全部定义在 start 函数中将难以维护。

Hapi 有插件的概念,这是将后端分解为独立业务逻辑片段的一种方式。插件是一种保持代码模块化的简洁方法。在这一步中,你将上一步中定义的路由移到插件中。

这需要两个步骤:

  1. 在新文件中定义插件。
  2. 在调用 server.start() 之前注册插件。

定义插件

首先,在 src/ 中创建一个名为 plugins 的新文件夹。

src/plugins/ 文件夹中创建一个名为 status.ts 的新文件。

并将以下内容添加到该文件:

一个 Hapi 插件是一个包含 name 属性和一个 register 函数的对象,通常你会在 register 函数中封装插件的逻辑。name 属性是插件名称字符串,并用作唯一键。

每个插件都可以通过标准的服务器接口操作服务器。在上面 app/status 插件中,server 被用于在 register 函数中定义 status 路由。

注册插件

要注册插件,回到 server.ts 文件并导入 status 插件,如下所示:

start 函数中,用以下 server.register() 调用替换上一步中的 route() 调用:

检查点:如果在浏览器中打开 http://localhost:3000,你应该会看到以下内容:{"up":true}

恭喜,你已成功创建了一个 Hapi 插件,它封装了 status 端点的逻辑。

在下一步中,你将定义一个测试来测试 status 端点。

为 status 端点定义测试

为了测试 status 端点,你将使用 Jest 作为测试运行器,并结合 Hapi 的 server.inject 测试辅助函数来模拟对服务器的 HTTP 请求。这将允许你验证是否正确实现了端点。

将 server.ts 拆分为两个文件

要使用 server.inject 方法,你的测试需要能够访问已注册插件但尚未启动服务器的 server 对象,以避免服务器在测试运行时监听请求。为此,修改 server.ts 文件如下:

你刚刚用两个函数替换了 start 函数:

  • createServer():注册插件并初始化服务器
  • startServer():启动服务器

注意:Hapi 的 server.initialize() 初始化服务器(启动缓存,完成插件注册),但不会开始监听连接端口。

现在你可以导入 server.ts 并在你的测试中使用 createServer() 初始化服务器,然后调用 server.inject() 模拟 HTTP 请求。

接下来,你将为应用程序创建一个新的入口点,它将调用 createServer()startServer()

创建一个新的 src/index.ts 文件并添加以下内容:

最后,更新 package.json 文件中的 dev 脚本,使其启动 src/index.ts 而非 src/server.ts

创建测试

要创建测试,在项目的根目录下创建一个名为 tests 的文件夹,然后创建一个名为 status.test.ts 的文件,并将以下内容添加到该文件:

在上面的测试中,beforeAllafterAll 被用作设置和拆卸函数来创建和停止服务器。

然后,调用 server.inject 模拟对根端点 /GET HTTP 请求。测试随后断言(asserts)HTTP 状态码和响应体(payload),以确保其与处理程序匹配。

检查点:运行 npm test 命令进行测试,你应该看到以下输出:

恭喜,你已创建了一个包含路由的插件并测试了该路由。

在下一步中,你将定义一个 Prisma 插件,以便在整个应用程序中访问 Prisma Client 实例。

定义 Prisma 插件

类似于你创建 status 插件的方式,为 Prisma 插件创建一个新的文件 src/plugins/prisma.ts

Prisma 插件的目标是实例化 Prisma Client,通过 server.app 对象使其可供应用程序的其余部分使用,并在服务器停止时断开与数据库的连接。server.app 提供了一个安全的场所来存储服务器特定的运行时应用程序数据,避免与框架内部产生潜在冲突。只要服务器可访问,这些数据就可以被访问。

将以下内容添加到 src/plugins/prisma.ts 文件中:

在这里,我们定义一个插件,实例化 Prisma Client,将其分配给 server.app,并添加一个扩展函数(可以认为是钩子)它将在 onPostStop 事件上运行,该事件在服务器的连接监听器停止后被调用。

要注册 Prisma 插件,在 server.ts 中导入该插件,并将其添加到传递给 server.register 调用的数组中,如下所示:

如果你使用 VSCode,你会在 src/plugins/prisma.ts 文件中 server.app.prisma = prisma 下方看到一条红色波浪线,表示类型错误。这是你遇到的第一个类型错误。如果你没有看到这条线,可以运行 compile 脚本来运行 TypeScript 编译器。

这个错误的原因是你修改了 server.app 但没有更新其类型。要解决此错误,请在 prismaPlugin 定义上方添加以下内容:

这将增强模块并为 server.app.prisma 属性分配 PrismaClient 类型。

注意:有关为何需要模块增强(module augmentation)的更多信息,请查看 DefinitelyTyped 仓库中的此评论

除了使 TypeScript 编译器满意之外,这还将使自动补全在应用程序中任何地方访问 server.app.prisma 时都能正常工作。

检查点:如果再次运行 npm run compile,应该不会发出任何错误。

做得好!你现在已经定义了两个插件,并将 Prisma Client 使应用程序的其余部分可用。在下一步中,你将为用户路由定义一个插件。

定义一个依赖于 Prisma 插件的用户路由插件

现在你将为用户路由定义一个新的插件。这个插件需要使用你在 Prisma 插件中定义的 Prisma Client,以便在用户特定的路由处理程序中执行 CRUD 操作。

Hapi 插件有一个可选的 dependencies 属性,可用于指示对其他插件的依赖。指定后,Hapi 将确保插件以正确的顺序加载。

首先,为 users 插件创建一个新文件 src/plugins/users.ts

将以下内容添加到该文件:

在这里,你向 dependencies 属性传递了一个数组,以确保 Hapi 首先加载 Prisma 插件。

现在你可以在 register 函数中定义用户特定的路由,并且知道 Prisma Client 将可访问。

最后,你需要导入插件并将其注册到 src/server.ts 文件中,如下所示:

在下一步中,你将定义一个创建用户的端点。

定义创建用户路由

定义用户插件后,你现在可以定义创建用户路由。

创建用户路由将使用 HTTP 方法 POST 和路径 /users

首先,在 src/plugins/users.ts 文件中 register 函数内添加以下 server.route 调用:

然后定义 createUserHandler 函数如下:

在这里,你从 server.app 对象(在 Prisma 插件中分配)访问 prisma,并在 prisma.user.create 调用中使用请求体(request payload)将用户保存到数据库。

你会在访问 payload 属性的行下方再次看到一条红色波浪线,表示类型错误。如果你没有看到错误,请再次运行 TypeScript 编译器。

这是因为 payload 的值是在运行时确定的,因此 TypeScript 编译器无法得知其类型。这可以通过类型断言(type assertion)来解决。类型断言是 TypeScript 中的一种机制,允许你覆盖变量的推断类型。TypeScript 的类型断言完全是你告诉编译器你比它更了解类型的情况,就像这里一样。

为此,为预期的请求体定义一个接口:

注意:类型(Types)和接口(Interfaces)在 TypeScript 中有很多相似之处。

然后添加类型断言:

插件应该如下所示:

为创建用户路由添加验证

在这一步中,你还将使用 Joi 添加请求体验证,以确保路由只处理包含正确数据的请求。

验证可以被认为是运行时类型检查。在使用 TypeScript 时,编译器执行的类型检查仅限于编译时已知的内容。由于用户 API 输入在编译时无法确定,运行时验证有助于处理这种情况。

为此,按如下方式导入 Joi:

Joi 允许你通过创建 Joi 验证对象来定义验证规则,该对象可以分配给路由处理程序,以便 Hapi 知道如何验证请求体。

在创建用户端点中,你需要验证用户输入是否符合你上面定义的类型

对应的 Joi 验证对象如下所示

接下来,你需要配置路由处理程序以使用验证器对象 userInputValidator。将以下内容添加到你的路由定义对象中

为创建用户路由编写测试

在这一步中,你将创建一个测试来验证创建用户的逻辑。测试将使用 server.injectPOST /users 端点发送请求,并检查响应是否包含 id 字段,从而验证用户已在数据库中创建。

首先创建 tests/users.tests.ts 文件并添加以下内容

该测试注入了一个带有 payload 的请求,并断言 statusCode 以及响应中的 id 是一个数字。

注意: 该测试通过确保每次测试运行时 email 的唯一性来避免唯一约束错误。

现在你已经为“成功路径”(成功创建用户)编写了测试,接下来你将编写另一个测试来验证验证逻辑。你可以通过构建另一个带有无效 payload 的请求来做到这一点,例如省略必需字段 firstName,如下所示

检查点: 运行 npm test 命令来运行测试,并验证所有测试都通过。

定义和测试获取用户路由

在这一步中,你将首先为获取用户端点定义一个测试,然后实现路由处理程序。

提醒一下,获取用户端点将具有 GET /users/{userId} 的签名。

先编写测试再实现的做法通常被称为 测试驱动开发(test-driven development)。测试驱动开发通过提供一个快速机制来验证更改的正确性,从而在你进行实现工作时提高生产力。

定义测试

首先,你将测试当用户未找到时路由返回 404 的情况。

打开 users.test.ts 文件并添加以下 test

第二个测试将测试“成功路径”——成功获取用户。你将使用上一步中创建的创建用户测试中设置的 userId 变量。这将确保你获取的是一个现有用户。添加以下测试

由于你尚未定义路由,现在运行测试将导致测试失败。下一步是定义路由。

定义路由

转到 users.ts 文件(用户插件),并将以下路由对象添加到 server.route() 调用中

与你为创建用户端点定义验证规则的方式类似,在上面的路由定义中,你验证了 userId URL 参数以确保传递的是一个数字。

接下来,定义 getUserHandler 函数如下

注意: 调用 findUnique 时,如果找不到结果,Prisma 将返回 null

在处理程序中,从请求参数中解析出 userId 并用于 Prisma Client 查询。如果找不到用户则返回 404,否则返回找到的用户对象。

检查点: 运行 npm test 来运行测试,并验证所有测试都已通过。

定义和测试删除用户路由

在这一步中,你将为删除用户端点定义一个测试,然后实现路由处理程序。

删除用户端点将具有 DELETE /users/{userId} 的签名。

定义测试

首先,你将为路由的参数验证编写一个测试。将以下测试添加到 users.test.ts

然后再添加一个删除用户逻辑的测试,在该测试中你将删除在创建用户测试中创建的用户

注意: 204 状态码表示请求已成功,但响应没有内容。

定义路由

转到 users.ts 文件(用户插件),并将以下路由对象添加到 server.route() 调用中

定义完路由后,定义 deleteUserHandler 如下

检查点: 运行 npm test 来运行测试,并验证所有测试都已通过。

定义和测试更新用户路由

在这一步中,你将为更新用户端点定义一个测试,然后实现路由处理程序。

更新用户端点将具有 PUT /users/{userId} 的签名。

编写更新用户路由的测试

首先,你将为路由的参数验证编写一个测试。将以下测试添加到 users.test.ts

为更新用户端点添加另一个测试,在该测试中你将更新用户的 firstNamelastName 字段(针对在创建用户测试中创建的用户)

定义更新用户验证规则

在这一步中,你将定义更新用户路由。在验证方面,该端点的 payload 不应要求任何特定字段(与创建用户端点不同,创建用户端点要求 emailfirstNamelastName 是必需的)。这将允许你使用该端点更新单个字段,例如 firstName

为了定义 payload 验证,你 可以 使用 userInputValidator Joi 对象,但是,如果你还记得的话,有些字段是必需的

在更新用户端点中,所有字段都应该是可选的。Joi 提供了一种使用 tailoralter 方法创建同一 Joi 对象不同变体的方式。这在定义具有相似验证规则的创建和更新路由同时保持代码 DRY(不要重复自己)时特别有用。

如下更新已定义的 userInputValidator

更新创建用户路由的 payload 验证

现在你可以更新创建用户路由定义,在 src/plugins/users.ts 文件(用户插件)中使用 createUserValidator

定义更新用户路由

定义了更新验证对象后,你现在可以定义更新用户路由了。转到 src/plugins/users.ts 文件(用户插件),并将以下路由对象添加到 server.route() 调用中

定义完路由后,定义 updateUserHandler 函数如下

检查点: 运行 npm test 来运行测试,并验证所有测试都已通过。

总结与下一步

如果你已经看到这里,恭喜你!本文涵盖了很多内容,从 REST 概念开始,然后深入探讨 Hapi 概念,如路由、插件、插件依赖、测试和验证。

你为 Hapi 实现了一个 Prisma 插件,使 Prisma 在你的整个应用中可用,并实现了利用它的路由。

此外,TypeScript 在整个应用程序中帮助实现了自动补全并验证了类型的正确使用(与数据库模式同步)。

本文涵盖了所有端点中的一部分的实现。作为下一步,你可以按照相同的原则实现其他路由。

你可以在 GitHub 上找到后端完整的源代码。

本文的重点是实现 REST API,然而,验证和测试等概念也适用于其他情况。

虽然 Prisma 旨在简化关系数据库的操作,但对底层数据库有更深入的理解也会有所帮助。

查看 Prisma 的数据指南(Data Guide),了解更多关于数据库如何工作、如何选择合适的数据库以及如何在应用程序中充分发挥数据库潜力。

在本系列的后续部分,你将了解更多关于

  • 认证:使用电子邮件和 JWT 实现无密码认证。
  • 持续集成:构建 GitHub Actions 流水线以自动化后端测试。
  • 与外部 API 集成:使用事务性电子邮件 API 发送电子邮件。
  • 授权:提供对不同资源的不同访问级别。
  • 部署

不要错过下一篇文章!

订阅 Prisma 新闻通讯