Menu
CoddyTech

使用 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 错误。

challenge icon

挑战

简单

此步骤会打开课程中准备好的任务跟踪器 API 项目,而不是你在验证输入课中编写的项目文件(包括 routes/tasks.js)。你之前的工作仍保留在原来的课中。编辑 routes/tasks.js 前,先阅读提供的文件。这份副本包含任务 CRUD 路由和手写的输入验证。

每增加一个字段或路由,手写的检查就会变多。用一个 zod schema 描述任务,然后让所有三个接收请求体的路由都使用它。

  1. 在 routes/tasks.js 中,从 zod 导入 z,并编写一个名为 Task 的 schema:
    • title:字符串,去除首尾空格后长度为 1 到 100 个字符。
    • done:true 或 false(z.boolean())。
    • schema 是严格的,因此它未列出的字段(例如 color)会被拒绝。

    删除提供的 titleError 辅助函数(这是课程中对“验证输入”课所用验证方式的实现)。

  2. 使用 .partial() 从 Task 派生两个版本。POST 使用的版本只将 done 设为可选。PATCH 使用的版本将两个字段都设为可选。PUT 直接使用 Task,因此两个字段都必须提供。
  3. 在 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"] } } }
  4. 检查通过后,从 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 在线编辑器