Menu
CoddyTech

Label

Termasuk bagian REST API dalam perjalanan Node.js di Coddy. Pelajaran 24 dari 25.

Tugas mendapatkan label seperti office atau money, dan aplikasi papan ingin menampilkan menu semua label beserta jumlah tugasnya. Langkah ini menambahkan tiga hal: aturan skema untuk list, filter, dan rute yang menghitung label. Menghitung ke dalam object, seperti di bawah ini, adalah hal baru.

// genres: z.array(z.string()).max(3).optional() dalam skema
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) mengubah object itu menjadi pasangan [["poetry", 2], ...], siap dipetakan menjadi list. Ringkasan seperti ini adalah resource tersendiri, dengan URL-nya sendiri.

optional() memungkinkan field ini tidak ada, tetapi tetap memeriksa nilainya jika tersedia. Ubah pasangan entri menjadi object dengan Object.entries(counts).map(([genre, count]) => ({ genre, count })): tanda kurung siku membongkar setiap pasangan; tanda kurung biasa mengembalikan object.

challenge icon

Tantangan

Mudah

Aplikasi papan ingin mengelompokkan tugas berdasarkan label seperti office atau money, dan menampilkan menu berisi semua label beserta jumlah tugas yang menggunakannya. Tambahkan label ke API-mu.

  1. Di routes/tasks.js, tambahkan field labels opsional ke skema Task: sebuah list yang berisi paling banyak 5 label, masing-masing berupa string sepanjang 1 hingga 20 karakter setelah dipangkas. POST dan PATCH juga mengambilnya karena skemanya dibuat dari Task. List yang tidak valid menghasilkan error 400 invalid_body seperti biasa. Contoh list yang tidak valid mencakup "labels": "office" (teks, bukan list), "labels": [""], dan enam label.
  2. Setiap tugas yang disimpan memiliki list labels:
    • Jika POST atau PUT tidak mengirim labels, simpan []. PUT mengganti seluruh tugas, jadi PUT tanpa label akan menghapus labelnya.
    • PATCH hanya mengubah labels jika mengirimkannya.
  3. Di store.js, beri label pada tugas awal: ["office"] untuk tugas 1 dan 2, serta ["money"] untuk tugas 3.
  4. Masih di routes/tasks.js, tambahkan filter ke route list: GET /api/v1/tasks?label=office hanya mengembalikan tugas yang labels-nya memuat tepat office. Filter ini berfungsi bersama filter lain dan halaman, seperti sebelumnya.
  5. Di server.js, tambahkan GET /api/v1/labels di samping pemasangan router, lalu impor tasks dari store.js di sana. Route ini mengembalikan setiap label yang digunakan beserta jumlah tugas yang menggunakannya, diurutkan berdasarkan label dari A ke Z. Dengan tugas awal:
    [
      { "label": "money", "count": 1 },
      { "label": "office", "count": 2 }
    ]

Coba:

  • GET /api/v1/labels mengembalikan list di atas.
  • Kirim POST { "title": "Order chairs", "labels": ["office"] } ke /api/v1/tasks: kamu mendapat 201, dan tugas baru memiliki "labels": ["office"]. GET /api/v1/labels kini menampilkan "count": 3 untuk office.
  • Kirim POST { "title": "Water the plants" }: tugas baru memiliki "labels": [].
  • GET /api/v1/tasks?label=money hanya mengembalikan tugas 3. GET /api/v1/tasks?label=nope mengembalikan "items": [] dan "total": 0.
  • PUT /api/v1/tasks/2 dengan { "title": "Book the meeting room", "done": false } mengembalikan tugas 2 dengan "labels": [].

Coba sendiri

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();

// Task lengkap seperti yang dikirim oleh PUT. .strict() menolak field yang tidak tercantum.
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 jika task lain sudah memiliki title ini, tanpa membedakan huruf besar dan kecil.
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;

Semua pelajaran di REST API

Latihan sendiri: Playground Express