CraftCMS 16 min de lecture

Construire un endpoint JSON sur mesure dans Craft CMS (sans plugin)

#craftcms#claude.ai

Apprenez à exposer un endpoint JSON performant dans Craft CMS sans plugin: filtres, pagination, CORS et cache HTTP (ETag/Last-Modified). Un tuto concret, prêt pour un frontend JS.

Construire un endpoint JSON sur mesure dans Craft CMS (sans plugin)

Exposer une API JSON simple et performante dans Craft CMS ne nécessite aucun plugin. Dans ce tutoriel, vous allez créer un endpoint GET /api/posts avec filtres, pagination, CORS, et cache HTTP (ETag/Last‑Modified). Le résultat est prêt à être consommé par un frontend JS moderne et pensé pour de bonnes performances.

Objectif

L’objectif est de construire pas à pas un endpoint JSON robuste et rapide dans Craft CMS, sans dépendre d’un plugin tiers. Vous verrez comment:

  • Définir des routes HTTP propres aux méthodes (GET/OPTIONS).
  • Gérer CORS proprement pour un frontend séparé (SPA, Next.js, Nuxt, etc.).
  • Implémenter une ElementQuery filtrable (recherche plein texte, catégorie, multi‑site).
  • Servir des réponses paginées avec métadonnées et images transformées.
  • Tirer parti du cache HTTP avec ETag/Last‑Modified pour renvoyer des 304 Not Modified.

Objectif, périmètre et prérequis

Nous allons exposer GET /api/posts, qui renverra une liste d’articles de blog filtrables par q (recherche plein texte) et category (slug de catégorie), avec pagination page/perPage, métadonnées (total, totalPages, liens next/prev), et une image transformée par article. Pour la performance, on activera l’eager‑loading des relations, et on ajoutera des en‑têtes Cache-Control, ETag et Last-Modified pour permettre des réponses 304 Not Modified. Côté sécurité, on exposera uniquement les champs nécessaires, on permettra CORS de manière configurable, on forcera le statut live par défaut et on bornera perPage.

Prérequis côté projet: Craft CMS 4+ fonctionnel, accès au code et à Composer, un environnement local roulant, et une section “blog” avec au moins un champ image (par ex. featuredImage) et une relation “categories”. Vous devez pouvoir modifier config/app.php, config/routes.php et créer un module sous modules/.

Préparer un module Craft minimal

Commencez par déclarer l’autoload PSR‑4 dans composer.json pour la racine modules/ (si ce n’est pas déjà fait). Ajoutez la section suivante puis régénérez l’autoload:

{
  "autoload": {
    "psr-4": {
      "modules\\": "modules/"
    }
  }
}

Générez les classes autoloadées:

composer dump-autoload -o

Créez ensuite un module minimal sous modules/api/Module.php. Ce module définira le namespace des contrôleurs:

<?php
// modules/api/Module.php

namespace modules\api;

use yii\base\Module as BaseModule;

class Module extends BaseModule
{
    public function init(): void
    {
        parent::init();
        $this->controllerNamespace = 'modules\\api\\controllers';
    }
}

Activez le module dans config/app.php en le déclarant et en le bootstrapant, afin qu’il soit chargé dès le démarrage de l’application:

<?php
// config/app.php

return [
    'modules' => [
        'api' => [
            'class' => \modules\api\Module::class,
        ],
    ],
    'bootstrap' => ['api'],
];

Redémarrez votre serveur PHP si nécessaire; vous pouvez vérifier le chargement du module via la Craft Debug Toolbar ou en ajoutant un log dans Module::init() pour confirmer qu’il est bien initialisé.

Déclarer les routes HTTP

Déclarez des routes propres aux méthodes HTTP pour pointer vers nos actions de contrôleur. Dans config/routes.php:

<?php
// config/routes.php

return [
    // Collection
    'GET api/posts' => 'api/posts/index',

    // Détail par slug (optionnel)
    'GET api/posts/<slug:[^\/]+>' => 'api/posts/view',

    // Prévol CORS pour toutes les routes du préfixe api/ (optionnel mais recommandé)
    'OPTIONS api/<path:.+>' => 'api/cors/options',
];

L’usage des verbes dans les clés de routes contraint proprement les méthodes acceptées (GET, OPTIONS), ce qui évite de devoir gérer des 405 manuellement. La route OPTIONS couvre tous les prévols CORS sur /api/*.

Contrôleur web: squelette et CORS

Créez un contrôleur PostsController pour exposer la collection et le détail. On autorise l’accès anonyme (lecture publique) et on gère les en‑têtes CORS en amont pour les requêtes cross‑origin. La valeur d’origine autorisée provient d’une variable d’environnement API_CORS_ORIGIN (à définir dans .env), avec fallback sur * pour un développement local.

Nous ajoutons aussi un petit CorsController qui répond aux prévols OPTIONS par un 204, ce qui évite de faire tourner l’action principale pour rien.

<?php
// modules/api/controllers/PostsController.php

namespace modules\api\controllers;

use Craft;
use craft\web\Controller;
use craft\elements\Entry;
use craft\elements\Category;
use craft\helpers\UrlHelper;
use craft\helpers\App;
use yii\web\Response;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;

class PostsController extends Controller
{
    public array|int|bool $allowAnonymous = true;

    public function beforeAction($action): bool
    {
        $response = Craft::$app->getResponse();
        $origin = App::env('API_CORS_ORIGIN') ?: '*';

        $headers = $response->getHeaders();
        $headers->set('Access-Control-Allow-Origin', $origin);
        $headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS');
        $headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, If-None-Match, If-Modified-Since');
        $headers->set('Access-Control-Expose-Headers', 'ETag, Last-Modified');
        $headers->set('Vary', 'Origin');

        return parent::beforeAction($action);
    }

    // Les actions arrivent plus bas (index/view)
}

Et le contrôleur dédié au prévol CORS:

<?php
// modules/api/controllers/CorsController.php

namespace modules\api\controllers;

use Craft;
use craft\web\Controller;
use craft\helpers\App;
use yii\web\Response;

class CorsController extends Controller
{
    public array|int|bool $allowAnonymous = true;

    public function actionOptions(string $path = ''): Response
    {
        $response = Craft::$app->getResponse();

        $origin = App::env('API_CORS_ORIGIN') ?: '*';
        $headers = $response->getHeaders();
        $headers->set('Access-Control-Allow-Origin', $origin);
        $headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS');
        $headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, If-None-Match, If-Modified-Since');
        $headers->set('Access-Control-Expose-Headers', 'ETag, Last-Modified');
        $headers->set('Vary', 'Origin');

        $response->statusCode = 204;
        return $response;
    }
}

Avec ces en‑têtes, un frontend séparé (par exemple http://localhost:5173) pourra requêter votre API tant que API_CORS_ORIGIN est correctement renseigné (par ex. http://localhost:5173, https://app.example.com).

Construire l'ElementQuery filtrable

Le cœur de l’action index consiste à lire les paramètres de requête (page, perPage, q, category, site), construire l’ElementQuery avec uniquement le nécessaire, et appliquer les filtres. On reste sur la section blog, on force l’eager‑loading des relations pour éviter le N+1, et on garde le statut live par défaut. Si un token Craft est présent (prévisualisation), on élargit la visibilité.

Exemple d’extraction et de construction de la query:

// Dans PostsController::actionIndex()

$request = Craft::$app->getRequest();

// Pagination et borne
$page = max((int)$request->getParam('page', 1), 1);
$perPage = (int)$request->getParam('perPage', 10);
if ($perPage < 1 || $perPage > 50) {
    throw new BadRequestHttpException('perPage doit être compris entre 1 et 50.');
}

// Filtres
$q = trim((string)$request->getParam('q', ''));
$categorySlug = $request->getParam('category');
$siteParam = $request->getParam('site');
$isToken = (bool)$request->getParam('token');

// Site courant (sécurité: éviter site('*') par défaut)
$sitesService = Craft::$app->getSites();
$currentSite = $sitesService->getCurrentSite();
$siteId = $currentSite->id;
if ($siteParam) {
    $site = $sitesService->getSiteByHandle($siteParam);
    if (!$site) {
        throw new BadRequestHttpException('Paramètre de site invalide.');
    }
    $siteId = $site->id;
}

// Base query
$baseQuery = Entry::find()
    ->section('blog')
    ->siteId($siteId)
    ->with(['featuredImage', 'categories']) // eager-loading
    ->orderBy('postDate desc');

// Statut (live par défaut, élargi en preview/token)
$baseQuery->status($isToken ? null : 'live');

// Recherche plein texte
if ($q !== '') {
    $baseQuery->search($q);
}

// Filtre par catégorie (slug)
if ($categorySlug) {
    $cat = Category::find()
        ->group('blogCategories')
        ->siteId($siteId)
        ->slug($categorySlug)
        ->one();
    if ($cat) {
        $baseQuery->relatedTo($cat);
    } else {
        // Aucun résultat si la catégorie n'existe pas
    }
}

Cette approche garde la query lisible et évite de charger des données inutiles. Elle garantit aussi qu’on ne fuite pas des contenus d’un autre site/langue.

Pagination et normalisation de la sortie

Pour la pagination, comptez le total sur un clone de la requête sans limit/offset, calculez totalPages, puis récupérez la page courante. La réponse JSON doit inclure des métadonnées utiles (page, perPage, total, totalPages, liens next/prev) et les données sérialisées par article (id, title, slug, url, dates, excerpt, image transformée, catégories).

Exemple:

// Compter avant pagination
$countQuery = clone $baseQuery;
$total = (int)$countQuery->count();
$totalPages = (int)ceil($total / $perPage);

// Valider l'offset
$offset = ($page - 1) * $perPage;
if ($offset >= $total && $total > 0) {
    throw new BadRequestHttpException('La pagination demandée dépasse le nombre de résultats.');
}

// Récupération des entrées
$entries = (clone $baseQuery)
    ->limit($perPage)
    ->offset($offset)
    ->all();

// Normalisation
$items = [];
foreach ($entries as $entry) {
    $asset = $entry->getFieldValue('featuredImage')->one();
    $image = $asset ? [
        'url' => $asset->getUrl(['width' => 1200, 'mode' => 'fit', 'quality' => 80]),
        'alt' => $asset->title,
    ] : null;

    $cats = [];
    foreach ($entry->getFieldValue('categories')->all() as $c) {
        $cats[] = ['title' => $c->title, 'slug' => $c->slug];
    }

    $items[] = [
        'id' => (int)$entry->id,
        'title' => (string)$entry->title,
        'slug' => (string)$entry->slug,
        'url' => $entry->getUrl(),
        'postDate' => $entry->postDate ? $entry->postDate->format(DATE_ATOM) : null,
        'excerpt' => (string)($entry->getFieldValue('excerpt') ?: ''),
        'image' => $image,
        'categories' => $cats,
    ];
}

// Liens next/prev
$params = array_filter([
    'q' => $q ?: null,
    'category' => $categorySlug ?: null,
    'perPage' => $perPage !== 10 ? $perPage : null,
    'site' => $siteParam ?: null,
]);

$next = $page < $totalPages ? UrlHelper::siteUrl('api/posts', array_merge($params, ['page' => $page + 1])) : null;
$prev = $page > 1 && $totalPages > 0 ? UrlHelper::siteUrl('api/posts', array_merge($params, ['page' => $page - 1])) : null;

$payload = [
    'meta' => [
        'page' => $page,
        'perPage' => $perPage,
        'total' => $total,
        'totalPages' => $totalPages,
        'next' => $next,
        'prev' => $prev,
    ],
    'data' => $items,
];

Notez qu’on whitelist les champs explicitement dans la normalisation pour éviter toute fuite accidentelle d’attributs internes.

ETag et Last-Modified pour 304 Not Modified

Pour permettre aux clients et aux CDN de valider efficacement le cache, calculez la dernière mise à jour de la collection correspondant aux filtres, générez un ETag stable en fonction des paramètres et de ce timestamp, et retournez un 304 Not Modified si les en‑têtes If-None-Match ou If-Modified-Since fournis par le client indiquent qu’il possède déjà une version à jour.

Voici un ensemble de méthodes utilitaires et leur usage:

// Récupérer le "last updated" (sans limit/offset)
$lastUpdatedQuery = (clone $baseQuery)
    ->orderBy('dateUpdated desc')
    ->limit(1);
$last = $lastUpdatedQuery->one();
$lastUpdatedTs = $last && $last->dateUpdated ? $last->dateUpdated->getTimestamp() : time();

// 304 si pas modifié
if ($this->isNotModified($request, $lastUpdatedTs, $payload, $siteId)) {
    $this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
    $response->statusCode = 304;
    return $response;
}

// Sinon, renvoyer JSON + en-têtes
$this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
return $this->asJson($payload);

Et les helpers correspondants dans le contrôleur:

private function isNotModified(\yii\web\Request $request, int $lastUpdatedTs, array $payload, int $siteId): bool
{
    $etag = $this->buildEtag($request, $lastUpdatedTs, $payload, $siteId);
    $ifNoneMatch = $request->getHeaders()->get('If-None-Match');
    if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
        return true;
    }

    $ifModifiedSince = $request->getHeaders()->get('If-Modified-Since');
    if ($ifModifiedSince) {
        $since = strtotime($ifModifiedSince);
        if ($since !== false && $lastUpdatedTs <= $since) {
            return true;
        }
    }

    return false;
}

private function applyCacheHeaders(Response $response, int $lastUpdatedTs, \yii\web\Request $request, array $payload, int $siteId): void
{
    $etag = $this->buildEtag($request, $lastUpdatedTs, $payload, $siteId);
    $response->getHeaders()
        ->set('ETag', $etag)
        ->set('Last-Modified', gmdate('D, d M Y H:i:s', $lastUpdatedTs) . ' GMT')
        ->set('Cache-Control', 'public, max-age=60, stale-while-revalidate=30');
}

private function buildEtag(\yii\web\Request $request, int $lastUpdatedTs, array $payload, int $siteId): string
{
    $params = $request->getQueryParams();
    ksort($params); // normalisation
    $fingerprint = json_encode([
        'siteId' => $siteId,
        'params' => $params,
        'last' => $lastUpdatedTs,
    ]);
    return '"' . sha1($fingerprint) . '"';
}

En exposant ces en‑têtes, les navigateurs et les reverse proxies peuvent éviter de re‑télécharger le corps en cas d’absence de changement, réduisant drastiquement la latence perçue et la charge serveur.

Erreurs, limites et sécurité

Validez systématiquement les paramètres d’entrée pour éviter les abus et les erreurs silencieuses. Par exemple, refusez un perPage en dehors de 1..50 ou une page hors bornes, et retournez un 400 Bad Request explicite. Ne divulguez jamais d’attributs internes: sérialisez vos entrées dans un tableau strictement whitelisté (id, title, slug, url, postDate, excerpt, image, categories). En multi‑site, forcez un siteId précis au lieu de site('*') par défaut, afin de ne jamais exposer des contenus d’une autre langue ou d’un autre environnement. Enfin, pensez coût: limitez perPage, refusez les filtres non pris en charge, et évitez les jointures superflues en vous cantonnant aux relations réellement utilisées.

Implémentation: blocs de code à placer

Voici les fichiers complets à copier‑coller dans votre projet.

Module du namespace api:

<?php
// modules/api/Module.php

namespace modules\api;

use yii\base\Module as BaseModule;

class Module extends BaseModule
{
    public function init(): void
    {
        parent::init();
        $this->controllerNamespace = 'modules\\api\\controllers';
    }
}

Routes HTTP:

<?php
// config/routes.php

return [
    'GET api/posts' => 'api/posts/index',
    'GET api/posts/<slug:[^\/]+>' => 'api/posts/view',
    'OPTIONS api/<path:.+>' => 'api/cors/options',
];

Activation du module:

<?php
// config/app.php

return [
    'modules' => [
        'api' => [
            'class' => \modules\api\Module::class,
        ],
    ],
    'bootstrap' => ['api'],
];

Contrôleur CORS (prévols):

<?php
// modules/api/controllers/CorsController.php

namespace modules\api\controllers;

use Craft;
use craft\web\Controller;
use craft\helpers\App;
use yii\web\Response;

class CorsController extends Controller
{
    public array|int|bool $allowAnonymous = true;

    public function actionOptions(string $path = ''): Response
    {
        $response = Craft::$app->getResponse();

        $origin = App::env('API_CORS_ORIGIN') ?: '*';
        $headers = $response->getHeaders();
        $headers->set('Access-Control-Allow-Origin', $origin);
        $headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS');
        $headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, If-None-Match, If-Modified-Since');
        $headers->set('Access-Control-Expose-Headers', 'ETag, Last-Modified');
        $headers->set('Vary', 'Origin');

        $response->statusCode = 204;
        return $response;
    }
}

Contrôleur des posts (collection + détail + cache HTTP):

<?php
// modules/api/controllers/PostsController.php

namespace modules\api\controllers;

use Craft;
use craft\web\Controller;
use craft\elements\Entry;
use craft\elements\Category;
use craft\helpers\UrlHelper;
use craft\helpers\App;
use yii\web\Response;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;

class PostsController extends Controller
{
    public array|int|bool $allowAnonymous = true;

    public function beforeAction($action): bool
    {
        $response = Craft::$app->getResponse();
        $origin = App::env('API_CORS_ORIGIN') ?: '*';

        $headers = $response->getHeaders();
        $headers->set('Access-Control-Allow-Origin', $origin);
        $headers->set('Access-Control-Allow-Methods', 'GET, OPTIONS');
        $headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, If-None-Match, If-Modified-Since');
        $headers->set('Access-Control-Expose-Headers', 'ETag, Last-Modified');
        $headers->set('Vary', 'Origin');

        return parent::beforeAction($action);
    }

    public function actionIndex(): Response
    {
        $request = Craft::$app->getRequest();
        $response = Craft::$app->getResponse();

        $page = max((int)$request->getParam('page', 1), 1);
        $perPage = (int)$request->getParam('perPage', 10);
        if ($perPage < 1 || $perPage > 50) {
            throw new BadRequestHttpException('perPage doit être compris entre 1 et 50.');
        }

        $q = trim((string)$request->getParam('q', ''));
        if (mb_strlen($q) > 100) {
            throw new BadRequestHttpException('La requête q est trop longue.');
        }

        $categorySlug = $request->getParam('category');
        $siteParam = $request->getParam('site');

        $sitesService = Craft::$app->getSites();
        $currentSite = $sitesService->getCurrentSite();
        $siteId = $currentSite->id;

        if ($siteParam) {
            $site = $sitesService->getSiteByHandle($siteParam);
            if ($site) {
                $siteId = $site->id;
            } else {
                throw new BadRequestHttpException('Paramètre de site invalide.');
            }
        }

        $isToken = (bool)$request->getParam('token');

        $baseQuery = Entry::find()
            ->section('blog')
            ->siteId($siteId)
            ->with(['featuredImage', 'categories'])
            ->orderBy('postDate desc');

        if ($isToken) {
            $baseQuery->status(null);
        } else {
            $baseQuery->status('live');
        }

        if ($q !== '') {
            $baseQuery->search($q);
        }

        if ($categorySlug) {
            $cat = Category::find()
                ->group('blogCategories')
                ->siteId($siteId)
                ->slug($categorySlug)
                ->one();
            if ($cat) {
                $baseQuery->relatedTo($cat);
            } else {
                $payload = [
                    'meta' => [
                        'page' => $page,
                        'perPage' => $perPage,
                        'total' => 0,
                        'totalPages' => 0,
                        'next' => null,
                        'prev' => null,
                    ],
                    'data' => [],
                ];
                $this->applyCacheHeaders($response, time(), $request, $payload, $siteId);
                return $this->asJson($payload);
            }
        }

        $countQuery = clone $baseQuery;
        $total = (int)$countQuery->count();
        $totalPages = (int)ceil($total / $perPage);

        $offset = ($page - 1) * $perPage;
        if ($offset >= $total && $total > 0) {
            throw new BadRequestHttpException('La pagination demandée dépasse le nombre de résultats.');
        }

        $entries = (clone $baseQuery)
            ->limit($perPage)
            ->offset($offset)
            ->all();

        $items = [];
        foreach ($entries as $entry) {
            $asset = $entry->getFieldValue('featuredImage')->one();
            $image = $asset ? [
                'url' => $asset->getUrl(['width' => 1200, 'mode' => 'fit', 'quality' => 80]),
                'alt' => $asset->title,
            ] : null;

            $cats = [];
            foreach ($entry->getFieldValue('categories')->all() as $c) {
                $cats[] = [
                    'title' => $c->title,
                    'slug' => $c->slug,
                ];
            }

            $items[] = [
                'id' => (int)$entry->id,
                'title' => (string)$entry->title,
                'slug' => (string)$entry->slug,
                'url' => $entry->getUrl(),
                'postDate' => $entry->postDate ? $entry->postDate->format(DATE_ATOM) : null,
                'excerpt' => (string)($entry->getFieldValue('excerpt') ?: ''),
                'image' => $image,
                'categories' => $cats,
            ];
        }

        $params = array_filter([
            'q' => $q ?: null,
            'category' => $categorySlug ?: null,
            'perPage' => $perPage !== 10 ? $perPage : null,
            'site' => $siteParam ?: null,
        ]);

        $next = null;
        $prev = null;
        if ($page < $totalPages) {
            $next = UrlHelper::siteUrl('api/posts', array_merge($params, ['page' => $page + 1]));
        }
        if ($page > 1 && $totalPages > 0) {
            $prev = UrlHelper::siteUrl('api/posts', array_merge($params, ['page' => $page - 1]));
        }

        $payload = [
            'meta' => [
                'page' => $page,
                'perPage' => $perPage,
                'total' => $total,
                'totalPages' => $totalPages,
                'next' => $next,
                'prev' => $prev,
            ],
            'data' => $items,
        ];

        $lastUpdatedQuery = (clone $baseQuery)
            ->orderBy('dateUpdated desc')
            ->limit(1);
        $last = $lastUpdatedQuery->one();
        $lastUpdatedTs = $last && $last->dateUpdated ? $last->dateUpdated->getTimestamp() : time();

        if ($this->isNotModified($request, $lastUpdatedTs, $payload, $siteId)) {
            $this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
            $response->statusCode = 304;
            return $response;
        }

        $this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
        return $this->asJson($payload);
    }

    public function actionView(string $slug): Response
    {
        $request = Craft::$app->getRequest();
        $sitesService = Craft::$app->getSites();
        $siteParam = $request->getParam('site');
        $site = $siteParam ? $sitesService->getSiteByHandle($siteParam) : $sitesService->getCurrentSite();
        $siteId = $site ? $site->id : $sitesService->getCurrentSite()->id;

        $isToken = (bool)$request->getParam('token');

        $query = Entry::find()
            ->section('blog')
            ->siteId($siteId)
            ->slug($slug)
            ->with(['featuredImage', 'categories']);

        if ($isToken) {
            $query->status(null);
        } else {
            $query->status('live');
        }

        $entry = $query->one();
        if (!$entry) {
            throw new NotFoundHttpException('Article introuvable.');
        }

        $asset = $entry->getFieldValue('featuredImage')->one();
        $image = $asset ? [
            'url' => $asset->getUrl(['width' => 1200, 'mode' => 'fit', 'quality' => 80]),
            'alt' => $asset->title,
        ] : null;

        $cats = [];
        foreach ($entry->getFieldValue('categories')->all() as $c) {
            $cats[] = ['title' => $c->title, 'slug' => $c->slug];
        }

        $payload = [
            'data' => [
                'id' => (int)$entry->id,
                'title' => (string)$entry->title,
                'slug' => (string)$entry->slug,
                'url' => $entry->getUrl(),
                'postDate' => $entry->postDate ? $entry->postDate->format(DATE_ATOM) : null,
                'excerpt' => (string)($entry->getFieldValue('excerpt') ?: ''),
                'image' => $image,
                'categories' => $cats,
            ],
        ];

        $response = Craft::$app->getResponse();
        $lastUpdatedTs = $entry->dateUpdated ? $entry->dateUpdated->getTimestamp() : time();
        if ($this->isNotModified($request, $lastUpdatedTs, $payload, $siteId)) {
            $this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
            $response->statusCode = 304;
            return $response;
        }

        $this->applyCacheHeaders($response, $lastUpdatedTs, $request, $payload, $siteId);
        return $this->asJson($payload);
    }

    private function isNotModified(\yii\web\Request $request, int $lastUpdatedTs, array $payload, int $siteId): bool
    {
        $etag = $this->buildEtag($request, $lastUpdatedTs, $payload, $siteId);
        $ifNoneMatch = $request->getHeaders()->get('If-None-Match');
        if ($ifNoneMatch && trim($ifNoneMatch) === $etag) {
            return true;
        }

        $ifModifiedSince = $request->getHeaders()->get('If-Modified-Since');
        if ($ifModifiedSince) {
            $since = strtotime($ifModifiedSince);
            if ($since !== false && $lastUpdatedTs <= $since) {
                return true;
            }
        }

        return false;
    }

    private function applyCacheHeaders(Response $response, int $lastUpdatedTs, \yii\web\Request $request, array $payload, int $siteId): void
    {
        $etag = $this->buildEtag($request, $lastUpdatedTs, $payload, $siteId);
        $response->getHeaders()
            ->set('ETag', $etag)
            ->set('Last-Modified', gmdate('D, d M Y H:i:s', $lastUpdatedTs) . ' GMT')
            ->set('Cache-Control', 'public, max-age=60, stale-while-revalidate=30');
    }

    private function buildEtag(\yii\web\Request $request, int $lastUpdatedTs, array $payload, int $siteId): string
    {
        $params = $request->getQueryParams();
        ksort($params);
        $fingerprint = json_encode([
            'siteId' => $siteId,
            'params' => $params,
            'last' => $lastUpdatedTs,
        ]);
        return '"' . sha1($fingerprint) . '"';
    }
}

Pensez à définir API_CORS_ORIGIN dans votre .env pour l’environnement de dev comme suit:

# .env
API_CORS_ORIGIN=http://localhost:5173

Vous pouvez en production relâcher ou resserrer cet origin selon vos besoins (ex.: https://app.example.com).

Tests rapides: curl et fetch

Vérifiez d’abord qu’une requête simple renvoie bien vos données paginées et filtrées. Un exemple avec curl pour chercher “craft” dans la catégorie “guides” et limiter à 5 résultats:

curl -i 'https://example.test/api/posts?perPage=5&q=craft&category=guides'

Récupérez l’ETag de la réponse, puis rejouez la requête avec If-None-Match pour simuler une validation côté client. Si rien n’a changé, vous devez obtenir un 304 Not Modified:

curl -i -H 'If-None-Match: "votre-etag-ici"' 'https://example.test/api/posts?perPage=5&q=craft&category=guides'

Côté navigateur, un fetch minimal de la page 2 ressemble à ceci:

fetch('/api/posts?page=2')
  .then((r) => r.json())
  .then((json) => console.log(json))
  .catch(console.error);

Ouvrez les DevTools réseau pour vérifier la présence de ETag et Last-Modified, ainsi que le statut 304 lors des requêtes conditionnelles.

Aller plus loin

Vous pouvez enrichir l’API avec un tri dynamique via un paramètre sort (par exemple, sort=-postDate,title pour trier par postDate décroissant puis title croissant), en validant une whitelist de champs triables. Un rate limiting simple peut être implémenté par IP à l’aide du cache applicatif de Yii, afin d’éviter les abus sur des endpoints publics. Côté CDN, tirez parti des validations conditionnelles (ETag/Last-Modified) pour réduire davantage la bande passante et améliorer la latence; certains CDN peuvent aussi faire du stale-while-revalidate côté edge. Enfin, exposez d’autres endpoints utiles comme une taxonomie (liste des catégories) et une recherche suggérée (autocomplete) pour l’UX du frontend.

Checklist

Avant de publier, relisez le code et les en‑têtes, vérifiez les contraintes de pagination et de sécurité, testez chaque endpoint avec curl et via votre frontend, contrôlez les réponses 304, et assurez-vous que les transformations d’images, les filtres et les liens next/prev se comportent correctement dans différents scénarios (aucun résultat, dernière page, site multi‑langue).

Ressources

Conclusion

Vous disposez maintenant d’un endpoint JSON propre, performant et sécurisé, construit uniquement avec les briques natives de Craft CMS. Entre les filtres, la pagination, le CORS et le cache HTTP, votre frontend pourra consommer l’API efficacement, tout en préservant la charge serveur. Ce socle est volontairement simple et extensible: ajoutez à la demande du tri, d’autres filtres, un rate limit minimal ou même des endpoints spécialisés pour couvrir des besoins plus avancés.