14个文本转图像AI 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开发的一部分,您将涉及以下几个方面:
- REST API:实现一个具有资源终端节点的HTTP服务器,用于处理不同模型的CRUD操作。您将Prisma与HTTP集成,以便在API端点处理程序中使用Prisma客户端。
- 验证:添加有效载荷验证规则,确保用户输入与Prisma架构预期的类型一致。
- 测试:使用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方法,例如
GET
、POST
和DELETE
。HTTP方法将确定端点公开的操作类型,例如GET /users
端点将允许获取用户,而POST /users
端点将允许创建用户。 - Handler:将处理端点请求的代码(在本例中为 TypeScript)。
- 路径,例如
- HTTP状态码:响应HTTP状态码将通知API消费者操作是否成功以及是否发生任何错误。查看此列表以了解不同的HTTP状态代码,例如,当资源成功创建时为
201
,当消费者输入验证失败时为400
。
注意:REST 方法的主要目标之一是使用 HTTP 作为应用程序协议,以避免因坚持约定而重新发明轮子。
API 端点
API 将具有以下端点(HTTP 方法后跟路径):
资源 | HTTP 方法 | 路线 | 描述 |
---|---|---|---|
User | POST | /users | 创建用户(并可选择与课程关联) |
User | GET | /users/{userId} | 获取用户 |
User | PUT | /users/{userId} | 更新用户 |
User | DELETE | /users/{userId} | 删除用户 |
User | GET | /users | 获取用户 |
CourseEnrollment | GET | /users/{userId}/courses | 获取用户的课程中注册 |
CourseEnrollment | POST | /users/{userId}/courses | 将用户注册到课程(作为学生或教师) |
CourseEnrollment | DELETE | /users/{userId}/courses/{courseId} | 删除用户对课程的注册 |
Course | POST | /courses | 创建课程 |
Course | GET | /courses | 获取课程 |
Course | GET | /courses/{courseId} | 获取课程 |
Course | PUT | /courses/{courseId} | 更新课程 |
Course | DELETE | /courses/{courseId} | 删除课程 |
Test | POST | /courses/{courseId}/tests | 为课程创建测试 |
Test | GET | /courses/tests/{testId} | 进行测试 |
Test | PUT | /courses/tests/{testId} | 更新测试 |
Test | DELETE | /courses/tests/{testId} | 删除测试 |
Test Result | GET | /users/{userId}/test-results | 获取用户的测试结果 |
Test Result | POST | /courses/tests/{testId}/test-results | 为与用户关联的测试创建测试结果 |
Test Result | GET | /courses/tests/{testId}/test-results | 获取测试的多个测试结果 |
Test Result | PUT | /courses/tests/test-results/{testResultId} | 更新测试结果(与用户和测试关联) |
Test Result | DELETE | /courses/tests/test-results/{testResultId} | 删除测试结果 |
注:包含参数的路径在
{}
中,例如{userId}
表示URL中插入的变量,例如在www.myapi.com/users/13
中userId
是13
。
上述端点已根据它们关联的主模型/资源进行了分组。分类将有助于将代码组织到单独的模块中,以实现可维护性。
在本文中,您将实现上述端点的子集(前 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 提供了插件的概念,这是一种将后端分解为独立的业务逻辑单元的方法。使用插件是保持代码模块化的有效方式。在本步骤中,您将把上一步中定义的路由移动到一个插件中。
这需要两个步骤:
- 在一个新的文件中定义插件。
- 在调用
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)
})
})
在上述测试中,beforeAll
和afterAll
被用作设置(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()
})
})
测试将注入一个带有有效载荷的请求,并断言响应中的statusCode
和id
是一个数字。
注:测试通过确保
现在,您已经为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)
})
为更新用户端点添加一个新的测试,该测试将更新在创建用户测试中创建的用户的firstName
和lastName
字段:
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)
})
定义更新用户验证规则
在本步骤中,您将为更新用户路由定义验证规则。与创建用户端点不同,后者要求有效载荷中必须包含特定的字段(如email
、firstName
和lastName
),更新用户端点的有效载荷不应强制要求任何特定字段。这样的设计允许您仅更新单个字段,例如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 提供了 tailor
和 alter
方法,可以用来创建相同 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发送电子邮件。
- 授权:提供对不同资源的不同级别访问权限。
- 部署