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 里写 createdelete 这种动词,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 会解析出 pagelimitdone 三个查询参数。

单个资源查询

// 获取单个待办
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
  • 泛型 &lt;&#123; title: string &#125;&gt; 给请求体加上类型约束,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 + &#123; message: '已删除' &#125;,这也行,看团队约定

c.body(null, 204) 返回一个空响应体,状态码 204。比 c.json(&#123;&#125;, 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)
})

常见错误状态码

状态码含义典型场景
400Bad Request请求体格式错误、必填字段缺失、参数类型不对
404Not Found资源不存在
409Conflict资源冲突,比如重复创建
422Unprocessable Entity格式正确但语义有误(比如日期格式对但值不合理)
500Internal 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 跑完,五个操作、正常流程、异常流程都覆盖到了。

延伸阅读

总结

这一篇用一个待办事项 API 把 GET / POST / PUT / DELETE 四个 HTTP 方法串了一遍:

  • GET 读数据,安全且幂等,支持列表和单个查询
  • POST 创建数据,非幂等,返回 201
  • PUT 全量更新,幂等,返回 200
  • DELETE 删除数据,幂等,返回 204
  • 错误处理统一格式,用状态码表达错误类型

内存数组只是演示用。下一篇会把数据持久化到 D1 数据库,看看真实的存储层怎么接入。