É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.
Défi
FacileL’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.
- Dans
routes/tasks.js, ajoute un champlabelsfacultatif au schémaTask: 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 deTask. Une liste invalide produit l’erreur 400 habituelleinvalid_body. Parmi les listes invalides, on trouve"labels": "office"(du texte, pas une liste),"labels": [""]et une liste de six étiquettes. - 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
labelsque si la requête les envoie.
- Lorsqu’une requête POST ou PUT n’envoie pas de
- 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. - Toujours dans
routes/tasks.js, ajoute un filtre à la route de liste :GET /api/v1/tasks?label=officerenvoie uniquement les tâches dontlabelscontient exactementoffice. Il fonctionne avec les autres filtres et avec les pages, comme auparavant. - Dans
server.js, ajouteGET /api/v1/labelsà côté du montage du routeur, et importetasksdepuisstore.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/labelsrenvoie 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/labelsaffiche maintenant"count": 3pouroffice. - Envoie POST
{ "title": "Water the plants" }: la nouvelle tâche a"labels": []. GET /api/v1/tasks?label=moneyrenvoie uniquement la tâche 3.GET /api/v1/tasks?label=noperenvoie"items": []et"total": 0.PUT /api/v1/tasks/2avec{ "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
1Ressources et verbes
Ressources et URLCréer avec POST4Navigateurs, versions et configuration
Qu’est-ce qu’une origineAutoriser une autre origineCORSUne page qui appelle l’APIEntraînez-vous par vous-même : Playground Express