用 Claude Code 构建第一套 REST API:CRUD、校验与测试
用 Claude Code 构建第一套 Express REST API:CRUD、校验、错误设计、测试与可运行代码。
第一次写 REST API 时,真正难的通常不是写 Express route,而是弄清楚一套可用 API 应该包含什么:清晰的 URL、正确的 HTTP method、输入校验、稳定的错误响应,以及能证明代码可运行的小测试。
本文会用 Claude Code 一起构建一个小型 Todo API。我们会创建项目,添加 CRUD endpoint,校验 JSON 输入,返回合适的 status code,并用 curl 与 Node.js 内置 test runner 检查结果。代码面向 Node.js 22/24 LTS 或更新版本。
如果你还不熟悉 Claude Code,可以先看 Claude Code 入门指南。想继续学习 API 测试,可以阅读 Claude Code API testing;想把校验做得更系统,可以看 Claude Code Zod validation。
先理解几个关键词
REST API 是一种通过 HTTP 操作 resource 的设计方式。本文里的 resource 是 todo。简单说,就是另一个应用调用某个 URL,发送或接收 JSON,再根据 status code 判断结果。
| 术语 | 简单解释 | 本文示例 |
|---|---|---|
| Endpoint | API 的 URL 入口 | GET /todos |
| Method | 表示动作的 HTTP 动词 | GET, POST, PUT, DELETE |
| Status code | 用数字表示请求结果 | 200, 201, 400, 404 |
| JSON | 轻量的数据交换文本格式 | { "title": "Learn REST" } |
| Validation | 检查输入是否有效 | 拒绝空 title |
| Idempotency | 重复同一个请求,最终状态保持一致 | 同一个 PUT 发送两次 |
建议收藏这些官方文档:MDN 的 HTTP request methods、HTTP status codes、Express routing、Express error handling,以及 Node.js test runner。
这次要做什么
这个 API 会把 Todos 保存在内存里。服务器重启后,数据会回到初始状态。这是刻意简化:先理解 REST API 的形状,再加入数据库。
| 操作 | Method 与 URL | 成功状态 |
|---|---|---|
| 健康检查 | GET /health | 200 OK |
| Todo 列表 | GET /todos | 200 OK |
| 读取单个 Todo | GET /todos/:id | 200 OK |
| 创建 Todo | POST /todos | 201 Created |
| 替换 Todo | PUT /todos/:id | 200 OK |
| 删除 Todo | DELETE /todos/:id | 204 No Content |
这个例子虽小,但可以迁移到真实场景。第一个用例是内部任务 API,把 Todo 换成 review request、ticket 或运营任务即可。第二个用例是前端开发用 mock backend,在 React、Vue、Astro 页面接入真实后端之前先验证交互。第三个用例是联系表单或 newsletter 注册 API,把 title 换成 email 和 message,校验与错误设计仍然适用。
给 Claude Code 明确完成条件
把技术栈、endpoint、错误行为和验证方式一起写进提示词。只说“帮我做个 API”通常会得到能启动的代码,但很难判断设计是否可靠。
Create a beginner Todo REST API with Express 5 and Node.js ES Modules.
Requirements:
- Provide package.json and server.js
- Add GET /health, GET /todos, GET /todos/:id, POST /todos, PUT /todos/:id, DELETE /todos/:id
- Accept JSON input
- title must be 1 to 120 characters; completed must be boolean
- Return JSON errors for 400, 404, and 500
- Keep PUT idempotent: repeating the same request should not change the final resource state
- Include curl requests and a node:test example
这里的 idempotent 很重要。PUT 表示替换 resource。如果同一个 body 发送两次,Todo 的最终状态应该一致。如果数据没变但 updatedAt 每次都更新,代码就和 REST 解释不一致。
创建项目
mkdir claude-rest-api
cd claude-rest-api
npm init -y
npm install express
替换 package.json:
{
"name": "claude-rest-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.js",
"start": "node server.js",
"test": "node --test"
},
"dependencies": {
"express": "^5.0.0"
}
}
server.js
import express from "express";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PORT = Number(process.env.PORT ?? 3000);
function createHttpError(status, message, details) {
const error = new Error(message);
error.status = status;
error.details = details;
return error;
}
function hasOwn(object, key) {
return Object.prototype.hasOwnProperty.call(object, key);
}
function parseId(rawId) {
const id = Number(rawId);
return Number.isInteger(id) && id > 0 ? id : null;
}
function validateTodoInput(body, { partial = false } = {}) {
const errors = [];
if (!partial || hasOwn(body, "title")) {
if (typeof body.title !== "string" || body.title.trim().length === 0) {
errors.push({ field: "title", message: "title is required" });
} else if (body.title.trim().length > 120) {
errors.push({ field: "title", message: "title must be 120 characters or fewer" });
}
}
if (!partial || hasOwn(body, "completed")) {
if (typeof body.completed !== "boolean") {
errors.push({ field: "completed", message: "completed must be a boolean" });
}
}
return errors;
}
export function createApp() {
const app = express();
let nextId = 3;
const todos = [
{
id: 1,
title: "Read MDN HTTP status docs",
completed: false,
updatedAt: "2026-06-03T00:00:00.000Z"
},
{
id: 2,
title: "Ask Claude Code to review API errors",
completed: true,
updatedAt: "2026-06-03T00:00:00.000Z"
}
];
app.use(express.json({ limit: "32kb" }));
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime() });
});
app.get("/todos", (req, res) => {
res.json({ data: todos, count: todos.length });
});
app.get("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const todo = todos.find((item) => item.id === id);
if (!todo) return next(createHttpError(404, "todo not found"));
res.json({ data: todo });
});
app.post("/todos", (req, res, next) => {
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const todo = {
id: nextId,
title: req.body.title.trim(),
completed: req.body.completed,
updatedAt: new Date().toISOString()
};
nextId += 1;
todos.push(todo);
res.status(201).location(`/todos/${todo.id}`).json({ data: todo });
});
app.put("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const errors = validateTodoInput(req.body);
if (errors.length > 0) {
return next(createHttpError(400, "invalid request body", errors));
}
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
const previous = todos[index];
const nextTodo = {
...previous,
title: req.body.title.trim(),
completed: req.body.completed
};
const changed =
previous.title !== nextTodo.title || previous.completed !== nextTodo.completed;
todos[index] = {
...nextTodo,
updatedAt: changed ? new Date().toISOString() : previous.updatedAt
};
res.json({ data: todos[index] });
});
app.delete("/todos/:id", (req, res, next) => {
const id = parseId(req.params.id);
if (!id) return next(createHttpError(400, "id must be a positive integer"));
const index = todos.findIndex((item) => item.id === id);
if (index === -1) return next(createHttpError(404, "todo not found"));
todos.splice(index, 1);
res.status(204).send();
});
app.use((req, res, next) => {
next(createHttpError(404, `route not found: ${req.method} ${req.originalUrl}`));
});
app.use((err, req, res, next) => {
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
const body = {
error: {
status,
message: status === 500 ? "Internal Server Error" : err.message
}
};
if (err.details) {
body.error.details = err.details;
}
res.status(status).json(body);
});
return app;
}
const currentFile = fileURLToPath(import.meta.url);
const startedFile = process.argv[1] ? resolve(process.argv[1]) : "";
if (startedFile === currentFile) {
createApp().listen(PORT, () => {
console.log(`REST API listening on http://localhost:${PORT}`);
});
}
关键点不只是 route 存在。这个 API 会校验输入,统一 JSON 错误结构,并为不同场景返回合适的 status code。
用 curl 验证
npm run dev
curl -i http://localhost:3000/health
curl -i http://localhost:3000/todos
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"Write API tests","completed":false}'
curl -i -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title":"","completed":false}'
curl -i -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title":"Read MDN HTTP status docs","completed":true}'
curl -i -X DELETE http://localhost:3000/todos/2
请同时检查成功和失败。只在 happy path 上可用的 API,还不能放心交给真实前端。
添加 node:test
创建 server.test.js:
import assert from "node:assert/strict";
import test from "node:test";
import { createApp } from "./server.js";
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, () => resolve(server));
});
}
async function request(baseUrl, path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
headers: { "Content-Type": "application/json", ...options.headers },
...options
});
const text = await response.text();
return {
response,
body: text ? JSON.parse(text) : null
};
}
test("creates, updates, and deletes a todo", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const created = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "Test the API", completed: false })
});
assert.equal(created.response.status, 201);
assert.equal(created.body.data.title, "Test the API");
const id = created.body.data.id;
const updated = await request(baseUrl, `/todos/${id}`, {
method: "PUT",
body: JSON.stringify({ title: "Test the API", completed: true })
});
assert.equal(updated.response.status, 200);
assert.equal(updated.body.data.completed, true);
const deleted = await request(baseUrl, `/todos/${id}`, { method: "DELETE" });
assert.equal(deleted.response.status, 204);
});
test("rejects invalid todo input", async (t) => {
const server = await listen(createApp());
t.after(() => server.close());
const baseUrl = `http://127.0.0.1:${server.address().port}`;
const result = await request(baseUrl, "/todos", {
method: "POST",
body: JSON.stringify({ title: "", completed: false })
});
assert.equal(result.response.status, 400);
assert.equal(result.body.error.details[0].field, "title");
});
运行:
npm test
这些测试很小,但很有用。以后让 Claude Code 添加数据库、OpenAPI 或认证时,可以要求它保持这套测试通过。
常见陷阱
不要把 endpoint 命名成 /getTodos。动词交给 method:GET /todos、POST /todos、PUT /todos/:id。
不要所有结果都返回 200。输入错误用 400,resource 不存在用 404,创建成功用 201,删除成功且没有 body 时用 204。
不要只依赖前端校验。API 可以被直接调用,所以服务器端必须校验。
不要把 stack trace 暴露给 client。详细信息记录在服务器日志里,对外返回受控的 JSON。
不要无意破坏 idempotency。如果同一个 PUT 每次都修改 timestamp 或随机值,重复请求后的最终状态就不稳定。
下一步与 CTA
接下来一次只加一件事:数据库、更强的 schema validation、API 文档,然后是认证。错误设计可以继续看 Claude Code error handling patterns。
如果想用自己的项目练习,可以边看 免费速查表 边给 Claude Code 写提示词。可复用 prompt、review checklist 与 CLAUDE.md 模板可以在 Claude Code 产品库 找到。团队 onboarding、API review 规则与权限设计,可以使用 Claude Code 培训与咨询。
实际按本文内容试做后,我发现对初学者最有帮助的不是 CRUD 代码本身,而是把成功和失败放在一起看:空 title 返回 400,未知 ID 返回 404,重复发送同一个 PUT 仍保持相同最终状态。这样 REST 就不再只是 URL 列表,而是 frontend 与 backend 之间的契约。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。