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. 验证:添加有效负载验证规则,以确保用户输入与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:应用程序编程接口。一组允许程序相互通信的规则。通常,开发者在服务器上创建API,并允许客户端与之通信。
  • REST:开发者遵循的一组约定,用于通过HTTP请求公开与状态相关的操作(在本例中是存储在数据库中的状态)。例如,请查看GitHub REST API
  • 端点:REST API的入口点,具有以下属性(非详尽):
    • 路径,例如/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中插入的变量,例如在www.myapi.com/users/13中,userId13

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

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

注意:在本文中,端点(endpoint)和路由(route)这两个词将交替使用。尽管它们指代同一事物,但在REST语境中称为端点,而在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

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

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

定义路由

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

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

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

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

将路由移至插件

在上一步中,您定义了一个状态端点。由于API将暴露许多不同的端点,将所有端点都定义在start函数中将难以维护。

Hapi引入了插件的概念,作为将后端分解为独立业务逻辑模块的一种方式。插件是保持代码模块化的精简方法。在这一步中,您将把上一步中定义的路由移到插件中。

这需要两个步骤:

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

定义插件

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

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

并向文件中添加以下内容:

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

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

注册插件

要注册插件,请返回到server.ts并按如下方式导入状态插件:

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

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

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

在下一步中,您将定义一个测试来测试状态端点。

为状态端点定义测试

为了测试状态端点,您将使用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用作创建和停止服务器的setup和teardown函数。

然后,调用server.inject来模拟对根端点/GET HTTP请求。测试接着断言HTTP状态码和有效负载,以确保它们与处理程序匹配。

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

恭喜,您已创建了一个带有路由的插件并测试了该路由。

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

定义Prisma插件

与创建状态插件类似,为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定义上方添加以下内容:

这将扩展模块并将PrismaClient类型分配给server.app.prisma属性。

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

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

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

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

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

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

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

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

向文件中添加以下内容:

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

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

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

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

定义创建用户路由

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

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

首先,在src/plugins/users.tsregister函数内部添加以下server.route调用:

然后按如下方式定义createUserHandler函数:

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

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

这是因为payload的值是在运行时确定的,因此TypeScript编译器无法知道它的类型。这可以通过类型断言来解决。类型断言是TypeScript中的一种机制,允许您覆盖变量的推断类型。TypeScript的类型断言纯粹是您告诉编译器您比它更了解类型,就像这里一样。

为此,请为预期有效负载定义一个接口:

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

然后添加类型断言:

该插件应如下所示:

向创建用户路由添加验证

在这一步中,您还将使用Joi添加有效负载验证,以确保路由只处理具有正确数据的请求。

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

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

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

在创建用户端点中,您希望验证用户输入是否符合您上面定义的类型。

相应的Joi验证对象将如下所示:

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

为创建用户路由创建测试

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

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

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

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

既然您已经为成功创建用户的“快乐路径”编写了测试,您将编写另一个测试来验证验证逻辑。您将通过制作另一个带有无效有效负载的请求来完成此操作,例如,省略所需的firstName字段,如下所示:

检查点:使用npm test命令运行测试,并验证所有测试是否通过。

定义和测试获取用户路由

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

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

先编写测试再实现代码的实践通常被称为测试驱动开发。测试驱动开发可以通过提供一种快速机制来验证更改的正确性,从而提高工作效率。

定义测试

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

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

第二个测试将测试“快乐路径”——成功检索到用户。您将使用上一步中创建用户测试中设置的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字段(针对在创建用户测试中创建的用户):

定义更新用户验证规则

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

要定义有效负载验证,您可以使用userInputValidator Joi对象,但是,如果您还记得,其中一些字段是必需的。

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

按如下方式更新已定义的userInputValidator

更新创建用户路由的有效负载验证

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

定义更新用户路由

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

定义路由后,按如下方式定义updateUserHandler函数:

检查点:使用npm test运行测试,并验证所有测试是否通过。

总结与后续步骤

如果您已经读到这里,恭喜您。本文涵盖了从REST概念到Hapi概念(如路由、插件、插件依赖、测试和验证)的许多内容。

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

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

本文涵盖了所有端点的一个子集的实现。下一步,您可以按照相同的原则实现其他路由。

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

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

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

查阅Prisma数据指南,了解更多关于数据库如何工作、如何选择合适的数据库以及如何在应用程序中充分利用数据库的信息。

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

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

不要错过下一篇文章!

订阅Prisma新闻通讯

© . All rights reserved.