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.
Aufgabe
EinfachDie 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.
- Füge in
routes/tasks.jsdem SchemaTaskein optionales Feldlabelshinzu: 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 ausTaskerstellt werden. Eine ungültige Liste führt wie üblich zu einem 400-Fehlerinvalid_body. Ungültige Listen sind zum Beispiel"labels": "office"(Text, keine Liste),"labels": [""]und sechs Labels. - Jede gespeicherte Aufgabe hat eine
labels-Liste:- Wenn POST oder PUT keine
labelssendet, speichere[]. PUT ersetzt die gesamte Aufgabe, daher werden die Labels durch ein PUT ohne Labels gelöscht. - PATCH ändert
labelsnur, wenn es das Feld sendet.
- Wenn POST oder PUT keine
- Gib in
store.jsden Startaufgaben Labels:["office"]für Aufgabe 1 und 2 sowie["money"]für Aufgabe 3. - Füge ebenfalls in
routes/tasks.jsder Listenroute einen Filter hinzu:GET /api/v1/tasks?label=officegibt nur Aufgaben zurück, derenlabelsgenauofficeenthalten. Der Filter funktioniert wie bisher zusammen mit den anderen Filtern und mit Seiten. - Füge in
server.jsneben der Einbindung des RoutersGET /api/v1/labelshinzu und importiere dorttasksausstore.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/labelsgibt 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/labelszeigt nun füroffice"count": 3an. - Sende POST
{ "title": "Water the plants" }: Die neue Aufgabe hat"labels": []. GET /api/v1/tasks?label=moneygibt nur Aufgabe 3 zurück.GET /api/v1/tasks?label=nopegibt"items": []und"total": 0zurück.PUT /api/v1/tasks/2mit{ "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
1Ressourcen und Verben
Ressourcen und URLsErstellen mit POST4Browser, Versionen und Konfiguration
Was ist ein Origin?Einen anderen Origin zulassenCORSEine Seite, die die API aufruftÜbe selbstständig: Express-Playground