GET、POST、PUT、DELETE接口
要点
- RESTful API 的核心思想:用 HTTP 方法表达操作意图
- GET / POST / PUT / DELETE 与 CRUD 的对应关系
- 幂等性和安全性:为什么 GET 可以随便调,DELETE 可以重复调
- 用内存数组实现一个完整的 CRUD API
- 每个操作对应的 HTTP 状态码和错误处理
1. CRUD 与 HTTP 方法的对应关系
RESTful API 的核心约定:用 HTTP 方法 + URL 路径表达一个完整的操作。不需要在 URL 里写 create、delete 这种动词,HTTP 方法本身就是动词。
| 操作 | HTTP 方法 | 语义 | 示例 | 幂等 | 安全 |
|---|---|---|---|---|---|
| 创建 | POST | 新增一个资源 | POST /users | ❌ | ❌ |
| 读取 | GET | 获取资源 | GET /users | ✅ | ✅ |
| 全量更新 | PUT | 替换整个资源 | PUT /users/1 | ✅ | ❌ |
| 部分更新 | PATCH | 修改资源的部分字段 | PATCH /users/1 | ❌ | ❌ |
| 删除 | DELETE | 移除资源 | DELETE /users/1 | ✅ | ❌ |
两个概念:
- 安全性(Safe):请求不会改变服务器状态。GET 只读数据,不修改任何东西,所以是安全的。HEAD 和 OPTIONS 也是。
- 幂等性(Idempotent):同一个请求执行多次,效果和执行一次一样。GET 读多次结果不变;PUT 用同样的数据覆盖多次,资源最终状态一样;DELETE 删一次和删十次,资源最终都不存在。POST 不幂等——创建两次就多出两条记录。
本篇用一个「待办事项」API 作为例子,覆盖 GET、POST、PUT、DELETE 四个方法。
2. 完整 CRUD 代码
用一个内存数组模拟数据库,所有操作都在一个文件里:
// src/index.ts
import { Hono } from 'hono'
const app = new Hono()
// 内存数据:模拟数据库
interface Todo {
id: number
title: string
done: boolean
}
let todos: Todo[] = [
{ id: 1, title: '学习 Hono', done: false },
{ id: 2, title: '写一个 CRUD API', done: false },
]
let nextId = 3
// GET /api/todos — 获取所有待办
app.get('/api/todos', (c) => {
return c.json({ data: todos })
})
// GET /api/todos/:id — 获取单个待办
app.get('/api/todos/:id', (c) => {
const id = Number(c.req.param('id'))
const todo = todos.find((t) => t.id === id)
if (!todo) {
return c.json({ error: '待办不存在' }, 404)
}
return c.json({ data: todo })
})
// POST /api/todos — 创建待办
app.post('/api/todos', async (c) => {
const body = await c.req.json<{ title: string }>()
if (!body.title || typeof body.title !== 'string') {
return c.json({ error: 'title 是必填字段' }, 400)
}
const todo: Todo = {
id: nextId++,
title: body.title.trim(),
done: false,
}
todos.push(todo)
return c.json({ data: todo }, 201)
})
// PUT /api/todos/:id — 全量更新待办
app.put('/api/todos/:id', async (c) => {
const id = Number(c.req.param('id'))
const body = await c.req.json<{ title: string; done: boolean }>()
const index = todos.findIndex((t) => t.id === id)
if (index === -1) {
return c.json({ error: '待办不存在' }, 404)
}
const updated: Todo = {
id,
title: body.title,
done: body.done,
}
todos[index] = updated
return c.json({ data: updated })
})
// DELETE /api/todos/:id — 删除待办
app.delete('/api/todos/:id', (c) => {
const id = Number(c.req.param('id'))
const index = todos.findIndex((t) => t.id === id)
if (index === -1) {
// 重复删除也返回 204,保证幂等性
return c.body(null, 204)
}
todos.splice(index, 1)
return c.body(null, 204)
})
export default app代码不长,但已经是一个可用的 CRUD API。下面逐个拆解。
3. GET:读取资源
列表查询
// 获取所有待办
app.get('/api/todos', (c) => {
return c.json({ data: todos })
})实际项目中列表接口通常要支持分页和过滤。加两个查询参数试试:
// 带分页和过滤的列表查询
app.get('/api/todos', (c) => {
const page = Number(c.req.query('page') || '1')
const limit = Number(c.req.query('limit') || '10')
const done = c.req.query('done')
let result = [...todos]
// 按完成状态过滤
if (done !== undefined) {
result = result.filter((t) => t.done === (done === 'true'))
}
// 分页
const start = (page - 1) * limit
const items = result.slice(start, start + limit)
return c.json({
data: items,
pagination: {
page,
limit,
total: result.length,
},
})
})c.req.query() 读取 URL 上的查询参数,返回字符串。没有传参时返回 undefined,所以要用 || 给默认值。
访问 GET /api/todos?page=1&limit=10&done=false,Hono 会解析出 page、limit、done 三个查询参数。
单个资源查询
// 获取单个待办
app.get('/api/todos/:id', (c) => {
const id = Number(c.req.param('id'))
const todo = todos.find((t) => t.id === id)
if (!todo) {
return c.json({ error: '待办不存在' }, 404)
}
return c.json({ data: todo })
})c.req.param('id') 读取路径参数。路径里的 :id 是动态段,匹配到的值就是 c.req.param('id') 的返回值。
找不到的时候返回 404。注意 c.json() 的第二个参数就是状态码,非常直观。
GET 的状态码
- 200 OK:正常返回,默认值
- 404 Not Found:资源不存在
- 400 Bad Request:参数格式错误(比如
id传了字母)
GET 是安全且幂等的,无论调多少次,服务器状态都不会变。
4. POST:创建资源
// 创建待办
app.post('/api/todos', async (c) => {
const body = await c.req.json<{ title: string }>()
if (!body.title || typeof body.title !== 'string') {
return c.json({ error: 'title 是必填字段' }, 400)
}
const todo: Todo = {
id: nextId++,
title: body.title.trim(),
done: false,
}
todos.push(todo)
return c.json({ data: todo }, 201)
})几个关键点:
- 处理函数标记为
async,因为c.req.json()返回 Promise - 泛型
<{ title: string }>给请求体加上类型约束,TypeScript 会做类型检查 - 用自增的
nextId生成 ID,实际项目中通常用 UUID 或数据库自增主键 - 状态码用 201 Created,表示资源已创建
POST 不是幂等操作。同样的请求发两次,会创建两条记录。
5. PUT:全量更新
PUT 的语义是「用请求体的数据完整替换目标资源」。和 PATCH 的区别:PUT 要求客户端提交资源的所有字段,PATCH 只提交要改的字段。
// 全量更新待办
app.put('/api/todos/:id', async (c) => {
const id = Number(c.req.param('id'))
const body = await c.req.json<{ title: string; done: boolean }>()
const index = todos.findIndex((t) => t.id === id)
if (index === -1) {
return c.json({ error: '待办不存在' }, 404)
}
const updated: Todo = {
id,
title: body.title,
done: body.done,
}
todos[index] = updated
return c.json({ data: updated })
})PUT 的两种处理方式:
- 资源不存在时返回 404:本例采用的方式,大多数 REST API 的做法
- 资源不存在时自动创建:这是 REST 规范允许的「upsert」行为,某些场景有用,但容易造成意外数据
状态码返回 200 OK,表示更新成功并返回最新资源。
6. DELETE:删除资源
// 删除待办
app.delete('/api/todos/:id', (c) => {
const id = Number(c.req.param('id'))
const index = todos.findIndex((t) => t.id === id)
if (index === -1) {
return c.body(null, 204)
}
todos.splice(index, 1)
return c.body(null, 204)
})几个细节:
- 删除成功返回 204 No Content——没有响应体,用
c.body(null, 204)返回 - 资源不存在时也返回 204,而不是 404。这是为了保证 DELETE 的幂等性:删一次和删十次效果一样。如果第一次返回 204、第二次返回 404,那客户端就得处理两种「成功」场景
- 有些 API 设计在删除成功时返回 200 +
{ message: '已删除' },这也行,看团队约定
c.body(null, 204) 返回一个空响应体,状态码 204。比 c.json({}, 204) 更准确——既然没有内容,就不该返回 JSON。
7. 错误处理
上面的代码里已经散落了一些错误处理逻辑。当项目变大之后,把错误响应统一成固定格式会省很多事。
统一错误响应格式
所有错误响应保持同一个结构:
{
"error": "错误信息",
"code": "ERROR_CODE"
}封装一个错误响应函数:
// 错误响应工具
function errorResponse(c: any, status: number, message: string, code: string) {
return c.json({ error: message, code }, status)
}改造一下路由里的错误处理:
// POST /api/todos — 创建待办(改进版)
app.post('/api/todos', async (c) => {
let body: any
try {
body = await c.req.json()
} catch {
return errorResponse(c, 400, '请求体不是合法的 JSON', 'INVALID_JSON')
}
if (!body.title || typeof body.title !== 'string') {
return errorResponse(c, 400, 'title 是必填字段', 'MISSING_TITLE')
}
// 检查标题是否重复
const exists = todos.some((t) => t.title === body.title.trim())
if (exists) {
return errorResponse(c, 409, '同名待办已存在', 'DUPLICATE_TODO')
}
const todo: Todo = {
id: nextId++,
title: body.title.trim(),
done: false,
}
todos.push(todo)
return c.json({ data: todo }, 201)
})常见错误状态码
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 400 | Bad Request | 请求体格式错误、必填字段缺失、参数类型不对 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突,比如重复创建 |
| 422 | Unprocessable Entity | 格式正确但语义有误(比如日期格式对但值不合理) |
| 500 | Internal Server Error | 服务器内部错误 |
全局错误处理
Hono 提供了 app.onError() 钩子,捕获所有未处理的异常:
// 全局错误处理
app.onError((err, c) => {
console.error(err)
return c.json(
{ error: '服务器内部错误', code: 'INTERNAL_ERROR' },
500
)
})把这段加在 export default app 之前。任何路由里抛出未捕获的异常,都会走到这里,返回统一的 500 响应,而不是 Hono 默认的空白错误页。
8. 用 curl 完整测试
启动开发服务器后,按顺序跑以下命令,验证完整的 CRUD 流程。
创建两条待办
// terminal
# 创建第一条
curl -X POST http://localhost:8787/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "读 Hono 文档"}'
# 响应:{"data":{"id":3,"title":"读 Hono 文档","done":false}}
# 创建第二条
curl -X POST http://localhost:8787/api/todos \
-H "Content-Type: application/json" \
-d '{"title": "写单元测试"}'
# 响应:{"data":{"id":4,"title":"写单元测试","done":false}}查询所有待办
// terminal
curl http://localhost:8787/api/todos
# 响应:{"data":[{"id":1,...},{"id":2,...},{"id":3,...},{"id":4,...}]}查询单个待办
// terminal
curl http://localhost:8787/api/todos/1
# 响应:{"data":{"id":1,"title":"学习 Hono","done":false}}
# 查询不存在的
curl http://localhost:8787/api/todos/999
# 响应:{"error":"待办不存在"}更新待办
// terminal
# 把 id=1 标记为已完成
curl -X PUT http://localhost:8787/api/todos/1 \
-H "Content-Type: application/json" \
-d '{"title": "学习 Hono", "done": true}'
# 响应:{"data":{"id":1,"title":"学习 Hono","done":true}}
# 更新不存在的资源
curl -X PUT http://localhost:8787/api/todos/999 \
-H "Content-Type: application/json" \
-d '{"title": "不存在", "done": false}'
# 响应:{"error":"待办不存在"}删除待办
// terminal
# 删除 id=2
curl -X DELETE http://localhost:8787/api/todos/2
# 响应:204 No Content,无响应体
# 重复删除(幂等性验证)
curl -X DELETE http://localhost:8787/api/todos/2
# 响应:仍然是 204 No Content
# 查看当前列表,确认 id=2 已经不在了
curl http://localhost:8787/api/todos错误场景测试
// terminal
# 缺少 title 字段
curl -X POST http://localhost:8787/api/todos \
-H "Content-Type: application/json" \
-d '{"done": false}'
# 响应:{"error":"title 是必填字段","code":"MISSING_TITLE"}
# 不合法的 JSON
curl -X POST http://localhost:8787/api/todos \
-H "Content-Type: application/json" \
-d '这不是JSON'
# 响应:{"error":"请求体不是合法的 JSON","code":"INVALID_JSON"}一套 curl 跑完,五个操作、正常流程、异常流程都覆盖到了。
延伸阅读
- Hono 路由文档 —
app.get()、app.post()等方法的完整 API - Hono Context API —
c.json()、c.body()、c.req.json()等上下文方法 - HTTP 幂等性 — MDN 对幂等性的解释
- RESTful API 设计指南 — REST 架构风格的完整参考
总结
这一篇用一个待办事项 API 把 GET / POST / PUT / DELETE 四个 HTTP 方法串了一遍:
- GET 读数据,安全且幂等,支持列表和单个查询
- POST 创建数据,非幂等,返回 201
- PUT 全量更新,幂等,返回 200
- DELETE 删除数据,幂等,返回 204
- 错误处理统一格式,用状态码表达错误类型
内存数组只是演示用。下一篇会把数据持久化到 D1 数据库,看看真实的存储层怎么接入。