Menu
CoddyTech

Étiquettes

Fait partie de la section API REST du Journey Node.js de Coddy. Leçon 24 sur 25.

Les tâches reçoivent des étiquettes comme office ou money, et l’application de tableau veut un menu répertoriant chaque étiquette et le nombre de tâches associées. Cette étape ajoute trois éléments : une règle de schéma pour une liste, un filtre et une route qui compte les étiquettes. Le comptage dans un objet, ci-dessous, est une nouveauté.

// genres: z.array(z.string()).max(3).optional() dans le schéma
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) transforme cet objet en paires [["poetry", 2], ...], prêtes à être converties en liste. Un récapitulatif comme celui-ci est une ressource à part entière, avec sa propre URL.

optional() permet à ce champ d’être absent tout en vérifiant sa valeur lorsqu’il est présent. Transformez les paires en objets avec Object.entries(counts).map(([genre, count]) => ({ genre, count })) : les crochets décomposent chaque paire ; les parenthèses renvoient un objet.

challenge icon

Défi

Facile

L’application du tableau veut regrouper les tâches par étiquettes comme office ou money, et afficher un menu de toutes les étiquettes avec le nombre de tâches qui les portent. Ajoute les étiquettes à ton API.

  1. Dans routes/tasks.js, ajoute un champ labels facultatif au schéma Task : une liste d’au plus 5 étiquettes, chacune étant une chaîne de 1 à 20 caractères après suppression des espaces en début et en fin. POST et PATCH le récupèrent aussi, puisque leurs schémas sont construits à partir de Task. Une liste invalide produit l’erreur 400 habituelle invalid_body. Parmi les listes invalides, on trouve "labels": "office" (du texte, pas une liste), "labels": [""] et une liste de six étiquettes.
  2. Chaque tâche enregistrée possède une liste labels :
    • Lorsqu’une requête POST ou PUT n’envoie pas de labels, enregistre []. PUT remplace la tâche entière, donc un PUT sans étiquettes les efface.
    • PATCH ne modifie labels que si la requête les envoie.
  3. Dans store.js, attribue des étiquettes aux tâches initiales : ["office"] pour les tâches 1 et 2, et ["money"] pour la tâche 3.
  4. Toujours dans routes/tasks.js, ajoute un filtre à la route de liste : GET /api/v1/tasks?label=office renvoie uniquement les tâches dont labels contient exactement office. Il fonctionne avec les autres filtres et avec les pages, comme auparavant.
  5. Dans server.js, ajoute GET /api/v1/labels à côté du montage du routeur, et importe tasks depuis store.js à cet endroit. Cette route renvoie toutes les étiquettes utilisées avec le nombre de tâches qui les portent, triées de A à Z par étiquette. Avec les tâches initiales :
    [
      { "label": "money", "count": 1 },
      { "label": "office", "count": 2 }
    ]

Essaie :

  • GET /api/v1/labels renvoie la liste ci-dessus.
  • Envoie POST { "title": "Order chairs", "labels": ["office"] } à /api/v1/tasks : tu obtiens 201, et la nouvelle tâche a "labels": ["office"]. GET /api/v1/labels affiche maintenant "count": 3 pour office.
  • Envoie POST { "title": "Water the plants" } : la nouvelle tâche a "labels": [].
  • GET /api/v1/tasks?label=money renvoie uniquement la tâche 3. GET /api/v1/tasks?label=nope renvoie "items": [] et "total": 0.
  • PUT /api/v1/tasks/2 avec { "title": "Book the meeting room", "done": false } renvoie la tâche 2 avec "labels": [].

Essayez vous-même

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

// Une tâche complète, telle que PUT l’envoie. .strict() rejette les champs qu’il ne répertorie pas.
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'];

// Vrai si une autre tâche possède déjà ce title, quelle que soit la casse.
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;

Toutes les leçons de API REST

Entraînez-vous par vous-même : Playground Express