RESTful API 的设计与实现 | 自在学RESTful API 的设计与实现
HTTP 这一层已经能正确接收、发送、限流和处理断开,但“报文传输正确”并不代表“业务语义正确”。状态码、方法、资源名称和重试行为如果没有稳定契约,服务端可能成功处理了请求,客户端却只能把它理解成失败;一次普通重试也可能变成第二次写入。
下面这次重复创建事故,就是从协议生命周期走向 API 设计的分界点。
那个“保存失败”为什么创建了两条任务
我遇到过一种很让人抓狂的接口事故:移动端点了一次“创建任务”,界面先转了几秒,随后提示保存失败;用户再点一次,列表里却出现了两条一模一样的任务。更麻烦的是,旧版桌面端把已经完成的任务也显示成未完成,而服务端监控几乎全绿——因为接口连业务失败都返回 200。
我最先怀疑的是按钮连点,于是让客户端加了防抖;重复任务仍然出现。接着我怀疑代理层偷偷重试,逐层核对后也没有证据。数据库里确实有两次插入,但这只说明结果,不说明请求为什么来了两次。
转折出现在请求日志里。同一份请求体在很短的间隔内出现两次,第一次已经提交到数据库,只是响应在网络超时前没能回到客户端。SDK 看见超时,自动重放了 POST /createTask;服务端没有识别“这还是同一次创建”,于是又生成了一个 ID。另一个异常也在此时对上了:新接口把 completed 改名为 done,没有版本边界,也没有兼容期。旧客户端读取不到新字段,只能按默认值 false 渲染。
三个症状看起来互不相干:
- 重复创建,是
POST 的重试语义没有设计;
- 监控失真,是状态码与错误正文没有形成契约;
- 旧客户端误判,是字段含义被悄悄改变。
问题不在某一行 Express 代码,而在 API 从未被当成一份长期协议。路径、方法、状态码、字段、分页、错误结构和重试规则,全是协议的一部分。服务内部可以换数据库、拆服务,只要协议没变,客户端就不该被迫同步重写。
日常开发所说的 RESTful JSON API,核心是以资源为中心,并兑现 HTTP 的通用语义。严格的 REST 还包含无状态、缓存、分层系统和超媒体等约束;工程里最先要守住的,是资源、方法、状态码和输入输出契约。
我先停掉“给函数起 URL”的做法
排查时,我把现有路由摊在一起:/getTaskList、/createTask、/updateTaskStatus、/deleteTaskById。每个名字单独看都能猜懂,放在一起却没有规律。客户端必须记忆四套动作词,网关也无法仅凭 HTTP 方法判断请求意图。
真正有效的第一步不是改名,而是先找业务名词:
- 客户端在操作什么对象?
- 它要访问一组对象,还是其中一个?
- 某个对象离开父对象后,能否独立存在和寻址?
以任务服务为例,可以先得到这张资源表:
任务标题会改,稳定 ID 不会,因此标题不应该承担资源身份。数据库主键、UUID 或稳定业务编号都可以放进 URI;数据库表名则未必应该出现,因为它只是内部实现。

路径回答“是谁”,查询参数回答“怎么看”
我后来用一句话审查 URI:路径负责定位资源,查询参数负责改变资源的观察方式。
GET /tasks?status=pending&sort=-createdAt&page=2&pageSize=20
这里的 /tasks 始终是任务集合;status 做筛选,sort 做排序,page 和 pageSize 控制分页。即使这些参数全被省略,集合依然有意义。相反,GET /tasks/{taskId} 里的 ID 一旦省略,目标就变成了另一种资源。
这个区分还保护了 GET 的只读语义。像 GET /tasks/42?action=delete 这样的接口,浏览器预取、爬虫访问、缓存重放都可能误触发删除。正确表达是 DELETE /tasks/42。
嵌套只保留真正的所有权
我不会为了“看起来有层级”不断加深路径。/users/{userId}/tasks/{taskId}/comments/{commentId} 把所有关系都写出来,却会让路由复用、权限判断和资源迁移变复杂。
我的判断标准是:父资源是否决定子资源的身份或访问边界。评论离开任务可能没有独立意义,/tasks/{taskId}/comments/{commentId} 很自然;任务已经能由 taskId 唯一定位,详情仍用 /tasks/{taskId}。需要按用户筛选时,再用 /users/{userId}/tasks 或 /tasks?ownerId=...。
有些业务意图确实很难直接翻成 CRUD,例如取消订单。可以把取消行为建模为资源:POST /orders/{orderId}/cancellations 表示创建一次取消记录。如果团队选择 POST /orders/{orderId}:cancel 这种动作形式,也要统一命名、权限、幂等和错误规则,不能让每个接口临时发明一种方言。
REST 不是把数据库表机械地暴露出来。我们设计的是客户端能理解的业务资源,以及它对资源表达的意图。
方法不是动词装饰,而是重试承诺
资源确定后,我才给每种意图选择 HTTP 方法。任务服务的骨架可以写成:
Express 路由只是在代码里兑现这张表:
router.get('/tasks', listTasks)
router.get('/tasks/:taskId', getTask)
router.post('/tasks', createTask)
router.put('/tasks/:taskId', replaceTask)
router.patch('/tasks/:taskId', updateTask)
router.delete
PUT 和 PATCH 的差别不在拼写
PUT 表示用完整表述替换目标资源。假设任务有 title、done、priority 三个可写字段,客户端通常要提交完整的新状态;遗漏字段究竟恢复默认值还是被删除,契约必须说清楚。
PATCH 传的是修改说明,只改变指定部分。它没有唯一格式,可以采用 JSON Patch、JSON Merge Patch,也可以使用项目定义的局部更新对象。若接口接受 { "done": true },就要明确“省略字段保持不变”,不能让调用方猜。
有一个很常见的坑:把“计数加一”设计成 PATCH { "views": "+1" },然后默认它幂等。这个补丁每执行一次都会继续改变状态,网络重放就会重复累加。若要求可安全重试,可以发送目标值并做并发控制,或把一次增量操作建模成带唯一操作 ID 的资源。
安全和幂等是两个问题
HTTP 里的“安全”不是没有安全漏洞,而是客户端没有要求服务端改变业务资源。记录访问日志和指标不破坏 GET 的安全语义,因为它们不是客户端想要的业务效果。
“幂等”是同一个请求执行一次或多次,客户端要求的最终效果相同。响应不必完全一致:第一次 DELETE /tasks/42 返回 204,第二次返回 404,只要最终都是“任务 42 不存在”,仍可符合幂等语义。

重试 POST,靠的不是前端防抖
事故里的防抖只能减少用户连点,挡不住 SDK、代理或用户在超时后的合理重试。对创建订单、扣款、提交工单这类操作,我会定义 Idempotency-Key:
POST /orders
Idempotency-Key: 8f5a1b72-25cb-48b6-9dc7-59c3c7795ab1
Content-Type: application/json
服务端不能只在单进程内存里做一次 if。一套可用的实现至少要处理五件事:
- 以“调用方身份 + 路由 + 幂等键”作为唯一作用域,建立唯一约束。
- 保存请求指纹;同一个键若配了不同请求内容,返回
409 Conflict,而不是复用旧结果。
- 业务写入与幂等记录需要事务或等价的原子保证,避免只完成一半。
- 重放时返回第一次的状态码、关键响应头和响应体,而不是再次执行。
- 明确键的有效期,以及并发收到同一键时是等待首次结果还是返回“处理中”。
这才把“客户端可以重试”变成服务端能兑现的承诺。
幂等也解决不了并发覆盖
两个客户端先读取同一任务,再分别写回,后提交的一方可能覆盖先提交者的更新。这不是重复请求,而是并发写冲突。
常见做法是给资源表述返回 ETag。客户端更新时携带 If-Match;服务端发现版本已经变化,就返回 412 Precondition Failed,让客户端重新读取并显式处理冲突。幂等键回答“这是不是同一次操作”,条件请求回答“我修改的是不是刚才读到的版本”,两者不能互相替代。
HTTP 方法不会自动保证语义。删除逻辑挂在 GET 上,GET 就不再安全;PUT 每次都追加记录,PUT 也不再幂等。协议给出承诺,应用代码负责兑现。
状态码让客户端不必猜
当时监控全绿,是因为接口把所有结果都包装成 200:
{
"success": false,
"code": 50001,
"message": "任务标题冲突"
}
人能看懂,网关、监控和通用 SDK 却只看到成功。状态码应该先表达通用结果,响应体再补充业务细节。

我选择状态码时会先问:
- 请求成功了吗?成功就从 2xx 中选择。
- 没成功是请求本身的问题、认证授权问题、状态冲突,还是服务端故障?
- 客户端改变请求后能否解决?若能,返回足够稳定的机器可读信息。
- 客户端是否应该重试?如果可以,方法是否幂等,是否需要退避或幂等键?
“任务标题已存在”可以用 409 表达冲突,再在错误体里给出 TASK_TITLE_CONFLICT。客户端不必解析中文句子,监控也不会再把失败算成成功。
所有入口都要过运行时校验
修完状态码后,我顺手追了一遍输入流,发现另一个危险假设:团队只校验 req.body,却把 req.params 和 req.query 原样交给数据层。TypeScript 类型只能约束编译时,挡不住真实网络请求。
一条写请求进入业务逻辑前,我会按这个顺序收紧边界:
限制传输层输入。确认写请求使用允许的 Content-Type,设置请求体大小上限,并拒绝无法解析的 JSON。
检查结构和类型。请求体必须是普通对象,必填字段必须存在,done 必须真是布尔值,字符串 "false" 不能被当成 false。
对字段做白名单校验。客户端只能修改公开字段,不能顺手写入 ownerId、role、createdAt 等服务端属性。

白名单比“删掉危险字段”更可靠
直接把请求体交给数据层,会形成批量赋值风险:
// 不建议:客户端能写哪些字段,取决于数据模型碰巧接受什么
await Task.updateOne({ _id: req.params.id }, req.body)
今天模型可能只有 title 和 done,明天新增 ownerId 或 isArchived 后,旧接口就可能意外开放新字段。更稳妥的方式是构造新的输入对象:
const allowedFields = ['title', 'done']
const unknownFields = Object.keys(req.body).filter(
(field) => !allowedFields.includes(field)
)
if (unknownFields.length > 0) {
throw
查询参数也一样:不要把 req.query 原样传给 MongoDB 或 ORM,不要允许任意字段排序。公开查询语言应该先被翻译成内部查询对象,并对字段、运算符、数据类型与复杂度设限。输入校验能缩小攻击面,但不能替代鉴权、数据库约束、参数化查询和限流。
列表接口最容易发生“温水煮青蛙”
事故修完两周后,任务数开始增长。GET /tasks 仍然一次返回全部数据,响应越来越慢。给旧接口突然加默认分页也不安全:旧客户端会悄悄只拿到第一批数据,却以为已经拿全。
所以筛选、排序、分页应该从第一版一起设计:
GET /api/v1/tasks?status=pending&sort=-createdAt&page=2&pageSize=20
每个参数都要有可测试的规则:
status 只接受 all、pending、done,默认 all。
sort 只接受 createdAt、-createdAt、title、-title,前导 - 表示降序。
page 是从 1 开始的正整数,默认 1。
pageSize 是正整数,默认 20,最大 50。
- 未声明参数直接报错,避免客户端拼错名字却以为筛选生效。
排序必须稳定。只按 createdAt 排序时,多条记录可能具有相同时间;数据库在不同请求中可能返回不同顺序,导致跨批读取出现重复或遗漏。追加唯一 ID 作为第二排序键,才能固定顺序。排序字段同样要白名单化,否则任意字段排序可能造成慢查询,还会泄露内部结构。

偏移分页和游标分页怎么选
游标应该是 URL 安全、对客户端不透明的字符串,内部通常携带当前批次最后一条记录的排序值和唯一 ID。客户端只负责原样回传,不能依赖编码细节。后续请求还要保持筛选与排序条件一致,否则游标会失去确定含义。
列表响应要同时告诉客户端“拿到了什么”和“如何继续”:
{
"data": [
{ "id": "tsk_42", "title": "补充接口测试", "done": false }
],
"meta": {
"page": 2,
"pageSize": 20,
"
total 不是所有场景都必须返回。超大数据集上的精确计数可能比取一批数据更昂贵;此时可以只返回 next 或 nextCursor,也可以明确总数是估算值。关键是把行为写进契约,不能在数据增长后悄悄改变。
错误契约要同时服务人、程序和排障
状态码只能说明错误大类。客户端还需要知道哪个字段有问题、稳定错误码是什么,排障人员则需要一个能关联日志的请求 ID。
我倾向采用 application/problem+json 的问题详情结构,再添加业务扩展字段:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
X-Request-Id: 7a7be254-f3d4-4f42-9bdf-8cc33f9d77ba
{
"type": "https://api.example.com/problems/validation-failed",
"title": "输入校验失败",
"status": 422,
"detail": "请修改标出的字段后重试",
"instance": "urn:request:7a7be254-f3d4-4f42-9bdf-8cc33f9d77ba",
"code": "VALIDATION_FAILED",
"errors": [
type 是稳定的问题类型标识;title 是简短类别;detail 描述这次失败;instance 标记本次问题;code 与 errors 是业务扩展。客户端应根据 status、type 或稳定 code 分支,不能解析中文 detail。
生产响应不要泄露堆栈、SQL、集合名、访问令牌或上游完整响应。完整上下文写入服务端日志,用请求 ID 与客户端错误关联。500 对外只返回可操作的通用说明。
Controller 越薄,契约越容易守住
我把一次请求拆成五个责任边界:
- 路由把“方法 + 路径”匹配到处理链。
- 校验层把不可信的 params、query、body 变成可信输入。
- Controller 调用服务层,并选择状态码、响应头与响应体。
- 服务层执行与 HTTP 无关的业务规则和数据操作。
- 错误处理中间件把已知异常映射成统一问题详情。

一个薄 Controller 读起来应该很平淡:
function getTask(req, res) {
const task = taskService.getById(req.params.taskId)
if (!task) {
throw new HttpError(404, 'TASK_NOT_FOUND', '任务不存在')
}
res.
查库、权限和状态迁移不应逐渐堆进这个函数。这样服务层能脱离 HTTP 做单元测试,Controller 测试只验证参数传递与 HTTP 映射。
版本号不是发布流水号,而是兼容边界
completed 改成 done 的事故让我重新定义“破坏性变化”:只要旧客户端按原契约工作却得到错误结果,就是破坏兼容,哪怕服务端只改了一行。
路径版本 /api/v1/tasks 容易观察、测试和网关分流。通过 Accept 头协商版本也可行,但缓存、SDK 和网关配置会更复杂。选哪种不是关键,关键是整个项目保持一致。
版本通常只标主版本,不要每次小改都创建 /v1.7。接口说明、契约测试和变更记录负责描述兼容演进。发布破坏性版本时,要让新旧版本并行一段时间,监测旧版本调用量,并明确停止维护与下线时间。
还有一种很隐蔽的破坏:路径仍是 /v1/tasks,字段也没改,却把 page 从 1 起算改成 0 起算,或更换默认排序。版本保护的不只是 URI,还包括默认值、日期格式、空值规则、错误码和分页含义。
我会把兼容方向说得更具体:客户端应容忍响应新增未知字段;服务端则对请求字段保持白名单,避免拼写错误和越权字段被静默接受。这不是让契约含糊,而是让双方明确哪些方向允许扩展。
把契约落进一个可运行的 Express 服务
下面用内存数组实现最小任务 API,重点验证资源路由、JSON 类型、字段白名单、列表查询、薄 Controller、405/404 和统一错误处理。进程重启后数据会恢复,它不适合作为生产存储;Idempotency-Key 与 ETag 也需要持久化、事务或条件写,因此不在这个内存示例里伪装实现。
先创建项目并安装 Express:
mkdir task-api
cd task-api
npm init -y
npm install express
新建 app.js:
const express = require('express')
const { randomUUID } = require('node:crypto')
const app = express()
const port = 3000
class HttpError extends Error {
constructor(status, code,
运行服务:
再开一个终端,创建任务并读取列表:
curl -i \
-X POST http://localhost:3000/api/v1/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"复查状态码","done":false}'
curl -i \
'http://localhost:3000/api/v1/tasks?status=pending&sort=-createdAt&page=1&pageSize=10'
创建响应应包含 201 Created、Location 和新任务;列表响应应包含 data、meta 与 links。还可以故意把 done 写成字符串、把 pageSize 改成 100,或向任务资源发送 PUT,分别观察 422、400 和带 Allow 头的 405。
最小实现的价值不在代码量,而在于让契约能被真实请求验证。路由表、状态码、校验规则和错误结构一旦可以运行,就能继续接入数据库、鉴权、缓存和契约测试,而不必推翻客户端接口。
我现在怎样审查一条 API
经历过那次排查后,我不再从 Controller 有没有写完开始验收,而是先做契约审查:
- 路径是否表达资源,HTTP 方法是否表达意图?
- GET 是否保持安全?PUT、PATCH、DELETE 的幂等行为是否与约定一致?
- 非幂等 POST 被超时重试时,是否有持久化幂等键或业务唯一约束?
- 创建是否返回 201 和
Location?204 是否真的没有响应体?
- 单个资源不存在与空集合,是否分别使用 404 和 200?
- params、query、body 是否都做运行时校验?字段与排序是否走白名单?
- 分页是否有限制、顺序是否稳定、客户端能否继续读取?
- 错误是否有稳定结构与请求 ID?500 是否隐藏内部细节?
- 并发更新是否需要
ETag 和 If-Match?
- 破坏性变化是否进入新主版本,并给旧客户端迁移时间?
- 监控是否按状态码、稳定错误码、延迟和重试次数统计?
这份清单背后的方法可以压成四步:先找资源,再定义意图;先写正常契约,再写失败契约;把重试和并发当成正常路径;最后用兼容性审查每一个“看起来很小”的变更。
1客户端重复发送同一个 DELETE 请求,第一次返回 204,第二次返回 404。这是否仍可符合幂等语义?
2GET /tasks 没有任何匹配记录时,最合适的响应是什么?
API 的稳定性不是靠“大家小心一点”维持的。资源命名减少记忆成本,HTTP 方法给重试提供依据,状态码让通用基础设施理解结果,错误结构让程序可靠分支,版本边界则给变化划出安全区。把这些决定写成可测试的契约,客户端和服务端才能真正独立演进。
契约一旦铺到几十个路由,新的重复会立刻出现:每个接口都要记录日志、解析身份、验证输入、处理限流,再把错误翻译成统一响应。如果这些规则复制在控制器里,API 表面一致,执行顺序却会逐渐漂移。中间件要解决的正是这种横切重复,但它也把请求何时继续、何时短路的控制权集中到了一条链上。
(
'/tasks/:taskId'
,
deleteTask)
做规范化和范围检查。标题先去首尾空格再检查长度;分页大小设置默认值与上限;排序字段和方向必须在允许列表中。
最后验证业务规则与当前状态,例如标题是否冲突、状态迁移是否合法、操作者能否修改目标资源。
new
HttpError
(
422
,
'UNKNOWN_FIELD'
,
'请求包含不可写字段'
)
}
const input = {}
if ('title' in req.body) input.title = req.body.title.trim()
if ('done' in req.body) input.done = req.body.done
total
"
:
67
,
"pageCount": 4
},
"links": {
"self": "/api/v1/tasks?status=pending&sort=-createdAt&page=2&pageSize=20",
"next": "/api/v1/tasks?status=pending&sort=-createdAt&page=3&pageSize=20"
}
}
{ "field": "title", "reason": "长度必须在 1 到 80 个字符之间" }
]
}
status
(
200
)
.
json
(
{
data
:
task
}
)
}
detail
,
errors
=
undefined
)
{
super(detail)
this.status = status
this.code = code
this.errors = errors
}
}
app.use((req, res, next) => {
req.requestId = randomUUID()
res.set('X-Request-Id', req.requestId)
next()
})
const jsonBodyMethods = new Set(['POST', 'PATCH'])
app.use('/api/v1', (req, res, next) => {
if (jsonBodyMethods.has(req.method) && !req.is('application/json')) {
return next(
new HttpError(
415,
'UNSUPPORTED_MEDIA_TYPE',
'写请求必须使用 application/json'
)
)
}
next()
})
app.use('/api/v1', express.json({ limit: '32kb' }))
let tasks = [
{
id: 'tsk_001',
title: '完成 RESTful API 练习',
done: false,
createdAt: '2026-08-10T09:00:00.000Z',
updatedAt: '2026-08-10T09:00:00.000Z'
},
{
id: 'tsk_002',
title: '检查错误响应契约',
done: true,
createdAt: '2026-08-09T13:30:00.000Z',
updatedAt: '2026-08-10T10:15:00.000Z'
}
]
const taskService = {
list({ status, sort, page, pageSize }) {
let result = tasks.filter((task) => {
if (status === 'done') return task.done
if (status === 'pending') return !task.done
return true
})
const descending = sort.startsWith('-')
const field = descending ? sort.slice(1) : sort
const direction = descending ? -1 : 1
result = result.slice().sort((left, right) => {
const comparison =
left[field] < right[field] ? -1 : left[field] > right[field] ? 1 : 0
return comparison === 0
? left.id.localeCompare(right.id)
: comparison * direction
})
const total = result.length
const start = (page - 1) * pageSize
return {
data: result.slice(start, start + pageSize),
total
}
},
getById(id) {
return tasks.find((task) => task.id === id)
},
create(input) {
const now = new Date().toISOString()
const task = {
id: `tsk_${randomUUID()}`,
title: input.title,
done: input.done,
createdAt: now,
updatedAt: now
}
tasks.push(task)
return task
},
update(id, input) {
const task = this.getById(id)
if (!task) return undefined
Object.assign(task, input, { updatedAt: new Date().toISOString() })
return task
},
remove(id) {
const index = tasks.findIndex((task) => task.id === id)
if (index === -1) return false
tasks.splice(index, 1)
return true
}
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key)
}
function parsePositiveInteger(value, fallback, field) {
if (value === undefined) return fallback
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new HttpError(400, 'INVALID_QUERY', `${field} 必须是正整数`)
}
const parsed = Number(value)
if (!Number.isSafeInteger(parsed)) {
throw new HttpError(400, 'INVALID_QUERY', `${field} 超出安全整数范围`)
}
return parsed
}
function validateListQuery(req, res, next) {
try {
const allowedQuery = new Set(['status', 'sort', 'page', 'pageSize'])
const unknownQuery = Object.keys(req.query).filter(
(field) => !allowedQuery.has(field)
)
if (unknownQuery.length > 0) {
throw new HttpError(
400,
'UNKNOWN_QUERY',
`不支持的查询参数:${unknownQuery.join(', ')}`
)
}
const status = req.query.status ?? 'all'
const sort = req.query.sort ?? '-createdAt'
const page = parsePositiveInteger(req.query.page, 1, 'page')
const pageSize = parsePositiveInteger(req.query.pageSize, 20, 'pageSize')
if (!['all', 'pending', 'done'].includes(status)) {
throw new HttpError(400, 'INVALID_STATUS', 'status 参数无效')
}
if (!['createdAt', '-createdAt', 'title', '-title'].includes(sort)) {
throw new HttpError(400, 'INVALID_SORT', 'sort 参数无效')
}
if (pageSize > 50) {
throw new HttpError(400, 'PAGE_SIZE_TOO_LARGE', 'pageSize 不能超过 50')
}
req.validatedQuery = { status, sort, page, pageSize }
next()
} catch (error) {
next(error)
}
}
function validateTaskId(req, res, next) {
if (!/^tsk_[0-9a-f-]+$/.test(req.params.taskId)) {
return next(new HttpError(400, 'INVALID_TASK_ID', '任务 ID 格式无效'))
}
next()
}
function validateTaskBody(mode) {
return (req, res, next) => {
const body = req.body
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return next(
new HttpError(422, 'INVALID_BODY', '请求体必须是 JSON 对象')
)
}
const allowedFields = new Set(['title', 'done'])
const fields = Object.keys(body)
const errors = fields
.filter((field) => !allowedFields.has(field))
.map((field) => ({ field, reason: '该字段不可写' }))
if (mode === 'create' && !hasOwn(body, 'title')) {
errors.push({ field: 'title', reason: '创建任务时必须提供标题' })
}
if (mode === 'patch' && fields.length === 0) {
errors.push({ field: '$', reason: '至少提供一个要修改的字段' })
}
const input = {}
if (hasOwn(body, 'title')) {
if (typeof body.title !== 'string') {
errors.push({ field: 'title', reason: '标题必须是字符串' })
} else {
const title = body.title.trim()
if (title.length < 1 || title.length > 80) {
errors.push({
field: 'title',
reason: '长度必须在 1 到 80 个字符之间'
})
} else {
input.title = title
}
}
}
if (hasOwn(body, 'done')) {
if (typeof body.done !== 'boolean') {
errors.push({ field: 'done', reason: 'done 必须是布尔值' })
} else {
input.done = body.done
}
} else if (mode === 'create') {
input.done = false
}
if (errors.length > 0) {
return next(
new HttpError(
422,
'VALIDATION_FAILED',
'请修改标出的字段后重试',
errors
)
)
}
req.validatedBody = input
next()
}
}
function pageLink(req, query, page) {
const params = new URLSearchParams({
status: query.status,
sort: query.sort,
page: String(page),
pageSize: String(query.pageSize)
})
return `${req.path}?${params.toString()}`
}
function listTasks(req, res) {
const query = req.validatedQuery
const { data, total } = taskService.list(query)
const pageCount = Math.ceil(total / query.pageSize)
const hasNextPage = query.page * query.pageSize < total
res.status(200).json({
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
pageCount
},
links: {
self: pageLink(req, query, query.page),
next: hasNextPage ? pageLink(req, query, query.page + 1) : null
}
})
}
function getTask(req, res, next) {
const task = taskService.getById(req.params.taskId)
if (!task) {
return next(new HttpError(404, 'TASK_NOT_FOUND', '任务不存在'))
}
res.status(200).json({ data: task })
}
function createTask(req, res) {
const task = taskService.create(req.validatedBody)
res
.status(201)
.location(`/api/v1/tasks/${task.id}`)
.json({ data: task })
}
function updateTask(req, res, next) {
const task = taskService.update(req.params.taskId, req.validatedBody)
if (!task) {
return next(new HttpError(404, 'TASK_NOT_FOUND', '任务不存在'))
}
res.status(200).json({ data: task })
}
function deleteTask(req, res, next) {
const removed = taskService.remove(req.params.taskId)
if (!removed) {
return next(new HttpError(404, 'TASK_NOT_FOUND', '任务不存在'))
}
res.status(204).end()
}
app.get('/api/v1/tasks', validateListQuery, listTasks)
app.post('/api/v1/tasks', validateTaskBody('create'), createTask)
app.get('/api/v1/tasks/:taskId', validateTaskId, getTask)
app.patch(
'/api/v1/tasks/:taskId',
validateTaskId,
validateTaskBody('patch'),
updateTask
)
app.delete('/api/v1/tasks/:taskId', validateTaskId, deleteTask)
app.all('/api/v1/tasks', (req, res, next) => {
res.set('Allow', 'GET, POST')
next(new HttpError(405, 'METHOD_NOT_ALLOWED', '该资源不支持此方法'))
})
app.all('/api/v1/tasks/:taskId', (req, res, next) => {
res.set('Allow', 'GET, PATCH, DELETE')
next(new HttpError(405, 'METHOD_NOT_ALLOWED', '该资源不支持此方法'))
})
app.use((req, res, next) => {
next(new HttpError(404, 'ROUTE_NOT_FOUND', '接口路径不存在'))
})
const titles = {
400: '请求格式错误',
404: '资源不存在',
405: '请求方法不受支持',
413: '请求体过大',
415: '内容类型不受支持',
422: '输入校验失败',
500: '服务器内部错误'
}
app.use((error, req, res, next) => {
if (res.headersSent) return next(error)
let status =
Number.isInteger(error.status) &&
error.status >= 400 &&
error.status <= 599
? error.status
: 500
let code = error.code ?? 'INTERNAL_ERROR'
let detail = status >= 500 ? '服务暂时无法处理请求' : error.message
if (error.type === 'entity.parse.failed') {
status = 400
code = 'INVALID_JSON'
detail = '请求体不是合法 JSON'
}
if (error.type === 'entity.too.large') {
status = 413
code = 'BODY_TOO_LARGE'
detail = '请求体超过大小限制'
}
const problem = {
type: 'about:blank',
title: titles[status] ?? '请求失败',
status,
detail,
instance: `urn:request:${req.requestId}`,
code
}
if (error.errors) problem.errors = error.errors
res.status(status).type('application/problem+json').json(problem)
})
app.listen(port, () => {
console.log(`Task API listening on http://localhost:${port}`)
})