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.
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.
- Trong
routes/tasks.js, nhậpztừzodvà viết một schema có tênTask: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:truehoặcfalse(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). - Tạo hai phiên bản từ
Taskbằng.partial(). Phiên bản POST chỉ đặtdonethà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ínhTask, nên cần cả hai trường. - Trong POST, PATCH và PUT, kiểm tra
req.bodybằngsafeParse. 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óaerror, tạo bằngz.flattenError(result.error). Với{ "title": 123 }, kết quả trông như sau:{ "error": { "formErrors": [], "fieldErrors": { "title": ["Invalid input: expected string, received number"] } } } - Khi kiểm tra thành công, lưu dữ liệu từ
result.data, không phảireq.body: dữ liệu này chứa title đã được cắt khoảng trắng. Trong POST, body không códonesẽ tạo công việc với"done": false(trường bị thiếu có giá trịundefinedtrongresult.data, và??sẽ cung cấp giá trị dự phòng). PUT vẫn giữidvàcreatedAttừ 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
1Tài nguyên và động từ
Tài nguyên và URLTạo tài nguyên bằng POST4Trình duyệt, phiên bản và cấu hình
Origin là gìCho phép một origin khácCORSTrang gọi API2Xác thực và lỗi
Xác thực đầu vàoTự luyện tập: Playground Express