Etiquetas
Parte de la sección API REST del Journey de Node.js de Coddy. Lección 24 de 25.
A las tareas se les asignan etiquetas como office o money, y la aplicación del tablero quiere un menú con todas las etiquetas y su número de tareas. Este paso añade tres cosas: una regla de esquema para una lista, un filtro y una ruta que cuenta las etiquetas. Contar en un objeto, como se muestra abajo, es algo nuevo.
// genres: z.array(z.string()).max(3).optional() en el esquema
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) convierte ese objeto en pares como [["poetry", 2], ...], listos para transformarlos en una lista. Un resumen como este es un recurso propio, con su propia URL.
optional() permite que este campo no esté presente y, aun así, comprueba su valor cuando sí lo está. Convierte los pares en objetos con Object.entries(counts).map(([genre, count]) => ({ genre, count })): los corchetes desempaquetan cada par; los paréntesis devuelven un objeto.
Desafío
FácilLa aplicación del tablero quiere agrupar las tareas por etiquetas como office o money, y mostrar un menú con todas las etiquetas y la cantidad de tareas que tiene cada una. Añade etiquetas a tu API.
- En
routes/tasks.js, añade un campo opcionallabelsal esquemaTask: una lista de como máximo 5 etiquetas, cada una una cadena de entre 1 y 20 caracteres después de recortarla. POST y PATCH también lo recogen, ya que sus esquemas se construyen a partir deTask. Una lista no válida genera el error habitual 400invalid_body. Entre las listas no válidas se incluyen"labels": "office"(texto, no una lista),"labels": [""]y seis etiquetas. - Todas las tareas guardadas tienen una lista
labels:- Cuando una solicitud POST o PUT no envía
labels, guarda[]. PUT reemplaza la tarea completa, así que un PUT sin etiquetas las borra. - PATCH solo cambia
labelscuando las envía.
- Cuando una solicitud POST o PUT no envía
- En
store.js, asigna etiquetas a las tareas iniciales:["office"]para las tareas 1 y 2, y["money"]para la tarea 3. - También en
routes/tasks.js, añade un filtro a la ruta de lista:GET /api/v1/tasks?label=officedevuelve solo las tareas cuyoslabelsincluyen exactamenteoffice. Funciona con los demás filtros y con la paginación, como antes. - En
server.js, añadeGET /api/v1/labelsjunto al montaje del enrutador e importa allítasksdesdestore.js. Devuelve todas las etiquetas en uso junto con la cantidad de tareas que tiene cada una, ordenadas alfabéticamente por etiqueta. Con las tareas iniciales:[ { "label": "money", "count": 1 }, { "label": "office", "count": 2 } ]
Pruébalo:
GET /api/v1/labelsdevuelve la lista anterior.- Envía POST
{ "title": "Order chairs", "labels": ["office"] }a/api/v1/tasks: obtienes 201 y la nueva tarea tiene"labels": ["office"].GET /api/v1/labelsahora muestra"count": 3paraoffice. - Envía POST
{ "title": "Water the plants" }: la nueva tarea tiene"labels": []. GET /api/v1/tasks?label=moneydevuelve solo la tarea 3.GET /api/v1/tasks?label=nopedevuelve"items": []y"total": 0.PUT /api/v1/tasks/2con{ "title": "Book the meeting room", "done": false }devuelve la tarea 2 con"labels": [].
Pruébalo tú mismo
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();
// Una tarea completa, tal como la envía PUT. .strict() rechaza los campos que no enumera.
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 cuando otra tarea ya tiene este título, sin importar las mayúsculas y minúsculas.
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;
Todas las lecciones de API REST
1Recursos y verbos
Recursos y URLCrear con POST4Navegadores, versiones y configuración
Qué es un origenPermitir otro origenCORSUna página que llama a la APIPractica por tu cuenta: Playground de Express