Menu
CoddyTech

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.

challenge icon

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.

  1. Trong routes/tasks.js, thêm trường labels khô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 400 invalid_body như 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.
  2. 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 labels khi có gửi trường này.
  3. 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.
  4. Vẫn trong routes/tasks.js, thêm bộ lọc vào tuyến danh sách: GET /api/v1/tasks?label=office chỉ trả về các nhiệm vụ có labels chứa chính xác office. Bộ lọc này hoạt động cùng các bộ lọc khác và phân trang như trước.
  5. Trong server.js, thêm GET /api/v1/labels bên cạnh phần gắn router, đồng thời nhập tasks từ store.js và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/labels trả 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/labels lúc này hiển thị "count": 3 cho office.
  • Gửi POST { "title": "Water the plants" }: nhiệm vụ mới có "labels": [].
  • GET /api/v1/tasks?label=money chỉ trả về nhiệm vụ 3. GET /api/v1/tasks?label=nope trả về "items": [] và "total": 0.
  • Gửi PUT /api/v1/tasks/2 kè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

Tự luyện tập: Playground Express