API 设计入门:RESTful 是什么、怎么用
后端写好之后,前端怎么跟它说话?答案就是 API。白物集目前有 19 个 API 路由,覆盖文章管理、知识卡片、收藏、数据统计等模块。这篇文章用白物集的实际代码,拆解 RESTful API 的核心概念和落地方式。
API 是一条「沟通通道」
前端页面(Astro SSR 渲染的 HTML)需要展示内容,但不能直接从数据库拿数据——数据库在前端不可达,也不应该暴露给客户端。API 层就是中间人:前端发一个 HTTP 请求,后端收到后去查数据库,把结果格式化成 JSON 返回。
白物集的 API 入口在 api/src/index.js。所有路由挂在一个版本前缀下:
const v1 = express.Router();
v1.use('/articles', apiLimiter, articlesRouter);
v1.use('/content/ingest', ingestLimiter, ingestRouter);
v1.use('/knowledge-cards', apiLimiter, cardsRouter);
// ... 其他路由
app.use('/api/v1', v1);
版本号 v1 不是装饰——API 一旦上线,改一个字段名都可能让前端崩溃。加版本号之后,升级时可以新旧并行,前端从容切换。
RESTful 的核心:资源 + 动词
REST 的全称是 Representational State Transfer。说人话:把每个业务实体当成一个「资源」(articles、cards、likes),用 HTTP 方法表示操作。
| 操作 | HTTP 方法 | URL 示例 | 作用 |
|---|---|---|---|
| 查列表 | GET | /api/v1/articles |
获取文章列表 |
| 查单个 | GET | /api/v1/articles/:id |
获取单篇文章 |
| 创建 | POST | /api/v1/content/ingest |
写入新内容 |
| 更新 | PATCH | /api/v1/articles/:id |
更新部分字段 |
| 删除 | DELETE | /api/v1/articles/:id |
删除文章 |
白物集的文章列表路由 GET /articles 就是典型实现——支持筛选、搜索、分页、排序:
router.get('/', async (req, res) => {
const parsed = articleQuerySchema.safeParse(req.query);
if (!parsed.success) return zodError(res, parsed.error);
const { category, status, search, tag, limit, offset, sort } = parsed.data;
// 查数据库、走缓存、返回分页数据
res.json({ articles: data, total: count, offset, limit });
});
参数全从 query string 里来:/api/v1/articles?category=AI&limit=10&offset=0。前端想看下一页就把 offset 加 10 再请求一次。分页用 offset/limit 而非 page/pageSize——前者在数据频繁插入时不会出现翻页重复,白物集踩过这个坑。
参数校验:不用 Zod 你就等着修 bug
RESTful API 最容易被忽略的一环是输入校验。没有校验的 API 等于开门迎客——谁来都能往数据库塞脏数据。
白物集用 Zod 做 schema 校验。定义一个文章查询参数的 schema:
export const articleQuerySchema = z.object({
category: z.enum(ALL_CATEGORIES).optional(),
status: z.enum(['published', 'draft', 'archived']).optional().default('published'),
search: z.string().max(200).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(20),
offset: z.coerce.number().int().min(0).optional().default(0),
sort: z.enum(['published_at', 'created_at', 'title', 'id']).optional().default('published_at'),
});
z.coerce.number() 会把 query string 里的 "10" 自动转成数字 10,z.enum() 直接拒绝无效的分类名。校验失败的响应也统一格式:
{ error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details: [...] } }
这个统一格式来自 errors.js,所有错误响应共用一套模板,前端一个 switch(error.code) 就能处理全部异常。
统一响应格式:前端开发者的救命稻草
没有统一格式的 API 是最折磨人的——有的接口返 { data: [...] },有的返 { results: [...] },还有的只在成功时返对象、失败时返字符串。前端对接时每调一个接口就要花 10 分钟查文档。
白物集的响应格式只有两种:
成功: 直接返回数据本体,即 res.json({ articles: [...], total: N })
失败: { error: { code: 'NOT_FOUND', message: 'Article #42 not found' } }
错误码用大写常量,前端直接判断 code:
export const ERROR_CODES = {
NOT_FOUND: 'NOT_FOUND',
UNAUTHORIZED: 'UNAUTHORIZED',
RATE_LIMITED: 'RATE_LIMITED',
VALIDATION_ERROR: 'VALIDATION_ERROR',
INTERNAL_ERROR: 'INTERNAL_ERROR',
};
这套设计看起来简单,但实际开发中常被忽视。白物集在早期版本中做过每个路由自己写 res.status(500).json({ msg: 'error' }) 的写法,后来迁移到统一错误函数,数了数删掉了 47 处重复的 try-catch 错误处理。
权限控制:x-api-key 就够了
白物集的内容是公开的——任何人都可以 GET 文章列表。但只有写操作需要认证。做法很简单:客户端在 Header 里传一个 x-api-key,后端比对环境变量:
function requireIngestAuth(req, res, next) {
const token = req.headers['x-api-key'];
if (!token || token !== (process.env.INGEST_API_KEY || '')) {
return unauthorized(res, 'Invalid or missing API key');
}
next();
}
// 只有写路由需要 auth
router.patch('/:id', requireIngestAuth, async (req, res) => { ... });
router.delete('/:id', requireIngestAuth, async (req, res) => { ... });
你没看错——就是比对两个字符串。对于个人项目的内容 API,不需要 OAuth 2.0 那一套。Key 只存在于服务器环境变量里,不出现在任何代码或前端代码中。日常管线脚本通过 SSH 登录 ECS 从 .env 读取 Key 后直接 POST 使用,Key 本身不会写入任何版本控制。
避坑:404 的两种写法
白物集犯过一个低级错误:文章的路由用了 /articles/:id,但如果前端把 URL 拼错了(比如传了字符串 "abc"),Zod 校验 z.coerce.number() 会返回 NaN,导致查询数据库时报错。
现在的做法是先校验参数再查数据库:
const parsed = articleIdParam.safeParse(req.params);
if (!parsed.success) return badRequest(res, 'Invalid article ID');
const { id } = parsed.data;
const { data, error } = await supabase.from('articles').select('*').eq('id', id).single();
if (error?.code === 'PGRST116') return notFound(res, `Article #${id} not found`);
PGRST116 是 Supabase 的「记录不存在」错误码,捕获它返回 404,不暴露数据库细节给客户端。
下一篇预告
API 设计入门之后,下一个问题是数据存哪。白物集选择了 Supabase(基于 PostgreSQL 的 BaaS)。下一篇讲数据库基础:从你熟悉的 Excel 表格出发,理解关系数据库的核心概念,以及如何在 Supabase 里建表、写查询。把 API 和数据库连起来,一个完整的数据链路就成型了。