阶段一:RESTful基础
API的请求与响应是客户端和服务端之间的契约。设计良好的请求格式让调用者直觉正确,设计良好的响应格式让数据处理变得简单。本课深入请求体验证、响应结构设计、内容协商、数据序列化等核心主题。
| Content-Type | 适用场景 | 示例 |
|---|---|---|
application/json | 结构化数据(默认推荐) | 创建/更新资源 |
multipart/form-data | 文件上传 + 表单混合 | 上传头像+用户信息 |
application/x-www-form-urlencoded | 简单表单(无文件) | 登录表单 |
text/plain | 纯文本数据 | 配置文件内容 |
application/octet-stream | 二进制文件流 | 直接上传文件 |
// validation.js - 请求验证中间件
class Validator {
constructor(schema) {
this.schema = schema;
}
validate(data) {
const errors = [];
for (const [field, rules] of Object.entries(this.schema)) {
const value = data[field];
// 必填检查
if (rules.required && (value === undefined || value === null || value === '')) {
errors.push({ field, message: `${field} 为必填项` });
continue;
}
// 可选字段且未提供则跳过后续检查
if (value === undefined || value === null) continue;
// 类型检查
if (rules.type) {
const actualType = Array.isArray(value) ? 'array' : typeof value;
if (actualType !== rules.type) {
errors.push({ field, message: `${field} 应为 ${rules.type} 类型,实际为 ${actualType}` });
continue;
}
}
// 字符串长度
if (rules.type === 'string' || typeof value === 'string') {
if (rules.minLength && value.length < rules.minLength) {
errors.push({ field, message: `${field} 最少 ${rules.minLength} 个字符` });
}
if (rules.maxLength && value.length > rules.maxLength) {
errors.push({ field, message: `${field} 最多 ${rules.maxLength} 个字符` });
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push({ field, message: `${field} 格式不正确` });
}
}
// 数值范围
if (typeof value === 'number') {
if (rules.min !== undefined && value < rules.min) {
errors.push({ field, message: `${field} 不能小于 ${rules.min}` });
}
if (rules.max !== undefined && value > rules.max) {
errors.push({ field, message: `${field} 不能大于 ${rules.max}` });
}
}
// 枚举值
if (rules.enum && !rules.enum.includes(value)) {
errors.push({ field, message: `${field} 必须为 ${rules.enum.join('/')} 之一` });
}
// 自定义验证
if (rules.custom) {
const customError = rules.custom(value);
if (customError) errors.push({ field, message: customError });
}
}
return errors;
}
}
// 使用示例:用户注册验证
const userSchema = {
username: {
required: true,
type: 'string',
minLength: 3,
maxLength: 20,
pattern: /^[a-zA-Z0-9_]+$/,
},
email: {
required: true,
type: 'string',
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
password: {
required: true,
type: 'string',
minLength: 8,
custom: (val) => {
if (!/[A-Z]/.test(val)) return '密码必须包含至少一个大写字母';
if (!/[a-z]/.test(val)) return '密码必须包含至少一个小写字母';
if (!/[0-9]/.test(val)) return '密码必须包含至少一个数字';
return null;
},
},
role: {
required: false,
type: 'string',
enum: ['admin', 'editor', 'viewer'],
},
age: {
required: false,
type: 'number',
min: 0,
max: 150,
},
};
// Express中间件
function validate(schema) {
const validator = new Validator(schema);
return (req, res, next) => {
const errors = validator.validate(req.body);
if (errors.length > 0) {
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: '请求数据验证失败',
details: errors,
},
});
}
next();
};
}
module.exports = { Validator, validate, userSchema };
// response-builder.js - 统一响应构建器
class ResponseBuilder {
// 成功响应 - 单个资源
static resource(data, options = {}) {
const response = {
success: true,
data,
};
// 可选:包含关联资源
if (options.included) {
response.included = options.included;
}
// 可选:包含元数据
if (options.meta) {
response.meta = options.meta;
}
// 可选:HATEOAS链接
if (options.links) {
response.links = options.links;
}
response.timestamp = new Date().toISOString();
return response;
}
// 成功响应 - 集合
static collection(items, pagination, options = {}) {
const response = {
success: true,
data: items,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: pagination.total,
totalPages: Math.ceil(pagination.total / pagination.limit),
hasNext: pagination.page * pagination.limit < pagination.total,
hasPrev: pagination.page > 1,
},
};
if (options.links) {
response.links = options.links;
}
response.timestamp = new Date().toISOString();
return response;
}
// 创建成功
static created(data, location) {
const response = this.resource(data);
response.meta = {
...response.meta,
location,
createdAt: data.createdAt || new Date().toISOString(),
};
return response;
}
// 删除成功
static deleted(id) {
return {
success: true,
data: null,
meta: {
deletedId: id,
timestamp: new Date().toISOString(),
},
};
}
// 错误响应
static error(statusCode, code, message, options = {}) {
const response = {
success: false,
error: {
code,
message,
},
};
if (options.details) {
response.error.details = options.details;
}
if (options.stack && process.env.NODE_ENV === 'development') {
response.error.stack = options.stack;
}
response.timestamp = new Date().toISOString();
response.meta = {
requestId: options.requestId,
documentation: options.docsUrl,
};
return response;
}
}
module.exports = ResponseBuilder;
// Level 3 REST: 响应中包含操作链接
{
"success": true,
"data": {
"id": 42,
"title": "RESTful API设计",
"status": "draft",
"author": {"id": 1, "name": "张三"}
},
"links": {
"self": "/api/articles/42",
"author": "/api/users/1",
"publish": {
"href": "/api/articles/42/publish",
"method": "POST"
},
"edit": {
"href": "/api/articles/42",
"method": "PATCH"
},
"delete": {
"href": "/api/articles/42",
"method": "DELETE"
},
"comments": "/api/articles/42/comments"
}
}
// full-api.js - 带完整验证和响应的API
const express = require('express');
const { validate, userSchema } = require('./validation');
const ResponseBuilder = require('./response-builder');
const { v4: uuidv4 } = require('crypto');
const app = express();
app.use(express.json());
// 请求ID中间件
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || uuidv4();
res.setHeader('X-Request-ID', req.requestId);
next();
});
// 数据存储
const users = new Map();
const posts = new Map();
let userIdSeq = 1;
let postIdSeq = 1;
// 初始化数据
users.set(1, { id: 1, username: 'zhangsan', email: 'zhang@example.com', role: 'admin', createdAt: '2025-01-01T00:00:00Z' });
users.set(2, { id: 2, username: 'lisi', email: 'li@example.com', role: 'editor', createdAt: '2025-01-02T00:00:00Z' });
posts.set(1, { id: 1, title: '第一篇文章', content: '内容...', authorId: 1, status: 'published', createdAt: '2025-01-10T00:00:00Z' });
posts.set(2, { id: 2, title: '第二篇文章', content: '内容...', authorId: 2, status: 'draft', createdAt: '2025-01-11T00:00:00Z' });
// ===== 用户接口 =====
// 注册
app.post('/api/users', validate(userSchema), (req, res) => {
const { username, email, role, age } = req.body;
// 业务验证
if ([...users.values()].some(u => u.username === username)) {
return res.status(409).json(
ResponseBuilder.error(409, 'CONFLICT', `用户名 ${username} 已存在`, { requestId: req.requestId })
);
}
if ([...users.values()].some(u => u.email === email)) {
return res.status(409).json(
ResponseBuilder.error(409, 'CONFLICT', `邮箱 ${email} 已注册`, { requestId: req.requestId })
);
}
const id = userIdSeq++;
const user = {
id,
username,
email,
role: role || 'viewer',
age,
createdAt: new Date().toISOString(),
};
users.set(id, user);
res.setHeader('Location', `/api/users/${id}`);
res.status(201).json(ResponseBuilder.created(user, `/api/users/${id}`));
});
// 用户列表
app.get('/api/users', (req, res) => {
const { page = 1, limit = 20, role, fields } = req.query;
let result = [...users.values()];
// 过滤
if (role) result = result.filter(u => u.role === role);
// 字段选择
if (fields) {
const fieldList = fields.split(',');
result = result.map(u => {
const filtered = {};
fieldList.forEach(f => { if (u[f] !== undefined) filtered[f] = u[f]; });
return filtered;
});
}
// 分页
const p = Math.max(1, parseInt(page));
const l = Math.min(100, Math.max(1, parseInt(limit)));
const total = result.length;
result = result.slice((p - 1) * l, p * l);
res.json(ResponseBuilder.collection(result, { page: p, limit: l, total }));
});
// 用户详情
app.get('/api/users/:id', (req, res) => {
const user = users.get(parseInt(req.params.id));
if (!user) {
return res.status(404).json(
ResponseBuilder.error(404, 'NOT_FOUND', `用户 ${req.params.id} 不存在`, {
requestId: req.requestId,
docsUrl: 'https://docs.example.com/api/errors#not-found',
})
);
}
// 包含用户的文章
const userPosts = [...posts.values()].filter(p => p.authorId === user.id);
res.json(ResponseBuilder.resource(user, {
included: { posts: userPosts },
links: {
self: `/api/users/${user.id}`,
posts: `/api/users/${user.id}/posts`,
},
}));
});
// ===== 内容协商 =====
// 根据Accept头返回不同格式
app.get('/api/users/:id/profile', (req, res) => {
const user = users.get(parseInt(req.params.id));
if (!user) return res.status(404).json(ResponseBuilder.error(404, 'NOT_FOUND', '用户不存在'));
const accept = req.headers.accept || '';
if (accept.includes('text/html')) {
// HTML格式
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.send(`<h1>${user.username}</h1><p>Email: ${user.email}</p><p>Role: ${user.role}</p>`);
} else if (accept.includes('text/csv')) {
// CSV格式
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.send(`id,username,email,role\n${user.id},${user.username},${user.email},${user.role}`);
} else if (accept.includes('text/plain')) {
// 纯文本
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.send(`用户: ${user.username}\n邮箱: ${user.email}\n角色: ${user.role}`);
} else {
// 默认JSON
res.json(ResponseBuilder.resource(user));
}
});
// 全局错误处理
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json(
ResponseBuilder.error(500, 'INTERNAL_ERROR', '服务器内部错误', {
requestId: req.requestId,
stack: err.stack,
})
);
});
app.listen(3000, () => console.log('🚀 API运行在 http://localhost:3000'));
# 测试1: 请求验证 - 缺少必填字段
curl -s -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"username":"ab"}' | jq .
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求数据验证失败",
"details": [
{"field":"username","message":"username 最少 3 个字符"},
{"field":"email","message":"email 为必填项"},
{"field":"password","message":"password 为必填项"}
]
},
"timestamp":"2025-01-15T10:00:00Z",
"meta": {"requestId":"..."}
}
# 测试2: 成功创建
curl -s -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"username":"wangwu","email":"wang@example.com","password":"Pass1234","role":"editor"}' | jq .
# 测试3: 重复用户名
curl -s -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"username":"wangwu","email":"other@example.com","password":"Pass1234"}' | jq .
# 测试4: 字段选择
curl -s "http://localhost:3000/api/users?fields=id,username" | jq .
# 测试5: 内容协商 - 请求CSV格式
curl -s http://localhost:3000/api/users/1/profile -H "Accept: text/csv"
# 测试6: 内容协商 - 请求HTML格式
curl -s http://localhost:3000/api/users/1/profile -H "Accept: text/html"
| 方式 | 机制 | 优点 | 缺点 |
|---|---|---|---|
| 服务端驱动 | 客户端发Accept头,服务端选择格式 | HTTP标准,单一URL | 服务端需维护多种格式 |
| 客户端驱动 | URL后缀或查询参数 | 简单直观,易测试 | 多URL指向同一资源 |
| 透明协商 | 中间缓存代理选择 | 减少服务端负载 | 实现复杂,较少使用 |
# JSON(最常用)
Accept: application/json
# JSON API规范
Accept: application/vnd.api+json
# 自定义媒体类型(带版本)
Accept: application/vnd.myapi.v2+json
# 任意格式
Accept: */*
为以下字段设计验证规则:title(1-200字), isbn(13位数字), price(>0), category(枚举), tags(数组,每项1-20字)
让GET /api/products支持JSON和CSV两种输出格式,通过Accept头切换。
区分以下三种错误的响应格式:验证错误(400)、业务逻辑错误(422)、系统错误(500)。
// 400 验证错误
{"success":false,"error":{"code":"VALIDATION_ERROR","details":[{"field":"email","message":"格式不正确"}]}}
// 422 业务逻辑错误
{"success":false,"error":{"code":"INSUFFICIENT_STOCK","message":"库存不足","available":5,"requested":10}}
// 500 系统错误
{"success":false,"error":{"code":"INTERNAL_ERROR","message":"服务器内部错误","requestId":"abc123"}}