From 566326f615c2541c33d4539ac54c26ddf6a5287b Mon Sep 17 00:00:00 2001 From: Steffen Beyer Date: Wed, 17 Dec 2025 12:36:06 +0100 Subject: [PATCH] fix: Preserve defaults and avoid type conflicts for polymorphic schemas Fixes parameter-level Schema attribute handling to support both object and array notation for properties parameters. Two related schema generation issues resolved: 1. Parameter-level Schema attributes with 'definition' key now preserve PHP signature defaults when the definition omits one. Previously defaults were lost when using full definition overrides. 2. Schema merging removes inferred type constraints when anyOf/oneOf/allOf is present WITHOUT an explicit type in the dominant schema. This prevents conflicts where inferred "type": "array" from PHP type hints conflicts with anyOf allowing object notation, while respecting explicit type declarations. Includes test fixtures and integration coverage for parameter- and method-level anyOf schemas. --- src/Utils/SchemaGenerator.php | 15 +++++++- .../Fixtures/Utils/SchemaGeneratorFixture.php | 33 +++++++++++++++++ tests/Integration/SchemaGenerationTest.php | 35 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/Utils/SchemaGenerator.php b/src/Utils/SchemaGenerator.php index 874dbdd..d21989a 100644 --- a/src/Utils/SchemaGenerator.php +++ b/src/Utils/SchemaGenerator.php @@ -184,7 +184,14 @@ private function buildParameterSchema(array $paramInfo, ?array $methodLevelParam $parameterLevelSchema = $paramInfo['parameter_schema']; if (!empty($parameterLevelSchema)) { if (isset($parameterLevelSchema['definition']) && is_array($parameterLevelSchema['definition'])) { - return $parameterLevelSchema['definition']; + $definitionSchema = $parameterLevelSchema['definition']; + + // Preserve PHP default values even when using a full definition + if ($paramInfo['has_default'] && !array_key_exists('default', $definitionSchema)) { + $definitionSchema['default'] = $paramInfo['default_value']; + } + + return $definitionSchema; } $mergedSchema = $this->mergeSchemas($mergedSchema, $parameterLevelSchema); @@ -203,6 +210,12 @@ private function mergeSchemas(array $recessiveSchema, array $dominantSchema): ar { $mergedSchema = array_merge($recessiveSchema, $dominantSchema); + $dominantHasPolymorphic = isset($dominantSchema['anyOf']) || isset($dominantSchema['oneOf']) || isset($dominantSchema['allOf']); + if ($dominantHasPolymorphic && !isset($dominantSchema['type'])) { + // Remove inferred/recessive type to avoid conflicts with polymorphic schemas + unset($mergedSchema['type']); + } + return $mergedSchema; } diff --git a/tests/Fixtures/Utils/SchemaGeneratorFixture.php b/tests/Fixtures/Utils/SchemaGeneratorFixture.php index db8373b..cc2909e 100644 --- a/tests/Fixtures/Utils/SchemaGeneratorFixture.php +++ b/tests/Fixtures/Utils/SchemaGeneratorFixture.php @@ -410,4 +410,37 @@ public function parameterWithRawDefinition( string $custom ): void { } + + /** + * Parameter with anyOf in definition; default comes from PHP signature. + * This tests the fix for schema merge conflicts where inferred "type": "array" + * from PHP type hint would conflict with anyOf allowing object notation. + */ + public function parameterWithAnyOfDefinition( + #[Schema(definition: [ + 'anyOf' => [ + ['type' => 'object', 'additionalProperties' => true], + ['type' => 'array', 'items' => ['type' => 'object']] + ], + 'description' => 'Properties as {"key":"value"} object or array format', + ])] + array|stdClass $properties = [] + ): void { + } + + /** + * Method-level schema with anyOf property and array type hint to ensure inferred type is dropped. + */ + #[Schema(properties: [ + 'payload' => [ + 'anyOf' => [ + ['type' => 'object', 'additionalProperties' => true], + ['type' => 'array', 'items' => ['type' => 'object']] + ], + 'description' => 'Payload as object or array of objects' + ] + ])] + public function methodLevelAnyOfProperty(array $payload): void + { + } } diff --git a/tests/Integration/SchemaGenerationTest.php b/tests/Integration/SchemaGenerationTest.php index 9cf7215..da4563f 100644 --- a/tests/Integration/SchemaGenerationTest.php +++ b/tests/Integration/SchemaGenerationTest.php @@ -367,3 +367,38 @@ expect($schema['required'])->toEqual(['custom']); }); + +it('handles anyOf schema without type conflict and preserves default from signature', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'parameterWithAnyOfDefinition'); + $schema = $this->schemaGenerator->generate($method); + + // The key test: anyOf should be present without conflicting "type": "array" + expect($schema['properties']['properties'])->toHaveKey('anyOf'); + expect($schema['properties']['properties'])->not->toHaveKey('type'); + + expect($schema['properties']['properties'])->toEqual([ + 'anyOf' => [ + ['type' => 'object', 'additionalProperties' => true], + ['type' => 'array', 'items' => ['type' => 'object']] + ], + 'description' => 'Properties as {"key":"value"} object or array format', + 'default' => [] + ]); +}); + +it('drops inferred type when method-level schema uses anyOf without type', function () { + $method = new ReflectionMethod(SchemaGeneratorFixture::class, 'methodLevelAnyOfProperty'); + $schema = $this->schemaGenerator->generate($method); + + expect($schema['properties']['payload'])->toHaveKey('anyOf'); + expect($schema['properties']['payload'])->not->toHaveKey('type'); + expect($schema['properties']['payload'])->toEqual([ + 'anyOf' => [ + ['type' => 'object', 'additionalProperties' => true], + ['type' => 'array', 'items' => ['type' => 'object']] + ], + 'description' => 'Payload as object or array of objects' + ]); + + expect($schema['required'])->toContain('payload'); +});