From 17cb7a17cf95aa3f5d1250053dddb35e24f9363d Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 28 Dec 2025 09:51:36 +0100 Subject: [PATCH] fix(openapi): respect schema type for non-collection parameter documentation When a filter parameter has an explicit schema type set to a non-array type (e.g., 'string'), the OpenAPI documentation now correctly generates a non-collection parameter instead of using array notation with '[]' suffix, deepObject style, and explode: true. Fixes #7548 --- .../Common/Filter/OpenApiFilterTrait.php | 13 ++++ src/OpenApi/Factory/OpenApiFactory.php | 11 +-- .../Entity/ProductWithQueryParameter.php | 10 +++ tests/Functional/Parameters/DoctrineTest.php | 69 +++++++++++++++++++ 4 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php index 7df7e8b9cfb..5371451f102 100644 --- a/src/Doctrine/Common/Filter/OpenApiFilterTrait.php +++ b/src/Doctrine/Common/Filter/OpenApiFilterTrait.php @@ -23,6 +23,19 @@ trait OpenApiFilterTrait { public function getOpenApiParameters(Parameter $parameter): OpenApiParameter|array|null { + $schema = $parameter->getSchema(); + $isArraySchema = 'array' === ($schema['type'] ?? null); + $castToArray = $parameter->getCastToArray(); + + // Use non-array notation if: + // 1. Schema type is explicitly set to a non-array type (string, number, etc.) + // 2. OR castToArray is explicitly false + $hasNonArraySchema = null !== $schema && !$isArraySchema; + + if ($hasNonArraySchema || false === $castToArray) { + return new OpenApiParameter(name: $parameter->getKey(), in: 'query'); + } + return new OpenApiParameter(name: $parameter->getKey().'[]', in: 'query', style: 'deepObject', explode: true); } } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 740f302032b..272feae5272 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -726,13 +726,14 @@ private function getFilterParameter(string $name, array $description, string $sh $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; - $style = 'array' === ($schema['type'] ?? null) && \in_array( + $isArraySchema = 'array' === ($schema['type'] ?? null); + $style = $isArraySchema && \in_array( $description['type'], [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form'; - $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $description['is_collection'] ?? false); + $parameter = isset($description['openapi']) && $description['openapi'] instanceof Parameter ? $description['openapi'] : new Parameter(in: 'query', name: $name, style: $style, explode: $isArraySchema ? ($description['is_collection'] ?? false) : false); if ('' === $parameter->getDescription() && ($str = $description['description'] ?? '')) { $parameter = $parameter->withDescription($str); @@ -771,6 +772,8 @@ private function getFilterParameter(string $name, array $description, string $sh $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $isArraySchema = 'array' === $schema['type']; + return new Parameter( $name, 'query', @@ -779,12 +782,12 @@ private function getFilterParameter(string $name, array $description, string $sh $description['openapi']['deprecated'] ?? false, $description['openapi']['allowEmptyValue'] ?? null, $schema, - 'array' === $schema['type'] && \in_array( + $isArraySchema && \in_array( $description['type'], [$arrayValueType, $objectValueType], true ) ? 'deepObject' : 'form', - $description['openapi']['explode'] ?? ('array' === $schema['type']), + $description['openapi']['explode'] ?? $isArraySchema, $description['openapi']['allowReserved'] ?? null, $description['openapi']['example'] ?? null, isset( diff --git a/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php b/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php index 9c8d143ae51..dae67eebe9a 100644 --- a/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php +++ b/tests/Fixtures/TestBundle/Entity/ProductWithQueryParameter.php @@ -42,6 +42,16 @@ filter: new OrderFilter(), properties: ['rating'] ), + 'exactBrand' => new QueryParameter( + filter: new ExactFilter(), + property: 'brand', + schema: ['type' => 'string'] + ), + 'exactCategory' => new QueryParameter( + filter: new ExactFilter(), + property: 'category', + castToArray: false + ), ] ), ] diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index 33846f4fd32..5e21ec429be 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -300,4 +300,73 @@ private function loadProductFixtures(string $resourceClass): void $manager->flush(); } + + #[DataProvider('openApiParameterDocumentationProvider')] + public function testOpenApiParameterDocumentation(string $parameterName, bool $shouldHaveArrayNotation, string $expectedStyle, bool $expectedExplode, ?string $expectedSchemaType = null): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $resource = ProductWithQueryParameter::class; + $this->recreateSchema([$resource]); + + $response = self::createClient()->request('GET', '/docs', [ + 'headers' => ['Accept' => 'application/vnd.openapi+json'], + ]); + + $this->assertResponseIsSuccessful(); + $openApiDoc = $response->toArray(); + + $parameters = $openApiDoc['paths']['/product_with_query_parameters']['get']['parameters']; + $foundParameter = null; + $expectedName = $shouldHaveArrayNotation ? $parameterName.'[]' : $parameterName; + $alternativeName = $shouldHaveArrayNotation ? $parameterName : $parameterName.'[]'; + + foreach ($parameters as $parameter) { + if ($parameter['name'] === $expectedName || $parameter['name'] === $alternativeName) { + $foundParameter = $parameter; + break; + } + } + + $this->assertNotNull($foundParameter, \sprintf('%s parameter should be present in OpenAPI documentation', $parameterName)); + $this->assertSame($expectedName, $foundParameter['name'], \sprintf('Parameter name should%s have [] suffix', $shouldHaveArrayNotation ? '' : ' NOT')); + $this->assertSame('query', $foundParameter['in']); + $this->assertFalse($foundParameter['required']); + + if ($expectedSchemaType) { + $this->assertSame($expectedSchemaType, $foundParameter['schema']['type'], \sprintf('Parameter schema type should be %s', $expectedSchemaType)); + } + + $this->assertSame($expectedStyle, $foundParameter['style'] ?? 'form', \sprintf('Style should be %s', $expectedStyle)); + $this->assertSame($expectedExplode, $foundParameter['explode'] ?? false, \sprintf('Explode should be %s', $expectedExplode ? 'true' : 'false')); + } + + public static function openApiParameterDocumentationProvider(): array + { + return [ + 'default behavior (no castToArray, no schema) should use array notation' => [ + 'parameterName' => 'brand', + 'shouldHaveArrayNotation' => true, + 'expectedStyle' => 'deepObject', + 'expectedExplode' => true, + 'expectedSchemaType' => 'string', + ], + 'explicit schema type string should not use array notation' => [ + 'parameterName' => 'exactBrand', + 'shouldHaveArrayNotation' => false, + 'expectedStyle' => 'form', + 'expectedExplode' => false, + 'expectedSchemaType' => 'string', + ], + 'castToArray false should not use array notation' => [ + 'parameterName' => 'exactCategory', + 'shouldHaveArrayNotation' => false, + 'expectedStyle' => 'form', + 'expectedExplode' => false, + 'expectedSchemaType' => 'string', + ], + ]; + } }