diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dc1594..db8f520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [11.2.1] - 2026-06-17 +### Fixed +- Response body mappers now honor the `oneOf`/`anyOf` discriminator `mapping`, dispatching to the correct setter and mapper instead of deriving them from the raw discriminator value (which broke when a mapping value differed from its schema name) + ## [11.2.0] - 2026-02-19 ### Added - RFC 6839 support: Content types with `+json` suffix (e.g., `application/vnd.api+json`, `application/hal+json`, `application/problem+json`) are now recognized as JSON-based formats diff --git a/src/Ast/Builder/CodeBuilder.php b/src/Ast/Builder/CodeBuilder.php index 419fabc..b413b2e 100644 --- a/src/Ast/Builder/CodeBuilder.php +++ b/src/Ast/Builder/CodeBuilder.php @@ -50,6 +50,7 @@ use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Name\Relative; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Case_; use PhpParser\Node\Stmt\Catch_; use PhpParser\Node\Stmt\Class_; @@ -289,6 +290,16 @@ public function case(Expr $condition, Stmt ...$stmts): Case_ return new Case_($condition, $stmts); } + public function default(Stmt ...$stmts): Case_ + { + return new Case_(null, $stmts); + } + + public function break(): Break_ + { + return new Break_(); + } + public function if(Expr $condition, array $stmts, array $elseIfs = [], Else_ $else = null): If_ { $subNodes = []; diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 70930b6..916b557 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -12,8 +12,10 @@ use DoclerLabs\ApiClientGenerator\Input\Specification; use DoclerLabs\ApiClientGenerator\Naming\SchemaMapperNaming; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Case_; use PhpParser\Node\Stmt\ClassMethod; class SchemaMapperGenerator extends MutatorAccessorClassGeneratorAbstract @@ -501,53 +503,7 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload if ($root->hasOneOf() || $root->hasAnyOf()) { if ($root->getDiscriminator()) { - $ifCondition = $this->builder->funcCall('array_key_exists', [ - /** @phpstan-ignore-next-line */ - $root->getDiscriminator()->propertyName, - $payloadVariable, - ]); - - $payloadDiscriminator = $this->builder->getArrayItem( - $payloadVariable, - /** @phpstan-ignore-next-line */ - $this->builder->val($root->getDiscriminator()->propertyName) - ); - - $assignMethodName = $this->builder->expr( - $this->builder->assign( - $this->builder->var('methodName'), - $this->builder->concat( - $this->builder->val('set'), - $this->builder->funcCall('ucfirst', [$payloadDiscriminator]) - ) - ) - ); - - $assignMapperName = $this->builder->expr( - $this->builder->assign( - $this->builder->var('mapperName'), - $this->builder->concat( - $payloadDiscriminator, - $this->builder->val('Mapper') - ) - ) - ); - - $schemaMethodCall = $this->builder->expr( - $this->builder->methodCall( - $this->builder->var('schema'), - '$methodName', - [ - $this->builder->methodCall( - $this->builder->localPropertyFetch('$mapperName'), - 'toSchema', - [$payloadVariable] - ), - ] - ) - ); - - $statements[] = $this->builder->if($ifCondition, [$assignMethodName, $assignMapperName, $schemaMethodCall]); + $statements[] = $this->generateDiscriminatorStatement($root, $payloadVariable); } else { $statements[] = $this->builder->assign($matchesVar, $this->builder->val(0)); @@ -597,4 +553,130 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload return $statements; } + + private function generateDiscriminatorStatement(Field $root, Variable $payloadVariable): Stmt + { + $discriminator = $root->getDiscriminator(); + + /** @phpstan-ignore-next-line */ + $propertyName = $discriminator->propertyName; + + $ifCondition = $this->builder->funcCall('array_key_exists', [ + $propertyName, + $payloadVariable, + ]); + + $payloadDiscriminator = $this->builder->getArrayItem( + $payloadVariable, + $this->builder->val($propertyName) + ); + + $fallbackStatements = $this->generateDiscriminatorFallbackStatements($payloadDiscriminator, $payloadVariable); + + /** @phpstan-ignore-next-line */ + $mapping = $discriminator->mapping ?? []; + $cases = $this->generateDiscriminatorMappingCases($root, $mapping, $payloadVariable); + + if ($cases === []) { + return $this->builder->if($ifCondition, $fallbackStatements); + } + + $defaultStatements = [...$fallbackStatements, $this->builder->break()]; + $cases[] = $this->builder->default(...$defaultStatements); + + return $this->builder->if($ifCondition, [$this->builder->switch($payloadDiscriminator, ...$cases)]); + } + + /** + * @return Stmt[] + */ + private function generateDiscriminatorFallbackStatements(Expr $payloadDiscriminator, Variable $payloadVariable): array + { + $assignMethodName = $this->builder->expr( + $this->builder->assign( + $this->builder->var('methodName'), + $this->builder->concat( + $this->builder->val('set'), + $this->builder->funcCall('ucfirst', [$payloadDiscriminator]) + ) + ) + ); + + $assignMapperName = $this->builder->expr( + $this->builder->assign( + $this->builder->var('mapperName'), + $this->builder->concat( + $payloadDiscriminator, + $this->builder->val('Mapper') + ) + ) + ); + + $schemaMethodCall = $this->builder->expr( + $this->builder->methodCall( + $this->builder->var('schema'), + '$methodName', + [ + $this->builder->methodCall( + $this->builder->localPropertyFetch('$mapperName'), + 'toSchema', + [$payloadVariable] + ), + ] + ) + ); + + return [$assignMethodName, $assignMapperName, $schemaMethodCall]; + } + + /** + * @param string[] $mapping + * + * @return Case_[] + */ + private function generateDiscriminatorMappingCases(Field $root, array $mapping, Variable $payloadVariable): array + { + $childrenByClassName = []; + foreach ($root->getObjectProperties() as $child) { + if ($child->isComposite()) { + $childrenByClassName[$child->getPhpClassName()] = $child; + } + } + + $cases = []; + foreach ($mapping as $discriminatorValue => $reference) { + $schemaName = $this->resolveSchemaNameFromReference($reference); + if (!isset($childrenByClassName[$schemaName])) { + continue; + } + + $child = $childrenByClassName[$schemaName]; + $cases[] = $this->builder->case( + $this->builder->val((string)$discriminatorValue), + $this->builder->expr( + $this->builder->methodCall( + $this->builder->var('schema'), + $this->getSetMethodName($child), + [ + $this->builder->methodCall( + $this->builder->localPropertyFetch(SchemaMapperNaming::getPropertyName($child)), + 'toSchema', + [$payloadVariable] + ), + ] + ) + ), + $this->builder->break() + ); + } + + return $cases; + } + + private function resolveSchemaNameFromReference(string $reference): string + { + $segments = explode('/', $reference); + + return (string)end($segments); + } } diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php index 1b632fc..e326539 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php @@ -14,23 +14,36 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - private AnimalMapper $animalMapper; + private AnimalResponseMapper $animalResponseMapper; - private MachineMapper $machineMapper; + private MachineResponseMapper $machineResponseMapper; - public function __construct(AnimalMapper $animalMapper, MachineMapper $machineMapper) + public function __construct(AnimalResponseMapper $animalResponseMapper, MachineResponseMapper $machineResponseMapper) { - $this->animalMapper = $animalMapper; - $this->machineMapper = $machineMapper; + $this->animalResponseMapper = $animalResponseMapper; + $this->machineResponseMapper = $machineResponseMapper; } public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php index 56cd530..5a480e6 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php @@ -14,7 +14,7 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - public function __construct(private AnimalMapper $animalMapper, private MachineMapper $machineMapper) + public function __construct(private AnimalResponseMapper $animalResponseMapper, private MachineResponseMapper $machineResponseMapper) { } @@ -22,9 +22,22 @@ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php index 156beb8..200536d 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php @@ -14,7 +14,7 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - public function __construct(private readonly AnimalMapper $animalMapper, private readonly MachineMapper $machineMapper) + public function __construct(private readonly AnimalResponseMapper $animalResponseMapper, private readonly MachineResponseMapper $machineResponseMapper) { } @@ -22,9 +22,22 @@ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php new file mode 100644 index 0000000..156beb8 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php @@ -0,0 +1,32 @@ +$methodName($this->$mapperName->toSchema($payload)); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php index 1b632fc..e326539 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php @@ -14,23 +14,36 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - private AnimalMapper $animalMapper; + private AnimalResponseMapper $animalResponseMapper; - private MachineMapper $machineMapper; + private MachineResponseMapper $machineResponseMapper; - public function __construct(AnimalMapper $animalMapper, MachineMapper $machineMapper) + public function __construct(AnimalResponseMapper $animalResponseMapper, MachineResponseMapper $machineResponseMapper) { - $this->animalMapper = $animalMapper; - $this->machineMapper = $machineMapper; + $this->animalResponseMapper = $animalResponseMapper; + $this->machineResponseMapper = $machineResponseMapper; } public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php index 56cd530..5a480e6 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php @@ -14,7 +14,7 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - public function __construct(private AnimalMapper $animalMapper, private MachineMapper $machineMapper) + public function __construct(private AnimalResponseMapper $animalResponseMapper, private MachineResponseMapper $machineResponseMapper) { } @@ -22,9 +22,22 @@ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php index 156beb8..200536d 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php @@ -14,7 +14,7 @@ class GetExampleResponseBodyMapper implements SchemaMapperInterface { - public function __construct(private readonly AnimalMapper $animalMapper, private readonly MachineMapper $machineMapper) + public function __construct(private readonly AnimalResponseMapper $animalResponseMapper, private readonly MachineResponseMapper $machineResponseMapper) { } @@ -22,9 +22,22 @@ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); if (array_key_exists('objectType', $payload)) { - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); + switch ($payload['objectType']) { + case 'animal': + $schema->setAnimalResponse($this->animalResponseMapper->toSchema($payload)); + + break; + case 'machine': + $schema->setMachineResponse($this->machineResponseMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['objectType']); + $mapperName = $payload['objectType'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/anyOf.yaml b/test/suite/functional/Generator/SchemaMapper/anyOf.yaml index e9131b5..114cba7 100644 --- a/test/suite/functional/Generator/SchemaMapper/anyOf.yaml +++ b/test/suite/functional/Generator/SchemaMapper/anyOf.yaml @@ -13,17 +13,17 @@ paths: application/json: schema: anyOf: - - $ref: '#/components/schemas/Animal' - - $ref: '#/components/schemas/Machine' + - $ref: '#/components/schemas/AnimalResponse' + - $ref: '#/components/schemas/MachineResponse' discriminator: propertyName: objectType mapping: - animal: '#/components/schemas/Animal' - machine: '#/components/schemas/Machine' + animal: '#/components/schemas/AnimalResponse' + machine: '#/components/schemas/MachineResponse' components: schemas: - Animal: + AnimalResponse: type: object required: - objectType @@ -58,7 +58,7 @@ components: enum: [bird] wingSpan: type: integer - Machine: + MachineResponse: type: object required: - objectType diff --git a/test/suite/functional/Generator/SchemaMapper/oneOf.yaml b/test/suite/functional/Generator/SchemaMapper/oneOf.yaml index 292ac98..c251a5a 100644 --- a/test/suite/functional/Generator/SchemaMapper/oneOf.yaml +++ b/test/suite/functional/Generator/SchemaMapper/oneOf.yaml @@ -13,17 +13,17 @@ paths: application/json: schema: oneOf: - - $ref: '#/components/schemas/Animal' - - $ref: '#/components/schemas/Machine' + - $ref: '#/components/schemas/AnimalResponse' + - $ref: '#/components/schemas/MachineResponse' discriminator: propertyName: objectType mapping: - animal: '#/components/schemas/Animal' - machine: '#/components/schemas/Machine' + animal: '#/components/schemas/AnimalResponse' + machine: '#/components/schemas/MachineResponse' components: schemas: - Animal: + AnimalResponse: type: object required: - objectType @@ -63,7 +63,7 @@ components: enum: [bird] wingSpan: type: integer - Machine: + MachineResponse: type: object required: - objectType diff --git a/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorWithoutMapping.yaml b/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorWithoutMapping.yaml new file mode 100644 index 0000000..0ad5ab1 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorWithoutMapping.yaml @@ -0,0 +1,42 @@ +openapi: 3.0.0 +info: + title: OneOf with a discriminator but without an explicit mapping + version: 1.0.0 + +paths: + /example: + get: + responses: + '200': + description: An example of an oneOf structure + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Animal' + - $ref: '#/components/schemas/Machine' + discriminator: + propertyName: objectType + +components: + schemas: + Animal: + type: object + required: + - objectType + - name + properties: + objectType: + type: string + name: + type: string + Machine: + type: object + required: + - objectType + - model + properties: + objectType: + type: string + model: + type: string diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index b6dd506..0caa9c5 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -106,6 +106,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'OneOf response with a discriminator but without mapping with php 8.1' => [ + '/SchemaMapper/oneOfDiscriminatorWithoutMapping.yaml', + '/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], 'OneOf response without discriminator with php 7.4' => [ '/SchemaMapper/oneOfWithoutDiscriminator.yaml', '/SchemaMapper/OneOfResponseBodyMapperWithoutDiscriminator74.php',