Published on
An OpenAPI description is a claim about an API. It says which fields exist, what types they have and how long a text may be. Nothing says the implementation keeps to it, and often it does not.
That is a problem for a generator. It knows only the description and builds code from it that has to run against the real API. This post is about where that went wrong in my PHP client generator, and how the generated code deals with it today.

An example
A shipping API calculates prices. The request carries a service code, for example EX or
ST. The codes have two characters. That is how the provider documents them, and that is
how the API accepts them.
The OpenAPI description of the same API says the code must have at least three characters, though:
serviceCode:
type: string
minLength: 3
A client that checks this rejects EX before it sends anything at all:
[serviceCode]: This value is too short. It should have 3 characters or more.
But there is no code with three characters. So the client rejects every value that is actually valid, and not a single request goes out.
This is not a bug in the generator and not one in your code. The provider's description and the provider's implementation do not match. The generator built exactly what the description says.
It is not a one-off
The same thing comes in other shapes, and I found every one of them in public descriptions:
- A weight has a maximum of
1according to the description. Real parcels weigh more. - A response field is one character longer than allowed.
- A field comes back as
nullwhere a value was promised. - The API returns an enum value the description does not know yet.
- The response carries a field the description explicitly rules out.
- The description names a way to sign in and requires it at no endpoint. Without it the API still answers 401.
You cannot fix any of this. The description belongs to the provider, and until they correct it you still have parcels to ship.
With ValidationMode::Strict nothing worked
My first idea was the obvious one: the description names lengths, patterns and ranges, so
the client checks them. The request before sending, the response after receiving. Today
that is called ValidationMode::Strict.
That works with clean descriptions. It does not work with real ones, as shown above. You cannot generate strict code that checks for three characters for an API like that. It would be correct by the description and useless against the API.
So the check can be turned off today, and it is off unless you ask for it.
ValidationMode::Lax is the default
In Lax mode the client holds nothing to the constraints of the description: no lengths,
no patterns, no ranges, no formats. A request goes out the way you built it. A response is
read as far as its model can hold it.
What the PHP types say still holds. A property has its declared type, a required property is there, and a request can only name enum values that exist.
If an API does keep to its description, you can turn on Strict mode:
use Acme\Shipping\Http\ValidationMode;
$config = $config->withValidation(ValidationMode::Strict);
That is also handy when it is your own API. Then you see whether everything really matches the description.
Both directions are checked:
| Case | Lax |
Strict |
|---|---|---|
| Request: text too long, pattern not met | sent as it is | ValidationException |
| Response: text too long, pattern not met | read as it is | ResponseValidationException |
Response: null in an optional property |
read as absent | ResponseValidationException |
| Response: a field that is ruled out | read past | ResponseValidationException |
| Response: an enum value nobody knows | read as UnknownEnumValue |
ResponseValidationException |
| Response: a value the model cannot hold | MalformedResponseException |
ResponseValidationException |
The last row is an error in both modes. A wrong type or a missing required property has nowhere to go in a typed model.
One thing matters with Strict. A ValidationException means nothing was sent. A
ResponseValidationException means the request arrived. The shipment may already exist,
the order may already be placed. So do not simply send it again; read the raw response
first. The exception hands it to you through getRawResponse().
New enum values
A provider introduces a new status before you have regenerated your package. A PHP enum
does not know the value, and Status::from() throws.
That is why every enum property of a model is typed Status|UnknownEnumValue. The unknown
value is kept, and ->value reads it on either. A match then needs a default arm:
$text = match ($shipment->getStatus()) {
ShipmentStatus::InTransit => 'in transit',
ShipmentStatus::Delivered => 'delivered',
default => 'unknown: ' . $shipment->getStatus()->value,
};
That is on purpose. The default arm is where your code decides what a value it has never
seen means. The names in the example are made up; in a generated package they are the ones
from the description.
Option instead of null
An optional field has three states: it is absent, it is null, or it has a value. A
?string can hold the last two and loses the first.
That matters. If you read an object, change one field and send it back, a field the server
never mentioned should not go back as null. Some APIs read a null as "please delete".
So optional properties are an Option. Required ones stay a plain type.
$value->isDefined(); // was the field there at all?
$value->get(); // the value, RuntimeException if it is absent
$value->getOrElse($other); // the value, or $other if it is absent
There are no other methods. toArray() writes back only what was actually set.
Sending is the one exception. Fields only the server sets, an id for example, are marked
readOnly in the description. toArray() has them, a request leaves them out.
When the description itself is wrong
So far this was about APIs that do not keep to their description. Often the description already breaks a rule of OpenAPI: a number in quotes, a reference that leads nowhere, a placeholder in a path without a parameter. I test the generator with 215 public descriptions, and 69 of them have at least one such place.
The generator does not refuse a document like that. Where there is only one way to read the place, it reads it that way. Otherwise it leaves the place out and generates the rest. Either way it tells you where the place is and what it costs: with an error something is missing from the package, with a warning nothing is.
What else the generated code brings
Clean code was the goal from the start:
declare(strict_types=1)in every file, types on every parameter and return valuefinal readonlyclasses, built and changed through a builder instead of setters- generics in PHPDoc where PHP itself is not precise enough
- checked with PHPStan at the highest level and with Psalm at the strictest
Strictly typed and lenient about constraints sounds like a contradiction. They are two different things. A type says what your code can do with a value. A constraint says what the provider claims about their API. You can rely on the first and not on the second.
If you want to try it: the PHP REST client generator takes an OpenAPI description and gives you a finished Composer package back.