使用 zod 定义模式
本课属于 Coddy Node.js 学习旅程的“REST API”单元。第 11 课,共 25 课。
手动检查会随着每个字段和每条路由而增加。zod 模式只需描述一次数据结构,然后用 safeParse 检查值是否符合该模式:
import { z } from 'zod';
const Event = z.object({
name: z.string().trim().min(1).max(80),
seats: z.number().int().min(1),
}).strict();
const result = Event.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: z.flattenError(result.error) });
}
// result.data 保存了通过检查的请求体,其中 name 已去除首尾空格.strict() 会拒绝模式中未列出的字段。Event.partial() 会将每个字段设为可选,这正是 PATCH 所需要的。Event.partial({ seats: true }) 只会将 seats 设为可选,而 const { seats = 1 } = result.data 会在缺少该字段时使用 1。z.flattenError(result.error) 会将问题整理为 { formErrors, fieldErrors }:formErrors 存放与整个请求体相关的问题,例如未知字段;fieldErrors 存放每个字段的问题。Event.parse 会执行相同的检查,但遇到无效值时会抛出异常,而路由中抛出的异常会导致 500 错误。
挑战
简单此步骤会打开课程中准备好的任务跟踪器 API 项目,而不是你在验证输入课中编写的项目文件(包括 routes/tasks.js)。你之前的工作仍保留在原来的课中。编辑 routes/tasks.js 前,先阅读提供的文件。这份副本包含任务 CRUD 路由和手写的输入验证。
每增加一个字段或路由,手写的检查就会变多。用一个 zod schema 描述任务,然后让所有三个接收请求体的路由都使用它。
- 在
routes/tasks.js中,从zod导入z,并编写一个名为Task的 schema:title:字符串,去除首尾空格后长度为 1 到 100 个字符。done:true或false(z.boolean())。- schema 是严格的,因此它未列出的字段(例如
color)会被拒绝。
删除提供的
titleError辅助函数(这是课程中对“验证输入”课所用验证方式的实现)。 - 使用
.partial()从Task派生两个版本。POST 使用的版本只将done设为可选。PATCH 使用的版本将两个字段都设为可选。PUT 直接使用Task,因此两个字段都必须提供。 - 在 POST、PATCH 和 PUT 中,使用
safeParse检查req.body。如果检查失败,返回状态码 400,并将 zod 的问题列表放在error下,通过z.flattenError(result.error)生成。对于{ "title": 123 },结果如下:{ "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } } - 检查通过后,从
result.data而不是req.body中保存数据:其中包含去除首尾空格后的标题。在 POST 中,如果请求体没有done,创建的任务会将其设为"done": false(result.data中缺失的字段值为undefined,而??会提供备用值)。PUT 仍会保留存储任务中的id和createdAt。
试试看:向 /api/tasks 发送 POST 请求,请求体为 { "title": " Order new chairs ", "color": "red" }。你会得到 400,且 formErrors 会显示 Unrecognized key: "color"。去掉 color 后再次发送:你会得到 201,响应中包含 "title": "Order new chairs"(没有空格)和 "done": false。使用 { "done": "yes" } 向 PATCH /api/tasks/2 发送请求,会返回 400,且 fieldErrors 中会包含 done 条目。使用 { "title": "Book the big room", "done": false } 向 PUT /api/tasks/2 发送请求,会返回 200,且任务 2 原有的 createdAt 会保留。
亲手试试
import { Router } from 'express';
import { nextId, tasks } from '../store.js';
const router = Router();
// 无法存储 title 的原因;如果可以存储,则返回 null。
const titleError = (title) => {
if (typeof title !== 'string' || title.trim() === '' || title.length > 100) {
return 'title must be a string of 1 to 100 characters';
}
return null;
};
router.get('/', (req, res) => {
res.json(tasks);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
res.json(task);
});
router.post('/', (req, res) => {
const { title } = req.body ?? {};
const problem = titleError(title);
if (problem) {
return res.status(400).json({ error: problem });
}
const task = { id: nextId(), title, done: false, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title !== undefined && titleError(title)) {
return res.status(400).json({ error: titleError(title) });
}
if (done !== undefined && typeof done !== 'boolean') {
return res.status(400).json({ error: 'done must be true or false' });
}
if (title !== undefined) task.title = title;
if (done !== undefined) task.done = done;
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
const { title, done } = req.body ?? {};
if (title === undefined || done === undefined) {
return res.status(400).json({ error: 'PUT needs every field: title and done' });
}
const { id, createdAt } = tasks[index];
tasks[index] = { id, title, done, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Task not found' });
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
REST API中的所有课
自己练习: Express 在线编辑器