Menu
CoddyTech

Schema với zod

Thuộc phần REST API trong hành trình Node.js của Coddy. Bài học 11 trên 25.

Việc kiểm tra thủ công sẽ tăng lên theo từng trường và từng tuyến. Lược đồ zod mô tả cấu trúc một lần, còn safeParse kiểm tra một giá trị theo lược đồ đó:

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 chứa body đã kiểm tra, với name đã loại bỏ khoảng trắng ở đầu và cuối

.strict() từ chối các trường không được liệt kê trong lược đồ. Event.partial() đặt mọi trường thành tùy chọn, phù hợp với yêu cầu của PATCH. Event.partial({ seats: true }) chỉ đặt seats thành tùy chọn, còn const { seats = 1 } = result.data dùng giá trị 1 khi trường này bị thiếu. z.flattenError(result.error) chuyển các lỗi thành { formErrors, fieldErrors }: formErrors chứa lỗi liên quan đến toàn bộ nội dung, chẳng hạn như trường không xác định, còn fieldErrors chứa lỗi của từng trường. Event.parse thực hiện cùng một phép kiểm tra nhưng sẽ ném lỗi nếu giá trị không hợp lệ, và lỗi phát sinh trong một tuyến sẽ trở thành lỗi 500.

challenge icon

Thử thách

Dễ

Bước này mở dự án API theo dõi công việc đã được chuẩn bị sẵn cho khóa học, chứ không phải các tệp dự án của bạn từ Xác thực đầu vào (bao gồm routes/tasks.js). Phần bạn làm trước đó vẫn nằm trong bài học riêng. Đọc các tệp được cung cấp trước khi chỉnh sửa routes/tasks.js. Bản sao này có các route CRUD cho công việc và phần xác thực đầu vào được viết thủ công.

Các bước kiểm tra viết thủ công sẽ tăng lên theo từng trường và từng route. Mô tả một công việc bằng schema zod, rồi dùng schema đó trong cả ba route nhận body.

  1. Trong routes/tasks.js, nhập z từ zod và viết một schema có tên Task:
    • title: một chuỗi, đã loại bỏ khoảng trắng ở đầu và cuối, còn từ 1 đến 100 ký tự sau khi loại bỏ khoảng trắng.
    • done: true hoặc false (z.boolean()).
    • Schema này là strict, vì vậy một trường không được liệt kê, chẳng hạn như color, sẽ bị từ chối.

    Xóa helper titleError được cung cấp (phiên bản của khóa học về phần xác thực trong bài Xác thực đầu vào).

  2. Tạo hai phiên bản từ Task bằng .partial(). Phiên bản POST chỉ đặt done thành không bắt buộc. Phiên bản PATCH đặt cả hai trường thành không bắt buộc. PUT dùng chính Task, nên cần cả hai trường.
  3. Trong POST, PATCH và PUT, kiểm tra req.body bằng safeParse. Nếu kiểm tra thất bại, trả về trạng thái 400 và danh sách vấn đề của zod dưới khóa error, tạo bằng z.flattenError(result.error). Với { "title": 123 }, kết quả trông như sau:
    { "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } }
  4. Khi kiểm tra thành công, lưu dữ liệu từ result.data, không phải req.body: dữ liệu này chứa title đã được cắt khoảng trắng. Trong POST, body không có done sẽ tạo công việc với "done": false (trường bị thiếu có giá trị undefined trong result.data, và ?? sẽ cung cấp giá trị dự phòng). PUT vẫn giữ id và createdAt từ công việc đã lưu.

Thử nhé: Gửi POST { "title": " Order new chairs ", "color": "red" } đến /api/tasks. Bạn nhận được 400, và formErrors cho biết Unrecognized key: "color". Gửi lại mà không có color: bạn nhận được 201 với "title": "Order new chairs" (không có khoảng trắng) và "done": false. Gửi PATCH /api/tasks/2 với { "done": "yes" }, kết quả trả về 400 kèm một mục done trong fieldErrors. Gửi PUT /api/tasks/2 với { "title": "Book the big room", "done": false }, kết quả trả về 200, cùng createdAt cũ của công việc 2.

Tự làm thử

import { Router } from 'express';
import { nextId, tasks } from '../store.js';

const router = Router();

// Lý do không thể lưu title, hoặc null nếu có thể lưu.
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;

Tất cả bài học trong REST API

Tự luyện tập: Playground Express