Nhãn
Thuộc phần REST API trong hành trình Node.js của Coddy. Bài học 24 trên 25.
Các nhiệm vụ có nhãn như office hoặc money, và ứng dụng bảng muốn có menu liệt kê mọi nhãn cùng số nhiệm vụ tương ứng. Bước này bổ sung ba thứ: một quy tắc schema cho danh sách, một bộ lọc và một route đếm số nhãn. Cách đếm vào một đối tượng bên dưới là kiến thức mới.
// genres: z.array(z.string()).max(3).optional() trong lược đồ
const counts = {};
for (const book of books) {
for (const genre of book.genres) {
counts[genre] = (counts[genre] ?? 0) + 1;
}
}
// { poetry: 2, drama: 1 }Object.entries(counts) chuyển đối tượng đó thành các cặp [["poetry", 2], ...], sẵn sàng để ánh xạ thành danh sách. Bản tóm tắt như thế này là một tài nguyên riêng, với URL riêng.
optional() cho phép trường này không xuất hiện, đồng thời vẫn kiểm tra giá trị của nó nếu có. Chuyển các cặp thành đối tượng bằng Object.entries(counts).map(([genre, count]) => ({ genre, count })): dấu ngoặc vuông tách từng cặp; dấu ngoặc tròn trả về một đối tượng.
Thử thách
DễỨng dụng bảng muốn nhóm các nhiệm vụ theo nhãn như office hoặc money, đồng thời hiển thị một menu gồm mọi nhãn và số nhiệm vụ mang nhãn đó. Hãy thêm nhãn vào API.
- Trong
routes/tasks.js, thêm trườnglabelskhông bắt buộc vào lược đồTask: một danh sách có tối đa 5 nhãn, mỗi nhãn là một chuỗi dài từ 1 đến 20 ký tự sau khi cắt khoảng trắng. POST và PATCH cũng nhận trường này vì lược đồ của chúng được tạo từTask. Danh sách không hợp lệ sẽ nhận lỗi 400invalid_bodynhư thường lệ. Ví dụ về danh sách không hợp lệ gồm"labels": "office"(văn bản, không phải danh sách),"labels": [""]và sáu nhãn. - Mọi nhiệm vụ đã lưu đều có danh sách
labels:- Khi POST hoặc PUT không gửi
labels, hãy lưu[]. PUT thay thế toàn bộ nhiệm vụ, nên PUT không có nhãn sẽ xóa các nhãn đó. - PATCH chỉ thay đổi
labelskhi có gửi trường này.
- Khi POST hoặc PUT không gửi
- Trong
store.js, gán nhãn cho các nhiệm vụ ban đầu:["office"]cho nhiệm vụ 1 và 2, và["money"]cho nhiệm vụ 3. - Vẫn trong
routes/tasks.js, thêm bộ lọc vào tuyến danh sách:GET /api/v1/tasks?label=officechỉ trả về các nhiệm vụ cólabelschứa chính xácoffice. Bộ lọc này hoạt động cùng các bộ lọc khác và phân trang như trước. - Trong
server.js, thêmGET /api/v1/labelsbên cạnh phần gắn router, đồng thời nhậptaskstừstore.jsvào đó. Tuyến này trả về mọi nhãn đang được dùng cùng số nhiệm vụ mang nhãn đó, sắp xếp theo nhãn từ A đến Z. Với các nhiệm vụ ban đầu:[ { "label": "money", "count": 1 }, { "label": "office", "count": 2 } ]
Hãy thử:
GET /api/v1/labelstrả về danh sách ở trên.- Gửi POST
{ "title": "Order chairs", "labels": ["office"] }đến/api/v1/tasks: bạn nhận được 201 và nhiệm vụ mới có"labels": ["office"].GET /api/v1/labelslúc này hiển thị"count": 3chooffice. - Gửi POST
{ "title": "Water the plants" }: nhiệm vụ mới có"labels": []. GET /api/v1/tasks?label=moneychỉ trả về nhiệm vụ 3.GET /api/v1/tasks?label=nopetrả về"items": []và"total": 0.- Gửi
PUT /api/v1/tasks/2kèm{ "title": "Book the meeting room", "done": false }: tuyến này trả về nhiệm vụ 2 với"labels": [].
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 đủ, đúng 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(),
})
.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, bất kể chữ hoa hay 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 } = 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 (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 } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, 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, 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