eugene-erg / open-api
Object-oriented builder and reader for OpenAPI 3.0 and 3.1 specifications, with automatic $ref deduplication.
Requires
- php: ^8.3
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10.5
Suggests
- ext-yaml: To read YAML specifications through Serialization\YamlDecoder. Not required for writing YAML.
Provides
None
Conflicts
None
Replaces
None
README
An object-oriented builder of OpenAPI 3.0 and 3.1 specifications for PHP 8.3+.
Schemas, parameters, responses and security schemes are described by ordinary PHP objects. When one
and the same object is used in components and somewhere else as well, the build turns it into a
$ref by itself — there are no references to write by hand. The deduplication works across several
files of a specification too.
What it can do
- Type safety. Whatever can be expressed by a type or an enum is expressed by a type or an enum.
- Automatic
$refs. Objects are compared by identity rather than by value: the same$userSchemain ten places gives ten references to one schema. - Multi-file specifications.
components.yaml+paths.yaml, with references such ascomponents.yaml#/components/schemas/User. - Every security scheme:
apiKey,http basic,http bearer,oauth2(all four flows),openIdConnect. - Two dialects. 3.0 and 3.1 differ in how
nullableand the exclusive bounds are written — the package picks the right form itself, and the schema objects stay the same. - Named arguments as keys.
new Schemas(User: $user)instead of arrays of strings. formaton any type. JSON Schema declares it an optional annotation, soformat: uri-mapon an object is kept; strings and numbers have an enum of the known values.- JSON and YAML on the way out, with no external dependencies.
x-*extensions on every object where the specification admits them.- Reading finished specifications back into objects, with the references pointing at the same instances.
- An invalid specification cannot be built: PHPStan catches some of the mistakes, the constructors the rest.
Installation
composer require eugene-erg/open-api
Quick start
<?php declare(strict_types = 1); use EugeneErg\OpenApi\Builder; use EugeneErg\OpenApi\Components; use EugeneErg\OpenApi\Components\RequestBodies; use EugeneErg\OpenApi\Components\Responses; use EugeneErg\OpenApi\Components\Schemas; use EugeneErg\OpenApi\Info; use EugeneErg\OpenApi\Openapi; use EugeneErg\OpenApi\Paths; use EugeneErg\OpenApi\Serialization\YamlEncoder; $user = new Schemas\Object\Schema( properties: new Schemas\Object\Properties( id: new Schemas\Object\Property( schema: new Schemas\Integer\Schema(format: Schemas\Integer\Format::Int64), required: true, ), email: new Schemas\Object\Property( schema: new Schemas\String\Schema(format: Schemas\String\Format::Email), required: true, ), ), ); $openapi = new Openapi( info: new Info(title: 'Example API', version: '1.0.0'), components: new Components( schemas: new Schemas\Untyped\Schemas(User: $user), ), paths: new Paths(...[ '/users' => new Paths\Path( get: new Paths\Operation( responses: new Responses( x200: new Responses\Response( description: 'A list of users', content: new RequestBodies\Contents(...[ // $user already lies in components, so a $ref stands here 'application/json' => new RequestBodies\Content( schema: new Schemas\Array\Schema(items: $user), ), ]), ), ), id: 'listUsers', ), ), ]), ); // The array key is the file name. It is what cross-file $refs carry. $builder = new Builder(...['openapi.json' => $openapi]); // Option 1: get the stdClass and do as you please with it $documents = $builder->prepareToSave(); // ['openapi.json' => stdClass] // Option 2: write it to disk straight away $builder->save(__DIR__ . '/public/docs'); // public/docs/openapi.json // Option 3: serialise only, without writing $contents = $builder->encode(); // ['openapi.json' => '{ ... }']
For YAML the file name comes from the same key, and the format from the encoder:
(new Builder(...['openapi.yaml' => $openapi]))->save(__DIR__ . '/public/docs', new YamlEncoder());
The file name has to be passed as a key.
new Builder($openapi)gives the numeric key0and, as a consequence, invalid references of the form0#/components/schemas/User. The constructor rejects such a call with anInvalidArgumentException.
The output format
Builder can do both JSON and YAML. Both implementations live in the package; there are no
external dependencies:
use EugeneErg\OpenApi\Serialization\JsonEncoder; use EugeneErg\OpenApi\Serialization\YamlEncoder; $builder->save('docs'); // JSON by default $builder->save('docs', new YamlEncoder()); // YAML $builder->save('docs', new JsonEncoder(JSON_UNESCAPED_SLASHES)); // flags of your own
The file name is taken from the key passed to Builder — the encoder does not change it, so the
extension is your choice: new Builder(...['openapi.yaml' => $openapi]). If the project already
has symfony/yaml and you would rather use it, implementing Serialization\EncoderInterface is
enough.
Quotes in YAML are put conservatively: a superfluous quote is acceptable, a lost type is not. The
response code "200" stays a string, so does 3.0.3, and descriptions in any language are printed
unquoted.
Brief and verbose
By default the printing is brief: a value that equals its default is not printed — required: false
on a parameter or uniqueItems: false on an array add nothing. The verbose form writes them out:
$builder->prepareToSave(verbose: true); $builder->encode(verbose: true); $builder->save('docs', new YamlEncoder(), verbose: true);
It is the same specification, and that is how it is checked: every test document is built verbosely, read back and built briefly — and the result has to match the brief form from the start. The third-party validator is run over both variants.
Where a default is not merely a default, the verbose form stays silent:
encodingin 3.1 — an explicitstyle,explodeorallowReservedchanges how multipart is handled there, so "writing out the default" would mean changing the meaning;exclusiveMinimumandexclusiveMaximum— in 3.0 those are flags beside a bound, and without the bound a flag is inadmissible; in 3.1 they are the bound itself, and there is nothing to write out in it;nullablein 3.1 — it does not exist there at all,nullis added as a second type.
What the build checks
Some of the mistakes are caught statically: the non-negative bounds are declared as int<0, max>,
so PHPStan will not let minLength: -5 through. The rest is checked in the constructors:
- a path that does not start with a slash, or
{id}occurring twice in a template - a template that has
{id}but no matching path parameter — or the other way round - an operation without a single response (in 3.0
responsesare required, in 3.1 they may be left out) - contradictory enumerations — see "Enumerations"
- a lower bound above the upper one, a
multipleOfthat is not positive - a
discriminatorwithoutoneOf/anyOf/allOf valueandexternalValue, orexampleandexamples, given at once
Whatever the types express is simply absent from the API: a path parameter has no allowEmptyValue
and no allowReserved (those are for query only), and its required is always true.
And where exactly
What refuses is always the deepest object: it knows what it is missing but not where it lies — and the call stack shows the insides of the package rather than the document. So the place is gathered on the way up: every container adds one step of its own.
openapi.json/paths/~1users~1{id}/get/responses/200/content/application~1json/schema:
An array schema must declare "items" in OpenAPI 3.0; it is optional only since 3.1.
The pointer is written as the reader writes it — by RFC 6901 — so the path template /users/{id}
looks like ~1users~1{id} inside it. The first step is the file name: with several documents there
is no telling which of them is at fault without it. The place is separately available as
$exception->place() on any exception of the package.
An object that refused inside its constructor (inverted bounds, a then without an if) does not
lie in any document yet — there place() returns null, and the call stack shows the place: that
is a line of your code rather than a line of the specification.
Enumerations
A closed set of values is described by a schema of its own — the EnumSchema of its type:
$status = new Schemas\String\EnumSchema( new Schemas\String\Strings('success', 'failure', 'neutral'), nullable: true, format: 'check-conclusion', default: new Schemas\String\Value('neutral'), ); $kind = new Schemas\String\EnumSchema(new Schemas\String\Strings('payment_intent')); $deleted = new Schemas\Boolean\EnumSchema(true); $size = new Schemas\Untyped\EnumSchema(new Schemas\Untyped\Values('auto', 0));
Why not an enum parameter on an ordinary schema. Once the values are listed, any keyword that
checks something (minLength, pattern, minimum, items, properties, allOf, not…) either
holds for every value and changes nothing, or cuts some of them off — and then it is a mistake.
There is no third case. So EnumSchema has no such parameters: the useless cannot be written, and
nothing useful is lost. What is left are the annotations — title, description,
readOnly/writeOnly, deprecated, xml, format, default and the 3.1 identifiers. example
is gone for the same reason: on an enumeration it adds nothing.
What the package does by itself:
const. A single value is printed asconstin 3.1 and as a one-elementenumin 3.0. There is no separateconstparameter: it is the same enumeration.nullable.nullis added both to the flag (the type) and to the list itself: by 3.0.3 null is inadmissible without that. Writingnullamong the values is not needed — it is an error.- The checks. An empty list is rejected, and so are repeats (
1and1.0are one value, and the order of an object's keys does not matter), adefaultoutside the list, and values that do not fit a knownformat(date,date-time,email,uuid,ipv4,ipv6,byte,int32,float). - The type. For values of one type there is only the typed schema;
Untyped\EnumSchemaaccepts mixed sets alone.Boolean\EnumSchemaaccepts a single value: a list oftrueandfalserestricts nothing.
When an enumeration does have to be combined with a composition, that is equivalent to an allOf:
$deletedDog = new Schemas\Untyped\Schema(allOf: new Schemas\Untyped\Schemas( $dog, new Schemas\Object\EnumSchema(new Schemas\Object\Objects( new Schemas\Object\OpenapiObject(deleted: true), )), ));
Reading a finished document, an enumeration is taken apart by its meaning. example and the
keywords that do not apply to the type are dropped. The simple checks (maxLength, pattern, the
bounds, required…) are applied to every value: when all of them pass, the keyword is dropped; when
they do not, the document is rejected, because some of the values are unreachable. Everything else is
kept without a loss, through allOf. A nullable: true without a null in the list is read as
nullable — which is how it is written in practice.
Extensions
Almost every object of the specification has x-* fields. The prefix is always added, so the name is
written without it:
use EugeneErg\OpenApi\Extensions; new Schemas\String\Schema(extensions: new Extensions(internal: true));
That gives {"type": "string", "x-internal": true}. A name with a hyphen cannot be written as a
named argument, so it is passed by unpacking. The two cannot be mixed: PHP forbids unpacking after
named arguments, so it is either all named arguments or all unpacking:
new Extensions(...[ 'internal' => true, 'code-samples' => new Schemas\Array\OpenapiArray('curl …'), ]);
- The rule is one and has no exceptions, so every field of the document has exactly one spelling:
x-legacygivesx-x-legacy, which the specification allows (of the name it asks only that it start withx-). The other side of it: the prefix is never written by hand — written, it doubles. - The 3.1 specification reserved the
x-oai-andx-oas-prefixes for the OpenAPI Initiative. The package accepts them — a document with them is legal — but an extension of your own is better named otherwise. - A value is any JSON value; objects and lists are given by the same
OpenapiObjectandOpenapiArrayas the examples. - Names such as
7are passed throughExtensions::fromArray(), as in every other map.
Three objects that look like ordinary maps have extensions as well: the Paths Object, an operation's
Responses Object and the Callback Object. Their extensions are passed as the second argument of
fromArray():
Paths::fromArray( ['/users' => new Paths\Path(get: $onUserCreated)], new Extensions(section: 'users'), );
webhooks and the sections of components, on the other hand, the specification declares as
Map[string, …]: extensions there belong not to the map but to the object itself (Openapi and
Components), and an attempt to set them is rejected. An x-… name in such a map is the name of a
component rather than an extension: in GitHub's components.headers that is the name of the
x-common-marker-version header, and it is read as exactly that — a component.
Response codes
A named argument in PHP cannot start with a digit, so the HTTP codes are written with an x prefix,
which the build takes off:
new Responses( x200: new Responses\Response(description: 'OK'), x404: new Responses\Response(description: 'Not found'), x4XX: new Responses\Response(description: 'Client error'), // a wildcard works too default: $generalError, );
On an operation a response key has to be a code or default: new Responses(ok: …) will not build.
The names '7', '-1' and fromArray()
The maps (Properties, Examples, Contents, Schemas and the rest) take names as named arguments
or by unpacking an array. Unpacking has one PHP trap: the key '7' or '-1' is stored as an integer
and turns into a positional argument — the name would quietly be gone. So an item of a map without a
name is an error, and fromArray() is there for any names at all:
$reactions = Schemas\Object\Properties::fromArray([ '+1' => new Schemas\Object\Property(new Schemas\Integer\Schema()), '-1' => new Schemas\Object\Property(new Schemas\Integer\Schema()), ]); $example = Schemas\Object\OpenapiObject::fromArray(['0' => 'first', '1' => 'second']);
The lists (Strings, Tags, Servers, allOf…) reject names instead — they would be gone when
written. Schemas serves both: components.schemas, mapping and $defs require names, while
allOf, anyOf, oneOf and prefixItems require their absence.
The one place where an item of a map may be passed without a name is an operation's parameters: a
parameter registered in components.parameters takes its name from the registration and is printed
as a $ref. An unregistered nameless parameter the build rejects. A nameless path parameter has
to be registered in the same document: the template is checked against the names its own
components declare, which is all a document knows of itself.
Required fields outside properties
A described property is made required by the Property(required: true) flag. The names that
properties does not describe — their shape comes from additionalProperties, patternProperties
or a composition — are listed in the schema's own required:
// "exactly one of the two fields is needed" $target = new Schemas\Untyped\Schema(oneOf: new Schemas\Untyped\Schemas( new Schemas\Object\Schema(declareType: false, required: new Strings('id')), new Schemas\Object\Schema(declareType: false, required: new Strings('owner', 'repo')), ));
A name out of properties is rejected here (the flag is there for it), and so is a name that, with
additionalProperties: false, matches no patternProperties: such an object is impossible.
Several files
Return several documents from the build — the references between them fall into place by themselves:
$components = new Openapi(info: $info, components: new Components(schemas: $schemas)); $paths = new Openapi(info: $info, paths: $paths); (new Builder(...[ 'components.yaml' => $components, 'paths.yaml' => $paths, ]))->prepareToSave();
In paths.yaml the references take the form components.yaml#/components/schemas/User.
Documents that refer to each other have to agree on the version. "An OpenAPI Description is
composed of an entry document and any/all of its referenced documents", and it conforms to one
version of the specification, so a 3.0 document pointing into a 3.1 document would get whatever 3.1
wrote there — {"type": "null"} is a legal target and an illegal schema on the side that reads it.
The build refuses that and names both files. One Builder may still hold documents of different
versions, as long as they do not reach into each other: building the same API in both dialects is
exactly that, because each document declares its own components.
Security
use EugeneErg\OpenApi\Components\SecuritySchemes; use EugeneErg\OpenApi\Components\SecuritySchemes\Oauth2Security; use EugeneErg\OpenApi\Components\SecuritySchemes\Oauth2Security\Flows\AuthorizationCodeFlow; use EugeneErg\OpenApi\Components\SecuritySchemes\Oauth2Security\Flows\Scope; use EugeneErg\OpenApi\Components\SecuritySchemes\Oauth2Security\Flows\Scopes; use EugeneErg\OpenApi\Securities; $readPets = new Scope('Read your pets'); $writePets = new Scope('Modify your pets'); $oauth = new Oauth2Security\Scheme( flows: Oauth2Security\Flows::createAuthorizationCode( new AuthorizationCodeFlow( authorizationUrl: 'https://example.com/oauth/authorize', tokenUrl: 'https://example.com/oauth/token', // scope names with a colon cannot be passed as named arguments scopes: new Scopes(...[ 'read:pets' => $readPets, 'write:pets' => $writePets, ]), ), ), ); $openapi = new Openapi( info: $info, components: new Components( securitySchemes: new SecuritySchemes(petstoreAuth: $oauth), ), // the Scope object finds its own scheme and its own name security: new Securities(new Securities\SecuritySchemes($readPets)), );
That gives security: [{ "petstoreAuth": ["read:pets"] }].
The other schemes:
new SecuritySchemes\ApiKeySecurity\Scheme(name: 'X-Api-Key', in: SecuritySchemes\ApiKeySecurity\In::Header); new SecuritySchemes\BasicHttpSecurityScheme(); new SecuritySchemes\BearerHttpSecurityScheme(format: 'JWT'); new SecuritySchemes\HttpSecurityScheme('Digest'); // any IANA HTTP scheme except basic and bearer new SecuritySchemes\OpenIdConnectSecurityScheme(openIdConnectUrl: 'https://example.com/.well-known/openid-configuration');
The oauth2 scopes are declared in the scheme itself, so a Scope object is what is passed: its name
and its scheme come from the declaration and cannot drift apart. The specification does not require a
scope to be declared — with openIdConnect the provider publishes the list, and an oauth2 document may
require a scope that no flow has. Such a scope is named by its name: Securities\ScopeName. The
other schemes may require roles in 3.1 — Securities\Role; in 3.0 their list has to be empty, and
the build checks that.
$bearer = new SecuritySchemes\BearerHttpSecurityScheme(); $oidc = new SecuritySchemes\OpenIdConnectSecurityScheme('https://example.com/.well-known/openid-configuration'); new Securities( new Securities\SecuritySchemes(new Securities\Role($bearer, 'admin')), // 3.1 only new Securities\SecuritySchemes(new Securities\ScopeName($oidc, 'openid')), new Securities\SecuritySchemes(), // {} — anonymous access is allowed );
On an operation security: null (the default) means "as the document has it", and
new Securities() means "the operation is open", even when the document requires authorisation.
The document's version
By default 3.0.3 is built. The version is set through the Version enum:
use EugeneErg\OpenApi\Version; new Openapi(info: $info, version: Version::V310);
One and the same schema objects give a different output:
| 3.0 | 3.1 | |
|---|---|---|
new Schemas\String\Schema(nullable: true) |
{"type": "string", "nullable": true} |
{"type": ["string", "null"]} |
new Schemas\Number\Schema(minimum: 0, exclusiveMinimum: true) |
{"minimum": 0, "exclusiveMinimum": true} |
{"exclusiveMinimum": 0} |
new Schemas\String\EnumSchema(new Schemas\String\Strings('a')) |
{"type": "string", "enum": ["a"]} |
{"type": "string", "const": "a"} |
webhooks exist in 3.1 only — an attempt to set them for 3.0 makes the constructor throw an
InvalidArgumentException:
new Openapi( info: $info, version: Version::V310, webhooks: new PathItems(...[ 'userCreated' => new Paths\Path(post: $onUserCreated), ]), );
Servers
use EugeneErg\OpenApi\Components\Schemas\String\Strings; use EugeneErg\OpenApi\Servers; new Servers( new Servers\Server( url: 'https://{region}.api.example.com/{ver}', variables: new Servers\Variables( region: new Servers\Variable(default: 'eu', enum: new Strings('eu', 'us')), ver: new Servers\Variable(default: 'v1'), ), ), );
Polymorphism
use EugeneErg\OpenApi\Components\Schemas\Abstract\Discriminator; $pet = new Schemas\Untyped\Schema( oneOf: new Schemas\Untyped\Schemas($dog, $cat), discriminator: new Discriminator( propertyName: 'petType', mapping: new Schemas\Untyped\Schemas(dog: $dog, cat: $cat), ), );
The schemas out of mapping have to be registered in components.schemas, or the build fails with
ComponentsNotFoundOpenapiException.
Exceptions
Every exception of the package implements EugeneErg\OpenApi\Exceptions\OpenapiExceptionInterface:
| Exception | When |
|---|---|
ScopeNotFoundOpenapiException |
a Scope is declared in no oauth2 flow of the document |
SecuritySchemeNotFoundOpenapiException |
the scheme is not registered in components.securitySchemes |
OperationNotFoundOpenapiException |
a Link refers to an operation that is not in paths |
ComponentsNotFoundOpenapiException |
a $ref is needed, but the object lies in no components |
InvalidPathOpenapiException |
a path template disagrees with the path parameters |
InvalidSchemaOpenapiException |
a schema's bounds or keywords disagree with each other |
InvalidArgumentOpenapiException |
the remaining violations of the specification's invariants |
Reading a finished specification
The task the other way round: take an existing document apart into objects, change it and write it back.
use EugeneErg\OpenApi\Reader; use EugeneErg\OpenApi\Serialization\YamlDecoder; $openapi = Reader::read(file_get_contents('openapi.json')); // several files go in by key, as in Builder: the keys are what resolve the cross-file references $documents = Reader::readAll([ 'components.yaml' => file_get_contents('components.yaml'), 'paths.yaml' => file_get_contents('paths.yaml'), ], new YamlDecoder());
The property that matters most: one and the same reference gives one and the same object. A
schema that a $ref points at from three places will be one instance — and writing it back turns
that into three references again rather than three copies. Recursive schemas are restored as a real
cycle, through DeferredSchema.
A document is read by its meaning rather than word for word. Whatever affects nothing is dropped, and the equivalent forms are brought to one:
- the values that equal their default, and the empty lists and maps;
- an
examplethat does not fit the type of its schema; encodingon content other than forms and multipart;- the siblings of a
$refin 3.0 (they do not apply there). In 3.1$refis an ordinary keyword, and{$ref, description}is read as{allOf: [$ref], description}; a schema of a singleallOfbecomes that member itself again when written; - what enumerations do — see "Enumerations".
A component that merely refers to another one (UserBase: {$ref: UserCompact}) stays an object of its
own: the references to UserBase will not wander off to UserCompact. A null in example,
default and value is kept — that is a value, not the absence of one.
A document that contradicts itself is rejected with the place named: a requirement for an oauth2 scope that no flow of the scheme declares, say.
Strict reading
Everything listed above the package drops knowing why. But a document may also carry a field the specification does not define at all — and that one cannot be dropped in silence: a document is read in order to be changed and written back, and such a field would disappear without a trace. So reading is strict by default and names every such place:
Reader::read($content); // names what it did not understand and refuses to read Reader::read($content, strict: false); // reads, dropping what it did not understand
The specification does not define these fields: openapi.json/info/slogan,
openapi.json/components/securitySchemes/bearer/in. Pass strict: false to read the document without them.
The second place here comes from the real Immich document: NestJS writes in and name on a scheme
of type http, while the "Applies To" table in the specification declares them for apiKey only.
The document does not break because of it, and strict: false reads it whole — but now it is known,
rather than "somehow gone by itself".
What was read and dropped does not count as misunderstood in strict mode: uniqueItems on a string,
the siblings of a $ref in 3.0, encoding on JSON, an example beside an enumeration — all of that
the package took apart and knows that it changes nothing.
Parsing JSON is built in. YAML needs ext-yaml, or a dozen lines of your own over
DecoderInterface. YamlDecoder reads by the YAML 1.2 core schema, as OpenAPI requires: by default
ext-yaml follows YAML 1.1 and turns no and on into booleans, and 12:30 and 1,5 into numbers.
One limitation: 1e3 and 0o17 ext-yaml hands over as a string before the parsing even starts, so
they are better avoided in the numeric keywords.
A decoder of your own over another parser:
use EugeneErg\OpenApi\Serialization\DecoderInterface; use EugeneErg\OpenApi\Serialization\Structure; use Symfony\Component\Yaml\Yaml; final readonly class SymfonyYamlDecoder implements DecoderInterface { public function decode(string $content): \stdClass { return Structure::toObject(Yaml::parse($content)); } }
How it works
Builder keeps the documents and knows their file names. All the work with references is done by
Process — the context in which one document is built. Every toObject() is handed a Process and
asks it: is this object already sitting in somebody's components? If it is, a $ref stands in its
place — local, or carrying the name of a neighbouring file; if it is not, the object is written out
where it is used. The document's version is available through Process as well, which is how the
schemas can print themselves in both dialects.
How to read the class names
Every namespace of a type holds four things, and their endings tell them apart:
| Name | What it is | Example |
|---|---|---|
Schema, EnumSchema |
a schema | String\Schema, String\EnumSchema |
Schemas |
a set of schemas — allOf, $defs, components.schemas |
Untyped\Schemas |
Value |
one value: default, example |
String\Value('a') |
| the plural of the type | a set of values: the members of an enumeration | String\Strings, Integer\Integers, Object\Objects |
That is, Schemas is about schemas, while Strings, Integers, Numbers, Objects and Arrays
are about values. Values of mixed types have no plural of their own, so they are called
Untyped\Values.
Object\OpenapiObject and Array\OpenapiArray stand apart — they are the JSON literals: the object
and the list that values and extensions are made of. The prefix is not there out of spite: Object
and Array are reserved words in PHP, and a class cannot be named that.
The schema reader has three neighbours to help it: ValueReader takes values apart, EnumReader the
enumerations, and Keywords knows which type of value a keyword applies to.
JSON Schema 2020-12
In 3.1 a schema is a full JSON Schema. The whole vocabulary is supported: const,
if/then/else, prefixItems, contains, patternProperties, propertyNames,
dependentRequired, dependentSchemas, unevaluated*, content*, $defs, $id, $anchor,
$vocabulary, $schema.
A schema as a resource
The words of the core vocabulary — $id, $schema, $vocabulary, $anchor, $dynamicAnchor,
$dynamicRef, $defs, $comment — are passed as one Resource object:
new Schemas\Object\Schema(resource: new Schemas\Abstract\Resource( id: 'https://example.com/schemas/node', schema: 'https://json-schema.org/draft/2020-12/schema', ));
They are gathered together not because they are rarely needed but because the specification declares
them together: these are words not about the value but about the schema itself — how to address it
and by which rules to read it. Hence the shared rules, which Resource now checks itself: a dialect
and a vocabulary are admissible at the root of a resource only, that is, beside an $id, and in 3.0
the whole group is absent.
Addressing stays by objects — raw reference strings are not to be written anywhere. A schema that
lies in $defs is referred to just as an ordinary component is:
$street = new Schemas\String\Schema(minLength: 1); $user = new Schemas\Object\Schema( properties: new Schemas\Object\Properties( road: new Schemas\Object\Property(schema: $street), ), resource: new Schemas\Abstract\Resource(defs: new Schemas\Untyped\Schemas(Street: $street)), ); // road => {"$ref": "#/components/schemas/User/$defs/Street"}
$dynamicRef takes an object as well rather than a string: the builder takes its $dynamicAnchor
itself.
$node = new Schemas\Object\Schema(resource: new Schemas\Abstract\Resource(dynamicAnchor: 'node')); new Schemas\Untyped\Schema(resource: new Schemas\Abstract\Resource(dynamicRef: $node)); // => {"$dynamicRef": "#node"}
A dynamic anchor is found by name rather than by pointer, and that has two consequences. A target in
a neighbouring file is written with that file — other.yaml#node — and a target that no document
declares in components.schemas is refused at build time: written out in place, it has no name, and
the reference would lead nowhere.
Several types and the null type
In 3.1 type may be an array. A null in it is nullable, and it is read as before:
new Schemas\String\Schema(nullable: true); // 3.1: {"type": ["string", "null"]}
A schema that has no other types at all is {"type": "null"}: a single value is admissible. It has a
schema of its own, and in 3.0, where there is no such type, the build rejects it:
new Schemas\Null\Schema(description: 'Always null.');
A union of several types the package describes by the type of every variant, so
{"type": ["string", "integer"]} is an anyOf of two schemas:
new Schemas\Untyped\Schema(anyOf: new Schemas\Untyped\Schemas( new Schemas\String\Schema(maxLength: 5), new Schemas\Integer\Schema(minimum: 0), ));
Reading the compact form, the package lays it out the same way: the keywords that apply to one of the
listed types go into its branch, and the common ones stay outside, through allOf.
A check without a declared type
{"minLength": 3} does not mean "a string longer than two": a value of any other type fits such a
schema, because a check on strings does not apply to it. So a check may be written without declaring
a type — declareType: false on the schema of the type in question:
new Schemas\String\Schema(minLength: 3, declareType: false); // {"minLength": 3} new Schemas\Object\Schema(properties: $properties, declareType: false);
The other way round: with a type declared, a keyword about values of another type means nothing —
uniqueItems on a string always holds, because a string is not an array. Such a keyword the package
does not print (see "What the build checks", the third principle), and reading drops it:
kube-openapi writes uniqueItems: true on every query parameter, and the Kubernetes document has
550 such places.
References to operations, and pagination
A Link points at an operation by the object rather than by a name: an operation that does not exist
cannot be referred to that way. But an operation that refers to itself — the "next page" — cannot be
passed to its own constructor, so the reference is deferred by a closure, as with recursive schemas:
$listUsers = new Paths\Operation( responses: new Responses(x200: new Responses\Response( description: 'A page of users', links: new Links(next: new Link( operation: new DeferredOperation(static function () use (&$listUsers): Paths\Operation { return $listUsers; }), parameters: new Link\Parameters(page: Link\Parameter::expression('$response.body#/next')), )), )), id: 'listUsers', );
An operation that has an operationId is printed by it, and one without by a pointer to its place
(operationRef: "#/paths/~1users/get"). A Path Item Object lives in four places — paths,
webhooks, components.pathItems and a Callback Object — and an operation in any of them is a
target a Link may name: operationId "MUST be resolved within the scope of the OpenAPI
Description", not within paths. When the target operation is in none of the documents handed to
Builder, the build rejects that — including when the link names it by operationId, where the
written document itself says nothing about where that operation is.
References with a description of their own
Usually there is no reference to make: pass the object, and the builder puts the $ref in itself. A
Reference Object is needed only where the reference has to override the component's description (in
3.1 it has a summary and a description of its own):
use EugeneErg\OpenApi\Reference; new Responses( x404: new Reference($notFound, description: 'The user was not found.'), x500: $notFound, // without an override, just the object );
That gives {"$ref": "#/components/responses/NotFound", "description": "..."} and a bare $ref
respectively. The target is given by the same object, so there is still one way to refer to something
in this package.
A request body, a parameter and a callback are referred to the same way:
new Paths\Operation( responses: $responses, requestBody: new Reference($upload, description: 'The user to create.'), callbacks: new Components\Callbacks(onCreated: $onEvent), parameters: new Parameters\Parameters( // the parameter's name comes from the component's declaration, so it is not passed here queries: new Parameters\Query\Queries(new Reference($pageQuery, description: 'A description of its own.')), ), );
An operation's list of parameters is checked for repeats: the specification counts a parameter unique
by the pair "name + location", and a second page in query is rejected — even when the first is
passed by name and the second as a reference.
Limitations
- By the specification a
Security Requirement Objectrefers to the schemes of its own document, so the scopes are not looked up in the neighbouring files. That is a limitation of the specification rather than of the package. - The invariants of the specification are checked rather than the meaning of the document: the
package will catch an
{id}without a parameter or inverted bounds, but it will not tell you that a schema describes something other than what the server returns.
Development
composer install
composer check # php-cs-fixer + phpstan (the maximum level) + phpunit
CI runs the same on PHP 8.3 and 8.4 with ext-yaml — plus composer real-world and
composer validate-output as a separate job: those reach for the network and for Node.js, and their
failure should not look like a failure of the tests. PHP 8.5 is run as well, as a job that does not
gate the build: composer.json allows ^8.3, so the runtime has to be tried somewhere, but a
deprecation raised inside php-cs-fixer or PHPStan on a runtime newer than they support is not a
defect of this package.
composer coverage prints which lines the suite never executed (it needs pcov or Xdebug; the test
jobs deliberately run without a driver). The number itself is not a target — the point is to look at
the untouched lines.
The tests are arranged as pairs of files: tests/Cases/<Test>/Objects/<name>.php returns the
objects, and tests/Cases/<Test>/Jsons/<name>.json the expected result. New cases are picked up
automatically; there is no need to register them in the test.
ReadmeTest separately executes every PHP example in this file, so the documentation cannot drift
away from the code unnoticed.
RoundTripPropertyTest does the same round trips on documents nobody wrote: RandomDocument
combines the objects of the package at random — one file and several, both versions, JSON and YAML,
brief and verbose — and the properties are stated as laws rather than as expected texts. Building
has to be idempotent: what is read from a built document and built again has to come out the same
text. The generator carries its own xorshift, so a failure names a seed that reproduces it.
Four defects came out of it: a Link naming an operation that lives in webhooks, a $dynamicRef
whose anchor is in a neighbouring file, an enumeration of strings that lost its content annotations,
and a reference to a whole components section that nothing could read back.
One property is about documents nobody could write on purpose: reading any text at all either
succeeds or is refused with an exception of the package. A TypeError, a warning or a walk that
never ends would mean the reader met a shape it does not describe. The documents are the generated
ones, mutated — values replaced by values of another kind, keys dropped, subtrees wrapped, keys
renamed to the ones the reader treats specially. That is how the hang on a recursive YAML anchor
was found.
composer coverage says what the suite never executed — 98% of the lines at the time of writing.
The point is the list rather than the number: it is how the unreachable $type parameter of the
value reader, an unused method of the registry and the untested refusals of the deferred schema were
found.
RealWorldTest runs the full read → write round trip on real specifications
(tests/Cases/RealWorldTest; the sources are in SOURCES.md) and compares the meaning rather than
the text. Everything that counts as an insignificant difference is listed in one place —
tests/Support/SemanticDiff.php.
The large public specifications are too big for the repository, and a separate command checks those:
composer real-world # seventeen documents composer real-world -- discord # a selection
Besides the large public APIs (GitHub in 3.0 and 3.1, Stripe, Twilio, Asana, Discord) it deliberately gathers documents from different generators: FastAPI (Airflow), NestJS (Immich), drf-spectacular (authentik), go-swagger (Ory Kratos), kube-openapi (Kubernetes), .NET (Sonarr), Go (Hyperledger FireFly), Spring Boot (Camunda 8), openapi-extractor for PHP (Nextcloud), the Elasticsearch specification compiler and Redocly's example. Each has habits of its own, and almost every one of them brought a bug along.
Two documents on that list the specification does not admit, and the package rejects them: Sonarr has a path parameter that the path template does not carry, Kratos a security requirement with an undeclared scheme. They cannot be read (the package's objects do not express such a thing), and quietly fixing them would be a lie, so what is checked is the refusal itself and its wording: the wording is what whoever brings such a document gets.
The report separately counts the places where the package chose an equivalent form over the original
(a union of types becomes an anyOf, an enumeration beside an applicator becomes an allOf).
Repeating that layout inside the check is out of the question — it would compare the code with itself
— so an independent condition is checked there: that not a single value was lost.
The read → write round trip proves the document was not lost, but not that it is valid: the package
could read and write in the same wrong way. That is exactly how a parameter with content lived for
a while — the content type was printed as a mimeType field instead of a key inside content, and
the reader read that mistake back. So the output is checked by a third-party validator:
composer validate-output # Node.js is needed; Redocly is downloaded through npx
The command builds every BuilderTest case in JSON and in YAML — the YAML encoder is our own,
and its mistakes are invisible in JSON (that is how the empty maps inside a sequence were found) —
and runs Redocly's structural rules. Among the cases are kitchen-sink-30 and kitchen-sink-31: one
document per version, holding every object of the specification.
The places where the validator itself departs from the specification are listed in KNOWN_DEFECTS
inside tools/validate-output.php — every one of them checked against the text of the specification:
- Redocly expects a string from
$vocabulary, while JSON Schema 2020-12 declares it an object of URI => bool. - Spectral (
oas3-schema) carries the OAS 3.1.0 schema, which has no$refon a Path Item (the 3.1.1 text does have it) and has a typo in the Link Object,bodyinstead ofserver.
A check that fails on somebody else's mistakes is worse than no check at all, so such a list is kept explicitly and with an explanation rather than silenced wholesale.
Licence
MIT. See LICENSE.