所有文章 > API开发 > 使用TypeScript、PostgreSQL与Prisma构建后端:REST API、数据验证与测试
使用TypeScript、PostgreSQL与Prisma构建后端:REST API、数据验证与测试

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

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

介绍

这个系列的目标是通过解决一个具体的问题——在线课程评分系统——来探索和展示现代后端开发中的不同模式、问题和架构。这个案例包含了多种关系类型,复杂度适中,能够很好地模拟现实世界的用例。

直播课程的录像可以在上方找到,内容与本文一致。

本系列将涵盖的内容

本系列将深入探讨数据库在后端开发中的多个方面,包括但不限于:

主题部分
数据建模第 1 部分
CRUD 系列第 1 部分
聚合第 1 部分
REST API 层第 2 部分 (current)
验证第 2 部分 (current)
测试第 2 部分 (current)
认证即将开始
授权即将开始
与外部 API 集成即将开始
部署即将开始

在今天的学习中,您将掌握以下内容:

在本系列的首篇文章中,您已经设计了问题域的数据模型,并编写了一个种子脚本,该脚本利用Prisma Client将数据存储到数据库中。

在系列的第二篇文章中,您将在第一篇文章的数据模型和Prisma架构的基础上,构建一个REST API。您将使用HAPI构建REST API。使用REST API,您将能够通过HTTP请求执行数据库操作

作为REST API开发的一部分,您将涉及以下几个方面:

  1. REST API:实现一个具有资源终端节点的HTTP服务器,用于处理不同模型的CRUD操作。您将Prisma与HTTP集成,以便在API端点处理程序中使用Prisma客户端。
  2. 验证:添加有效载荷验证规则,确保用户输入与Prisma架构预期的类型一致。
  3. 测试:使用Jest和HTTP请求模拟工具server.inject,为REST端点编写测试,以验证REST终端节点的验证和持久性逻辑。

完成本文后,您将拥有一个完整的REST API,它包含用于CRUD(创建、读取、更新和删除)操作的端点,以及测试端点。REST资源将HTTP请求映射到Prisma模式中的模型,例如,GET /users端点将处理与User模型相关的操作。

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

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

先决条件

假定知识

本系列教程假设您已经具备了TypeScript、Node.js和关系数据库的基础知识。即便您只有JavaScript经验而尚未接触过TypeScript,您应该也能够跟上进度。本系列将主要使用PostgreSQL,但所讲述的大多数概念也适用于其他关系数据库,如MySQL。此外,对REST概念的了解也是有益的。您不需要事先了解Prisma,因为本系列将对其进行详细介绍。

开发环境

您应该已安装以下内容:

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

如果您使用的是Visual Studio Code,建议安装Prisma扩展,以便于进行语法高亮、格式化以及其他辅助功能。

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

克隆存储库

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

要开始使用,请克隆存储库并安装依赖项:

git clone -b part-2 git@github.com:2color/real-world-grading-app.git
cd real-world-grading-app
npm install

注意:通过查看part-2分支,您将能够从相同的起点跟踪文章。

启动 PostgreSQL

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

docker-compose up -d

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

构建 REST API

在深入研究实现之前,我们将介绍一些与 REST API 上下文相关的基本概念:

  • 应用程序接口应用程序编程接口。允许程序相互通信的一组规则。通常情况下,开发者会在服务器上创建API,以便客户端能够与之交互。
  • 休息:开发人员遵循的一组约定,用于通过 HTTP 请求公开与状态相关的(在本例中为存储在数据库中的状态)操作。例如,请查看 GitHub REST API。
  • 端点:REST API 的入口点,该 API 具有以下属性(不限于此):
    • 路径,例如/users/,用于访问用户端点。路径确定用于访问端点的URL,例如www.myapi.com/users/
    • HTTP方法,例如GETPOSTDELETE。HTTP方法将确定端点公开的操作类型,例如GET /users端点将允许获取用户,而POST /users端点将允许创建用户。
    • Handler:将处理端点请求的代码(在本例中为 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/13userId13

上述端点已根据它们关联的主模型/资源进行了分组。分类将有助于将代码组织到单独的模块中,以实现可维护性。

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

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

Hapi

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

Hapi由一个名为HTTP服务器的核心模块和扩展核心功能的插件组成。在这个后端项目中,您还将使用以下Hapi插件:

  • @hapi/hapi:Hapi框架的核心模块。
  • @hapi/joi:用于声明性输入验证
  • @hapi/boom:用于 HTTP 友好的错误对象

要使用TypeScript开发Hapi应用,您需要添加Hapi和Joi的类型定义。这是必要的,因为Hapi是用JavaScript编写的。通过添加这些类型定义,您将获得丰富的自动完成功能,并允许TypeScript编译器确保代码的类型安全。

安装以下软件包:

npm install --save @hapi/boom @hapi/hapi @hapi/joi
npm install --save-dev @types/hapi__hapi @types/hapi__joi

创建服务器

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

将以下Hapi服务器添加到 src/server.ts

import Hapi from '@hapi/hapi'

const server: Hapi.Server = Hapi.server({
port: process.env.PORT || 3000,
host: process.env.HOST || 'localhost',
})

export async function start(): Promise<Hapi.Server> {
await server.start()
return server
}

process.on('unhandledRejection', err => {
console.log(err)
process.exit(1)
})

start()
.then(server => {
console.log(`Server running on ${server.info.uri}`)
})
.catch(err => {
console.log(err)
})

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

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

npm run dev

> ts-node-dev --respawn ./src/server.ts

Using ts-node version 8.10.2, typescript version 3.9.6
Server running on http://localhost:3000

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

恭喜,您已成功创建服务器。但是,目前服务器还没有定义任何路由。接下来的步骤中,您将开始定义第一个路由。

定义路由

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

为此,请在start文件夹中的server.ts文件顶部添加以下内容:

export async function start(): Promise<Hapi.Server> {
server.route({
method: 'GET',
path: '/',
handler: (_, h: Hapi.ResponseToolkit) => {
return h.response({ up: true }).code(200)
},
})
await server.start()
console.log(`Server running on ${server.info.uri}`)
return server
}

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

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

将路由移动到插件

在上一步中,您定义了状态端点。由于该API将公开许多不同的端点,如果将它们全部定义在start函数中,将不利于维护。

Hapi 提供了插件的概念,这是一种将后端分解为独立的业务逻辑单元的方法。使用插件是保持代码模块化的有效方式。在本步骤中,您将把上一步中定义的路由移动到一个插件中。

这需要两个步骤:

  1. 在一个新的文件中定义插件。
  2. 在调用server.start()之前,将插件注册到服务器。

定义插件

开始,在 src/ 中创建一个名为 plugins 的新文件夹:

mkdir src/plugins

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

touch src/plugins/status.ts

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

import Hapi from '@hapi/hapi'

const plugin: Hapi.Plugin<undefined> = {
name: 'app/status',
register: async function(server: Hapi.Server) {
server.route({
method: 'GET',
path: '/',
handler: (_, h: Hapi.ResponseToolkit) => {
return h.response({ up: true }).code(200)
},
})
},
}

export default plugin

Hapi插件是一个包含name属性和register函数的对象,通常用来封装插件的逻辑。name属性是插件的名称,它是一个字符串,用作插件的唯一标识。

每个插件都可以通过标准的服务器接口来操作服务器。例如,在您之前创建的 app/status插件中,server 对象在 register 函数中被用来定义状态路由。

注册插件

要注册插件,请返回server.ts并导入状态插件,如下所示:

import status from './plugins/status'

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

export async function start(): Promise<Hapi.Server> {
await server.register([status])
await server.start()
console.log(`Server running on ${server.info.uri}`)
return server
}

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

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

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

定义 status 端点的测试

为了测试状态端点,您将使用Jest作为测试运行器,并使用Hapi的server.inject测试助手来模拟对服务器的HTTP请求。这将帮助您确认端点是否按预期正确实现。

将server.ts拆分为两个文件

为了在测试中使用 server.inject 方法,您需要在插件注册之后但在启动服务器之前访问 server 对象,以防止服务器在测试执行时监听请求。为此,您需要对 server.ts 文件进行如下修改:

const server: Hapi.Server = Hapi.server({
port: process.env.PORT || 3000,
host: process.env.HOST || 'localhost',
})

export async function createServer(): Promise<Hapi.Server> {
await server.register([statusPlugin])
await server.initialize()

return server
}

export async function startServer(server: Hapi.Server): Promise<Hapi.Server> {
await server.start()
console.log(`Server running on ${server.info.uri}`)
return server
}

process.on('unhandledRejection', err => {
console.log(err)
process.exit(1)
})

您已经用两个新的函数取代了原有的start函数

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

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

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

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

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

import { createServer, startServer } from './server'

createServer()
.then(startServer)
.catch(err => {
console.log(err)
})

最后,更新 dev 中的 package.json 脚本,以启动 src/index.ts 而不是 src/server.ts

- "dev": "ts-node-dev --respawn ./src/server.ts",
"dev": "ts-node-dev --respawn ./src/index.ts",

创建测试

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

import { createServer } from '../src/server'
import Hapi from '@hapi/hapi'

describe('Status plugin', () => {
let server: Hapi.Server

beforeAll(async () => {
server = await createServer()
})

afterAll(async () => {
await server.stop()
})

test('status endpoint returns 200', async () => {
const res = await server.inject({
method: 'GET',
url: '/',
})
expect(res.statusCode).toEqual(200)
const response = JSON.parse(res.payload)
expect(response.up).toEqual(true)
})
})

在上述测试中,beforeAllafterAll被用作设置(setup)和清理(teardown)函数,分别用于创建和停止服务器。

接着,通过调用server.inject来模拟对根端点GET /的HTTP请求。然后,测试将断言HTTP状态码和有效载荷,确保它们与处理程序的预期结果相匹配。

检查点:运行npm test来执行测试,您应该看到以下输出

PASS  tests/status.test.ts
Status plugin
✓ status endpoint returns 200 (9 ms)

Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 0.886 s, estimated 1 s
Ran all test suites.

恭喜,您已经成功创建了一个带有路由的插件,并且对该路由进行了测试。

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

定义 Prisma 插件

类似于创建状态插件的方式,您需要为Prisma插件创建一个新的文件src/plugins/prisma.ts

Prisma插件的目的是实例化Prisma客户端,并通过server.app对象使其在整个应用程序中可用,并在服务器停止时断开与数据库的连接。server.app提供了一个安全的地方来存储特定于服务器的运行时应用程序数据,这样可以避免与框架内部发生潜在的冲突。只要服务器处于可访问状态,就可以访问这些数据。

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

import { PrismaClient } from '@prisma/client'
import Hapi from '@hapi/hapi'

// plugin to instantiate Prisma Client
const prismaPlugin: Hapi.Plugin<null> = {
name: 'prisma',
register: async function(server: Hapi.Server) {
const prisma = new PrismaClient()

server.app.prisma = prisma

// Close DB connection after the server's connection listeners are stopped
// Related issue: https://github.com/hapijs/hapi/issues/2839
server.ext({
type: 'onPostStop',
method: async (server: Hapi.Server) => {
server.app.prisma.disconnect()
},
})
},
}

export default prismaPlugin

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

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

await server.register([status, prisma])

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

npm run compile

src/plugins/prisma.ts:21:16 - error TS2339: Property 'prisma' does not exist on type 'ServerApplicationState'.

21 server.app.prisma = prisma

出现此错误的原因是您修改了server.app但没有更新其类型。要解决此错误,请在prismaPlugin定义的顶部添加以下内容:

declare module '@hapi/hapi' {
interface ServerApplicationState {
prisma: PrismaClient
}
}

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

这样做不仅能够平息TypeScript编译器的警告,还能使得在应用程序的其他部分中访问server.app.prisma时,自动完成功能可以正常工作。

检查站::如果再次运行npm run compile,应该不会出现错误。

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

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

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

Hapi插件有一个可选的dependencies属性,可以用来声明对其他插件的依赖。一旦指定,Hapi将确保这些插件按正确的顺序加载。

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

然后,将以下内容添加到该文件中:

import Hapi from '@hapi/hapi'

// plugin to instantiate Prisma Client
const usersPlugin = {
name: 'app/users',
dependencies: ['prisma'],
register: async function(server: Hapi.Server) {
// here you can use server.app.prisma
},
}
export default usersPlugin

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

现在,您可以在register函数中定义特定于用户的路由,因为您可以确信Prisma Client将是可访问的

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

await server.register([status, prisma])
await server.register([status, prisma, users])

在下一步中,您将定义一个 create user 端点。

定义 create user 路由

定义用户插件后,您现在可以定义 create user 路由。

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

开始,在server.route函数中的src/plugins/users.ts中添加以下register调用:

server.route([
{
method: 'POST',
path: '/users',
handler: createUserHandler,
},
])

然后定义createUserHandler函数如下:

async function createUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { prisma } = request.server.app
const payload = request.payload

try {
const createdUser = await prisma.user.create({
data: {
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
social: JSON.stringify(payload.social),
},
select: {
id: true,
},
})
return h.response(createdUser).code(201)
} catch (err) {
console.log(err)
}
}

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

您可能会在访问payload属性的行下面再次看到一条红色的波浪线,这表示存在类型错误。如果没有看到错误,请再次运行TypeScript编译器来检查代码:

npm run compile
src/plugins/users.ts:27:28 - error TS2339: Property 'firstName' does not exist on type 'string | object | Buffer | Readable'.
Property 'firstName' does not exist on type 'string'.

27 firstName: payload.firstName,


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

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

interface UserInput {
firstName: string
lastName: string
email: string
social: {
facebook?: string
twitter?: string
github?: string
website?: string
}
}

注意:类型和接口在 TypeScript 中有许多相似之处。

然后添加类型断言:

const payload = request.payload as UserInput

该插件应如下所示:

// plugin to instantiate Prisma Client
const usersPlugin = {
name: 'app/users',
dependencies: ['prisma'],
register: async function(server: Hapi.Server) {
server.route([
{
method: 'POST',
path: '/users',
handler: registerHandler,
},
])
},
}

export default usersPlugin

interface UserInput {
firstName: string
lastName: string
email: string
social: {
facebook?: string
twitter?: string
github?: string
website?: string
}
}

async function registerHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { prisma } = request.server.app
const payload = request.payload as UserInput

try {
const createdUser = await prisma.user.create({
data: {
firstName: payload.firstName,
lastName: payload.lastName,
email: payload.email,
social: JSON.stringify(payload.social),
},
select: {
id: true,
},
})
return h.response(createdUser).code(201)
} catch (err) {
console.log(err)
}
}

将验证添加到 create user 路由

在本步骤中,您将使用Joi为创建用户路由添加有效载荷验证,确保该路由只处理携带正确数据的请求。

可以将验证看作是运行时的类型检查。在使用TypeScript时,编译器执行的类型检查是基于编译时已知的内容。由于在编译时无法预知用户API输入的具体情况,因此运行时验证对于处理这类情况非常有帮助。

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

import Joi from '@hapi/joi'

Joi 使您能够创建一个验证对象来定义验证规则,这个对象可以被赋予路由处理程序,从而让 Hapi 知道如何验证传入的有效载荷。

在创建用户端点的情况下,您需要确保用户输入的数据符合您之前定义的类型规范:

interface UserInput {
firstName: string
lastName: string
email: string
social: {
facebook?: string
twitter?: string
github?: string
website?: string
}
}

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

const userInputValidator = Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
email: Joi.string()
.email()
.required(),
social: Joi.object({
facebook: Joi.string().optional(),
twitter: Joi.string().optional(),
github: Joi.string().optional(),
website: Joi.string().optional(),
}).optional(),
})

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

{
method: 'POST',
path: '/users',
handler: registerHandler,
options: {
validate: {
payload: userInputValidator
}
},
}

为 create user 路由创建测试

在这一步中,您将编写一个测试用例来验证创建用户的功能。这个测试将通过向POST /users端点发送请求来使用server.inject,并且会检查响应是否包含id字段,以此来确认用户是否已经成功创建在数据库中。

首先,创建一个名为tests/users.tests.ts的文件,并在其中添加以下内容:

import { createServer } from '../src/server'
import Hapi from '@hapi/hapi'

describe('POST /users - create user', () => {
let server: Hapi.Server

beforeAll(async () => {
server = await createServer()
})

afterAll(async () => {
await server.stop()
})

let userId

test('create user', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: {
firstName: 'test-first-name',
lastName: 'test-last-name',
email: `test-${Date.now()}@prisma.io`,
social: {
twitter: 'thisisalice',
website: 'https://www.thisisalice.com'
}
}
})

expect(response.statusCode).toEqual(201)
userId = JSON.parse(response.payload)?.id
expect(typeof userId === 'number').toBeTruthy()
})

})

测试将注入一个带有有效载荷的请求,并断言响应中的statusCodeid是一个数字。

注:测试通过确保email在每次测试运行中都是唯一的,从而避免了唯一约束错误。

现在,您已经为Hapi路径编写了一个测试(成功创建了一个用户),接下来您将编写另一个测试来验证验证逻辑。您可以通过创建一个带有无效载荷的请求来实现这一点,例如,省略必填字段firstName,如下所示:

test('create user validation', async () => {
const response = await server.inject({
method: 'POST',
url: '/users',
payload: {
lastName: 'test-last-name',
email: `test-${Date.now()}@prisma.io`,
social: {
twitter: 'thisisalice',
website: 'https://www.thisisalice.com',
},
},
})

console.log(response.payload)
expect(response.statusCode).toEqual(400)
})

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

定义和测试 get user 路由

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

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

先编写测试,然后实现的做法通常称为测试驱动开发(Test-Driven Development, TDD)。测试驱动开发可以通过提供一种快速验证更改正确性的机制,从而提高工作效率。

定义测试

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

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

test('get user returns 404 for non existant user', async () => {
const response = await server.inject({
method: 'GET',
url: '/users/9999',
})

expect(response.statusCode).toEqual(404)
})

第二个测试将针对成功路径——即成功检索到用户的情况。您将使用在上一步创建用户测试中设置的userId变量。这样可以确保您获取的是一个已存在的用户。请添加以下测试:

test('get user returns user', async () => {
const response = await server.inject({
method: 'GET',
url: `/users/${userId}`,
})
expect(response.statusCode).toEqual(200)
const user = JSON.parse(response.payload)

expect(user.id).toBe(userId)
})

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

定义路由

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

server.route([
{
method: 'GET',
path: '/users/{userId}',
handler: getUserHandler,
options: {
validate: {
params: Joi.object({
userId: Joi.number().integer(),
}),
},
},
},
])

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

接下来,请按照以下方式定义getUserHandler函数:

async function getUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { prisma } = request.server.app
const userId = parseInt(request.params.userId, 10)

try {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
})
if (!user) {
return h.response().code(404)
} else {
return h.response(user).code(200)
}
} catch (err) {
console.log(err)
return Boom.badImplementation()
}
}

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

在处理程序中,从请求参数中解析出userId,并将其用于Prisma Client查询。如果找不到用户,则返回404状态码;如果找到了用户,则返回该用户对象。

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

定义和测试删除用户路由

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

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

定义测试

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

test('delete user fails with invalid userId parameter', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/users/aa22`,
})
expect(response.statusCode).toEqual(400)
})

然后为 delete user 逻辑添加另一个测试,您将在该逻辑中删除在 create user 测试中创建的用户:

test('delete user', async () => {
const response = await server.inject({
method: 'DELETE',
url: `/users/${userId}`,
})
expect(response.statusCode).toEqual(204)
})

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

定义路由

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

server.route([
{
method: 'DELETE',
path: '/users/{userId}',
handler: deleteUserHandler,
options: {
validate: {
params: Joi.object({
userId: Joi.number().integer(),
}),
},
},
},
])

定义路由后,按如下方式定义deleteUserHandler

async function deleteUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { prisma } = request.server.app
const userId = parseInt(request.params.userId, 10)

try {
await prisma.user.delete({
where: {
id: userId,
},
})
return h.response().code(204)
} catch (err) {
console.log(err)
return h.response().code(500)
}
}

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

定义和测试更新用户路由

在该步骤中,您将为 update user 端点定义一个测试,然后实施路由处理程序。

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

为 update user 路由编写测试

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

test('update user fails with invalid userId parameter', async () => {
const response = await server.inject({
method: 'PUT',
url: `/users/aa22`,
})
expect(response.statusCode).toEqual(400)
})

为更新用户端点添加一个新的测试,该测试将更新在创建用户测试中创建的用户的firstNamelastName字段:

test('update user', async () => {
const updatedFirstName = 'test-first-name-UPDATED'
const updatedLastName = 'test-last-name-UPDATED'

const response = await server.inject({
method: 'PUT',
url: `/users/${userId}`,
payload: {
firstName: updatedFirstName,
lastName: updatedLastName,
},
})
expect(response.statusCode).toEqual(200)
const user = JSON.parse(response.payload)
expect(user.firstName).toEqual(updatedFirstName)
expect(user.lastName).toEqual(updatedLastName)
})

定义更新用户验证规则

在本步骤中,您将为更新用户路由定义验证规则。与创建用户端点不同,后者要求有效载荷中必须包含特定的字段(如emailfirstNamelastName),更新用户端点的有效载荷不应强制要求任何特定字段。这样的设计允许您仅更新单个字段,例如firstName

要定义有效载荷的验证规则,您可以使用userInputValidator Joi对象但如果您还记得,某些字段在创建用户时是必需的:

const userInputValidator = Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
email: Joi.string()
.email()
.required(),
social: Joi.object({
facebook: Joi.string().optional(),
twitter: Joi.string().optional(),
github: Joi.string().optional(),
website: Joi.string().optional(),
}).optional(),
})

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

请按照以下方式更新已定义的 userInputValidator

const userInputValidator = Joi.object({
firstName: Joi.string().alter({
create: schema => schema.required(),
update: schema => schema.optional(),
}),
lastName: Joi.string().alter({
create: schema => schema.required(),
update: schema => schema.optional(),
}),
email: Joi.string()
.email()
.alter({
create: schema => schema.required(),
update: schema => schema.optional(),
}),
social: Joi.object({
facebook: Joi.string().optional(),
twitter: Joi.string().optional(),
github: Joi.string().optional(),
website: Joi.string().optional(),
}).optional(),
})

const createUserValidator = userInputValidator.tailor('create')
const updateUserValidator = userInputValidator.tailor('update')

更新 create user 路由的有效负载验证

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

{
method: 'POST',
path: '/users',
handler: createUserHandler,
options: {
validate: {
- payload: userInputValidator,
payload: createUserValidator,
}
}
}

定义更新用户路由

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

server.route([
{
method: 'PUT',
path: '/users/{userId}',
handler: updateUserHandler,
options: {
validate: {
params: Joi.object({
userId: Joi.number().integer(),
}),
payload: createUserValidator,
},
},
])

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

async function updateUserHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { prisma } = request.server.app
const userId = parseInt(request.params.userId, 10)
const payload = request.payload as Partial<UserInput>

try {
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: payload,
})
return h.response(updatedUser).code(200)
} catch (err) {
console.log(err)
return h.response().code(500)
}
}

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

摘要和后续步骤

如果您已经完成了之前的步骤,那么恭喜您。在本文中,我们涵盖了从REST概念的基础出发,深入到一些有趣的主题,如路由、插件、插件依赖、测试和验证。

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

此外,TypeScript在整个应用程序中帮助自动完成并验证类型(与数据库架构同步)的正确使用。

本文介绍了所有端点子集的实现。接下来,您可以按照相同的原则来实现其他的路由。

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

虽然本文的重点在于实现REST API,但验证和测试等概念同样适用于其他情况。

尽管Prisma旨在简化关系数据库的使用,但深入了解底层数据库也是很有帮助的。

查看Prisma的数据指南,详细了解数据库的工作原理、如何选择适合的数据库以及如何将数据库与应用程序结合使用,以充分发挥其潜力。

在本系列的下一部分中,您将深入了解:

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

原文链接:https://www.prisma.io/blog/backend-prisma-typescript-orm-with-postgresql-rest-api-validation-dcba1ps7kip3

#你可能也喜欢这些API文章!