Skip to content

Contrat d'API & types partagés

Le frontend (Vue / Capacitor) et le backend partagent le contrat HTTP via un unique artefact généré : un document OpenAPI 3.1. Le backend en est la source de vérité et le sert lui-même sur GET /api/contract ; le frontend le récupère depuis l'environnement visé et génère son client typé dans son propre dépôt.

Backend (Laravel + Scramble)  →  api.json (OpenAPI 3.1)      →  GET /api/contract        →  codegen frontend
        source de vérité          artefact CI (chaque commit,      servi par l'app           @hey-api/openapi-ts,
                                  embarqué dans l'image)           (token développeur)       dans le dépôt frontend

Historique : jusqu'en juillet 2026, un paquet npm typé (@spektrum/roadtrip-api-client) était généré ici et publié sur le registre privé à chaque tag. Il a été retiré au profit de l'endpoint : le frontend possède désormais son codegen. Les versions déjà publiées restent installables sur npm.internal.spektrum-suisse.ch mais ne sont plus alimentées.

Pourquoi cette approche

  • Contrat uniqueapi.json est le contrat HTTP réel, pas une copie à maintenir à la main. Le frontend en dérive un client typé (appels d'endpoints auto-complétés + types requête/réponse), pas seulement des interfaces.
  • Le contrat suit l'environnement — chaque environnement sert le contrat du code qui y tourne réellement : générer contre staging documente exactement ce que staging expose, sans attendre la publication d'un paquet.
  • Agnostique au langage — le même contrat sert la PWA et l'app Capacitor, et documenterait tout autre consommateur.
  • Documentation gratuite — Scramble expose une UI Swagger/Stoplight sur /docs/api à partir du même contrat.

Alternative écartée : l'export PHP→TS direct (spatie/typescript-transformer) ne décrit pas les endpoints (quel verbe renvoie quelle forme) — il faudrait recâbler chaque appel à la main. Le contrat OpenAPI a été préféré.

Côté backend — Scramble

dedoc/scramble (dépendance de dev) infère le document OpenAPI statiquement depuis le code, sans annotations PHPDoc :

  • la commande php artisan scramble:export --path=api.json écrit le contrat ;
  • la config est dans config/scramble.php (chemin d'API documenté : api) ;
  • l'UI de documentation est servie sur /docs/api.

Une base de données est requise pour l'export. Dès qu'une route renvoie un modèle Eloquent (ou une JsonResource qui l'enveloppe), Scramble introspecte le schéma réel de la table via ModelInfo::getColumns() pour en déduire la forme de la réponse. Le job CI contract provisionne donc le même service PostgreSQL jetable que coverage et le migre (php artisan migrate --force) avant d'exporter — voir Intégration continue.

Groupement des endpoints

Sans indication, Scramble dérive un tag par contrôleur (les contrôleurs étant mono-action, le contrat devient plat : un groupe par opération). Chaque contrôleur déclare donc l'attribut #[Group('…', weight: n)] (Dedoc\Scramble\Attributes\Group) pour rattacher son opération à un groupe de ressource ; le weight ordonne les groupes dans l'UI de documentation.

Les groupes en vigueur, par ordre de poids : Auth (0), Users (1), Organizations (2), Invitations (3), Activation codes (4), Stats (5), System (6), Import (7). Le test de contrat (OpenApiExportTest::test_every_operation_is_tagged_with_a_known_group) échoue si une opération porte un tag hors de cette liste — c'est-à-dire si un nouveau contrôleur oublie son #[Group]. Les dossiers de bruno-requests/ reflètent ces mêmes groupes.

En-tête X-Viewing-As

Les routes derrière le middleware resolve-viewing-context exigent l'en-tête X-Viewing-As (voir Identité et accès). Le contrat le documente automatiquement via une extension maison, App\OpenApi\ViewingContextHeaderExtension (enregistrée dans config/scramble.phpextensions) :

  • toute opération dont la route porte le middleware reçoit un paramètre d'en-tête requis X-Viewing-As, plus la réponse 422 du middleware — sauf si l'opération documente déjà un 422 (p. ex. une erreur de validation), qui est alors conservé ;
  • chaque contrôleur concerné déclare l'attribut purement descriptif#[App\OpenApi\AcceptsViewingContext(...)] précisant les contextes acceptés : enum stricte quand l'UUID d'organisation n'est pas accepté, description en prose sinon. Sans argument, tous les contextes sont acceptés. L'attribut ne fait aucune application à l'exécution — les vérifications restent dans le middleware et les contrôleurs.

Le test OpenApiExportTest::test_viewing_context_routes_document_the_required_header prend le routeur comme source de vérité : toute route sous le middleware dont l'opération n'expose pas l'en-tête requis (ou l'inverse) fait échouer la suite.

Codes de refus des intentions voyageur (claim, gift)

Les endpoints POST /activation-codes/claim et POST /activation-codes/{id}/gift refusent en 422 avec un champ additif reason : un code stable et machine-interprétable (énum PHP App\Enums\ActivationCodeRejectionReason, valeurs en snake_case comme already_claimed) à côté du message lisible — le frontend branche sur ce code, jamais sur le texte. Côté contrat, App\OpenApi\ActivationCodeRejectionResponseExtension (enregistrée dans config/scramble.phpextensions) remplace le 422 inféré de ces deux opérations par un anyOf : la forme erreur-de-validation enrichie de reason (énum restreinte aux refus possibles de l'endpoint, optionnelle car absente des erreurs de champ classiques) et la forme error du middleware de contexte.

Le garde-fou OpenApiExportTest::test_claim_and_gift_document_the_rejection_reason_enum épingle l'énum documentée sur l'énum PHP : ajouter ou retirer un motif de refus sans que le contrat le reflète fait échouer la suite.

Enveloppe de pagination

Les réponses paginées n'exposent que data + meta — pas d'objet links racine ni de tableau meta.links : le frontend construit ses URLs de pagination lui-même (les liens générés par Laravel ne reflétaient de toute façon pas les filtres actifs). Deux pièces portent cette convention :

  • à l'exécution, une macro paginationInformation sur ResourceCollection (enregistrée dans AppServiceProvider::boot()) retire les deux clés de toute réponse paginée — collections anonymes et nommées, endpoints futurs compris ;
  • côté contrat, App\OpenApi\TrimmedPaginationTypeToSchema (enregistrée dans config/scramble.phpextensions) émet le même schéma réduit, Scramble résolvant la pagination statiquement sans voir la macro.

Le garde-fou OpenApiExportTest::test_paginated_responses_do_not_document_pagination_links échoue si un schéma paginé du contrat réintroduit ces liens (p. ex. après une mise à jour de Scramble qui renommerait la méthode surchargée).

La qualité du contrat dépend du typage du code — ce qui recoupe les conventions du projet :

  • une JsonResource typée par réponse (Scramble en déduit la forme renvoyée) ;
  • une FormRequest avec rules() typées pour les corps et paramètres de requête — y compris sur les GET : les filtres des listings de codes d'activation (ListActivationCodesRequest) sont documentés comme paramètres de query dans le contrat via ses rules() ;
  • des types de retour explicites sur les actions de contrôleur ;
  • quand l'inférence ne voit pas le paginateur — Scramble ne suit pas les scopes déclarés avec l'attribut #[Scope], comme ActivationCode::fromStatusView() — l'action annote sa forme paginée en PHPDoc : @return AnonymousResourceCollection<LengthAwarePaginator<ActivationCodeResource>>. Le garde-fou OpenApiExportTest::test_list_endpoints_document_the_paginated_envelope échoue si un endpoint de listing retombe sur un simple tableau ;
  • les enums (UserStatus, OrganizationType, MembershipStatus, ActivationCodeStatus) ressortent automatiquement en enums de chaînes dans le contrat.

api.json est un artefact généré (gitignoré) : il est régénéré en CI, jamais commité. La commande est verrouillée par un test (tests/Feature/Contract/OpenApiExportTest.php) qui échoue si l'export casse.

Distribution — GET /api/contract

Le contrat est servi par l'application elle-même (ContractController, groupe System, comme GET /api/version) :

  • En environnement déployé, l'endpoint sert le api.json embarqué dans l'image : le job CI contract exporte le document, publish-docker consomme l'artefact (needs: contract) et le COPY . . du Dockerfile l'embarque à la racine de l'app. Le contrat servi correspond donc exactement au commit déployé.
  • En local, le document est toujours généré à la volée — même si un api.json traîne à la racine du projet. Ce fichier est l'artefact de build du commit qui a lancé scramble:export pour la dernière fois : le servir à un npm run api:update local rend un contrat antérieur au travail en cours, et une lecture périmée est indiscernable d'un « le contrat n'a pas changé ». Comme le contrat est l'interface entre les deux dépôts, ce silence est exactement la façon dont les deux côtés divergent pendant que tous les voyants restent au vert. La règle est donc : en local, le vivant gagne, toujours (ContractEndpointTest).
  • Chaque réponse annonce sa source dans l'en-tête X-Contract-Source : generated (document vivant) ou static-export (fichier). Là où le fichier l'emporte légitimement — les environnements déployés — une lecture périmée devient ainsi visible au lieu d'être silencieuse.
  • Hors local, si le fichier manque et que Scramble est installé (staging conserve les dépendances de dev), l'endpoint génère à la volée ; en production sans fichier il répondrait 503 (contract_unavailable), cas qui ne se présente pas tant que la CI embarque l'artefact.
  • Version du contrat = version de l'application. info.version du document suit APP_VERSION (config/scramble.php et config/app.php lisent la même variable) : le job CI contract exporte avec APP_VERSION=$CI_COMMIT_TAG dans le pipeline de tag, et le fallback à la volée hérite de la valeur injectée dans l'image (ENV APP_VERSION du Dockerfile) — donc la même que GET /api/version. En local et hors pipeline de tag : dev. Verrouillé par OpenApiExportTest::test_the_contract_version_follows_the_application_version.
  • URL de serveur du contrat. L'entrée servers du document est dérivée d'APP_URL (config/scramble.php laisse servers à null). Sur un tag staging (X.Y.Z-staging.N), le job CI contract fixe APP_URL=https://travelise-roadtrip.staging.spektrum-suisse.ch pour que le contrat embarqué dans l'image pointe vers l'URL publique de staging ; ailleurs l'export garde la valeur d'.env.example.
  • Accès : token développeur statique. Le middleware contract-token (App\Http\Middleware\EnsureContractToken) exige Authorization: Bearer $CONTRACT_ACCESS_TOKEN (comparaison hash_equals, fail closed si la variable n'est pas définie). Le contrôle est désactivé en env local pour que le frontend puisse générer contre un backend local sans configuration. Voir Identité et accès.

Le flux côté frontend (dépôt frère) : récupérer le document puis générer le client dans son propre dépôt avec @hey-api/openapi-ts (plugins @hey-api/client-fetch, @hey-api/typescript, @hey-api/sdk — la config de l'ancien workspace api-client/openapi-ts.config.ts sert de modèle) :

bash
curl -H "Authorization: Bearer $CONTRACT_ACCESS_TOKEN" \
  https://travelise-roadtrip.staging.spektrum-suisse.ch/api/contract > api.json
npx openapi-ts   # input: api.json → code généré, commité dans le dépôt frontend

Le code généré est commité côté frontend : c'est lui qui fige la version du contrat contre laquelle le frontend est construit (reproductibilité des builds), la régénération étant un geste explicite du développeur.

Intégration GitLab CI

Le contrat traverse deux jobs (voir Intégration continue) :

JobStageDéclenchementRôle
contractcontractchaque commitscramble:exportapi.json en artefact ; échoue si le contrat casse
publish-dockerpublishtag uniquementconsomme l'artefact (needs: contract) et l'embarque dans l'image

Couplage backend ↔ frontend : découplé

Backend et frontend sont versionnés et déployés indépendamment. Le lien entre les deux est le code généré commité côté frontend : il fige la version du contrat contre laquelle le frontend a été construit. On ne coordonne pas les releases.

Changement d'API cassant → déployer le backend d'abord, le frontend ensuite, selon le motif expand / contract :

  1. Expand — le backend ajoute la nouvelle forme de façon additive (sans retirer l'ancienne) ; le frontend en place, construit sur l'ancien contrat, continue de fonctionner.
  2. Le frontend régénère son client depuis GET /api/contract de l'environnement déployé et adopte la nouvelle forme.
  3. Contract — le backend retire l'ancienne forme dans une release ultérieure.

Ne jamais casser-et-retirer dans une même release backend. Le versionnage d'API (/api/vN, Eloquent Resources + routes versionnées) reste la soupape de secours quand expand/contract ne suffit pas.

Contributors

No contributors

Changelog

No recent changes