From e42501b21974b97540730b3ed27c1eeb3c638dd6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 14:10:58 +0200 Subject: [PATCH 1/4] fix(openapi): emit Draft 4 boolean exclusive bounds for spec 3.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI 3.0 inherits JSON Schema Draft 4 semantics where `exclusiveMinimum` / `exclusiveMaximum` MUST be booleans qualifying `minimum` / `maximum`. The runtime SchemaFactory emits the JSON Schema 2020-12 form with these keywords as numbers, so `api:openapi:export --spec-version=3.0.0` and `GET /docs.jsonopenapi?spec_version=3.0.0` produced invalid 3.0 documents for properties with Assert\Positive, Assert\GreaterThan, Assert\LessThan, or Assert\Range. BackwardCompatibleSchemaFactory was supposed to handle the conversion (introduced in #6098 closing #6041) but only fires when the serializer context contains draft_4 => true, a flag set today only by ApiTestAssertionsTrait. OpenApiFactory's buildSchema calls passed serializerContext: null. Wire spec_version === '3.0.0' from OpenApiFactory::__invoke into the schema serializer context for all three buildSchema call sites (output, input, error responses). Thread the context as a new optional argument on addOperationErrors(). Also guard BackwardCompatibleSchemaFactory against re-running on schemas where exclusiveMinimum/exclusiveMaximum is already boolean — the same property ArrayObject can be reached through multiple definitions (one per format), and the previous unconditional swap turned `{minimum: 10, exclusiveMinimum: true}` into `{minimum: true, exclusiveMinimum: true}` on the second pass. Closes #7936 --- .../BackwardCompatibleSchemaFactory.php | 4 +-- src/OpenApi/Factory/OpenApiFactory.php | 22 +++++++++------ tests/Functional/OpenApiTest.php | 27 +++++++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/JsonSchema/BackwardCompatibleSchemaFactory.php b/src/JsonSchema/BackwardCompatibleSchemaFactory.php index 678ea8f606e..f833bc19c85 100644 --- a/src/JsonSchema/BackwardCompatibleSchemaFactory.php +++ b/src/JsonSchema/BackwardCompatibleSchemaFactory.php @@ -52,11 +52,11 @@ public function buildSchema(string $className, string $format = 'json', string $ continue; } - if (isset($property['exclusiveMinimum'])) { + if (isset($property['exclusiveMinimum']) && !\is_bool($property['exclusiveMinimum'])) { $property['minimum'] = $property['exclusiveMinimum']; $property['exclusiveMinimum'] = true; } - if (isset($property['exclusiveMaximum'])) { + if (isset($property['exclusiveMaximum']) && !\is_bool($property['exclusiveMaximum'])) { $property['maximum'] = $property['exclusiveMaximum']; $property['exclusiveMaximum'] = true; } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 76217d781c6..cc9fc432eb3 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\OpenApi\Factory; +use ApiPlatform\JsonSchema\BackwardCompatibleSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\ApiResource; @@ -167,6 +168,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection return; } + $schemaSerializerContext = '3.0.0' === ($context['spec_version'] ?? null) + ? [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true] + : null; + $defaultError = $this->getErrorResource($this->openApiOptions->getErrorResourceClass() ?? ApiResourceError::class); $defaultValidationError = $this->getErrorResource($this->openApiOptions->getValidationErrorResourceClass() ?? ValidationException::class, 422, 'Unprocessable entity'); @@ -268,7 +273,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection foreach ($responseMimeTypes as $operationFormat) { $operationOutputSchema = null; - $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, null, $forceSchemaCollection); + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); $this->appendSchemaDefinitions($schemas, $operationOutputSchema->getDefinitions()); $operationOutputSchemas[$operationFormat] = $operationOutputSchema; @@ -409,7 +414,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $errorOperations[$error] = $this->getErrorResource($error); } - $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation); + $openapiOperation = $this->addOperationErrors($openapiOperation, $errorOperations, $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if ($overrideResponses || !$existingResponses) { @@ -427,7 +432,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(400)->withDescription('Invalid input'), $defaultValidationError, - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } break; case 'PATCH': @@ -439,7 +444,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(400)->withDescription('Invalid input'), $defaultValidationError, - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } break; case 'DELETE': @@ -452,13 +457,13 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($overrideResponses && !isset($existingResponses[403]) && $operation->getSecurity()) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(403)->withDescription('Forbidden'), - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if ($overrideResponses && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod() && !isset($existingResponses[404]) && null === $errors) { $openapiOperation = $this->addOperationErrors($openapiOperation, [ $defaultError->withStatus(404)->withDescription('Not found'), - ], $resourceMetadataCollection, $schema, $schemas, $operation); + ], $resourceMetadataCollection, $schema, $schemas, $operation, $schemaSerializerContext); } if (!$openapiOperation->getResponses()) { @@ -474,7 +479,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = null; - $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, null, $forceSchemaCollection); + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operation, $schema, $schemaSerializerContext, $forceSchemaCollection); $this->appendSchemaDefinitions($schemas, $operationInputSchema->getDefinitions()); $operationInputSchemas[$operationFormat] = $operationInputSchema; @@ -997,6 +1002,7 @@ private function addOperationErrors( Schema $schema, \ArrayObject $schemas, HttpOperation $originalOperation, + ?array $serializerContext = null, ): Operation { foreach ($errors as $errorResource) { $responseMimeTypes = $this->flattenMimeTypes($errorResource->getOutputFormats() ?: $this->errorFormats); @@ -1017,7 +1023,7 @@ private function addOperationErrors( $operationErrorSchemas = []; foreach ($responseMimeTypes as $operationFormat) { $operationErrorSchema = null; - $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema); + $operationErrorSchema = $this->jsonSchemaFactory->buildSchema($errorResource->getClass(), $operationFormat, Schema::TYPE_OUTPUT, null, $schema, $serializerContext); $this->appendSchemaDefinitions($schemas, $operationErrorSchema->getDefinitions()); $operationErrorSchemas[$operationFormat] = $operationErrorSchema; } diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 546861f1a70..a672afd8b84 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -35,6 +35,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6041\NumericValidated; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonSchemaResource; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonSchemaResourceRelated; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NoCollectionDummy; @@ -99,6 +100,7 @@ public static function getResources(): array OverrideOpenApiResponses::class, DummyAddress::class, RamseyUuidDummy::class, + NumericValidated::class, JsonSchemaResource::class, JsonSchemaResourceRelated::class, WrappedResponseEntity::class, @@ -601,6 +603,31 @@ public function testRetrieveTheOpenApiDocumentationWith30Specification(): void $this->assertArrayNotHasKey('owl:maxCardinality', $json['components']['schemas']['DummyBoolean']['properties']['isDummyBoolean']); } + public function testOpenApi30EmitsBooleanExclusiveBoundsForNumericConstraints(): void + { + $kernel = self::bootKernel(); + if ('mongodb' === $kernel->getEnvironment()) { + $this->markTestSkipped('Resource not loaded with MongoDB.'); + } + + $response = self::createClient()->request('GET', '/docs.jsonopenapi?spec_version=3.0.0', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); + $this->assertResponseIsSuccessful(); + $json = $response->toArray(); + + $this->assertSame('3.0.0', $json['openapi']); + $this->assertArrayHasKey('NumericValidated', $json['components']['schemas']); + $properties = $json['components']['schemas']['NumericValidated']['properties']; + + $this->assertSame(10, $properties['greaterThanMe']['minimum']); + $this->assertTrue($properties['greaterThanMe']['exclusiveMinimum']); + + $this->assertSame(99, $properties['lessThanMe']['maximum']); + $this->assertTrue($properties['lessThanMe']['exclusiveMaximum']); + + $this->assertSame(0, $properties['positive']['minimum']); + $this->assertTrue($properties['positive']['exclusiveMinimum']); + } + public function testRetrieveTheOpenApiDocumentationInJson(): void { $response = self::createClient()->request('GET', '/docs.jsonopenapi', [ From 78d4a184604c80a4c6fa51d9da81a6805cab364d Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 15:59:13 +0200 Subject: [PATCH 2/4] fix(openapi): thread spec_version into CLI __invoke call api:openapi:export --spec-version=3.0.0 passed spec_version only to the normalizer, not to OpenApiFactory::__invoke. collectPaths therefore never set the BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION flag on the schema serializer context, so the CLI emitted JSON Schema 2020-12 numeric exclusiveMinimum/exclusiveMaximum for properties with Assert\Positive, Assert\GreaterThan, Assert\LessThan, or Assert\Range even when 3.0.0 was requested. HTTP path was unaffected because SerializerContextBuilder already injects spec_version into the OpenApiProvider context. Closes #8176 --- src/OpenApi/Command/OpenApiCommand.php | 8 +++++-- tests/OpenApi/Command/OpenApiCommandTest.php | 22 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/OpenApi/Command/OpenApiCommand.php b/src/OpenApi/Command/OpenApiCommand.php index 30802a482e6..26573d8f64f 100644 --- a/src/OpenApi/Command/OpenApiCommand.php +++ b/src/OpenApi/Command/OpenApiCommand.php @@ -56,10 +56,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $filesystem = new Filesystem(); $io = new SymfonyStyle($input, $output); + $specVersion = $input->getOption('spec-version'); $data = $this->normalizer->normalize( - $this->openApiFactory->__invoke(['filter_tags' => $input->getOption('filter-tags')]), + $this->openApiFactory->__invoke([ + 'filter_tags' => $input->getOption('filter-tags'), + 'spec_version' => $specVersion, + ]), 'json', - ['spec_version' => $input->getOption('spec-version')] + ['spec_version' => $specVersion] ); if ($input->getOption('yaml') && !class_exists(Yaml::class)) { diff --git a/tests/OpenApi/Command/OpenApiCommandTest.php b/tests/OpenApi/Command/OpenApiCommandTest.php index 36052b7d02f..f4b4e6de600 100644 --- a/tests/OpenApi/Command/OpenApiCommandTest.php +++ b/tests/OpenApi/Command/OpenApiCommandTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Crud; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6317\Issue6317; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5625\Currency; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6041\NumericValidated; use ApiPlatform\Tests\SetupClassResourcesTrait; use PHPUnit\Framework\Attributes\Group; use Symfony\Bundle\FrameworkBundle\Console\Application; @@ -55,6 +56,7 @@ public static function getResources(): array Issue6317::class, Currency::class, Crud::class, + NumericValidated::class, ]; } @@ -162,6 +164,26 @@ private function assertYaml(string $data): void $this->addToAssertionCount(1); } + public function testSpecVersion30EmitsDraft4BooleanExclusiveBounds(): void + { + $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => '3.0.0']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, true, 512, \JSON_THROW_ON_ERROR); + + $this->assertSame('3.0.0', $json['openapi']); + $this->assertArrayHasKey('NumericValidated', $json['components']['schemas']); + $properties = $json['components']['schemas']['NumericValidated']['properties']; + + $this->assertSame(10, $properties['greaterThanMe']['minimum']); + $this->assertTrue($properties['greaterThanMe']['exclusiveMinimum']); + + $this->assertSame(99, $properties['lessThanMe']['maximum']); + $this->assertTrue($properties['lessThanMe']['exclusiveMaximum']); + + $this->assertSame(0, $properties['positive']['minimum']); + $this->assertTrue($properties['positive']['exclusiveMinimum']); + } + public function testFilterXApiPlatformTag(): void { $this->tester->run(['command' => 'api:openapi:export', '--filter-tags' => 'anotherone']); From 01ab039ef7a020009aefd7be2f62cbfd4799d711 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 16:19:14 +0200 Subject: [PATCH 3/4] fixup! fix(openapi): thread spec_version into CLI __invoke call --- tests/OpenApi/Command/OpenApiCommandTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/OpenApi/Command/OpenApiCommandTest.php b/tests/OpenApi/Command/OpenApiCommandTest.php index f4b4e6de600..8d7637c3165 100644 --- a/tests/OpenApi/Command/OpenApiCommandTest.php +++ b/tests/OpenApi/Command/OpenApiCommandTest.php @@ -164,6 +164,7 @@ private function assertYaml(string $data): void $this->addToAssertionCount(1); } + #[Group('orm')] public function testSpecVersion30EmitsDraft4BooleanExclusiveBounds(): void { $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => '3.0.0']); From 6466d4a1d550ea817390d076b250f34c5dcafdfe Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 16:24:48 +0200 Subject: [PATCH 4/4] fixup! fix(openapi): thread spec_version into CLI __invoke call --- tests/OpenApi/Command/OpenApiCommandTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/OpenApi/Command/OpenApiCommandTest.php b/tests/OpenApi/Command/OpenApiCommandTest.php index 8d7637c3165..6112badb2d2 100644 --- a/tests/OpenApi/Command/OpenApiCommandTest.php +++ b/tests/OpenApi/Command/OpenApiCommandTest.php @@ -164,9 +164,12 @@ private function assertYaml(string $data): void $this->addToAssertionCount(1); } - #[Group('orm')] public function testSpecVersion30EmitsDraft4BooleanExclusiveBounds(): void { + if ('mongodb' === static::$kernel->getEnvironment()) { + $this->markTestSkipped('Resource not loaded with MongoDB.'); + } + $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => '3.0.0']); $result = $this->tester->getDisplay(); $json = json_decode($result, true, 512, \JSON_THROW_ON_ERROR);