Menu
CoddyTech

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.

challenge icon

Desafío

Fácil

La 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.

  1. En routes/tasks.js, añade un campo opcional labels al esquema Task: 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 de Task. Una lista no válida genera el error habitual 400 invalid_body. Entre las listas no válidas se incluyen "labels": "office" (texto, no una lista), "labels": [""] y seis etiquetas.
  2. 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 labels cuando las envía.
  3. En store.js, asigna etiquetas a las tareas iniciales: ["office"] para las tareas 1 y 2, y ["money"] para la tarea 3.
  4. También en routes/tasks.js, añade un filtro a la ruta de lista: GET /api/v1/tasks?label=office devuelve solo las tareas cuyos labels incluyen exactamente office. Funciona con los demás filtros y con la paginación, como antes.
  5. En server.js, añade GET /api/v1/labels junto al montaje del enrutador e importa allí tasks desde store.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/labels devuelve 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/labels ahora muestra "count": 3 para office.
  • Envía POST { "title": "Water the plants" }: la nueva tarea tiene "labels": [].
  • GET /api/v1/tasks?label=money devuelve solo la tarea 3. GET /api/v1/tasks?label=nope devuelve "items": [] y "total": 0.
  • PUT /api/v1/tasks/2 con { "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

Practica por tu cuenta: Playground de Express