Tips & Tricks (更新: 2026/6/3)

用 Claude Code 构建第一套 REST API:CRUD、校验与测试

用 Claude Code 构建第一套 Express REST API:CRUD、校验、错误设计、测试与可运行代码。

用 Claude Code 构建第一套 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 判断结果。

术语简单解释本文示例
EndpointAPI 的 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 methodsHTTP status codesExpress routingExpress error handling,以及 Node.js test runner

这次要做什么

这个 API 会把 Todos 保存在内存里。服务器重启后,数据会回到初始状态。这是刻意简化:先理解 REST API 的形状,再加入数据库。

操作Method 与 URL成功状态
健康检查GET /health200 OK
Todo 列表GET /todos200 OK
读取单个 TodoGET /todos/:id200 OK
创建 TodoPOST /todos201 Created
替换 TodoPUT /todos/:id200 OK
删除 TodoDELETE /todos/:id204 No Content

这个例子虽小,但可以迁移到真实场景。第一个用例是内部任务 API,把 Todo 换成 review request、ticket 或运营任务即可。第二个用例是前端开发用 mock backend,在 React、Vue、Astro 页面接入真实后端之前先验证交互。第三个用例是联系表单或 newsletter 注册 API,把 title 换成 emailmessage,校验与错误设计仍然适用。

给 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 /todosPOST /todosPUT /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 之间的契约。

#claude-code #rest-api #beginner #typescript #backend #tutorial
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。