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 frontendHistorique : 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 surnpm.internal.spektrum-suisse.chmais ne sont plus alimentées.
Pourquoi cette approche
- Contrat unique —
api.jsonest 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
JsonResourcequi l'enveloppe), Scramble introspecte le schéma réel de la table viaModelInfo::getColumns()pour en déduire la forme de la réponse. Le job CIcontractprovisionne donc le même service PostgreSQL jetable quecoverageet 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.php → extensions) :
- 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.php → extensions) 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
paginationInformationsurResourceCollection(enregistrée dansAppServiceProvider::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 dansconfig/scramble.php→extensions) é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
JsonResourcetypée par réponse (Scramble en déduit la forme renvoyée) ; - une
FormRequestavecrules()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 sesrules(); - 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], commeActivationCode::fromStatusView()— l'action annote sa forme paginée en PHPDoc :@return AnonymousResourceCollection<LengthAwarePaginator<ActivationCodeResource>>. Le garde-fouOpenApiExportTest::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.jsonembarqué dans l'image : le job CIcontractexporte le document,publish-dockerconsomme l'artefact (needs: contract) et leCOPY . .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.jsontraîne à la racine du projet. Ce fichier est l'artefact de build du commit qui a lancéscramble:exportpour la dernière fois : le servir à unnpm run api:updatelocal 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 : enlocal, le vivant gagne, toujours (ContractEndpointTest). - Chaque réponse annonce sa source dans l'en-tête
X-Contract-Source:generated(document vivant) oustatic-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épondrait503(contract_unavailable), cas qui ne se présente pas tant que la CI embarque l'artefact. - Version du contrat = version de l'application.
info.versiondu document suitAPP_VERSION(config/scramble.phpetconfig/app.phplisent la même variable) : le job CIcontractexporte avecAPP_VERSION=$CI_COMMIT_TAGdans le pipeline de tag, et le fallback à la volée hérite de la valeur injectée dans l'image (ENV APP_VERSIONdu Dockerfile) — donc la même queGET /api/version. En local et hors pipeline de tag :dev. Verrouillé parOpenApiExportTest::test_the_contract_version_follows_the_application_version. - URL de serveur du contrat. L'entrée
serversdu document est dérivée d'APP_URL(config/scramble.phplaisseserversànull). Sur un tag staging (X.Y.Z-staging.N), le job CIcontractfixeAPP_URL=https://travelise-roadtrip.staging.spektrum-suisse.chpour 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) exigeAuthorization: Bearer $CONTRACT_ACCESS_TOKEN(comparaisonhash_equals, fail closed si la variable n'est pas définie). Le contrôle est désactivé en envlocalpour 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) :
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 frontendLe 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) :
| Job | Stage | Déclenchement | Rôle |
|---|---|---|---|
contract | contract | chaque commit | scramble:export → api.json en artefact ; échoue si le contrat casse |
publish-docker | publish | tag uniquement | consomme 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 :
- 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.
- Le frontend régénère son client depuis
GET /api/contractde l'environnement déployé et adopte la nouvelle forme. - 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.

