Skip to content

How-to: API-Versionierung

FT-Referenz: FT346 (NENE2-FT/versionlog) — URL-Pfad-Versionierung mit /v1/- und /v2/-Namespaces, veraltetes V1 mit Deprecation/Sunset/Link-Headern, V2 mit angereicherter Antwortstruktur, gemeinsame Datenspeicherung, 16 Tests PASS.

Diese Anleitung zeigt, wie Sie URL-Pfad-Versionierung implementieren: zwei API-Versionen gleichzeitig mit unterschiedlichen Antwortstrukturen betreiben, die alte Version mit HTTP-Headern als veraltet markieren und eine einzige Datenbank über Versionen hinweg teilen.

Versionsstrategie

VersionStatusPräfixListen-Wrapper
V1Veraltet/v1/{"notes": [...]}
V2Aktuell/v2/{"data": [...], "meta": {...}}

Beide Versionen teilen dieselben Datenbanktabellen. V1-Clients können ihre bestehende Integration weiterhin nutzen, während die Deprecation-Header einen Migrationsstichtag signalisieren.

Endpunkte

MethodeV1-PfadV2-PfadBeschreibung
POST/v1/notes/v2/notesNotiz erstellen
GET/v1/notes/v2/notesNotizen auflisten
GET/v1/notes/{id}/v2/notes/{id}Einzelne Notiz abrufen

V1-Antwortstruktur

php
// POST /v1/notes
{"title": "Hello", "content": "World"}
 201
{
  "id": 1,
  "title": "Hello",
  "content": "World",    // ← Feldname: "content"
  "created_at": "..."
  // Kein "body", kein "tags", kein "updated_at"
}

// GET /v1/notes
 200
{
  "notes": [              // ← Wrapper-Schlüssel: "notes"
    {"id": 1, "title": "Hello", "content": "World", ...}
  ]
}

V2-Antwortstruktur

php
// POST /v2/notes
{"title": "Hello", "body": "World", "tags": ["php", "api"]}
 201
{
  "data": {               // ← Envelope-Schlüssel: "data"
    "id": 2,
    "title": "Hello",
    "body": "World",      // ← Feldname: "body"
    "tags": ["php", "api"],  // ← Tags hinzugefügt
    "updated_at": "...",     // ← updated_at hinzugefügt
    "created_at": "..."
  }
}

// GET /v2/notes
 200
{
  "data": [...],          // ← Listen-Wrapper: "data"
  "meta": {               // ← Meta-Abschnitt
    "limit": 20,
    "offset": 0
  }
}

V1-Deprecation-Header

Jede V1-Antwort enthält drei Header, die Clients zur Migration auffordern:

Deprecation: true
Sunset: Sat, 01 Jan 2027 00:00:00 GMT
Link: </v2/notes>; rel="successor-version"
php
// Jeder V1-Endpunkt fügt hinzu:
return $response
    ->withHeader('Deprecation', 'true')
    ->withHeader('Sunset', 'Sat, 01 Jan 2027 00:00:00 GMT')
    ->withHeader('Link', '</v2/notes>; rel="successor-version"');

V2-Antworten enthalten keinen dieser Header.

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

// V2 GET /v2/notes Header:
// (kein Deprecation, Sunset oder Link)

Gemeinsamer Speicher — versionsübergreifender Zugriff

Beide Versionen teilen dieselbe notes-Tabelle. Eine über V1 erstellte Notiz ist von V2 aus lesbar (und umgekehrt):

php
// Über V1 erstellen
POST /v1/notes  {"title": "Cross-version", "content": "Shared body"}
 201  {"id": 5, "title": "Cross-version", "content": "Shared body", ...}

// Über V2 lesen — gleicher Datensatz, V2-Struktur
GET /v2/notes/5
 200
{
  "data": {
    "id": 5,
    "title": "Cross-version",
    "body": "Shared body",    // V2 nennt es "body", nicht "content"
    "tags": [],
    "updated_at": "...",
    "created_at": "..."
  }
}

V1-Clients sehen niemals tags (nicht in der V1-Antwortstruktur), auch wenn die Notiz Tags aus einem V2-Schreibvorgang hat.

Schema

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

Die zugrunde liegende Spalte heißt body. V1 mappt sie in der Antwort-Transformation auf content.

Implementierung — Antwort-Transformatoren

php
// V1-Transformator — mappt "body"-Spalte → "content"-Feld, versteckt 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'],   // Feldumbenennung
            'created_at' => $row['created_at'],
            // Kein "body", "tags", "updated_at"
        ];
    }
}

// V2-Transformator — vollständige Zeile, in "data" eingebettet
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'],
        ];
    }
}

Routen-Registrierung

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']);

Beide Registrars werden an RuntimeApplicationFactory übergeben — Routen von beiden werden im selben Router registriert.

Unbekannte Version → 404

php
GET /v3/notes
 404

Es gibt keine V3-Route; der Router gibt 404 zurück. Kein "Version nicht unterstützt"-Fehlertyp wird benötigt — 404 ist ausreichend.

Validierung

php
POST /v1/notes  {"content": "no title"}
 422  // title ist erforderlich

POST /v2/notes  {"body": "no title"}
 422  // title ist erforderlich

Beide Versionen erfordern title. V1 akzeptiert content als Body-Feld; V2 akzeptiert body.


Was Sie NICHT tun sollten

Anti-PatternRisiko
Verschiedene DB-Tabellen pro VersionVersionsübergreifende Lesevorgänge scheitern; Daten migrieren schlecht, wenn Versionen keinen gemeinsamen Zustand haben
Deprecation: true bei V2 zurückgebenClients können nicht unterscheiden, welche Version aktuell ist
Kein Link-Header mit NachfolgerVeraltete Clients wissen nicht, wohin sie migrieren sollen
DB-Spalte bodycontent für V1 umbenennenGesamter V2-Code muss sich ändern; Antwort-Transformator zur Umbenennung verwenden, nicht das Schema
Sunset-Datum in Tests fest codierenTests schlagen nach dem Sunset-Datum fehl; stattdessen eine zukünftige Konstante oder Konfigurationswert verwenden
V1 tags in der Antwort ausgebenV1-Clients erhalten ein Feld, das sie nicht verstehen; Strukturverträge brechen stillschweigend

Veröffentlicht unter der MIT-Lizenz.