Skip to content

How-to : Versionnage d'API

Référence FT : FT346 (NENE2-FT/versionlog) — Versionnage par chemin URL avec namespaces /v1/ et /v2/, V1 dépréciée portant les en-têtes Deprecation/Sunset/Link, V2 avec forme de réponse enrichie, stockage sous-jacent partagé, 16 tests PASS.

Ce guide montre comment implémenter le versionnage par chemin URL : exécuter deux versions d'API côte à côte avec différentes formes de réponse, marquer l'ancienne version comme dépréciée avec des en-têtes HTTP, et partager une seule base de données entre les versions.

Stratégie de version

VersionStatutPréfixeEnveloppe de liste
V1Dépréciée/v1/{"notes": [...]}
V2Actuelle/v2/{"data": [...], "meta": {...}}

Les deux versions partagent les mêmes tables de base de données. Les clients V1 peuvent continuer à utiliser leur intégration existante pendant que les en-têtes de dépréciation signalent une date limite de migration.

Endpoints

MéthodeChemin V1Chemin V2Description
POST/v1/notes/v2/notesCréer une note
GET/v1/notes/v2/notesLister les notes
GET/v1/notes/{id}/v2/notes/{id}Obtenir une note unique

Forme de réponse V1

php
// POST /v1/notes
{"title": "Hello", "content": "World"}
 201
{
  "id": 1,
  "title": "Hello",
  "content": "World",    // ← nom de champ : "content"
  "created_at": "..."
  // Pas de "body", "tags", ni "updated_at"
}

// GET /v1/notes
 200
{
  "notes": [              // ← clé d'enveloppe : "notes"
    {"id": 1, "title": "Hello", "content": "World", ...}
  ]
}

Forme de réponse V2

php
// POST /v2/notes
{"title": "Hello", "body": "World", "tags": ["php", "api"]}
 201
{
  "data": {               // ← clé d'enveloppe : "data"
    "id": 2,
    "title": "Hello",
    "body": "World",      // ← nom de champ : "body"
    "tags": ["php", "api"],  // ← tags ajoutés
    "updated_at": "...",     // ← updated_at ajouté
    "created_at": "..."
  }
}

// GET /v2/notes
 200
{
  "data": [...],          // ← enveloppe de liste : "data"
  "meta": {               // ← section meta
    "limit": 20,
    "offset": 0
  }
}

En-têtes de dépréciation V1

Chaque réponse V1 porte trois en-têtes informant les clients de migrer :

Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </v2/notes>; rel="successor-version"
php
// Chaque endpoint V1 ajoute :
return $response
    ->withHeader('Deprecation', 'true')
    ->withHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT')
    ->withHeader('Link', '</v2/notes>; rel="successor-version"');

Les réponses V2 ne portent aucun de ces en-têtes.

php
// En-têtes GET /v1/notes V1 :
Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </v2/notes>; rel="successor-version"

// En-têtes GET /v2/notes V2 :
// (pas de Deprecation, Sunset, ni Link)

Stockage partagé — Accès inter-version

Les deux versions partagent la même table notes. Une note créée via V1 est lisible depuis V2 (et vice versa) :

php
// Créer via V1
POST /v1/notes  {"title": "Cross-version", "content": "Shared body"}
 201  {"id": 5, "title": "Cross-version", "content": "Shared body", ...}

// Lire via V2 — même enregistrement, forme V2
GET /v2/notes/5
 200
{
  "data": {
    "id": 5,
    "title": "Cross-version",
    "body": "Shared body",    // V2 l'appelle "body", pas "content"
    "tags": [],
    "updated_at": "...",
    "created_at": "..."
  }
}

Les clients V1 ne voient jamais tags (pas dans la forme de réponse V1), même si la note a des tags d'une écriture V2.

Schéma

sql
CREATE TABLE notes (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    title      TEXT    NOT NULL,
    body       TEXT    NOT NULL DEFAULT '',
    tags       TEXT    NOT NULL DEFAULT '[]',  -- tableau JSON
    created_at TEXT    NOT NULL,
    updated_at TEXT    NOT NULL
);

La colonne sous-jacente est body. V1 la mappe en content dans le transformateur de réponse.

Implémentation — Transformateurs de réponse

php
// Transformateur V1 — mappe la colonne "body" → champ "content", cache tags/updated_at
final class V1NoteTransformer
{
    /** @param array<string, mixed> $row */
    public function transform(array $row): array
    {
        return [
            'id'         => $row['id'],
            'title'      => $row['title'],
            'content'    => $row['body'],   // renommage de champ
            'created_at' => $row['created_at'],
            // Pas de "body", "tags", "updated_at"
        ];
    }
}

// Transformateur V2 — ligne complète, enveloppée dans "data"
final class V2NoteTransformer
{
    /** @param array<string, mixed> $row */
    public function transform(array $row): array
    {
        return [
            'id'         => $row['id'],
            'title'      => $row['title'],
            'body'       => $row['body'],
            'tags'       => json_decode($row['tags'], true),
            'updated_at' => $row['updated_at'],
            'created_at' => $row['created_at'],
        ];
    }
}

Enregistrement des routes

php
// V1Registrar::register()
$router->get('/v1/notes',       [V1ListHandler::class, 'handle']);
$router->post('/v1/notes',      [V1CreateHandler::class, 'handle']);
$router->get('/v1/notes/{id}',  [V1GetHandler::class, 'handle']);

// V2Registrar::register()
$router->get('/v2/notes',       [V2ListHandler::class, 'handle']);
$router->post('/v2/notes',      [V2CreateHandler::class, 'handle']);
$router->get('/v2/notes/{id}',  [V2GetHandler::class, 'handle']);

Les deux registrars sont passés à RuntimeApplicationFactory — les routes des deux sont enregistrées dans le même routeur.

Version inconnue → 404

php
GET /v3/notes
 404

Il n'y a pas de route V3 ; le routeur retourne 404. Aucun type d'erreur "version non supportée" n'est nécessaire — 404 est suffisant.

Validation

php
POST /v1/notes  {"content": "no title"}
 422  // title est requis

POST /v2/notes  {"body": "no title"}
 422  // title est requis

Les deux versions requièrent title. V1 accepte content comme champ de corps ; V2 accepte body.


Ce qu'il ne faut PAS faire

Anti-patternRisque
Tables DB différentes par versionLes lectures inter-version se cassent ; les données migrent mal quand les versions ne partagent aucun état
Retourner Deprecation: true sur V2Les clients ne peuvent pas distinguer quelle version est actuelle
Pas d'en-tête Link avec successeurLes clients dépréciés ne savent pas où migrer
Renommer la colonne DB bodycontent pour V1Tout le code V2 doit changer ; utiliser le transformateur de réponse pour renommer, pas le schéma
Coder en dur la date Sunset dans les testsLes tests échouent après la date Sunset ; utiliser une constante future ou une valeur de config
Exposer les tags V1 dans la réponseLes clients V1 reçoivent un champ qu'ils ne comprennent pas ; les contrats de forme se cassent silencieusement

Publié sous licence MIT.