Fechas límite
Parte de la sección API REST del Journey de Node.js de Coddy. Lección 25 de 25.
Las tareas reciben una fecha de vencimiento y el tablero muestra cuáles están atrasadas. Una fecha sin hora se representa como una cadena ISO, 2026-03-15, que zod valida con z.iso.date():
z.iso.date().safeParse('2026-07-01').success // true
z.iso.date().safeParse('2026-02-30').success // false: ese día no existe
'2026-03-01' < '2026-03-15' // trueLas fechas ISO tienen la misma longitud y ponen primero el año, así que compararlas como cadenas da el orden cronológico. Una ruta fija como /overdue debe ir antes de /:id en el enrutador: Express prueba las rutas en orden y /:id coincide con cualquier palabra.
Desafío
FácilEl tablero muestra qué tareas están atrasadas. Asigna una fecha de vencimiento opcional a las tareas y añade una lista de las que están vencidas.
- En
routes/tasks.js, añade un campo opcionaldueal esquemaTask, validado conz.iso.date(). Solo acepta fechas reales escritas comoYYYY-MM-DD."2026-03-15"es válido, mientras que"15/03/2026","2026-02-30"y"tomorrow"generan el error 400 habitualinvalid_body. Asegúrate de que POST guardedueen la nueva tarea. - Añade
GET /api/v1/tasks/overdueal enrutador. Declárala antes de la ruta/:id; de lo contrario, Express interpretaráoverduecomo un id y lo enviará a/:id. - La ruta lee la fecha con la que se debe comparar desde
?today=, por ejemplo?today=2026-03-20. Si no se indicatoday, usa la fecha actual escrita comoYYYY-MM-DD. Sitodayno es una fecha real, como?today=soon, responde con el estado 400:{ "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } } - La ruta devuelve un array simple de tareas, no el objeto de paginación que devuelve la lista. Una tarea está vencida cuando se cumplen estas tres condiciones: no está completada, tiene una fecha
duey esa fecha es anterior atoday. Una tarea cuya fecha de vencimiento estodayaún no está vencida. Ordena el array pordue, empezando por la fecha más temprana.
Pruébalo: en una ejecución nueva, envía mediante POST { "title": "Renew the lease", "due": "2026-03-15" } y luego { "title": "File the taxes", "due": "2026-03-01" } a /api/v1/tasks. Ambas solicitudes devuelven 201 con su due. Después, GET /api/v1/tasks/overdue?today=2026-03-20 devuelve primero la fecha de vencimiento más temprana (los valores de createdAt serán distintos):
[
{ "id": 5, "title": "File the taxes", "done": false, "labels": [], "due": "2026-03-01", "createdAt": "2026-10-30T09:15:02.118Z" },
{ "id": 4, "title": "Renew the lease", "done": false, "labels": [], "due": "2026-03-15", "createdAt": "2026-10-30T09:14:40.006Z" }
]GET /api/v1/tasks/overdue?today=2026-03-15 devuelve solo "File the taxes". GET /api/v1/tasks/overdue?today=soon devuelve 400 con el código invalid_query.
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(),
labels: z.array(z.string().trim().min(1).max(20)).max(5).optional(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// True si otra tarea ya tiene este título, sin distinguir entre 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, label } = 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 (label !== undefined) {
list = list.filter((t) => t.labels.includes(String(label)));
}
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, labels = [] } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, labels, 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, labels: result.data.labels ?? [], 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