Dates d’échéance
Fait partie de la section API REST du Journey Node.js de Coddy. Leçon 25 sur 25.
Les tâches reçoivent une date d’échéance, et le tableau indique lesquelles sont en retard. Une date sans heure est transmise sous forme de chaîne ISO, 2026-03-15, que zod vérifie avec z.iso.date() :
z.iso.date().safeParse('2026-07-01').success // true
z.iso.date().safeParse('2026-02-30').success // false : ce jour n’existe pas
'2026-03-01' < '2026-03-15' // trueLes dates ISO ont toutes la même longueur et placent l’année en premier ; les comparer sous forme de chaînes donne donc l’ordre chronologique. Un chemin fixe comme /overdue doit apparaître avant /:id dans le routeur : Express essaie les routes dans l’ordre, et /:id correspond à n’importe quel mot.
Défi
FacileLe tableau indique quelles tâches sont en retard. Ajoutez une date d’échéance facultative aux tâches et ajoutez une liste de celles qui sont en retard.
- Dans
routes/tasks.js, ajoutez un champ facultatifdueau schémaTask, validé avecz.iso.date(). Il n’accepte que des dates réelles écrites au formatYYYY-MM-DD."2026-03-15"est accepté, tandis que"15/03/2026","2026-02-30"et"tomorrow"provoquent l’erreur 400 habituelleinvalid_body. Assurez-vous que POST enregistreduesur la nouvelle tâche. - Ajoutez
GET /api/v1/tasks/overdueau routeur. Déclarez cette route au-dessus de la route/:id, sinon Express interprèteoverduecomme un identifiant pour/:id. - La route lit la date de comparaison dans
?today=, par exemple?today=2026-03-20. Sitodayn’est pas fourni, elle utilise la date actuelle, écrite au formatYYYY-MM-DD. Sitodayn’est pas une date réelle, comme?today=soon, la route renvoie le statut 400 :{ "error": { "code": "invalid_query", "message": "today must be a date like 2026-03-10" } } - La route renvoie un tableau simple de tâches, et non l’objet de pagination renvoyé par la liste. Une tâche est en retard si les trois conditions suivantes sont réunies : elle n’est pas terminée, elle a une date
dueet cette date est antérieure àtoday. Une tâche dont l’échéance tombe le jour même detodayn’est pas encore en retard. Triez le tableau pardue, de la date la plus proche à la plus éloignée.
Essayez : lors d’une nouvelle exécution avec Run, envoyez POST avec { "title": "Renew the lease", "due": "2026-03-15" }, puis avec { "title": "File the taxes", "due": "2026-03-01" } à /api/v1/tasks. Les deux requêtes renvoient 201 avec leur valeur due. Ensuite, GET /api/v1/tasks/overdue?today=2026-03-20 renvoie d’abord la tâche dont l’échéance est la plus proche (les heures createdAt seront différentes) :
[
{ "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 renvoie uniquement « File the taxes ». GET /api/v1/tasks/overdue?today=soon renvoie le statut 400 avec le code invalid_query.
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() refuse les champs qu’elle ne répertorie pas.
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'];
// Vrai lorsqu’une autre tâche porte déjà ce titre, 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, 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;
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