Skip to content

How-to : API de rappels programmés

Référence FT : FT235 (NENE2-FT/reminderlog) — API de rappels programmés

Démontre une API de planification de rappels avec validation de datetime future tenant compte des fuseaux horaires, identification légère par utilisateur via en-tête, prévention IDOR via requêtes scopées par propriété, et distinction 404/409 lors de l'annulation d'un rappel.


Routes

MéthodeCheminDescription
POST/remindersCréer un rappel (remind_at futur requis)
GET/remindersLister les rappels de l'appelant (filtrable par statut)
PATCH/reminders/{id}/cancelAnnuler un rappel en attente

Toutes les routes nécessitent l'en-tête X-User-Id.


Identification légère de l'utilisateur via en-tête

Plutôt que Bearer JWT, cette API utilise un en-tête entier X-User-Id comme mécanisme d'authentification/identification minimal :

php
$userId = V::userId($request->getHeaderLine('X-User-Id'));

if ($userId === null) {
    return $this->responseFactory->create(
        ['error' => 'X-User-Id header must be a positive integer.'],
        401,
    );
}

V::userId() valide la valeur de l'en-tête :

php
public static function userId(string $header): ?int
{
    // ctype_digit('') === false — la chaîne vide est déjà rejetée.
    if (!ctype_digit($header) || strlen($header) > 18) {
        return null;
    }

    $id = (int) $header;

    return $id > 0 ? $id : null;
}

Propriétés clés :

  • ctype_digit() — immunisé aux ReDoS, rejette 0, -1, 1.5, abc, chaîne vide.
  • strlen > 18 — garde de débordement avant le cast (int) (PHP_INT_MAX est à 19 chiffres).
  • $id > 0 — rejette le zéro entier analysé.

Pour la production, remplacer par une validation JWT ou de session. Le pattern X-User-Id est adapté aux services internes où la passerelle en amont a déjà authentifié l'utilisateur et transmet son ID.


Validation de datetime future (tenant compte des fuseaux horaires)

remind_at doit être un datetime ISO 8601 valide avec un décalage de fuseau horaire explicite et doit être strictement dans le futur par rapport à maintenant :

php
$now      = (new DateTimeImmutable())->format(DATE_ATOM);
$remindAt = V::futureDatetime($rawRemindAt, $now);

if ($remindAt === null) {
    return $this->responseFactory->create(
        ['error' => 'remind_at must be a valid ISO 8601 datetime with timezone offset and must be in the future.'],
        422,
    );
}

V::futureDatetime() compose deux vérifications :

php
public static function futureDatetime(mixed $raw, string $now): ?string
{
    $dt = self::isoDatetime($raw);   // Étape 1 : validation format + plage

    if ($dt === null) {
        return null;
    }

    // Étape 2 : vérification future tenant compte du fuseau horaire
    $dtObj  = DateTimeImmutable::createFromFormat(DATE_ATOM, $dt);
    $nowObj = DateTimeImmutable::createFromFormat(DATE_ATOM, $now);

    if ($dtObj === false || $nowObj === false) {
        return null;
    }

    return $dtObj > $nowObj ? $dt : null;  // La comparaison d'objets normalise en UTC
}

V::isoDatetime() effectue d'abord la vérification de format :

php
public static function isoDatetime(mixed $raw): ?string
{
    // Regex strict : nécessite le décalage ±HH:MM — rejette 'Z', date seule, décalage manquant.
    if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}([+-])(\d{2}):(\d{2})$/', $raw, $m)) {
        return null;
    }

    // Valider la plage du décalage de fuseau horaire : les décalages UTC valides sont −14:00 … +14:00.
    $tzHours   = (int) $m[2];
    $tzMinutes = (int) $m[3];

    if ($tzHours > 14 || $tzMinutes > 59 || ($tzHours === 14 && $tzMinutes > 0)) {
        return null;
    }
    // ... validation aller-retour pour les dates de débordement (30 fév etc.)
}

La comparaison d'objets DateTimeImmutable (>) convertit les deux côtés en UTC avant de comparer — donc 2026-06-01T09:00:00+09:00 (00:00 UTC) est correctement comparé à 2026-06-01T01:00:00+01:00 (00:00 UTC) comme égaux.


Prévention IDOR : recherche scopée par propriété

Toutes les opérations qui touchent un rappel spécifique utilisent WHERE id = ? AND user_id = ? :

php
public function findForUser(int $id, int $userId): ?Reminder
{
    $stmt = $this->pdo->prepare('SELECT * FROM reminders WHERE id = ? AND user_id = ?');
    $stmt->execute([$id, $userId]);
    $row = $stmt->fetch(PDO::FETCH_ASSOC);

    return is_array($row) ? $this->hydrate($row) : null;
}

Si le rappel appartient à un autre utilisateur, findForUser() retourne null — l'appelant reçoit 404 Not Found, indiscernable de "le rappel n'existe pas". Retourner 403 Forbidden confirmerait que l'ID existe, révélant des informations d'énumération.


404 vs 409 : annulation avec récupération préalable

Le handler d'annulation récupère le rappel avant de vérifier le statut. Cette approche en deux étapes permet de retourner le statut HTTP correct pour chaque mode d'échec :

php
// Récupérer d'abord pour distinguer 404 (non trouvé/mauvais propriétaire) de 409 (mauvais statut)
$reminder = $this->repository->findForUser($id, $userId);

if ($reminder === null) {
    return $this->responseFactory->create(['error' => 'Reminder not found.'], 404);
}

if ($reminder->status !== ReminderStatus::Pending) {
    return $this->responseFactory->create(
        ['error' => sprintf('Cannot cancel a reminder with status "%s".', $reminder->status->value)],
        409,
    );
}

$this->repository->cancel($id, $userId);

L'annulation au niveau DB inclut la garde de statut comme filet de sécurité :

php
public function cancel(int $id, int $userId): bool
{
    $stmt = $this->pdo->prepare(
        "UPDATE reminders SET status = 'cancelled' WHERE id = ? AND user_id = ? AND status = 'pending'"
    );
    $stmt->execute([$id, $userId]);

    return $stmt->rowCount() > 0;
}

WHERE status = 'pending' dans le UPDATE assure qu'une condition de course (deux requêtes d'annulation concurrentes) résulte en seulement une ligne mise à jour.


Validation des paramètres de requête (?limit= et ?status=)

limit utilise V::queryInt() qui distingue clé absente (utiliser la valeur par défaut) de valeur invalide (retourner 422) :

php
$limit = V::queryInt(
    $params,
    'limit',
    ReminderRepository::MIN_LIMIT,   // 1
    ReminderRepository::MAX_LIMIT,   // 100
    ReminderRepository::DEFAULT_LIMIT, // 20 — retourné quand la clé est absente
);

if ($limit === null) {
    return $this->responseFactory->create(
        ['error' => sprintf('limit must be between %d and %d.', MIN_LIMIT, MAX_LIMIT)],
        422,
    );
}

?status= utilise V::enum() pour valider contre le backed enum :

php
$status = V::enum($rawStatus, ReminderStatus::class);

if ($status === null) {
    return $this->responseFactory->create(
        ['error' => 'status must be one of: pending, triggered, cancelled.'],
        422,
    );
}

V::enum() appelle BackedEnum::tryFrom() en interne, retournant null pour les valeurs inconnues.


Schéma

sql
CREATE TABLE reminders (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    message    TEXT    NOT NULL,
    remind_at  TEXT    NOT NULL,  -- ISO 8601 avec décalage de fuseau horaire, stocké tel quel
    status     TEXT    NOT NULL DEFAULT 'pending',
    created_at TEXT    NOT NULL,
    CHECK (status IN ('pending', 'triggered', 'cancelled'))
);

CREATE INDEX idx_reminders_user   ON reminders (user_id, id);
CREATE INDEX idx_reminders_status ON reminders (status, id);

remind_at est stocké comme la chaîne ISO 8601 originale avec le décalage de fuseau horaire du soumetteur (ex. 2026-06-01T09:00:00+09:00). La DB ne normalise pas en UTC — l'application est responsable de la comparaison correcte (voir V::futureDatetime()).

Deux index :

  • (user_id, id) — couvre la liste par utilisateur et les recherches d'annulation
  • (status, id) — couvre une requête de scruteur qui récupère les rappels pending prêts à se déclencher

Enum de statut

php
enum ReminderStatus: string
{
    case Pending   = 'pending';
    case Triggered = 'triggered';
    case Cancelled = 'cancelled';
}

Seuls les rappels pending peuvent être annulés (409 sinon). triggered est défini par un job en arrière-plan quand le rappel se déclenche — cette API n'inclut pas l'endpoint de déclenchement, qui s'exécuterait sur une tâche planifiée en dehors du serveur HTTP.


Howtos connexes

Publié sous licence MIT.