Veröffentlicht am
Eine OpenAPI-Beschreibung ist eine Behauptung über eine API. Sie sagt, welche Felder es gibt, welche Typen sie haben und wie lang ein Text sein darf. Ob die Implementierung sich daran hält, steht nirgends, und oft tut sie es nicht.
Für einen Generator ist das ein Problem. Er kennt nur die Beschreibung und baut daraus Code, der gegen die echte API laufen muss. Hier steht, wo das bei meinem PHP-Client-Generator gehakt hat und wie der erzeugte Code heute damit umgeht.

Ein Beispiel
Eine Versand-API berechnet Preise. Im Request steht ein Service-Code, zum Beispiel EX
oder ST. Die Codes haben zwei Zeichen. So stehen sie in der Dokumentation des Anbieters,
und so nimmt die API sie an.
In der OpenAPI-Beschreibung derselben API steht aber, dass der Code mindestens drei Zeichen haben muss:
serviceCode:
type: string
minLength: 3
Ein Client, der das prüft, lehnt EX ab, bevor er überhaupt etwas sendet:
[serviceCode]: This value is too short. It should have 3 characters or more.
Einen Code mit drei Zeichen gibt es aber nicht. Der Client lehnt also jeden tatsächlich gültigen Wert ab, und es geht kein einziger Request raus.
Das ist kein Fehler im Generator und keiner in deinem Code. Beschreibung und Implementierung passen beim Anbieter nicht zusammen. Der Generator hat genau das gebaut, was in der Beschreibung steht.
Das ist kein Einzelfall
Dasselbe gibt es in anderer Form, und alles davon habe ich in öffentlichen Beschreibungen gefunden:
- Ein Gewicht hat laut Beschreibung das Maximum
1. Echte Pakete wiegen mehr. - Ein Feld in der Antwort ist ein Zeichen länger als erlaubt.
- Ein Feld kommt als
nullzurück, obwohl dort ein Wert versprochen ist. - Die API liefert einen Enum-Wert, den die Beschreibung noch nicht kennt.
- Die Antwort enthält ein Feld, das die Beschreibung ausdrücklich ausschließt.
- Die Beschreibung nennt eine Anmeldung, verlangt sie aber an keinem Endpunkt. Ohne Anmeldung antwortet die API trotzdem mit 401.
Ändern kannst du daran nichts. Die Beschreibung gehört dem Anbieter, und bis er sie korrigiert, musst du trotzdem Pakete verschicken.
Mit ValidationMode::Strict ging nichts mehr
Mein erster Gedanke war naheliegend: Die Beschreibung nennt Längen, Muster und
Wertebereiche, also prüft der Client sie. Vor dem Senden den Request, nach dem Empfangen
die Antwort. Heute heißt das ValidationMode::Strict.
Mit sauberen Beschreibungen funktioniert das. Mit echten nicht, siehe oben. Strengen Code, der auf drei Zeichen prüft, kannst du für so eine API nicht erzeugen. Er wäre korrekt nach der Beschreibung und nutzlos gegen die API.
Deshalb ist die Prüfung heute abschaltbar, und sie ist von Haus aus aus.
ValidationMode::Lax ist der Standard
Im Modus Lax hält der Client nichts an die Constraints der Beschreibung: keine Längen,
keine Muster, keine Wertebereiche, keine Formate. Ein Request geht so raus, wie du ihn
gebaut hast. Eine Antwort wird gelesen, soweit das Modell sie halten kann.
Was die PHP-Typen sagen, gilt trotzdem. Ein Feld hat seinen deklarierten Typ, ein Pflichtfeld ist da, und ein Request kann nur Enum-Werte nennen, die es gibt.
Hält sich eine API an ihre Beschreibung, kannst du den Modus Strict aktivieren:
use Acme\Shipping\Http\ValidationMode;
$config = $config->withValidation(ValidationMode::Strict);
Das ist auch praktisch, wenn es deine eigene API ist. Dann siehst du, ob wirklich alles zur Beschreibung passt.
Geprüft wird in beide Richtungen:
| Fall | Lax |
Strict |
|---|---|---|
| Request: Text zu lang, Muster verletzt | wird gesendet | ValidationException |
| Antwort: Text zu lang, Muster verletzt | wird gelesen | ResponseValidationException |
Antwort: null in einem optionalen Feld |
gilt als nicht vorhanden | ResponseValidationException |
| Antwort: Feld, das ausgeschlossen ist | wird überlesen | ResponseValidationException |
| Antwort: Enum-Wert, den niemand kennt | wird als UnknownEnumValue gelesen |
ResponseValidationException |
| Antwort: Wert passt nicht ins Modell | MalformedResponseException |
ResponseValidationException |
Die letzte Zeile ist in beiden Modi ein Fehler. Ein falscher Typ oder ein fehlendes Pflichtfeld hat in einem typisierten Modell keinen Platz.
Eine Sache ist bei Strict wichtig. Eine ValidationException heißt: Es wurde nichts
gesendet. Eine ResponseValidationException heißt: Der Request ist angekommen. Die
Sendung ist vielleicht schon angelegt, die Bestellung schon aufgegeben. Schick ihn also
nicht einfach noch einmal, sondern lies erst die rohe Antwort. Die Exception gibt sie dir
über getRawResponse().
Neue Enum-Werte
Ein Anbieter führt einen neuen Status ein, bevor du dein Paket neu erzeugt hast. Ein Enum
in PHP kennt den Wert dann nicht, und Status::from() wirft.
Darum ist jedes Enum-Feld eines Modells als Status|UnknownEnumValue typisiert. Der
unbekannte Wert bleibt erhalten, und ->value liest ihn auf beiden. Ein match braucht
dann einen default-Zweig:
$text = match ($shipment->getStatus()) {
ShipmentStatus::InTransit => 'unterwegs',
ShipmentStatus::Delivered => 'zugestellt',
default => 'unbekannt: ' . $shipment->getStatus()->value,
};
Das ist Absicht. Der default-Zweig ist die Stelle, an der dein Code entscheidet, was ein
Wert bedeutet, den er noch nie gesehen hat. Die Namen im Beispiel sind ausgedacht, im
erzeugten Paket heißen sie wie in der Beschreibung.
Option statt null
Ein optionales Feld hat drei Zustände: Es fehlt, es ist null, oder es hat einen Wert. Ein
?string kann die letzten beiden halten und verliert den ersten.
Das ist nicht egal. Liest du ein Objekt, änderst ein Feld und schickst es zurück, dann
soll ein Feld, das der Server nie erwähnt hat, auch nicht als null zurückgehen. Manche
APIs lesen ein null als „bitte löschen“.
Optionale Felder sind deshalb ein Option. Pflichtfelder bleiben ein normaler Typ.
$value->isDefined(); // war das Feld überhaupt da?
$value->get(); // der Wert, RuntimeException wenn er fehlt
$value->getOrElse($other); // der Wert, oder $other wenn er fehlt
Mehr Methoden gibt es nicht. toArray() schreibt nur zurück, was wirklich gesetzt war.
Eine Ausnahme gibt es beim Senden. Felder, die nur der Server setzt, etwa eine id, sind
in der Beschreibung als readOnly markiert. In toArray() stehen sie drin, in einen
Request gehen sie nicht mit.
Wenn schon die Beschreibung nicht stimmt
Bisher ging es um APIs, die von ihrer Beschreibung abweichen. Oft bricht aber schon die Beschreibung eine Regel von OpenAPI: eine Zahl in Anführungszeichen, ein Verweis ins Leere, ein Platzhalter im Pfad ohne Parameter. Ich teste den Generator mit 215 öffentlichen Beschreibungen, und 69 davon haben mindestens eine solche Stelle.
Der Generator lehnt so ein Dokument nicht ab. Gibt es nur eine Lesart, liest er die Stelle so. Sonst lässt er sie weg und erzeugt den Rest. In beiden Fällen sagt er dir, wo die Stelle ist und was sie kostet: Bei einem Fehler fehlt im Paket etwas, bei einer Warnung nicht.
Was der erzeugte Code sonst mitbringt
Sauberer Code war von Anfang an das Ziel:
declare(strict_types=1)in jeder Datei, Typen an jedem Parameter und Rückgabewertfinal readonlyKlassen, gebaut und geändert über einen Builder statt über Setter- Generics im PHPDoc, wo PHP selbst nicht genau genug ist
- geprüft mit PHPStan auf der höchsten Stufe und mit Psalm auf der strengsten
Streng typisiert und nachsichtig bei den Constraints klingt nach einem Widerspruch. Es sind aber zwei verschiedene Dinge. Ein Typ sagt, was dein Code mit einem Wert machen kann. Ein Constraint sagt, was der Anbieter über seine API behauptet. Auf das erste kannst du dich verlassen, auf das zweite nicht.
Wenn du es ausprobieren willst: Der PHP-REST-Client-Generator nimmt eine OpenAPI-Beschreibung und gibt dir ein fertiges Composer-Paket zurück.