From d24e43897bbb7072518c397b8ac7962ddf92ef42 Mon Sep 17 00:00:00 2001 From: Vizzent Date: Sat, 7 Nov 2026 11:43:35 +0100 Subject: [PATCH 1/2] Support boolean schemas and missing OpenAPI 3.1 fields Reading a valid 3.1 document with a boolean schema, such as "items: false", failed with a TypeErrorException. Keep these values as booleans, also when they are the target of a reference. Add jsonSchemaDialect, Info::$summary, License::$identifier, Components::$pathItems and the mutualTLS security scheme type. Read webhooks as a map, so that empty webhooks are written as an object. Validate the new tests/spec/data/openapi-3.1.yaml with the CLI too. Co-Authored-By: Claude Opus 5.5 --- Makefile | 4 +- src/ReferenceContext.php | 6 + src/SpecBaseObject.php | 6 + src/spec/Components.php | 4 +- src/spec/Header.php | 2 +- src/spec/Info.php | 2 + src/spec/License.php | 5 + src/spec/MediaType.php | 10 +- src/spec/OpenApi.php | 6 +- src/spec/Parameter.php | 4 +- src/spec/Reference.php | 5 + src/spec/Schema.php | 14 +- src/spec/SecurityScheme.php | 3 +- tests/spec/OpenApi31Test.php | 131 +++++++++++++++++++ tests/spec/SchemaTest.php | 1 - tests/spec/data/openapi-3.1-definitions.yaml | 3 + tests/spec/data/openapi-3.1.yaml | 44 +++++++ 17 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 tests/spec/OpenApi31Test.php create mode 100644 tests/spec/data/openapi-3.1-definitions.yaml create mode 100644 tests/spec/data/openapi-3.1.yaml diff --git a/Makefile b/Makefile index 6295bbaf..673fbd72 100755 --- a/Makefile +++ b/Makefile @@ -41,12 +41,12 @@ fix-style: php-cs-fixer.phar install: composer.lock $(DOCKER_PHP) composer install --prefer-dist --no-interaction --no-progress --ansi -test: unit test-recursion.json test-recursion2.yaml test-recursion3_index.yaml test-empty-maps.json test-const.json +test: unit test-recursion.json test-recursion2.yaml test-recursion3_index.yaml test-empty-maps.json test-const.json test-openapi-3.1.yaml unit: $(DOCKER_PHP) php $(PHPARGS) $(XPHPARGS) vendor/bin/phpunit --colors=always $(TESTCASE) -test-debug: unit-debug test-recursion.json test-recursion2.yaml test-recursion3_index.yaml test-empty-maps.json test-const.json +test-debug: unit-debug test-recursion.json test-recursion2.yaml test-recursion3_index.yaml test-empty-maps.json test-const.json test-openapi-3.1.yaml unit-debug: $(DOCKER_PHP) php $(PHPARGS) $(XPHPARGS) vendor/bin/phpunit --debug --testdox --colors=always $(TESTCASE) diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 9b993d1c..76fe8500 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -11,6 +11,7 @@ use cebe\openapi\exceptions\UnresolvableReferenceException; use cebe\openapi\json\JsonPointer; use cebe\openapi\spec\Reference; +use cebe\openapi\spec\Schema; use Symfony\Component\Yaml\Yaml; /** @@ -317,6 +318,11 @@ public function resolveReferenceData($uri, JsonPointer $pointer, $data, $toType) return null; } + // OpenAPI 3.1: a Schema Object can also be a boolean + if ($toType === Schema::class && is_bool($referencedData)) { + return $referencedData; + } + // transitive reference if (isset($referencedData['$ref'])) { return new Reference($referencedData, $toType); diff --git a/src/SpecBaseObject.php b/src/SpecBaseObject.php index 7a41828f..fe6eece7 100644 --- a/src/SpecBaseObject.php +++ b/src/SpecBaseObject.php @@ -12,6 +12,7 @@ use cebe\openapi\json\JsonPointer; use cebe\openapi\json\JsonReference; use cebe\openapi\spec\Reference; +use cebe\openapi\spec\Schema; use cebe\openapi\spec\Type; /** @@ -153,6 +154,11 @@ protected function instantiate($type, $data) return $data; } + // OpenAPI 3.1: a Schema Object can also be a boolean, which is kept as it is. + if ($type === Schema::class && is_bool($data)) { + return $data; + } + if (is_array($data) && isset($data['$ref'])) { return new Reference($data, $type); } diff --git a/src/spec/Components.php b/src/spec/Components.php index c7e7dc1e..3a4d481d 100644 --- a/src/spec/Components.php +++ b/src/spec/Components.php @@ -17,7 +17,7 @@ * * @link https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#componentsObject * - * @property Schema[]|Reference[] $schemas + * @property (Schema|Reference|bool)[] $schemas * @property Response[]|Reference[] $responses * @property Parameter[]|Reference[] $parameters * @property Example[]|Reference[] $examples @@ -26,6 +26,7 @@ * @property SecurityScheme[]|Reference[] $securitySchemes * @property Link[]|Reference[] $links * @property Callback[]|Reference[] $callbacks + * @property PathItem[]|Reference[] $pathItems * * */ @@ -46,6 +47,7 @@ protected function attributes(): array 'securitySchemes' => [Type::STRING, SecurityScheme::class], 'links' => [Type::STRING, Link::class], 'callbacks' => [Type::STRING, Callback::class], + 'pathItems' => [Type::STRING, PathItem::class], ]; } diff --git a/src/spec/Header.php b/src/spec/Header.php index 3668454d..2d410486 100644 --- a/src/spec/Header.php +++ b/src/spec/Header.php @@ -29,7 +29,7 @@ public function performValidation() if (!empty($this->in)) { $this->addError("'in' must not be specified in Header Object."); } - if (!empty($this->content) && !empty($this->schema)) { + if (!empty($this->content) && $this->schema !== null) { $this->addError("A Header Object MUST contain either a schema property, or a content property, but not both. "); } } diff --git a/src/spec/Info.php b/src/spec/Info.php index 3705e5a6..9f3939db 100644 --- a/src/spec/Info.php +++ b/src/spec/Info.php @@ -17,6 +17,7 @@ * @link https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#infoObject * * @property string $title + * @property string $summary * @property string $description * @property string $termsOfService * @property Contact|null $contact @@ -33,6 +34,7 @@ protected function attributes(): array { return [ 'title' => Type::STRING, + 'summary' => Type::STRING, 'description' => Type::STRING, 'termsOfService' => Type::STRING, 'contact' => Contact::class, diff --git a/src/spec/License.php b/src/spec/License.php index 0796f6b6..ae61891e 100644 --- a/src/spec/License.php +++ b/src/spec/License.php @@ -15,6 +15,7 @@ * @link https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#licenseObject * * @property string $name + * @property string $identifier * @property string $url * */ @@ -27,6 +28,7 @@ protected function attributes(): array { return [ 'name' => Type::STRING, + 'identifier' => Type::STRING, 'url' => Type::STRING, ]; } @@ -38,5 +40,8 @@ protected function performValidation() { $this->requireProperties(['name']); $this->validateUrl('url'); + if (!empty($this->identifier) && !empty($this->url)) { + $this->addError('A License Object MUST NOT contain both an identifier and a url.'); + } } } diff --git a/src/spec/MediaType.php b/src/spec/MediaType.php index 326f8c55..a7cc2196 100644 --- a/src/spec/MediaType.php +++ b/src/spec/MediaType.php @@ -15,7 +15,7 @@ * * @link https://github.com/OAI/OpenAPI-Specification/blob/3.0.2/versions/3.0.2.md#mediaTypeObject * - * @property Schema|Reference|null $schema + * @property Schema|Reference|bool|null $schema * @property mixed $example * @property Example[]|Reference[] $examples * @property Encoding[] $encoding @@ -54,11 +54,11 @@ public function __construct(array $data) $encoding[$property] = $encodingData; } elseif (is_array($encodingData)) { $schema = $this->schema->properties[$property] ?? null; - // Don't pass the schema if it's still an unresolved reference. - if ($schema instanceof Reference) { - $encoding[$property] = new Encoding($encodingData); - } else { + // Don't pass the schema if it's still an unresolved reference or a boolean schema. + if ($schema instanceof Schema) { $encoding[$property] = new Encoding($encodingData, $schema); + } else { + $encoding[$property] = new Encoding($encodingData); } } else { $givenType = gettype($encodingData); diff --git a/src/spec/OpenApi.php b/src/spec/OpenApi.php index 8843cc28..0dbce9a8 100644 --- a/src/spec/OpenApi.php +++ b/src/spec/OpenApi.php @@ -17,10 +17,11 @@ * * @property string $openapi * @property Info $info + * @property string|null $jsonSchemaDialect * @property Server[] $servers * @property Paths|PathItem[] $paths * @property Components|null $components - * @property PathItem[]|null $webhooks + * @property (PathItem|Reference)[] $webhooks * @property SecurityRequirement[] $security * @property Tag[] $tags * @property ExternalDocumentation|null $externalDocs @@ -45,9 +46,10 @@ protected function attributes(): array return [ 'openapi' => Type::STRING, 'info' => Info::class, + 'jsonSchemaDialect' => Type::STRING, 'servers' => [Server::class], 'paths' => Paths::class, - 'webhooks' => [PathItem::class], + 'webhooks' => [Type::STRING, PathItem::class], 'components' => Components::class, 'security' => [SecurityRequirement::class], 'tags' => [Tag::class], diff --git a/src/spec/Parameter.php b/src/spec/Parameter.php index d133ff67..ebe45557 100644 --- a/src/spec/Parameter.php +++ b/src/spec/Parameter.php @@ -25,7 +25,7 @@ * @property string $style * @property boolean $explode * @property boolean $allowReserved - * @property Schema|Reference|null $schema + * @property Schema|Reference|bool|null $schema * @property mixed $example * @property Example[] $examples * @@ -114,7 +114,7 @@ protected function performValidation() $this->addError("Parameter 'required' must be true for 'in': 'path'."); } } - if (!empty($this->content) && !empty($this->schema)) { + if (!empty($this->content) && $this->schema !== null) { $this->addError('A Parameter Object MUST contain either a schema property, or a content property, but not both.'); } if (!empty($this->content) && count($this->content) !== 1) { diff --git a/src/spec/Reference.php b/src/spec/Reference.php index e784af75..78c52943 100644 --- a/src/spec/Reference.php +++ b/src/spec/Reference.php @@ -340,6 +340,11 @@ private function resolveTransitiveReference(Reference $referencedObject, Referen */ private function adjustRelativeReferences($referencedDocument, $basePath, $baseDocument = null, $oContext = null) { + // nothing to adjust in scalar values, for example a boolean schema + if (!is_array($referencedDocument)) { + return $referencedDocument; + } + $context = new ReferenceContext(null, $basePath); if ($baseDocument === null) { $baseDocument = $referencedDocument; diff --git a/src/spec/Schema.php b/src/spec/Schema.php index d5670e2f..7792924a 100644 --- a/src/spec/Schema.php +++ b/src/spec/Schema.php @@ -42,12 +42,14 @@ * @property mixed $const * * @property string|string[] $type type can only be `string` in OpenAPI 3.0, but can be an array of strings since OpenAPI 3.1 - * @property Schema[]|Reference[] $allOf - * @property Schema[]|Reference[] $oneOf - * @property Schema[]|Reference[] $anyOf - * @property Schema|Reference|null $not - * @property Schema|Reference|null $items - * @property Schema[]|Reference[] $properties + * Since OpenAPI 3.1 a Schema Object can also be a boolean, so every property below that holds Schema Objects can contain `true` or `false` too. + * + * @property (Schema|Reference|bool)[] $allOf + * @property (Schema|Reference|bool)[] $oneOf + * @property (Schema|Reference|bool)[] $anyOf + * @property Schema|Reference|bool|null $not + * @property Schema|Reference|bool|null $items + * @property (Schema|Reference|bool)[] $properties * @property Schema|Reference|bool $additionalProperties * @property string $description * @property string $format diff --git a/src/spec/SecurityScheme.php b/src/spec/SecurityScheme.php index 2962f743..d201e903 100644 --- a/src/spec/SecurityScheme.php +++ b/src/spec/SecurityScheme.php @@ -29,7 +29,8 @@ class SecurityScheme extends SpecBaseObject "apiKey", "http", "oauth2", - "openIdConnect" + "openIdConnect", + "mutualTLS" ]; /** diff --git a/tests/spec/OpenApi31Test.php b/tests/spec/OpenApi31Test.php new file mode 100644 index 00000000..f8bd0c66 --- /dev/null +++ b/tests/spec/OpenApi31Test.php @@ -0,0 +1,131 @@ +readDocument(); + + $this->assertTrue($openapi->validate(), implode("\n", $openapi->getErrors())); + $this->assertSame([], $openapi->getErrors()); + } + + public function testBooleanSchemas() + { + $schemas = $this->readDocument()->components->schemas; + + $this->assertTrue($schemas['Anything']); + $this->assertFalse($schemas['Nothing']); + + $this->assertInstanceOf(Schema::class, $schemas['EmptyList']); + $this->assertFalse($schemas['EmptyList']->items); + + $combined = $schemas['Combined']; + $this->assertTrue($combined->properties['anything']); + $this->assertFalse($combined->properties['nothing']); + $this->assertSame([true], $combined->allOf); + $this->assertFalse($combined->not); + $this->assertFalse($combined->additionalProperties); + } + + public function testExternalReferenceToBooleanSchema() + { + $combined = $this->readDocument()->components->schemas['Combined']; + + $this->assertTrue($combined->properties['external']); + $this->assertTrue($combined->properties['externalAlias']); + } + + public function testBooleanSchemasAreWritten() + { + $data = json_decode(Writer::writeToJson($this->readDocument()), true); + $schemas = $data['components']['schemas']; + + $this->assertTrue($schemas['Anything']); + $this->assertFalse($schemas['Nothing']); + $this->assertFalse($schemas['EmptyList']['items']); + $this->assertSame(['anything' => true, 'nothing' => false, 'external' => true, 'externalAlias' => true], $schemas['Combined']['properties']); + $this->assertSame([true], $schemas['Combined']['allOf']); + $this->assertFalse($schemas['Combined']['not']); + } + + public function testMediaTypeWithBooleanSchemas() + { + $mediaType = new MediaType(['schema' => true]); + $this->assertTrue($mediaType->schema); + + $mediaType = new MediaType([ + 'schema' => ['properties' => ['file' => true]], + 'encoding' => ['file' => ['contentType' => 'text/plain']], + ]); + $this->assertInstanceOf(Encoding::class, $mediaType->encoding['file']); + } + + public function testParameterDoesNotAllowBooleanSchemaAndContent() + { + $parameter = new Parameter(['name' => 'a', 'in' => 'query', 'schema' => true]); + $this->assertTrue($parameter->validate(), implode("\n", $parameter->getErrors())); + + $parameter = new Parameter(['name' => 'a', 'in' => 'query', 'schema' => false, 'content' => ['text/plain' => []]]); + $this->assertFalse($parameter->validate()); + } + + public function testFields() + { + $openapi = $this->readDocument(); + + $this->assertSame('https://json-schema.org/draft/2020-12/schema', $openapi->jsonSchemaDialect); + $this->assertSame('Fields and boolean schemas added in OpenAPI 3.1', $openapi->info->summary); + $this->assertSame('Apache-2.0', $openapi->info->license->identifier); + $this->assertInstanceOf(PathItem::class, $openapi->components->pathItems['Shared']); + $this->assertInstanceOf(PathItem::class, $openapi->webhooks['newPet']); + $this->assertSame('mutualTLS', $openapi->components->securitySchemes['tls']->type); + } + + public function testFieldsAreOptional() + { + $openapi = new OpenApi([]); + + $this->assertNull($openapi->jsonSchemaDialect); + $this->assertSame([], $openapi->webhooks); + } + + public function testEmptyWebhooksAreWrittenAsMap() + { + $openapi = new OpenApi([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'webhooks' => [], + ]); + + $this->assertEquals(new stdClass(), json_decode(Writer::writeToJson($openapi))->webhooks); + } + + public function testLicenseDoesNotAllowIdentifierAndUrl() + { + $license = new License(['name' => 'MIT', 'identifier' => 'MIT']); + $this->assertTrue($license->validate(), implode("\n", $license->getErrors())); + + $license = new License(['name' => 'MIT', 'identifier' => 'MIT', 'url' => 'https://opensource.org/license/mit']); + $this->assertFalse($license->validate()); + $this->assertSame(['A License Object MUST NOT contain both an identifier and a url.'], $license->getErrors()); + } +} diff --git a/tests/spec/SchemaTest.php b/tests/spec/SchemaTest.php index 6825c8d9..1342fe5f 100644 --- a/tests/spec/SchemaTest.php +++ b/tests/spec/SchemaTest.php @@ -243,7 +243,6 @@ public static function badSchemaProvider() { yield [['properties' => ['a' => 'foo']], 'Unable to instantiate cebe\openapi\spec\Schema Object with data \'foo\'']; yield [['properties' => ['a' => 42]], 'Unable to instantiate cebe\openapi\spec\Schema Object with data \'42\'']; - yield [['properties' => ['a' => false]], 'Unable to instantiate cebe\openapi\spec\Schema Object with data \'\'']; yield [['properties' => ['a' => new stdClass()]], "Unable to instantiate cebe\openapi\spec\Schema Object with data 'stdClass Object\n(\n)\n'"]; yield [['additionalProperties' => 'foo'], 'Schema::$additionalProperties MUST be either boolean or a Schema/Reference object, "string" given']; diff --git a/tests/spec/data/openapi-3.1-definitions.yaml b/tests/spec/data/openapi-3.1-definitions.yaml new file mode 100644 index 00000000..699719c1 --- /dev/null +++ b/tests/spec/data/openapi-3.1-definitions.yaml @@ -0,0 +1,3 @@ +Anything: true +Alias: + $ref: '#/Anything' diff --git a/tests/spec/data/openapi-3.1.yaml b/tests/spec/data/openapi-3.1.yaml new file mode 100644 index 00000000..665a25bb --- /dev/null +++ b/tests/spec/data/openapi-3.1.yaml @@ -0,0 +1,44 @@ +openapi: 3.1.0 +jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema +info: + title: OpenAPI 3.1 features + summary: Fields and boolean schemas added in OpenAPI 3.1 + version: 1.0.0 + license: + name: Apache 2.0 + identifier: Apache-2.0 +webhooks: + newPet: + post: + responses: + '200': + description: OK +components: + pathItems: + Shared: + get: + responses: + '200': + description: OK + securitySchemes: + tls: + type: mutualTLS + schemas: + Anything: true + Nothing: false + EmptyList: + type: array + items: false + Combined: + type: object + properties: + anything: true + nothing: false + external: + $ref: 'openapi-3.1-definitions.yaml#/Anything' + externalAlias: + $ref: 'openapi-3.1-definitions.yaml#/Alias' + allOf: + - true + not: false + additionalProperties: false From 71d563497fd4ff1a936e7d082e29b9286878e1b4 Mon Sep 17 00:00:00 2001 From: Vizzent Date: Sun, 8 Nov 2026 08:06:08 +0100 Subject: [PATCH 2/2] Document boolean return value of resolveReferenceData() Co-Authored-By: Claude Opus 5.5 --- src/ReferenceContext.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ReferenceContext.php b/src/ReferenceContext.php index 76fe8500..4054b84c 100644 --- a/src/ReferenceContext.php +++ b/src/ReferenceContext.php @@ -303,7 +303,7 @@ private function getUriScheme(string $uri): string * @param JsonPointer $pointer * @param array $data * @param string|null $toType - * @return SpecObjectInterface|array|null + * @return SpecObjectInterface|array|bool|null a boolean for an OpenAPI 3.1 boolean schema */ public function resolveReferenceData($uri, JsonPointer $pointer, $data, $toType) {