Skip to content

How-to : API de marque-pages

Référence FT : FT295 (NENE2-FT/bookmarklog) — Gestion des marque-pages : UNIQUE(user_id, item_id) empêche les marque-pages en doublon, regroupement par collection avec filtre optionnel, accès scopé par utilisateur (prévention IDOR), 409 sur doublon, 22 tests / 64 assertions PASS.

Ce guide montre comment construire une API de marque-pages où les utilisateurs sauvegardent des éléments dans des collections nommées avec déduplication et contrôle d'accès scopé par utilisateur.

Schéma

sql
CREATE TABLE bookmarks (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id    INTEGER NOT NULL,
    item_id    INTEGER NOT NULL,
    collection TEXT    NOT NULL DEFAULT 'default',
    created_at TEXT    NOT NULL,
    UNIQUE (user_id, item_id),
    FOREIGN KEY (user_id) REFERENCES users(id),
    FOREIGN KEY (item_id) REFERENCES items(id)
);

UNIQUE(user_id, item_id) assure que chaque utilisateur ne peut mettre un élément en marque-page qu'une seule fois. Le champ collection regroupe les marque-pages en listes nommées (défaut : 'default').

Endpoints

MéthodeCheminDescription
POST/users/{userId}/bookmarksAjouter un marque-page
DELETE/users/{userId}/bookmarks/{itemId}Supprimer un marque-page
GET/users/{userId}/bookmarksLister les marque-pages (optionnellement filtrés par collection)
GET/users/{userId}/bookmarks/countCompter les marque-pages
GET/users/{userId}/bookmarks/{itemId}Obtenir un marque-page spécifique

Ordre d'enregistrement des routes

/users/{userId}/bookmarks/count doit être enregistrée avant /users/{userId}/bookmarks/{itemId} pour éviter que count soit capturé comme {itemId} :

php
$router->get('/users/{userId}/bookmarks', $this->listBookmarks(...));
$router->get('/users/{userId}/bookmarks/count', $this->countBookmarks(...));  // statique avant dynamique
$router->get('/users/{userId}/bookmarks/{itemId}', $this->getBookmark(...));

Ajout d'un marque-page

php
private function addBookmark(ServerRequestInterface $request): ResponseInterface
{
    $params = (array) $request->getAttribute(Router::PARAMETERS_ATTRIBUTE);
    $userId = isset($params['userId']) && is_numeric($params['userId']) ? (int) $params['userId'] : 0;

    if ($userId <= 0 || !$this->repo->findUserById($userId)) {
        return $this->responseFactory->create(['error' => 'user not found'], 404);
    }

    $body       = JsonRequestBodyParser::parse($request);
    $itemId     = isset($body['item_id']) && is_int($body['item_id']) ? $body['item_id'] : 0;
    $collection = isset($body['collection']) && is_string($body['collection'])
        ? trim($body['collection']) : 'default';

    if ($itemId <= 0 || !$this->repo->findItemById($itemId)) {
        return $this->responseFactory->create(['error' => 'item not found'], 404);
    }

    if ($collection === '') {
        $collection = 'default';  // chaîne de collection vide → repli sur 'default'
    }

    $now      = date('Y-m-d H:i:s');
    $bookmark = $this->repo->add($userId, $itemId, $collection, $now);
    return $this->responseFactory->create($bookmark->toArray(), 201);
}

item_id nécessite is_int() — la chaîne JSON "5" est rejetée. La contrainte UNIQUE dans la DB capture les courses ; le repository devrait capturer la violation de contrainte et retourner 409.

Filtre de collection sur la liste

php
$query      = $request->getQueryParams();
$collection = isset($query['collection']) && is_string($query['collection']) && $query['collection'] !== ''
    ? $query['collection'] : null;

$items = $this->repo->listByUser($userId, $collection);

Sans ?collection=, tous les marque-pages sont retournés. Avec ?collection=favorites, seule cette collection est retournée. Le paramètre de requête de collection vide est traité comme "pas de filtre".

Scope utilisateur — Prévention IDOR

Chaque endpoint valide userId contre la DB avant de retourner des données :

php
if ($userId <= 0 || !$this->repo->findUserById($userId)) {
    return $this->responseFactory->create(['error' => 'user not found'], 404);
}

Demander /users/999/bookmarks en tant qu'utilisateur différent retourne 404 (pas les marque-pages de l'autre utilisateur). Toutes les requêtes sont scopées au userId du chemin.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Pas de UNIQUE(user_id, item_id)L'utilisateur met le même élément en marque-page plusieurs fois ; doublons déroutants
Retourner 200 sur un marque-page en doublonLe client ne peut pas distinguer "ajouté" de "existe déjà" ; utiliser 409
Accepter item_id comme chaîne depuis le corpsConfusion de type JSON : "5"5 ; utiliser is_int()
Enregistrer /{itemId} avant /countGET /users/1/bookmarks/count résout en itemId = "count" (mauvais gestionnaire)
Pas de vérification d'existence utilisateurUn userId inexistant retourne une liste vide au lieu de 404
Pas de scope utilisateur dans les requêtesL'Utilisateur A voit les marque-pages de l'Utilisateur B (IDOR)
Pas de défaut de collectionLe champ collection manquant plante ou laisse NULL dans la DB

Publié sous licence MIT.