Hạn chót
Thuộc phần REST API trong hành trình Node.js của Coddy. Bài học 25 trên 25.
Các nhiệm vụ có ngày đến hạn, và bảng cho biết nhiệm vụ nào đã quá hạn. Ngày không kèm giờ được truyền dưới dạng chuỗi ISO, 2026-03-15, mà zod kiểm tra bằng z.iso.date():
z.iso.date().safeParse('2026-07-01').success // true
z.iso.date().safeParse('2026-02-30').success // false: không có ngày này
'2026-03-01' < '2026-03-15' // trueNgày ISO có cùng độ dài và đặt năm lên trước, nên so sánh chúng dưới dạng chuỗi sẽ cho thứ tự thời gian. Trong bộ định tuyến, đường dẫn cố định như /overdue phải đứng trước /:id: Express thử các tuyến theo thứ tự, và /:id khớp với bất kỳ từ nào.
Thử thách
DễBảng hiển thị những việc đã quá hạn. Thêm ngày đến hạn tùy chọn cho các việc và thêm danh sách những việc đã quá hạn.
- Trong
routes/tasks.js, thêm trườngduetùy chọn vào lược đồTask, kiểm tra bằngz.iso.date(). Trường này chỉ chấp nhận ngày hợp lệ viết theo định dạngYYYY-MM-DD."2026-03-15"hợp lệ, còn"15/03/2026","2026-02-30"và"tomorrow"sẽ gây ra lỗi 400invalid_bodynhư thường lệ. Đảm bảo POST lưuduevào việc mới. - Thêm
GET /api/v1/tasks/overduevào router. Khai báo route này phía trên route/:id, nếu không Express sẽ coioverduelà một id và chuyển nó đến/:id. - Route đọc ngày dùng để so sánh từ
?today=, ví dụ?today=2026-03-20. Nếu không cótoday, route sẽ dùng ngày hiện tại viết theo định dạngYYYY-MM-DD. Nếutodaykhông phải ngày hợp lệ, chẳng hạn?today=soon, route sẽ trả về trạng thái 400:{ "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } } - Route trả về một mảng thuần gồm các việc, không phải đối tượng phân trang mà danh sách trả về. Một việc quá hạn khi thỏa cả ba điều kiện: việc đó chưa hoàn thành, có ngày
duevà ngày đó trướctoday. Việc có ngày đến hạn chính làtodaythì chưa quá hạn. Sắp xếp mảng theodue, ngày sớm nhất đứng trước.
Thử ngay: trong một lần chạy mới, gửi POST { "title": "Renew the lease", "due": "2026-03-15" }, sau đó gửi { "title": "File the taxes", "due": "2026-03-01" } đến /api/v1/tasks. Cả hai đều trả về 201 cùng với due. Sau đó, GET /api/v1/tasks/overdue?today=2026-03-20 trả về ngày đến hạn sớm hơn trước (thời điểm createdAt của bạn sẽ khác):
[
{ "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
{ "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]GET /api/v1/tasks/overdue?today=2026-03-15 chỉ trả về "File the taxes". GET /api/v1/tasks/overdue?today=soon trả về 400 với mã invalid_query.
Tự làm thử
import { Router } from 'express';
import { z } from 'zod';
import { config } from '../config.js';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// Một tác vụ đầy đủ như PUT gửi đến. .strict() từ chối các trường không có trong danh sách của nó.
const Task = z
.object({
title: z.string().trim().min(1).max(100),
done: z.boolean(),
labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// True nếu một tác vụ khác đã có tiêu đề này, không phân biệt chữ hoa chữ thường.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q, sort, label } = req.query;
let list = tasks;
if (done !== undefined) {
if (done !== 'true' && done !== 'false') {
return sendError(res, 400, 'invalid_query', 'done must be true or false');
}
list = list.filter((t) => t.done === (done === 'true'));
}
if (q !== undefined) {
const words = String(q).toLowerCase();
list = list.filter((t) => t.title.toLowerCase().includes(words));
}
if (label !== undefined) {
list = list.filter((t) => t.labels.includes(String(label)));
}
if (sort !== undefined) {
const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
if (!SORT_FIELDS.includes(field)) {
return sendError(
res,
400,
'invalid_query',
'sort must be title or createdAt, with - in front to reverse',
);
}
const direction = String(sort).startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
}
const page = req.query.page === undefined ? 1 : Number(req.query.page);
const limit = req.query.limit === undefined ? config.pageSize : Number(req.query.limit);
if (!Number.isInteger(page) || page < 1) {
return sendError(res, 400, 'invalid_query', 'page must be a whole number from 1');
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return sendError(res, 400, 'invalid_query', 'limit must be a whole number from 1 to 50');
}
const start = (page - 1) * limit;
res.json({
items: list.slice(start, start + limit),
page,
total: list.length,
pages: Math.ceil(list.length / limit),
});
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
res.json(task);
});
router.post('/', (req, res) => {
const result = NewTask.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { title, done = false, labels = [] } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, labels, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/v1/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = TaskChanges.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
Object.assign(task, result.data);
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = Task.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { id, createdAt } = tasks[index];
if (titleTaken(result.data.title, id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
tasks[index] = { id, ...result.data, labels: result.data.labels ?? [], 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 sendError(res, 404, 'not_found', '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 APITự luyện tập: Playground Express