Menu
CoddyTech

Labels

Teil des Abschnitts REST-APIs der Node.js-Journey von Coddy. Lektion 24 von 25.

Aufgaben erhalten Bezeichnungen wie office oder money, und die Board-App möchte ein Menü mit allen Bezeichnungen und der jeweiligen Anzahl an Aufgaben. Dieser Schritt ergänzt drei Dinge: eine Schema-Regel für eine Liste, einen Filter und eine Route, die Bezeichnungen zählt. In ein Objekt hineinzuzählen, wie unten, ist neu.

// genres: z.array(z.string()).max(3).optional() im Schema
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) wandelt dieses Objekt in Paare wie [["poetry", 2], ...] um, die bereit sind, in eine Liste umgewandelt zu werden. Eine Zusammenfassung wie diese ist eine eigene Ressource mit einer eigenen URL.

optional() erlaubt, dass dieses Feld fehlt, und prüft seinen Wert dennoch, wenn es vorhanden ist. Wandle Paare in Objekte um mit Object.entries(counts).map(([genre, count]) => ({ genre, count })): Die Klammern entpacken jedes Paar; die runden Klammern geben ein Objekt zurück.

challenge icon

Aufgabe

Einfach

Die Board-App möchte Aufgaben nach Labels wie office oder money gruppieren und ein Menü mit allen Labels sowie der Anzahl der Aufgaben anzeigen, die jedes Label tragen. Ergänze deine API um Labels.

  1. Füge in routes/tasks.js dem Schema Task ein optionales Feld labels hinzu: eine Liste mit höchstens 5 Labels, von denen jedes nach dem Entfernen der Leerzeichen am Anfang und Ende eine Zeichenfolge mit 1 bis 20 Zeichen ist. POST und PATCH übernehmen das Feld ebenfalls, da ihre Schemas aus Task erstellt werden. Eine ungültige Liste führt wie üblich zu einem 400-Fehler invalid_body. Ungültige Listen sind zum Beispiel "labels": "office" (Text, keine Liste), "labels": [""] und sechs Labels.
  2. Jede gespeicherte Aufgabe hat eine labels-Liste:
    • Wenn POST oder PUT keine labels sendet, speichere []. PUT ersetzt die gesamte Aufgabe, daher werden die Labels durch ein PUT ohne Labels gelöscht.
    • PATCH ändert labels nur, wenn es das Feld sendet.
  3. Gib in store.js den Startaufgaben Labels: ["office"] für Aufgabe 1 und 2 sowie ["money"] für Aufgabe 3.
  4. Füge ebenfalls in routes/tasks.js der Listenroute einen Filter hinzu: GET /api/v1/tasks?label=office gibt nur Aufgaben zurück, deren labels genau office enthalten. Der Filter funktioniert wie bisher zusammen mit den anderen Filtern und mit Seiten.
  5. Füge in server.js neben der Einbindung des Routers GET /api/v1/labels hinzu und importiere dort tasks aus store.js. Der Endpunkt gibt alle verwendeten Labels mit der Anzahl der Aufgaben zurück, die sie tragen, alphabetisch nach Label sortiert. Bei den Startaufgaben lautet die Antwort:
    [
      { "label": "money", "count": 1 },
      { "label": "office", "count": 2 }
    ]

Probiere es aus:

  • GET /api/v1/labels gibt die obige Liste zurück.
  • Sende POST { "title": "Order chairs", "labels": ["office"] } an /api/v1/tasks: Du erhältst 201, und die neue Aufgabe hat "labels": ["office"]. GET /api/v1/labels zeigt nun für office "count": 3 an.
  • Sende POST { "title": "Water the plants" }: Die neue Aufgabe hat "labels": [].
  • GET /api/v1/tasks?label=money gibt nur Aufgabe 3 zurück. GET /api/v1/tasks?label=nope gibt "items": [] und "total": 0 zurück.
  • PUT /api/v1/tasks/2 mit { "title": "Book the meeting room", "done": false } gibt Aufgabe 2 mit "labels": [] zurück.

Probier es selbst

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

// Eine vollständige Aufgabe, wie sie per PUT gesendet wird. .strict() lehnt nicht aufgeführte Felder ab.
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, wenn eine andere Aufgabe diesen Titel bereits hat, unabhängig von der Groß- und Kleinschreibung.
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;

Alle Lektionen in REST-APIs

Übe selbstständig: Express-Playground