From a1b8582347a27d8ce16ae9b23dc18acb00e39b8f Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:11:04 +0200 Subject: [PATCH 01/21] fix: throw on unmapped 2xx status codes in multi-response match Operations with several 2xx responses are generated as a `match` on the status code for PHP 8.0+ targets. The match had no default arm, so a 2xx status code missing from the specification raised an UnhandledMatchError (an engine Error) instead of an exception. Add a default arm that throws the same RuntimeException the pre-8.0 switch branch already throws after the switch, so every target behaves the same. Co-Authored-By: Claude Opus 5.5 --- src/Ast/Builder/CodeBuilder.php | 16 +++++++++++ src/Generator/ClientGenerator.php | 12 ++++++++- .../Client/MultipleResponsesClient74.php | 14 ++++++++++ .../Client/MultipleResponsesClient80.php | 18 ++++++++++++- .../Client/MultipleResponsesClient81.php | 18 ++++++++++++- .../Generator/Client/multiple-responses.yaml | 8 ++++++ .../unit/Ast/Builder/CodeBuilderTest.php | 27 +++++++++++++++++++ 7 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/Ast/Builder/CodeBuilder.php b/src/Ast/Builder/CodeBuilder.php index b413b2e..5f64daa 100644 --- a/src/Ast/Builder/CodeBuilder.php +++ b/src/Ast/Builder/CodeBuilder.php @@ -44,6 +44,7 @@ use PhpParser\Node\Expr\NullsafePropertyFetch; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\Ternary; +use PhpParser\Node\Expr\Throw_ as ThrowExpression; use PhpParser\Node\Expr\Variable; use PhpParser\Node\MatchArm; use PhpParser\Node\Name; @@ -280,6 +281,11 @@ public function matchArm(array $conditions, Expr $body): MatchArm return new MatchArm($conditions, $body); } + public function defaultMatchArm(Expr $body): MatchArm + { + return new MatchArm(null, $body); + } + public function switch(Expr $condition, Case_ ...$cases): Switch_ { return new Switch_($condition, $cases); @@ -370,6 +376,16 @@ public function throw(string $exceptionClassName, Expr $message = null): Stmt return new Throw_($this->new($exceptionClassName, $args)); } + public function throwExpression(string $exceptionClassName, Expr $message = null): ThrowExpression + { + $args = []; + if ($message !== null) { + $args = [$message]; + } + + return new ThrowExpression($this->new($exceptionClassName, $args)); + } + public function foreach(Expr $array, Variable $asValue, array $stmts, Variable $asKey = null): Foreach_ { $subNodes = ['stmts' => $stmts]; diff --git a/src/Generator/ClientGenerator.php b/src/Generator/ClientGenerator.php index 3782889..0c70827 100644 --- a/src/Generator/ClientGenerator.php +++ b/src/Generator/ClientGenerator.php @@ -35,6 +35,8 @@ class ClientGenerator extends GeneratorAbstract { + private const UNMAPPED_STATUS_CODE_MESSAGE = 'Response status code not properly mapped in schema.'; + public function __construct( string $baseNamespace, CodeBuilder $builder, @@ -369,6 +371,14 @@ private function multiBodyAction( foreach ($matchBodies as $phpClassName => $matchBody) { $matchArms[] = $this->builder->matchArm($caseConditions[$phpClassName], $matchBody); } + + $this->addImport(RuntimeException::class); + $matchArms[] = $this->builder->defaultMatchArm( + $this->builder->throwExpression( + 'RuntimeException', + $this->builder->val(self::UNMAPPED_STATUS_CODE_MESSAGE) + ) + ); $stmts[] = $this->builder->return( $this->builder->match( $this->builder->methodCall($responseVar, 'getStatusCode'), @@ -394,7 +404,7 @@ private function multiBodyAction( $this->addImport(RuntimeException::class); $stmts[] = $this->builder->throw( 'RuntimeException', - $this->builder->val('Response status code not properly mapped in schema.') + $this->builder->val(self::UNMAPPED_STATUS_CODE_MESSAGE) ); } diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient74.php b/test/suite/functional/Generator/Client/MultipleResponsesClient74.php index 37ab010..a3b70ce 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient74.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient74.php @@ -15,6 +15,7 @@ use Psr\Http\Message\ResponseInterface; use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Request\SavePetRequest; @@ -70,6 +71,19 @@ public function addPetTask(AddPetTaskRequest $request) throw new RuntimeException('Response status code not properly mapped in schema.'); } + public function importPets(ImportPetsRequest $request) + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + switch ($response->getStatusCode()) { + case 201: + return null; + case 202: + return null; + } + throw new RuntimeException('Response status code not properly mapped in schema.'); + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient80.php b/test/suite/functional/Generator/Client/MultipleResponsesClient80.php index bd4c45a..291e1de 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient80.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient80.php @@ -13,7 +13,9 @@ use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Request\SavePetRequest; @@ -41,6 +43,7 @@ public function savePet(SavePetRequest $request): ?Pet return match ($response->getStatusCode()) { 204 => null, 200, 201 => $this->container->get(PetMapper::class)->toSchema($unserializedResponse), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), }; } @@ -52,7 +55,20 @@ public function addPetTask(AddPetTaskRequest $request) return match ($response->getStatusCode()) { 204 => null, 200, 201 => $this->container->get(PetMapper::class)->toSchema($unserializedResponse), - 202 => $this->container->get(PetTaskMapper::class)->toSchema($unserializedResponse), + 202 => $this->container->get(PetTaskMapper::class)->toSchema($unserializedResponse), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), + }; + } + + public function importPets(ImportPetsRequest $request) + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + + return match ($response->getStatusCode()) { + 201 => null, + 202 => null, + default => throw new RuntimeException('Response status code not properly mapped in schema.'), }; } diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient81.php b/test/suite/functional/Generator/Client/MultipleResponsesClient81.php index 17f2604..3eb297a 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient81.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient81.php @@ -13,7 +13,9 @@ use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; +use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Request\SavePetRequest; @@ -41,6 +43,7 @@ public function savePet(SavePetRequest $request): ?Pet return match ($response->getStatusCode()) { 204 => null, 200, 201 => $this->container->get(PetMapper::class)->toSchema($unserializedResponse), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), }; } @@ -52,7 +55,20 @@ public function addPetTask(AddPetTaskRequest $request) return match ($response->getStatusCode()) { 204 => null, 200, 201 => $this->container->get(PetMapper::class)->toSchema($unserializedResponse), - 202 => $this->container->get(PetTaskMapper::class)->toSchema($unserializedResponse), + 202 => $this->container->get(PetTaskMapper::class)->toSchema($unserializedResponse), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), + }; + } + + public function importPets(ImportPetsRequest $request) + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + + return match ($response->getStatusCode()) { + 201 => null, + 202 => null, + default => throw new RuntimeException('Response status code not properly mapped in schema.'), }; } diff --git a/test/suite/functional/Generator/Client/multiple-responses.yaml b/test/suite/functional/Generator/Client/multiple-responses.yaml index 08976d8..5179223 100644 --- a/test/suite/functional/Generator/Client/multiple-responses.yaml +++ b/test/suite/functional/Generator/Client/multiple-responses.yaml @@ -59,6 +59,14 @@ paths: $ref: '#/components/schemas/PetTask' '204': description: pet did not change + /pets-import: + post: + operationId: importPets + responses: + '201': + description: pets imported + '202': + description: pets import accepted components: schemas: Pet: diff --git a/test/suite/unit/Ast/Builder/CodeBuilderTest.php b/test/suite/unit/Ast/Builder/CodeBuilderTest.php index dd0a214..af617e0 100644 --- a/test/suite/unit/Ast/Builder/CodeBuilderTest.php +++ b/test/suite/unit/Ast/Builder/CodeBuilderTest.php @@ -287,6 +287,33 @@ public function testTernary(): void self::assertEquals('true ? $left : null', $this->printer->prettyPrint([$ternary])); } + public function testThrowExpression(): void + { + $throw = $this->sut->throwExpression('RuntimeException', $this->sut->val('message')); + self::assertEquals( + '$var ?? throw new RuntimeException(\'message\')', + $this->printer->prettyPrintExpr($this->sut->coalesce($this->sut->var('var'), $throw)) + ); + } + + public function testMatchWithDefaultArm(): void + { + $match = $this->sut->match( + $this->sut->var('code'), + $this->sut->matchArm([$this->sut->val(200)], $this->sut->val(null)), + $this->sut->defaultMatchArm($this->sut->throwExpression('RuntimeException')) + ); + self::assertEquals( + <<<'EOD' +match ($code) { + 200 => null, + default => throw new RuntimeException(), +} +EOD, + $this->printer->prettyPrintExpr($match) + ); + } + public function testNotEquals(): void { $left = $this->sut->var('left'); From dc658b1b40f23e7efe156764069f79fa22f2896e Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:12:08 +0200 Subject: [PATCH 02/21] fix: support nullable optional request parameters An optional path/query/header/cookie parameter whose schema (or referenced schema) is `nullable: true` made RequestGenerator::generateSetters() throw "Nullable optional parameter is not supported", so the whole generation aborted. The setter is now generated like for any other optional parameter, with a nullable type hint (docblock only on PHP 7.0) and null-guarded constraint checks. An explicit null is treated like an unset parameter: null values are already filtered out of the query, header and cookie parameters, exactly as they are for required nullable parameters. Co-Authored-By: Claude Opus 5.5 --- src/Generator/RequestGenerator.php | 6 +- .../Generator/Request/FindItemsRequest72.php | 149 ++++++++++++++++++ .../Generator/Request/FindItemsRequest74.php | 142 +++++++++++++++++ .../Generator/Request/FindItemsRequest81.php | 139 ++++++++++++++++ .../Generator/Request/FindItemsRequest83.php | 139 ++++++++++++++++ .../Generator/Request/findItems.yaml | 56 +++++++ .../Generator/RequestGeneratorTest.php | 24 +++ 7 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 test/suite/functional/Generator/Request/FindItemsRequest72.php create mode 100644 test/suite/functional/Generator/Request/FindItemsRequest74.php create mode 100644 test/suite/functional/Generator/Request/FindItemsRequest81.php create mode 100644 test/suite/functional/Generator/Request/FindItemsRequest83.php create mode 100644 test/suite/functional/Generator/Request/findItems.yaml diff --git a/src/Generator/RequestGenerator.php b/src/Generator/RequestGenerator.php index c51f203..881ce8e 100644 --- a/src/Generator/RequestGenerator.php +++ b/src/Generator/RequestGenerator.php @@ -13,7 +13,6 @@ use DoclerLabs\ApiClientGenerator\Entity\Operation; use DoclerLabs\ApiClientGenerator\Entity\Request; use DoclerLabs\ApiClientGenerator\Generator\Security\SecurityStrategyInterface; -use DoclerLabs\ApiClientGenerator\Input\InvalidSpecificationException; use DoclerLabs\ApiClientGenerator\Input\Specification; use DoclerLabs\ApiClientGenerator\Naming\CopiedNamespace; use DoclerLabs\ApiClientGenerator\Naming\RequestNaming; @@ -253,9 +252,8 @@ private function generateSetters(Request $request): array if ($field->isRequired()) { continue; } - if ($field->isNullable()) { - throw new InvalidSpecificationException('Nullable optional parameter is not supported'); - } + // A nullable optional parameter accepts an explicit null. It is treated like an unset parameter: + // null values are left out of the query, header and cookie parameters, as they are for required ones. $statements[] = $this->generateSet($field); } diff --git a/test/suite/functional/Generator/Request/FindItemsRequest72.php b/test/suite/functional/Generator/Request/FindItemsRequest72.php new file mode 100644 index 0000000..8108fc1 --- /dev/null +++ b/test/suite/functional/Generator/Request/FindItemsRequest72.php @@ -0,0 +1,149 @@ +contentType; + } + + /** + * @throws RequestValidationException + */ + public function setLimit(?int $limit): self + { + if ($limit !== null && $limit < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Cannot be less than 1.', 'limit', $limit)); + } + $this->limit = $limit; + + return $this; + } + + /** + * @throws RequestValidationException + */ + public function setName(?string $name): self + { + if ($name !== null && grapheme_strlen($name) < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Length should be greater than 1.', 'name', $name)); + } + $this->name = $name; + + return $this; + } + + public function setStatus(?string $status): self + { + $this->status = $status; + + return $this; + } + + public function setCreatedAfter(?DateTimeInterface $createdAfter): self + { + $this->createdAfter = $createdAfter; + + return $this; + } + + public function setXRequestSource(?string $xRequestSource): self + { + $this->xRequestSource = $xRequestSource; + + return $this; + } + + public function setSession(?string $session): self + { + $this->session = $session; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/items'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status, 'createdAfter' => $this->createdAfter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status, 'createdAfter' => $this->createdAfter]; + } + + public function getCookies(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['session' => $this->session], static function ($value) { + return null !== $value; + })); + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Request-Source' => $this->xRequestSource], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/FindItemsRequest74.php b/test/suite/functional/Generator/Request/FindItemsRequest74.php new file mode 100644 index 0000000..15ed5cc --- /dev/null +++ b/test/suite/functional/Generator/Request/FindItemsRequest74.php @@ -0,0 +1,142 @@ +contentType; + } + + /** + * @throws RequestValidationException + */ + public function setLimit(?int $limit): self + { + if ($limit !== null && $limit < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Cannot be less than 1.', 'limit', $limit)); + } + $this->limit = $limit; + + return $this; + } + + /** + * @throws RequestValidationException + */ + public function setName(?string $name): self + { + if ($name !== null && grapheme_strlen($name) < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Length should be greater than 1.', 'name', $name)); + } + $this->name = $name; + + return $this; + } + + public function setStatus(?string $status): self + { + $this->status = $status; + + return $this; + } + + public function setCreatedAfter(?DateTimeInterface $createdAfter): self + { + $this->createdAfter = $createdAfter; + + return $this; + } + + public function setXRequestSource(?string $xRequestSource): self + { + $this->xRequestSource = $xRequestSource; + + return $this; + } + + public function setSession(?string $session): self + { + $this->session = $session; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/items'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status, 'createdAfter' => $this->createdAfter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status, 'createdAfter' => $this->createdAfter]; + } + + public function getCookies(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['session' => $this->session], static function ($value) { + return null !== $value; + })); + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Request-Source' => $this->xRequestSource], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/FindItemsRequest81.php b/test/suite/functional/Generator/Request/FindItemsRequest81.php new file mode 100644 index 0000000..59097c0 --- /dev/null +++ b/test/suite/functional/Generator/Request/FindItemsRequest81.php @@ -0,0 +1,139 @@ +contentType; + } + + /** + * @throws RequestValidationException + */ + public function setLimit(?int $limit): self + { + if ($limit !== null && $limit < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Cannot be less than 1.', 'limit', $limit)); + } + $this->limit = $limit; + + return $this; + } + + /** + * @throws RequestValidationException + */ + public function setName(?string $name): self + { + if ($name !== null && grapheme_strlen($name) < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Length should be greater than 1.', 'name', $name)); + } + $this->name = $name; + + return $this; + } + + public function setStatus(?StatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + public function setCreatedAfter(?DateTimeInterface $createdAfter): self + { + $this->createdAfter = $createdAfter; + + return $this; + } + + public function setXRequestSource(?string $xRequestSource): self + { + $this->xRequestSource = $xRequestSource; + + return $this; + } + + public function setSession(?string $session): self + { + $this->session = $session; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/items'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status?->value, 'createdAfter' => $this->createdAfter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status?->value, 'createdAfter' => $this->createdAfter]; + } + + public function getCookies(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['session' => $this->session], static function ($value) { + return null !== $value; + })); + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Request-Source' => $this->xRequestSource], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/FindItemsRequest83.php b/test/suite/functional/Generator/Request/FindItemsRequest83.php new file mode 100644 index 0000000..59097c0 --- /dev/null +++ b/test/suite/functional/Generator/Request/FindItemsRequest83.php @@ -0,0 +1,139 @@ +contentType; + } + + /** + * @throws RequestValidationException + */ + public function setLimit(?int $limit): self + { + if ($limit !== null && $limit < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Cannot be less than 1.', 'limit', $limit)); + } + $this->limit = $limit; + + return $this; + } + + /** + * @throws RequestValidationException + */ + public function setName(?string $name): self + { + if ($name !== null && grapheme_strlen($name) < 1) { + throw new RequestValidationException(sprintf('Invalid %s value. Given: `%s`. Length should be greater than 1.', 'name', $name)); + } + $this->name = $name; + + return $this; + } + + public function setStatus(?StatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + public function setCreatedAfter(?DateTimeInterface $createdAfter): self + { + $this->createdAfter = $createdAfter; + + return $this; + } + + public function setXRequestSource(?string $xRequestSource): self + { + $this->xRequestSource = $xRequestSource; + + return $this; + } + + public function setSession(?string $session): self + { + $this->session = $session; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/items'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status?->value, 'createdAfter' => $this->createdAfter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['limit' => $this->limit, 'name' => $this->name, 'status' => $this->status?->value, 'createdAfter' => $this->createdAfter]; + } + + public function getCookies(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['session' => $this->session], static function ($value) { + return null !== $value; + })); + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Request-Source' => $this->xRequestSource], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/findItems.yaml b/test/suite/functional/Generator/Request/findItems.yaml new file mode 100644 index 0000000..f5e97c8 --- /dev/null +++ b/test/suite/functional/Generator/Request/findItems.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/items': + get: + description: Find items with nullable optional parameters + operationId: findItems + parameters: + - $ref: '#/components/parameters/Limit' + - name: name + in: query + schema: + type: string + nullable: true + minLength: 1 + - name: status + in: query + schema: + type: string + nullable: true + enum: + - active + - inactive + - name: createdAfter + in: query + schema: + type: string + format: date-time + nullable: true + - name: X-Request-Source + in: header + schema: + type: string + nullable: true + - name: session + in: cookie + schema: + type: string + nullable: true + responses: + '204': + description: Items found. +components: + schemas: + Limit: + type: integer + nullable: true + minimum: 1 + parameters: + Limit: + name: limit + in: query + schema: + $ref: '#/components/schemas/Limit' diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 9c867a7..3de47cd 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -250,6 +250,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with nullable optional parameters with php 7.2' => [ + '/Request/findItems.yaml', + '/Request/FindItemsRequest72.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindItemsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP72)->build(), + ], + 'Request with nullable optional parameters with php 7.4' => [ + '/Request/findItems.yaml', + '/Request/FindItemsRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindItemsRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with nullable optional parameters with php 8.1' => [ + '/Request/findItems.yaml', + '/Request/FindItemsRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindItemsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with nullable optional parameters with php 8.3' => [ + '/Request/findItems.yaml', + '/Request/FindItemsRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindItemsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], ]; } From 3fd9a70f414c3d88a38ea7b3f1093b76e1206296 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:12:54 +0200 Subject: [PATCH 03/21] fix: handle empty bodies of literal (scalar) responses A 2xx response whose schema is a scalar is read from the `__literalResponseValue` key that the JSON serializer adds. An empty body unserializes to `[]`, so the generated client raised an "Undefined array key" warning and then a TypeError from the non-nullable return type. Treat a missing (or null) literal value like an empty object response: throw UnexpectedResponseBodyException. PHP 8.0+ targets use a throw expression (`$response[KEY] ?? throw new ...`), older targets guard with an `if (!isset(...))` statement. Nullable literal responses return null (`$response[KEY] ?? null`), which is what they returned before, without the warning. Co-Authored-By: Claude Opus 5.5 --- src/Ast/PhpVersion.php | 5 ++ src/Generator/ClientGenerator.php | 75 +++++++++++++++++-- .../Client/LiteralResponsesClient74.php | 62 +++++++++++++++ .../Client/LiteralResponsesClient80.php | 53 +++++++++++++ .../Generator/Client/literal-responses.yaml | 26 +++++++ .../Generator/ClientGeneratorTest.php | 12 +++ test/suite/unit/Output/Php/PhpVersionTest.php | 7 ++ 7 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 test/suite/functional/Generator/Client/LiteralResponsesClient74.php create mode 100644 test/suite/functional/Generator/Client/LiteralResponsesClient80.php create mode 100644 test/suite/functional/Generator/Client/literal-responses.yaml diff --git a/src/Ast/PhpVersion.php b/src/Ast/PhpVersion.php index 22d075b..3fb5e32 100644 --- a/src/Ast/PhpVersion.php +++ b/src/Ast/PhpVersion.php @@ -84,6 +84,11 @@ public function isNullSafeSupported(): bool return $this->isVersionGreaterThanOrEqualTo80(); } + public function isThrowExpressionSupported(): bool + { + return $this->isVersionGreaterThanOrEqualTo80(); + } + public function isMixedTypehintSupported(): bool { return $this->isVersionGreaterThanOrEqualTo80(); diff --git a/src/Generator/ClientGenerator.php b/src/Generator/ClientGenerator.php index 0c70827..53319be 100644 --- a/src/Generator/ClientGenerator.php +++ b/src/Generator/ClientGenerator.php @@ -4,6 +4,7 @@ namespace DoclerLabs\ApiClientGenerator\Generator; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use DoclerLabs\ApiClientGenerator\Ast\Builder\CodeBuilder; use DoclerLabs\ApiClientGenerator\Ast\Builder\MethodBuilder; use DoclerLabs\ApiClientGenerator\Ast\Builder\ParameterBuilder; @@ -23,9 +24,11 @@ use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\ContentTypeSerializerInterface; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Scalar\LNumber; +use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Property; use Psr\Container\ContainerInterface; @@ -37,6 +40,8 @@ class ClientGenerator extends GeneratorAbstract { private const UNMAPPED_STATUS_CODE_MESSAGE = 'Response status code not properly mapped in schema.'; + private const MISSING_LITERAL_VALUE_MESSAGE = 'Response body does not contain the expected non-null value.'; + public function __construct( string $baseNamespace, CodeBuilder $builder, @@ -265,13 +270,69 @@ private function processResponse(Variable $unserializedResponseVar, Field $respo return $this->builder->methodCall($getMethod, 'toSchema', [$unserializedResponseVar]); } + $literalValue = $this->getLiteralResponseValue($unserializedResponseVar); + + // An empty response body carries no literal value: a nullable response maps it to null, any other throws + // (inline where throw expressions are supported, otherwise via generateLiteralResponseGuard()). + if ($responseBody->isNullable()) { + return $this->builder->coalesce($literalValue, $this->builder->val(null)); + } + + if ($this->phpVersion->isThrowExpressionSupported()) { + $this->addImport(UnexpectedResponseBodyException::class); + + return $this->builder->coalesce( + $literalValue, + $this->builder->throwExpression( + 'UnexpectedResponseBodyException', + $this->builder->val(self::MISSING_LITERAL_VALUE_MESSAGE) + ) + ); + } + + return $literalValue; + } + + /** + * Guards a non-nullable literal response against an empty body on targets without throw expressions. + * + * @return Stmt[] + */ + private function generateLiteralResponseGuard(Variable $unserializedResponseVar, Field $responseBody): array + { + if ( + $responseBody->isComposite() + || $responseBody->isNullable() + || $this->phpVersion->isThrowExpressionSupported() + ) { + return []; + } + + $this->addImport(UnexpectedResponseBodyException::class); + + return [ + $this->builder->if( + $this->builder->not( + $this->builder->funcCall('isset', [$this->getLiteralResponseValue($unserializedResponseVar)]) + ), + [ + $this->builder->throw( + 'UnexpectedResponseBodyException', + $this->builder->val(self::MISSING_LITERAL_VALUE_MESSAGE) + ), + ] + ), + ]; + } + + private function getLiteralResponseValue(Variable $unserializedResponseVar): ArrayDimFetch + { $this->addImport(CopiedNamespace::getImport($this->baseNamespace, ContentTypeSerializerInterface::class)); - $literalValue = $this->builder->getArrayItem( + + return $this->builder->getArrayItem( $unserializedResponseVar, $this->builder->classConstFetch('ContentTypeSerializerInterface', 'LITERAL_VALUE_KEY') ); - - return $literalValue; } private function emptyBodyAction( @@ -300,6 +361,7 @@ private function singleBodyAction( $handleResponseStmt = $this->builder->localMethodCall('handleResponse', $this->builder->args([$sendRequestStmt])); $stmts = [ $this->builder->assign($responseVar, $handleResponseStmt), + ...$this->generateLiteralResponseGuard($responseVar, $responseBody), $this->builder->return($this->processResponse($responseVar, $responseBody)), ]; @@ -357,7 +419,10 @@ private function multiBodyAction( if ($this->phpVersion->isMatchSupported()) { $matchBodies[$phpClassName] = $response; } else { - $caseBodies[$phpClassName] = $this->builder->return($response); + $caseBodies[$phpClassName] = [ + ...$this->generateLiteralResponseGuard($unserializedResponseVar, $responseBody), + $this->builder->return($response), + ]; } } } @@ -394,7 +459,7 @@ private function multiBodyAction( for ($i = 0, $l = count($caseConditions[$phpClassName]) - 1; $i < $l; ++$i) { $cases[] = $this->builder->case($caseConditions[$phpClassName][$i]); } - $cases[] = $this->builder->case($caseConditions[$phpClassName][$l], $caseBody); + $cases[] = $this->builder->case($caseConditions[$phpClassName][$l], ...$caseBody); } $stmts[] = $this->builder->switch( $this->builder->methodCall($responseVar, 'getStatusCode'), diff --git a/test/suite/functional/Generator/Client/LiteralResponsesClient74.php b/test/suite/functional/Generator/Client/LiteralResponsesClient74.php new file mode 100644 index 0000000..1ce6048 --- /dev/null +++ b/test/suite/functional/Generator/Client/LiteralResponsesClient74.php @@ -0,0 +1,62 @@ +client = $client; + $this->container = $container; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->client->sendRequest($this->container->get(RequestMapperInterface::class)->map($request)); + } + + public function getClock(GetClockRequest $request): string + { + $response = $this->handleResponse($this->sendRequest($request)); + if (!isset($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY])) { + throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + + public function getLastSeen(GetLastSeenRequest $request): ?string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; + } + + protected function handleResponse(ResponseInterface $response) + { + return $this->container->get(ResponseHandler::class)->handle($response); + } +} diff --git a/test/suite/functional/Generator/Client/LiteralResponsesClient80.php b/test/suite/functional/Generator/Client/LiteralResponsesClient80.php new file mode 100644 index 0000000..902dc1c --- /dev/null +++ b/test/suite/functional/Generator/Client/LiteralResponsesClient80.php @@ -0,0 +1,53 @@ +client->sendRequest($this->container->get(RequestMapperInterface::class)->map($request)); + } + + public function getClock(GetClockRequest $request): string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + public function getLastSeen(GetLastSeenRequest $request): ?string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; + } + + protected function handleResponse(ResponseInterface $response) + { + return $this->container->get(ResponseHandler::class)->handle($response); + } +} diff --git a/test/suite/functional/Generator/Client/literal-responses.yaml b/test/suite/functional/Generator/Client/literal-responses.yaml new file mode 100644 index 0000000..f04288c --- /dev/null +++ b/test/suite/functional/Generator/Client/literal-responses.yaml @@ -0,0 +1,26 @@ +openapi: "3.0.0" +info: + version: 1.0.0 + title: Literal Responses +paths: + /clock: + get: + operationId: getClock + responses: + '200': + description: Server time + content: + application/json: + schema: + type: string + /last-seen: + get: + operationId: getLastSeen + responses: + '200': + description: Last seen time, if any + content: + application/json: + schema: + type: string + nullable: true diff --git a/test/suite/functional/Generator/ClientGeneratorTest.php b/test/suite/functional/Generator/ClientGeneratorTest.php index a46dca8..1c43ef7 100644 --- a/test/suite/functional/Generator/ClientGeneratorTest.php +++ b/test/suite/functional/Generator/ClientGeneratorTest.php @@ -52,6 +52,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . '\\MultipleResponsesClient', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Literal responses with php 7.4' => [ + '/Client/literal-responses.yaml', + '/Client/LiteralResponsesClient74.php', + self::BASE_NAMESPACE . '\\LiteralResponsesClient', + ConfigurationBuilder::fake()->build(), + ], + 'Literal responses with php 8.0' => [ + '/Client/literal-responses.yaml', + '/Client/LiteralResponsesClient80.php', + self::BASE_NAMESPACE . '\\LiteralResponsesClient', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), + ], 'Basic schema with php 8.1 and include tags' => [ '/Client/petstore.yaml', '/Client/SwaggerPetstoreClientWithTags.php', diff --git a/test/suite/unit/Output/Php/PhpVersionTest.php b/test/suite/unit/Output/Php/PhpVersionTest.php index 74ac1f9..2f60211 100644 --- a/test/suite/unit/Output/Php/PhpVersionTest.php +++ b/test/suite/unit/Output/Php/PhpVersionTest.php @@ -44,6 +44,13 @@ public function testIsNullableTypeHintSupported() self::assertTrue((new PhpVersion(PhpVersion::VERSION_PHP74))->isNullableTypeHintSupported()); } + public function testIsThrowExpressionSupported() + { + self::assertFalse((new PhpVersion(PhpVersion::VERSION_PHP74))->isThrowExpressionSupported()); + self::assertTrue((new PhpVersion(PhpVersion::VERSION_PHP80))->isThrowExpressionSupported()); + self::assertTrue((new PhpVersion(PhpVersion::VERSION_PHP83))->isThrowExpressionSupported()); + } + public function testIsClassConstantVisibilitySupported() { self::assertFalse( From 476d604c32e20bda954e95f2e837f2135104b567 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:13:23 +0200 Subject: [PATCH 04/21] fix: keep nullable on nullable: true + allOf references The OAS 3.0 nullable reference idiom `{ nullable: true, allOf: [ $ref ] }` lost its nullability in FieldFactory::mergeAllOfAttributes(): cebe gives every typed schema `nullable: false` by default, so the referenced schema overrode the outer `nullable: true`. The generated schema and mapper then treated the property as non-nullable and a null in the response ended in a TypeError (`Mapper::toSchema(): Argument #1 ($payload) must be of type array, null given`). An explicit `nullable: true` next to allOf is now kept; the merge of the allOf members is unchanged otherwise. Co-Authored-By: Claude Opus 5.5 --- src/Input/Factory/FieldFactory.php | 5 +- .../Schema/NullableAllOfReference74.php | 75 +++++++++++++++++++ .../Schema/NullableAllOfReference81.php | 69 +++++++++++++++++ .../Generator/SchemaGeneratorTest.php | 12 +++ .../NullableAllOfReferenceMapper74.php | 47 ++++++++++++ .../NullableAllOfReferenceMapper81.php | 38 ++++++++++ .../SchemaMapper/nullableAllOfReference.yaml | 43 +++++++++++ .../Generator/SchemaMapperGeneratorTest.php | 12 +++ 8 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 test/suite/functional/Generator/Schema/NullableAllOfReference74.php create mode 100644 test/suite/functional/Generator/Schema/NullableAllOfReference81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/nullableAllOfReference.yaml diff --git a/src/Input/Factory/FieldFactory.php b/src/Input/Factory/FieldFactory.php index 370d7dd..02bdd7a 100644 --- a/src/Input/Factory/FieldFactory.php +++ b/src/Input/Factory/FieldFactory.php @@ -283,6 +283,9 @@ private function mergeAllOfProperties(string $operationName, SpecObjectInterface private function mergeAllOfAttributes(SpecObjectInterface $schema, bool &$nullable): SpecObjectInterface { + // `nullable: true` next to `allOf` (the OAS 3.0 nullable reference idiom) must survive the merge: + // the referenced schemas carry `nullable: false` by default, which would otherwise override it. + $explicitlyNullable = $nullable; foreach ($schema->allOf as $allOfSchema) { if ($allOfSchema instanceof Reference) { $allOfSchema = $allOfSchema->resolve(); @@ -291,7 +294,7 @@ private function mergeAllOfAttributes(SpecObjectInterface $schema, bool &$nullab if (in_array('null', (array)$allOfSchema->type, true)) { // 3.1 schema nullable does not exist anymore, rather it's done via type $nullable = true; - } elseif (isset($allOfSchema->nullable)) { + } elseif (!$explicitlyNullable && isset($allOfSchema->nullable)) { $nullable = $allOfSchema->nullable; } diff --git a/test/suite/functional/Generator/Schema/NullableAllOfReference74.php b/test/suite/functional/Generator/Schema/NullableAllOfReference74.php new file mode 100644 index 0000000..c571d31 --- /dev/null +++ b/test/suite/functional/Generator/Schema/NullableAllOfReference74.php @@ -0,0 +1,75 @@ + false]; + + public function __construct(?Membership $membership, Subscription $subscription) + { + $this->membership = $membership; + $this->subscription = $subscription; + } + + public function setFailedCondition(?FailedCondition $failedCondition): self + { + $this->failedCondition = $failedCondition; + $this->optionalPropertyChanged['failedCondition'] = true; + + return $this; + } + + public function hasFailedCondition(): bool + { + return $this->optionalPropertyChanged['failedCondition']; + } + + public function getMembership(): ?Membership + { + return $this->membership; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } + + public function getFailedCondition(): ?FailedCondition + { + return $this->failedCondition; + } + + public function toArray(): array + { + $fields = []; + $fields['membership'] = $this->membership !== null ? $this->membership->toArray() : null; + $fields['subscription'] = $this->subscription->toArray(); + if ($this->hasFailedCondition()) { + $fields['failedCondition'] = $this->failedCondition !== null ? $this->failedCondition->toArray() : null; + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/NullableAllOfReference81.php b/test/suite/functional/Generator/Schema/NullableAllOfReference81.php new file mode 100644 index 0000000..5ac7441 --- /dev/null +++ b/test/suite/functional/Generator/Schema/NullableAllOfReference81.php @@ -0,0 +1,69 @@ + false]; + + public function __construct(private readonly ?Membership $membership, private readonly Subscription $subscription) + { + } + + public function setFailedCondition(?FailedCondition $failedCondition): self + { + $this->failedCondition = $failedCondition; + $this->optionalPropertyChanged['failedCondition'] = true; + + return $this; + } + + public function hasFailedCondition(): bool + { + return $this->optionalPropertyChanged['failedCondition']; + } + + public function getMembership(): ?Membership + { + return $this->membership; + } + + public function getSubscription(): Subscription + { + return $this->subscription; + } + + public function getFailedCondition(): ?FailedCondition + { + return $this->failedCondition; + } + + public function toArray(): array + { + $fields = []; + $fields['membership'] = $this->membership?->toArray(); + $fields['subscription'] = $this->subscription->toArray(); + if ($this->hasFailedCondition()) { + $fields['failedCondition'] = $this->failedCondition?->toArray(); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/SchemaGeneratorTest.php b/test/suite/functional/Generator/SchemaGeneratorTest.php index 8d2b217..32b9905 100644 --- a/test/suite/functional/Generator/SchemaGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaGeneratorTest.php @@ -82,6 +82,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithArraysOfEnumProperties', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), ], + 'Nullable allOf reference with PHP 7.4' => [ + '/SchemaMapper/nullableAllOfReference.yaml', + '/Schema/NullableAllOfReference74.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\Profile', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Nullable allOf reference with PHP 8.1' => [ + '/SchemaMapper/nullableAllOfReference.yaml', + '/Schema/NullableAllOfReference81.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\Profile', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper74.php b/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper74.php new file mode 100644 index 0000000..d2f9781 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper74.php @@ -0,0 +1,47 @@ +membershipMapper = $membershipMapper; + $this->subscriptionMapper = $subscriptionMapper; + $this->failedConditionMapper = $failedConditionMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): Profile + { + $missingFields = implode(', ', array_diff(['membership', 'subscription'], array_keys($payload))); + if (!empty($missingFields)) { + throw new UnexpectedResponseBodyException('Required attributes for `Profile` missing in the response body: ' . $missingFields); + } + $schema = new Profile($payload['membership'] !== null ? $this->membershipMapper->toSchema($payload['membership']) : null, $this->subscriptionMapper->toSchema($payload['subscription'])); + if (array_key_exists('failedCondition', $payload)) { + $schema->setFailedCondition($payload['failedCondition'] !== null ? $this->failedConditionMapper->toSchema($payload['failedCondition']) : null); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper81.php b/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper81.php new file mode 100644 index 0000000..cefc536 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/NullableAllOfReferenceMapper81.php @@ -0,0 +1,38 @@ +membershipMapper->toSchema($payload['membership']) : null, $this->subscriptionMapper->toSchema($payload['subscription'])); + if (array_key_exists('failedCondition', $payload)) { + $schema->setFailedCondition($payload['failedCondition'] !== null ? $this->failedConditionMapper->toSchema($payload['failedCondition']) : null); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/nullableAllOfReference.yaml b/test/suite/functional/Generator/SchemaMapper/nullableAllOfReference.yaml new file mode 100644 index 0000000..5960085 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/nullableAllOfReference.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Nullable references expressed with allOf + version: 1.0.0 + +paths: + /profile: + get: + operationId: getProfile + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Profile' + +components: + schemas: + Profile: + type: object + required: + - membership + - subscription + properties: + membership: + nullable: true + allOf: + - $ref: '#/components/schemas/Membership' + subscription: + allOf: + - $ref: '#/components/schemas/Membership' + failedCondition: + nullable: true + allOf: + - $ref: '#/components/schemas/Membership' + Membership: + type: object + required: + - id + properties: + id: + type: integer diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 0caa9c5..323693e 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -178,6 +178,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ItemWithArraysOfEnumPropertiesMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), ], + 'Nullable allOf reference with php 7.4' => [ + '/SchemaMapper/nullableAllOfReference.yaml', + '/SchemaMapper/NullableAllOfReferenceMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ProfileMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Nullable allOf reference with php 8.1' => [ + '/SchemaMapper/nullableAllOfReference.yaml', + '/SchemaMapper/NullableAllOfReferenceMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ProfileMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } From 85f4f7c35b75359b340138718bf0d32233710127 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:13:56 +0200 Subject: [PATCH 05/21] fix: accept request body media types without schema A requestBody media type object without `schema` (for example `application/x-www-form-urlencoded: {}`) crashed the generator with a TypeError from SchemaNaming::getClassName(null) in RequestFactory, and EXCLUDE_TAGS could not help because tags are filtered after parsing. In OpenAPI the Media Type Object's `schema` is optional; without it the content of that media type is simply not described. Such a media type now still counts as an accepted request content type (it drives the Content-Type header and the contentType constructor parameter as before), but contributes no body field of its own: - if it is the only media type, the request has no body field and sends an empty body with that Content-Type; - next to media types with a schema, the body field comes from those, and the schemaless media type no longer trips the "Multiple schemas per request" check. That check used to pass or fail depending on the order of the media types. Co-Authored-By: Claude Opus 5.5 --- src/Input/Factory/RequestFactory.php | 17 +++-- .../Request/CheckEmailStatusRequest72.php | 57 +++++++++++++++ .../Request/CheckEmailStatusRequest74.php | 56 +++++++++++++++ .../Request/CheckEmailStatusRequest81.php | 56 +++++++++++++++ .../Request/CheckEmailStatusRequest83.php | 56 +++++++++++++++ .../Generator/Request/CreateNoteRequest72.php | 71 +++++++++++++++++++ .../Generator/Request/CreateNoteRequest74.php | 69 ++++++++++++++++++ .../Generator/Request/CreateNoteRequest81.php | 63 ++++++++++++++++ .../Generator/Request/CreateNoteRequest83.php | 63 ++++++++++++++++ .../Generator/Request/checkEmailStatus.yaml | 38 ++++++++++ .../Generator/RequestGeneratorTest.php | 48 +++++++++++++ 11 files changed, 588 insertions(+), 6 deletions(-) create mode 100644 test/suite/functional/Generator/Request/CheckEmailStatusRequest72.php create mode 100644 test/suite/functional/Generator/Request/CheckEmailStatusRequest74.php create mode 100644 test/suite/functional/Generator/Request/CheckEmailStatusRequest81.php create mode 100644 test/suite/functional/Generator/Request/CheckEmailStatusRequest83.php create mode 100644 test/suite/functional/Generator/Request/CreateNoteRequest72.php create mode 100644 test/suite/functional/Generator/Request/CreateNoteRequest74.php create mode 100644 test/suite/functional/Generator/Request/CreateNoteRequest81.php create mode 100644 test/suite/functional/Generator/Request/CreateNoteRequest83.php create mode 100644 test/suite/functional/Generator/Request/checkEmailStatus.yaml diff --git a/src/Input/Factory/RequestFactory.php b/src/Input/Factory/RequestFactory.php index 365bbd5..11576fd 100644 --- a/src/Input/Factory/RequestFactory.php +++ b/src/Input/Factory/RequestFactory.php @@ -6,7 +6,6 @@ use cebe\openapi\spec\Reference; use cebe\openapi\spec\RequestBody; -use cebe\openapi\SpecObjectInterface; use DoclerLabs\ApiClientGenerator\Entity\Request; use DoclerLabs\ApiClientGenerator\Entity\RequestFieldRegistry; use DoclerLabs\ApiClientGenerator\Input\InvalidSpecificationException; @@ -27,6 +26,7 @@ public function create( RequestBody $body = null ): Request { $contentTypes = []; + $schema = null; $collection = new RequestFieldRegistry(); foreach ($parameters as $parameter) { $referenceName = ''; @@ -47,17 +47,22 @@ public function create( } if ($body !== null) { - $schema = null; foreach ($body->content as $contentType => $content) { + $contentTypes[] = $contentType; + + // A media type without schema does not describe its content, so it has no body field of its own. + // The media type is still accepted, it can be sent with the body of the other media types or empty. + if ($content->schema === null) { + continue; + } if ($schema !== null && !Parity::isEqualTo($content->schema, $schema)) { throw new InvalidSpecificationException('Multiple schemas per request is not currently supported.'); } - $schema = $content->schema; - $contentTypes[] = $contentType; + $schema = $content->schema; } + } - /** @var SpecObjectInterface $schema */ - + if ($schema !== null) { $schemaName = SchemaNaming::getClassName($schema, ucfirst($operationName) . 'RequestBody'); $collection->add( diff --git a/test/suite/functional/Generator/Request/CheckEmailStatusRequest72.php b/test/suite/functional/Generator/Request/CheckEmailStatusRequest72.php new file mode 100644 index 0000000..13b064d --- /dev/null +++ b/test/suite/functional/Generator/Request/CheckEmailStatusRequest72.php @@ -0,0 +1,57 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/email-status'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/CheckEmailStatusRequest74.php b/test/suite/functional/Generator/Request/CheckEmailStatusRequest74.php new file mode 100644 index 0000000..611796c --- /dev/null +++ b/test/suite/functional/Generator/Request/CheckEmailStatusRequest74.php @@ -0,0 +1,56 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/email-status'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/CheckEmailStatusRequest81.php b/test/suite/functional/Generator/Request/CheckEmailStatusRequest81.php new file mode 100644 index 0000000..611796c --- /dev/null +++ b/test/suite/functional/Generator/Request/CheckEmailStatusRequest81.php @@ -0,0 +1,56 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/email-status'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/CheckEmailStatusRequest83.php b/test/suite/functional/Generator/Request/CheckEmailStatusRequest83.php new file mode 100644 index 0000000..611796c --- /dev/null +++ b/test/suite/functional/Generator/Request/CheckEmailStatusRequest83.php @@ -0,0 +1,56 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/email-status'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/CreateNoteRequest72.php b/test/suite/functional/Generator/Request/CreateNoteRequest72.php new file mode 100644 index 0000000..a3e244d --- /dev/null +++ b/test/suite/functional/Generator/Request/CreateNoteRequest72.php @@ -0,0 +1,71 @@ +note = $note; + $this->contentType = $contentType; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/notes'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return Note + */ + public function getBody() + { + return $this->note; + } +} diff --git a/test/suite/functional/Generator/Request/CreateNoteRequest74.php b/test/suite/functional/Generator/Request/CreateNoteRequest74.php new file mode 100644 index 0000000..783d25a --- /dev/null +++ b/test/suite/functional/Generator/Request/CreateNoteRequest74.php @@ -0,0 +1,69 @@ +note = $note; + $this->contentType = $contentType; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/notes'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return Note + */ + public function getBody() + { + return $this->note; + } +} diff --git a/test/suite/functional/Generator/Request/CreateNoteRequest81.php b/test/suite/functional/Generator/Request/CreateNoteRequest81.php new file mode 100644 index 0000000..b812d07 --- /dev/null +++ b/test/suite/functional/Generator/Request/CreateNoteRequest81.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/notes'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return Note + */ + public function getBody() + { + return $this->note; + } +} diff --git a/test/suite/functional/Generator/Request/CreateNoteRequest83.php b/test/suite/functional/Generator/Request/CreateNoteRequest83.php new file mode 100644 index 0000000..b812d07 --- /dev/null +++ b/test/suite/functional/Generator/Request/CreateNoteRequest83.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return 'v1/notes'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return Note + */ + public function getBody() + { + return $this->note; + } +} diff --git a/test/suite/functional/Generator/Request/checkEmailStatus.yaml b/test/suite/functional/Generator/Request/checkEmailStatus.yaml new file mode 100644 index 0000000..cb685ed --- /dev/null +++ b/test/suite/functional/Generator/Request/checkEmailStatus.yaml @@ -0,0 +1,38 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/email-status': + post: + description: Media type without schema, the request has no body field + operationId: checkEmailStatus + requestBody: + content: + application/x-www-form-urlencoded: {} + responses: + '204': + description: Email status checked. + '/v1/notes': + post: + description: Media type without schema next to one with a schema + operationId: createNote + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Note' + application/x-www-form-urlencoded: {} + responses: + '201': + description: Note created. +components: + schemas: + Note: + type: object + properties: + text: + type: string + required: + - text diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 3de47cd..03f4022 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -274,6 +274,54 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindItemsRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with a body media type without schema with php 7.2' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CheckEmailStatusRequest72.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CheckEmailStatusRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP72)->build(), + ], + 'Request with a body media type without schema with php 7.4' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CheckEmailStatusRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CheckEmailStatusRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with a body media type without schema with php 8.1' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CheckEmailStatusRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CheckEmailStatusRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with a body media type without schema with php 8.3' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CheckEmailStatusRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CheckEmailStatusRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], + 'Request with a body media type without schema next to one with schema with php 7.2' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CreateNoteRequest72.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CreateNoteRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP72)->build(), + ], + 'Request with a body media type without schema next to one with schema with php 7.4' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CreateNoteRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CreateNoteRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with a body media type without schema next to one with schema with php 8.1' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CreateNoteRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CreateNoteRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with a body media type without schema next to one with schema with php 8.3' => [ + '/Request/checkEmailStatus.yaml', + '/Request/CreateNoteRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CreateNoteRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], ]; } From f36d0583c818fc25b5c2f7894d48e99612f8a1ed Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:14:30 +0200 Subject: [PATCH 06/21] fix: map null for optional nullable enum properties For PHP 8.1+ targets, an optional property with a nullable enum schema (inline or $ref) was mapped with `::from($payload['x'])` behind an array_key_exists() guard, so an explicit null in the response ended in a TypeError (`::from(): Argument #1 ($value) must be of type string, null given`). Wrap it in the same `!== null ? ... : null` ternary that the required nullable enum, date and object branches already use. Co-Authored-By: Claude Opus 5.5 --- src/Generator/SchemaMapperGenerator.php | 9 ++- .../SchemaMapper/NullableEnumMapper74.php | 43 ++++++++++++++ .../SchemaMapper/NullableEnumMapper81.php | 47 +++++++++++++++ .../Generator/SchemaMapper/nullableEnum.yaml | 58 +++++++++++++++++++ .../Generator/SchemaMapperGeneratorTest.php | 12 ++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 test/suite/functional/Generator/SchemaMapper/NullableEnumMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/NullableEnumMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/nullableEnum.yaml diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 916b557..a6a180e 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -433,7 +433,14 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload } } elseif ($field->isEnum() && $this->phpVersion->isEnumSupported()) { $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $field->getPhpClassName())); - $optionalVar = $this->builder->staticCall($field->getPhpClassName(), 'from', [$optionalResponseItems[$i]]); + $newEnum = $this->builder->staticCall($field->getPhpClassName(), 'from', [$optionalResponseItems[$i]]); + $optionalVar = $field->isNullable() + ? $this->builder->ternary( + $this->builder->notEquals($optionalResponseItems[$i], $this->builder->val(null)), + $newEnum, + $this->builder->val(null) + ) + : $newEnum; } elseif ($field->isArrayOfEnums() && $this->phpVersion->isEnumSupported()) { $enumField = $field->getArrayItem(); $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $enumField->getPhpClassName())); diff --git a/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper74.php b/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper74.php new file mode 100644 index 0000000..516b438 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper74.php @@ -0,0 +1,43 @@ +setOptionalType($payload['optionalType']); + } + if (array_key_exists('optionalNullableType', $payload)) { + $schema->setOptionalNullableType($payload['optionalNullableType']); + } + if (array_key_exists('category', $payload)) { + $schema->setCategory($payload['category']); + } + if (empty($schema->toArray())) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper81.php b/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper81.php new file mode 100644 index 0000000..0212fc6 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/NullableEnumMapper81.php @@ -0,0 +1,47 @@ +setOptionalType(CookieOptionalTypeEnum::from($payload['optionalType'])); + } + if (array_key_exists('optionalNullableType', $payload)) { + $schema->setOptionalNullableType($payload['optionalNullableType'] !== null ? CookieOptionalNullableTypeEnum::from($payload['optionalNullableType']) : null); + } + if (array_key_exists('category', $payload)) { + $schema->setCategory($payload['category'] !== null ? CookieCategoryEnum::from($payload['category']) : null); + } + if (empty($schema->toArray())) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/nullableEnum.yaml b/test/suite/functional/Generator/SchemaMapper/nullableEnum.yaml new file mode 100644 index 0000000..d988916 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/nullableEnum.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.0 +info: + title: Nullable enum properties + version: 1.0.0 + +paths: + /cookies/{cookieId}: + get: + operationId: getCookie + parameters: + - in: path + name: cookieId + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Cookie' + +components: + schemas: + Cookie: + type: object + required: + - name + - mandatoryNullableType + properties: + name: + type: string + mandatoryNullableType: + type: string + enum: + - session + - persistent + nullable: true + optionalType: + type: string + enum: + - session + - persistent + optionalNullableType: + type: string + enum: + - session + - persistent + nullable: true + category: + $ref: '#/components/schemas/CookieCategory' + CookieCategory: + type: string + enum: + - analytical + - essential + nullable: true diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 323693e..0faf101 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -190,6 +190,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ProfileMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Nullable enums with php 7.4' => [ + '/SchemaMapper/nullableEnum.yaml', + '/SchemaMapper/NullableEnumMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\CookieMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Nullable enums with php 8.1' => [ + '/SchemaMapper/nullableEnum.yaml', + '/SchemaMapper/NullableEnumMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\CookieMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } From 7a480c0c80366e682b78c1fda39b476eb98c3e68 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:15:18 +0200 Subject: [PATCH 07/21] fix: generate enums for inline enum response bodies For PHP 8.1+ targets a 2xx response whose schema is an inline enum (for example a health check returning `type: string, enum: [OK]`) got an `ResponseBodyEnum` return type, but EnumGenerator never generated that enum and the client returned the raw literal, so every call ended in a TypeError. Generate the enum for enum response bodies and convert the literal value with `::from()` (nullable responses map a missing or null value to null). Targets without native enums keep returning the scalar. Co-Authored-By: Claude Opus 5.5 --- src/Generator/ClientGenerator.php | 29 +++++++- src/Generator/EnumGenerator.php | 5 ++ .../Client/LiteralResponsesClient74.php | 19 +++++ .../Client/LiteralResponsesClient80.php | 16 +++++ .../Client/LiteralResponsesClient81.php | 71 +++++++++++++++++++ .../Generator/Client/literal-responses.yaml | 26 +++++++ .../Generator/ClientGeneratorTest.php | 6 ++ .../Generator/EnumGeneratorTest.php | 12 ++++ .../Schema/GetHealthResponseBodyEnum.php | 16 +++++ .../Schema/GetModeResponseBodyEnum.php | 17 +++++ 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 test/suite/functional/Generator/Client/LiteralResponsesClient81.php create mode 100644 test/suite/functional/Generator/Schema/GetHealthResponseBodyEnum.php create mode 100644 test/suite/functional/Generator/Schema/GetModeResponseBodyEnum.php diff --git a/src/Generator/ClientGenerator.php b/src/Generator/ClientGenerator.php index 53319be..449f0ab 100644 --- a/src/Generator/ClientGenerator.php +++ b/src/Generator/ClientGenerator.php @@ -271,17 +271,36 @@ private function processResponse(Variable $unserializedResponseVar, Field $respo } $literalValue = $this->getLiteralResponseValue($unserializedResponseVar); + $isEnum = $responseBody->isEnum() && $this->phpVersion->isEnumSupported(); + if ($isEnum) { + $this->addImport( + sprintf( + '%s%s\\%s', + $this->baseNamespace, + EnumGenerator::NAMESPACE_SUBPATH, + $responseBody->getPhpClassName() + ) + ); + } // An empty response body carries no literal value: a nullable response maps it to null, any other throws // (inline where throw expressions are supported, otherwise via generateLiteralResponseGuard()). if ($responseBody->isNullable()) { + if ($isEnum) { + return $this->builder->ternary( + $this->builder->funcCall('isset', [$literalValue]), + $this->builder->staticCall($responseBody->getPhpClassName(), 'from', [$literalValue]), + $this->builder->val(null) + ); + } + return $this->builder->coalesce($literalValue, $this->builder->val(null)); } + $value = $literalValue; if ($this->phpVersion->isThrowExpressionSupported()) { $this->addImport(UnexpectedResponseBodyException::class); - - return $this->builder->coalesce( + $value = $this->builder->coalesce( $literalValue, $this->builder->throwExpression( 'UnexpectedResponseBodyException', @@ -290,7 +309,11 @@ private function processResponse(Variable $unserializedResponseVar, Field $respo ); } - return $literalValue; + if ($isEnum) { + return $this->builder->staticCall($responseBody->getPhpClassName(), 'from', [$value]); + } + + return $value; } /** diff --git a/src/Generator/EnumGenerator.php b/src/Generator/EnumGenerator.php index dcb1a51..0353518 100644 --- a/src/Generator/EnumGenerator.php +++ b/src/Generator/EnumGenerator.php @@ -54,6 +54,11 @@ public function generate(Specification $specification, PhpFileCollection $fileRe $this->generateEnum($field, $fileRegistry); } } + foreach ($operation->successfulResponses as $response) { + if ($response->body !== null && $response->body->isEnum()) { + $this->generateEnum($response->body, $fileRegistry); + } + } } } diff --git a/test/suite/functional/Generator/Client/LiteralResponsesClient74.php b/test/suite/functional/Generator/Client/LiteralResponsesClient74.php index 1ce6048..3a2b934 100644 --- a/test/suite/functional/Generator/Client/LiteralResponsesClient74.php +++ b/test/suite/functional/Generator/Client/LiteralResponsesClient74.php @@ -15,7 +15,9 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use Test\Request\GetClockRequest; +use Test\Request\GetHealthRequest; use Test\Request\GetLastSeenRequest; +use Test\Request\GetModeRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Response\ResponseHandler; @@ -55,6 +57,23 @@ public function getLastSeen(GetLastSeenRequest $request): ?string return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; } + public function getHealth(GetHealthRequest $request): string + { + $response = $this->handleResponse($this->sendRequest($request)); + if (!isset($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY])) { + throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + + public function getMode(GetModeRequest $request): ?string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/LiteralResponsesClient80.php b/test/suite/functional/Generator/Client/LiteralResponsesClient80.php index 902dc1c..2c22414 100644 --- a/test/suite/functional/Generator/Client/LiteralResponsesClient80.php +++ b/test/suite/functional/Generator/Client/LiteralResponsesClient80.php @@ -15,7 +15,9 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use Test\Request\GetClockRequest; +use Test\Request\GetHealthRequest; use Test\Request\GetLastSeenRequest; +use Test\Request\GetModeRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Response\ResponseHandler; @@ -46,6 +48,20 @@ public function getLastSeen(GetLastSeenRequest $request): ?string return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; } + public function getHealth(GetHealthRequest $request): string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + public function getMode(GetModeRequest $request): ?string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/LiteralResponsesClient81.php b/test/suite/functional/Generator/Client/LiteralResponsesClient81.php new file mode 100644 index 0000000..fd2116a --- /dev/null +++ b/test/suite/functional/Generator/Client/LiteralResponsesClient81.php @@ -0,0 +1,71 @@ +client->sendRequest($this->container->get(RequestMapperInterface::class)->map($request)); + } + + public function getClock(GetClockRequest $request): string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + public function getLastSeen(GetLastSeenRequest $request): ?string + { + $response = $this->handleResponse($this->sendRequest($request)); + + return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? null; + } + + public function getHealth(GetHealthRequest $request): GetHealthResponseBodyEnum + { + $response = $this->handleResponse($this->sendRequest($request)); + + return GetHealthResponseBodyEnum::from($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.')); + } + + public function getMode(GetModeRequest $request): ?GetModeResponseBodyEnum + { + $response = $this->handleResponse($this->sendRequest($request)); + + return isset($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]) ? GetModeResponseBodyEnum::from($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]) : null; + } + + protected function handleResponse(ResponseInterface $response) + { + return $this->container->get(ResponseHandler::class)->handle($response); + } +} diff --git a/test/suite/functional/Generator/Client/literal-responses.yaml b/test/suite/functional/Generator/Client/literal-responses.yaml index f04288c..3a61e6c 100644 --- a/test/suite/functional/Generator/Client/literal-responses.yaml +++ b/test/suite/functional/Generator/Client/literal-responses.yaml @@ -24,3 +24,29 @@ paths: schema: type: string nullable: true + /health: + get: + operationId: getHealth + responses: + '200': + description: Health check + content: + application/json: + schema: + type: string + enum: + - OK + /mode: + get: + operationId: getMode + responses: + '200': + description: Mode, if set + content: + application/json: + schema: + type: string + nullable: true + enum: + - auto + - manual diff --git a/test/suite/functional/Generator/ClientGeneratorTest.php b/test/suite/functional/Generator/ClientGeneratorTest.php index 1c43ef7..f27df56 100644 --- a/test/suite/functional/Generator/ClientGeneratorTest.php +++ b/test/suite/functional/Generator/ClientGeneratorTest.php @@ -64,6 +64,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . '\\LiteralResponsesClient', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), ], + 'Literal responses with php 8.1' => [ + '/Client/literal-responses.yaml', + '/Client/LiteralResponsesClient81.php', + self::BASE_NAMESPACE . '\\LiteralResponsesClient', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], 'Basic schema with php 8.1 and include tags' => [ '/Client/petstore.yaml', '/Client/SwaggerPetstoreClientWithTags.php', diff --git a/test/suite/functional/Generator/EnumGeneratorTest.php b/test/suite/functional/Generator/EnumGeneratorTest.php index 35ea966..6f313cb 100644 --- a/test/suite/functional/Generator/EnumGeneratorTest.php +++ b/test/suite/functional/Generator/EnumGeneratorTest.php @@ -53,6 +53,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesStatusesItemEnum', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Inline enum response body' => [ + '/Client/literal-responses.yaml', + '/Schema/GetHealthResponseBodyEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetHealthResponseBodyEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Inline nullable enum response body' => [ + '/Client/literal-responses.yaml', + '/Schema/GetModeResponseBodyEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetModeResponseBodyEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/GetHealthResponseBodyEnum.php b/test/suite/functional/Generator/Schema/GetHealthResponseBodyEnum.php new file mode 100644 index 0000000..c609f8e --- /dev/null +++ b/test/suite/functional/Generator/Schema/GetHealthResponseBodyEnum.php @@ -0,0 +1,16 @@ + Date: Mon, 28 Sep 2026 13:15:31 +0200 Subject: [PATCH 08/21] fix: send integer, number and boolean header parameters as strings Header parameters were put into getHeaders() with their native PHP type. guzzlehttp/psr7 >= 2.11 raises E_USER_DEPRECATED for every non-string header value ("guzzlehttp/psr7 3.0 requires string|string[]"), which shows up on every request of e.g. the mailer clients (integer X-User-Id), and nyholm/psr7 rejects boolean header values outright. RequestGenerator::generateGetHeadersMethod() now casts integer and number header values (including integer backed enums on PHP 8.1+) with (string), and turns booleans into '1'/'0', which is how this generator already serializes booleans in query and cookie parameters (http_build_query). OpenAPI leaves the textual form of booleans implementation-defined. Unset optional and null values stay null, so they are still filtered out. String headers are generated exactly as before. On the wire integer/number values and `true` are unchanged; `false` is now sent as '0' instead of an empty header value. Co-Authored-By: Claude Opus 5.5 --- src/Ast/Builder/CodeBuilder.php | 5 + src/Generator/RequestGenerator.php | 46 ++++++ .../Request/GetMessagesRequest72.php | 133 ++++++++++++++++++ .../Request/GetMessagesRequest74.php | 124 ++++++++++++++++ .../Request/GetMessagesRequest81.php | 113 +++++++++++++++ .../Request/GetMessagesRequest83.php | 113 +++++++++++++++ .../Generator/Request/getMessages.yaml | 56 ++++++++ .../Generator/RequestGeneratorTest.php | 24 ++++ .../unit/Ast/Builder/CodeBuilderTest.php | 6 + 9 files changed, 620 insertions(+) create mode 100644 test/suite/functional/Generator/Request/GetMessagesRequest72.php create mode 100644 test/suite/functional/Generator/Request/GetMessagesRequest74.php create mode 100644 test/suite/functional/Generator/Request/GetMessagesRequest81.php create mode 100644 test/suite/functional/Generator/Request/GetMessagesRequest83.php create mode 100644 test/suite/functional/Generator/Request/getMessages.yaml diff --git a/src/Ast/Builder/CodeBuilder.php b/src/Ast/Builder/CodeBuilder.php index b413b2e..c60e6b5 100644 --- a/src/Ast/Builder/CodeBuilder.php +++ b/src/Ast/Builder/CodeBuilder.php @@ -260,6 +260,11 @@ public function castToArray(Expr $expr): Cast\Array_ return new Cast\Array_($expr); } + public function castToString(Expr $expr): Cast\String_ + { + return new Cast\String_($expr); + } + public function expr(Expr $expr): Expression { return new Expression($expr); diff --git a/src/Generator/RequestGenerator.php b/src/Generator/RequestGenerator.php index 881ce8e..c1b1b04 100644 --- a/src/Generator/RequestGenerator.php +++ b/src/Generator/RequestGenerator.php @@ -18,6 +18,7 @@ use DoclerLabs\ApiClientGenerator\Naming\RequestNaming; use DoclerLabs\ApiClientGenerator\Output\Copy\Schema\SerializableInterface; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Stmt\ClassMethod; @@ -501,6 +502,14 @@ private function generateGetHeadersMethod( $fieldsArr = $this->generateFieldsArray($fields); $returnType = 'array'; + foreach ($fields as $field) { + /** @var Field $field */ + $headerValue = $this->generateHeaderStringValue($field); + if ($headerValue !== null) { + $fieldsArr[$field->getName()] = $headerValue; + } + } + if (!empty($fieldsArr)) { $returnVal = $this->builder->funcCall( 'array_merge', @@ -519,6 +528,43 @@ private function generateGetHeadersMethod( ->getNode(); } + /** + * PSR-7 header values are strings (guzzlehttp/psr7 >= 2.11 deprecates anything else, nyholm/psr7 rejects booleans), + * so integer, number and boolean header parameters (including integer backed enums) are cast to string. + * Booleans are sent as '1' and '0', the same way the query and cookie parameters serialize them. + * A null value (unset optional or nullable parameter) stays null, so it is still filtered out. + * Returns null when the header value needs no conversion. + */ + private function generateHeaderStringValue(Field $field): ?Expr + { + $type = $field->getType(); + if (!$type->isInteger() && !$type->isFloat() && !$type->isBoolean()) { + return null; + } + + $property = $this->builder->localPropertyFetch($field->getPhpVariableName()); + $value = $property; + if ($field->isEnum() && $this->phpVersion->isEnumSupported()) { + $value = $this->builder->propertyFetch($property, 'value'); + } + + if ($type->isBoolean()) { + $stringValue = $this->builder->ternary($value, $this->builder->val('1'), $this->builder->val('0')); + } else { + $stringValue = $this->builder->castToString($value); + } + + if ($field->isNullable() || $field->isOptional()) { + $stringValue = $this->builder->ternary( + $this->builder->equals($property, $this->builder->val(null)), + $this->builder->val(null), + $stringValue + ); + } + + return $stringValue; + } + private function getSecurityHeadersStmts(Operation $operation, Specification $specification): array { $stmts = []; diff --git a/test/suite/functional/Generator/Request/GetMessagesRequest72.php b/test/suite/functional/Generator/Request/GetMessagesRequest72.php new file mode 100644 index 0000000..d9ff5f5 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetMessagesRequest72.php @@ -0,0 +1,133 @@ +xAccountId = $xAccountId; + $this->xDebug = $xDebug; + $this->xTraceId = $xTraceId; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function setXUserId(int $xUserId): self + { + $this->xUserId = $xUserId; + + return $this; + } + + public function setXScore(?float $xScore): self + { + $this->xScore = $xScore; + + return $this; + } + + public function setXPreview(bool $xPreview): self + { + $this->xPreview = $xPreview; + + return $this; + } + + public function setXPriority(int $xPriority): self + { + $this->xPriority = $xPriority; + + return $this; + } + + public function setXUserType(string $xUserType): self + { + $this->xUserType = $xUserType; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/messages'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Account-Id' => (string) $this->xAccountId, 'X-User-Id' => $this->xUserId === null ? null : (string) $this->xUserId, 'X-Score' => $this->xScore === null ? null : (string) $this->xScore, 'X-Debug' => $this->xDebug ? '1' : '0', 'X-Preview' => $this->xPreview === null ? null : ($this->xPreview ? '1' : '0'), 'X-Priority' => $this->xPriority === null ? null : (string) $this->xPriority, 'X-User-Type' => $this->xUserType, 'X-Trace-Id' => $this->xTraceId], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetMessagesRequest74.php b/test/suite/functional/Generator/Request/GetMessagesRequest74.php new file mode 100644 index 0000000..794cabf --- /dev/null +++ b/test/suite/functional/Generator/Request/GetMessagesRequest74.php @@ -0,0 +1,124 @@ +xAccountId = $xAccountId; + $this->xDebug = $xDebug; + $this->xTraceId = $xTraceId; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function setXUserId(int $xUserId): self + { + $this->xUserId = $xUserId; + + return $this; + } + + public function setXScore(?float $xScore): self + { + $this->xScore = $xScore; + + return $this; + } + + public function setXPreview(bool $xPreview): self + { + $this->xPreview = $xPreview; + + return $this; + } + + public function setXPriority(int $xPriority): self + { + $this->xPriority = $xPriority; + + return $this; + } + + public function setXUserType(string $xUserType): self + { + $this->xUserType = $xUserType; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/messages'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Account-Id' => (string) $this->xAccountId, 'X-User-Id' => $this->xUserId === null ? null : (string) $this->xUserId, 'X-Score' => $this->xScore === null ? null : (string) $this->xScore, 'X-Debug' => $this->xDebug ? '1' : '0', 'X-Preview' => $this->xPreview === null ? null : ($this->xPreview ? '1' : '0'), 'X-Priority' => $this->xPriority === null ? null : (string) $this->xPriority, 'X-User-Type' => $this->xUserType, 'X-Trace-Id' => $this->xTraceId], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetMessagesRequest81.php b/test/suite/functional/Generator/Request/GetMessagesRequest81.php new file mode 100644 index 0000000..75fb4e1 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetMessagesRequest81.php @@ -0,0 +1,113 @@ +contentType; + } + + public function setXUserId(int $xUserId): self + { + $this->xUserId = $xUserId; + + return $this; + } + + public function setXScore(?float $xScore): self + { + $this->xScore = $xScore; + + return $this; + } + + public function setXPreview(bool $xPreview): self + { + $this->xPreview = $xPreview; + + return $this; + } + + public function setXPriority(XPriorityEnum $xPriority): self + { + $this->xPriority = $xPriority; + + return $this; + } + + public function setXUserType(XUserTypeEnum $xUserType): self + { + $this->xUserType = $xUserType; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/messages'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Account-Id' => (string) $this->xAccountId, 'X-User-Id' => $this->xUserId === null ? null : (string) $this->xUserId, 'X-Score' => $this->xScore === null ? null : (string) $this->xScore, 'X-Debug' => $this->xDebug ? '1' : '0', 'X-Preview' => $this->xPreview === null ? null : ($this->xPreview ? '1' : '0'), 'X-Priority' => $this->xPriority === null ? null : (string) $this->xPriority->value, 'X-User-Type' => $this->xUserType?->value, 'X-Trace-Id' => $this->xTraceId], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetMessagesRequest83.php b/test/suite/functional/Generator/Request/GetMessagesRequest83.php new file mode 100644 index 0000000..75fb4e1 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetMessagesRequest83.php @@ -0,0 +1,113 @@ +contentType; + } + + public function setXUserId(int $xUserId): self + { + $this->xUserId = $xUserId; + + return $this; + } + + public function setXScore(?float $xScore): self + { + $this->xScore = $xScore; + + return $this; + } + + public function setXPreview(bool $xPreview): self + { + $this->xPreview = $xPreview; + + return $this; + } + + public function setXPriority(XPriorityEnum $xPriority): self + { + $this->xPriority = $xPriority; + + return $this; + } + + public function setXUserType(XUserTypeEnum $xUserType): self + { + $this->xUserType = $xUserType; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/messages'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return array_merge([], array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['X-Account-Id' => (string) $this->xAccountId, 'X-User-Id' => $this->xUserId === null ? null : (string) $this->xUserId, 'X-Score' => $this->xScore === null ? null : (string) $this->xScore, 'X-Debug' => $this->xDebug ? '1' : '0', 'X-Preview' => $this->xPreview === null ? null : ($this->xPreview ? '1' : '0'), 'X-Priority' => $this->xPriority === null ? null : (string) $this->xPriority->value, 'X-User-Type' => $this->xUserType?->value, 'X-Trace-Id' => $this->xTraceId], static function ($value) { + return null !== $value; + }))); + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/getMessages.yaml b/test/suite/functional/Generator/Request/getMessages.yaml new file mode 100644 index 0000000..336b9b6 --- /dev/null +++ b/test/suite/functional/Generator/Request/getMessages.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/messages': + get: + description: Get messages, with non-string header parameters + operationId: getMessages + parameters: + - name: X-Account-Id + in: header + required: true + schema: + type: integer + - name: X-User-Id + in: header + schema: + type: integer + - name: X-Score + in: header + schema: + type: number + format: float + nullable: true + - name: X-Debug + in: header + required: true + schema: + type: boolean + - name: X-Preview + in: header + schema: + type: boolean + - name: X-Priority + in: header + schema: + type: integer + enum: + - 1 + - 2 + - name: X-User-Type + in: header + schema: + type: string + enum: + - member + - performer + - name: X-Trace-Id + in: header + required: true + schema: + type: string + responses: + '204': + description: Messages found. diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 03f4022..3bcff1e 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -322,6 +322,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\CreateNoteRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with integer, number and boolean header parameters with php 7.2' => [ + '/Request/getMessages.yaml', + '/Request/GetMessagesRequest72.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetMessagesRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP72)->build(), + ], + 'Request with integer, number and boolean header parameters with php 7.4' => [ + '/Request/getMessages.yaml', + '/Request/GetMessagesRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetMessagesRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with integer, number and boolean header parameters with php 8.1' => [ + '/Request/getMessages.yaml', + '/Request/GetMessagesRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetMessagesRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with integer, number and boolean header parameters with php 8.3' => [ + '/Request/getMessages.yaml', + '/Request/GetMessagesRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetMessagesRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], ]; } diff --git a/test/suite/unit/Ast/Builder/CodeBuilderTest.php b/test/suite/unit/Ast/Builder/CodeBuilderTest.php index dd0a214..cee57a5 100644 --- a/test/suite/unit/Ast/Builder/CodeBuilderTest.php +++ b/test/suite/unit/Ast/Builder/CodeBuilderTest.php @@ -287,6 +287,12 @@ public function testTernary(): void self::assertEquals('true ? $left : null', $this->printer->prettyPrint([$ternary])); } + public function testCastToString(): void + { + $cast = $this->sut->castToString($this->sut->var('value')); + self::assertEquals('(string) $value', $this->printer->prettyPrintExpr($cast)); + } + public function testNotEquals(): void { $left = $this->sut->var('left'); From d32f7455cdae13f4a281ecadd7a52b9d82b92dd9 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:18:02 +0200 Subject: [PATCH 09/21] fix: support literal bodies in operations with several 2xx responses multiBodyAction() grouped the responses by getPhpClassName(), which throws for scalar bodies, so an operation with for example a `200` string response and a `204` aborted the generation with "Call of getPhpClassName on the non-composite field". Enum bodies only worked on PHP 8.1+ targets. Group the responses by getPhpTypeHint() instead. It is the class name for every body getPhpClassName() accepts, so the output for composite, date and enum bodies is unchanged, and the scalar type for other literal bodies, which now get the same handling as a single literal response. Co-Authored-By: Claude Opus 5.5 --- src/Generator/ClientGenerator.php | 31 ++++++++++--------- .../Client/MultipleResponsesClient74.php | 20 ++++++++++++ .../Client/MultipleResponsesClient80.php | 15 +++++++++ .../Client/MultipleResponsesClient81.php | 16 ++++++++++ .../Generator/Client/multiple-responses.yaml | 15 +++++++++ 5 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/Generator/ClientGenerator.php b/src/Generator/ClientGenerator.php index 449f0ab..7bce42c 100644 --- a/src/Generator/ClientGenerator.php +++ b/src/Generator/ClientGenerator.php @@ -431,18 +431,19 @@ private function multiBodyAction( $nullableCases[$response->statusCode] = $this->builder->return($this->builder->val(null)); } } else { - $returnTypeHints[$responseBody->getPhpTypeHint()] = true; - $isNullable = $isNullable || $responseBody->isNullable(); - - $phpClassName = $responseBody->getPhpClassName(); - - $caseConditions[$phpClassName][] = new LNumber($response->statusCode); - if (!isset($caseBodies[$phpClassName])) { + // Responses are grouped by type hint: the class name for composite, date and enum bodies, + // the scalar type for any other literal body. + $typeHint = $responseBody->getPhpTypeHint(); + $returnTypeHints[$typeHint] = true; + $isNullable = $isNullable || $responseBody->isNullable(); + + $caseConditions[$typeHint][] = new LNumber($response->statusCode); + if (!isset($caseBodies[$typeHint])) { $response = $this->processResponse($unserializedResponseVar, $responseBody); if ($this->phpVersion->isMatchSupported()) { - $matchBodies[$phpClassName] = $response; + $matchBodies[$typeHint] = $response; } else { - $caseBodies[$phpClassName] = [ + $caseBodies[$typeHint] = [ ...$this->generateLiteralResponseGuard($unserializedResponseVar, $responseBody), $this->builder->return($response), ]; @@ -456,8 +457,8 @@ private function multiBodyAction( foreach ($nullableMatchArms as $statusCode => $nullableMatchArm) { $matchArms[] = $this->builder->matchArm([new LNumber($statusCode)], $nullableMatchArm); } - foreach ($matchBodies as $phpClassName => $matchBody) { - $matchArms[] = $this->builder->matchArm($caseConditions[$phpClassName], $matchBody); + foreach ($matchBodies as $typeHint => $matchBody) { + $matchArms[] = $this->builder->matchArm($caseConditions[$typeHint], $matchBody); } $this->addImport(RuntimeException::class); @@ -478,11 +479,11 @@ private function multiBodyAction( foreach ($nullableCases as $statusCode => $nullableCase) { $cases[] = $this->builder->case(new LNumber($statusCode), $nullableCase); } - foreach ($caseBodies as $phpClassName => $caseBody) { - for ($i = 0, $l = count($caseConditions[$phpClassName]) - 1; $i < $l; ++$i) { - $cases[] = $this->builder->case($caseConditions[$phpClassName][$i]); + foreach ($caseBodies as $typeHint => $caseBody) { + for ($i = 0, $l = count($caseConditions[$typeHint]) - 1; $i < $l; ++$i) { + $cases[] = $this->builder->case($caseConditions[$typeHint][$i]); } - $cases[] = $this->builder->case($caseConditions[$phpClassName][$l], ...$caseBody); + $cases[] = $this->builder->case($caseConditions[$typeHint][$l], ...$caseBody); } $stmts[] = $this->builder->switch( $this->builder->methodCall($responseVar, 'getStatusCode'), diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient74.php b/test/suite/functional/Generator/Client/MultipleResponsesClient74.php index a3b70ce..d1ace7a 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient74.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient74.php @@ -10,11 +10,13 @@ namespace Test; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\GetPetsStatusRequest; use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; @@ -23,6 +25,7 @@ use Test\Schema\Mapper\PetMapper; use Test\Schema\Mapper\PetTaskMapper; use Test\Schema\Pet; +use Test\Serializer\ContentType\ContentTypeSerializerInterface; class MultipleResponsesClient { @@ -84,6 +87,23 @@ public function importPets(ImportPetsRequest $request) throw new RuntimeException('Response status code not properly mapped in schema.'); } + public function getPetsStatus(GetPetsStatusRequest $request): ?string + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + switch ($response->getStatusCode()) { + case 204: + return null; + case 200: + if (!isset($unserializedResponse[ContentTypeSerializerInterface::LITERAL_VALUE_KEY])) { + throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } + + return $unserializedResponse[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + throw new RuntimeException('Response status code not properly mapped in schema.'); + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient80.php b/test/suite/functional/Generator/Client/MultipleResponsesClient80.php index 291e1de..206e763 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient80.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient80.php @@ -10,11 +10,13 @@ namespace Test; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\GetPetsStatusRequest; use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; @@ -23,6 +25,7 @@ use Test\Schema\Mapper\PetMapper; use Test\Schema\Mapper\PetTaskMapper; use Test\Schema\Pet; +use Test\Serializer\ContentType\ContentTypeSerializerInterface; class MultipleResponsesClient { @@ -72,6 +75,18 @@ public function importPets(ImportPetsRequest $request) }; } + public function getPetsStatus(GetPetsStatusRequest $request): ?string + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + + return match ($response->getStatusCode()) { + 204 => null, + 200 => $unserializedResponse[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), + }; + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/MultipleResponsesClient81.php b/test/suite/functional/Generator/Client/MultipleResponsesClient81.php index 3eb297a..02f5793 100644 --- a/test/suite/functional/Generator/Client/MultipleResponsesClient81.php +++ b/test/suite/functional/Generator/Client/MultipleResponsesClient81.php @@ -10,19 +10,23 @@ namespace Test; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Psr\Container\ContainerInterface; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\ResponseInterface; use RuntimeException; use Test\Request\AddPetTaskRequest; +use Test\Request\GetPetsStatusRequest; use Test\Request\ImportPetsRequest; use Test\Request\Mapper\RequestMapperInterface; use Test\Request\RequestInterface; use Test\Request\SavePetRequest; use Test\Response\ResponseHandler; +use Test\Schema\GetPetsStatusResponseBodyEnum; use Test\Schema\Mapper\PetMapper; use Test\Schema\Mapper\PetTaskMapper; use Test\Schema\Pet; +use Test\Serializer\ContentType\ContentTypeSerializerInterface; class MultipleResponsesClient { @@ -72,6 +76,18 @@ public function importPets(ImportPetsRequest $request) }; } + public function getPetsStatus(GetPetsStatusRequest $request): ?GetPetsStatusResponseBodyEnum + { + $response = $this->sendRequest($request); + $unserializedResponse = $this->handleResponse($response); + + return match ($response->getStatusCode()) { + 204 => null, + 200 => GetPetsStatusResponseBodyEnum::from($unserializedResponse[ContentTypeSerializerInterface::LITERAL_VALUE_KEY] ?? throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.')), + default => throw new RuntimeException('Response status code not properly mapped in schema.'), + }; + } + protected function handleResponse(ResponseInterface $response) { return $this->container->get(ResponseHandler::class)->handle($response); diff --git a/test/suite/functional/Generator/Client/multiple-responses.yaml b/test/suite/functional/Generator/Client/multiple-responses.yaml index 5179223..86cddba 100644 --- a/test/suite/functional/Generator/Client/multiple-responses.yaml +++ b/test/suite/functional/Generator/Client/multiple-responses.yaml @@ -67,6 +67,21 @@ paths: description: pets imported '202': description: pets import accepted + /pets-status: + get: + operationId: getPetsStatus + responses: + '200': + description: pet store status + content: + application/json: + schema: + type: string + enum: + - open + - closed + '204': + description: pet store status unknown components: schemas: Pet: From b3e75f00f937ac7242c4f9428392f0fc79bbe858 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:19:15 +0200 Subject: [PATCH 10/21] fix: catch enum ValueError when matching oneOf/anyOf alternatives Without a discriminator, the mapper tries every oneOf/anyOf alternative in a try/catch that only caught UnexpectedResponseBodyException. For PHP 8.1+ targets the alternatives map enums with `::from()`, which throws a ValueError for the value of another alternative (e.g. `type: [performer]` vs `type: [member]`), so every valid payload of a later alternative failed. Catch ValueError as well for the alternatives whose mapping goes through an enum (directly or nested); the other catch blocks and the pre-8.1 output are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/Generator/SchemaMapperGenerator.php | 36 +++++++- .../OneOfWithoutDiscriminatorEnumMapper74.php | 71 +++++++++++++++ .../OneOfWithoutDiscriminatorEnumMapper81.php | 60 +++++++++++++ .../oneOfWithoutDiscriminatorEnum.yaml | 86 +++++++++++++++++++ .../Generator/SchemaMapperGeneratorTest.php | 12 +++ 5 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index a6a180e..45cb26a 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -17,6 +17,7 @@ use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Case_; use PhpParser\Node\Stmt\ClassMethod; +use ValueError; class SchemaMapperGenerator extends MutatorAccessorClassGeneratorAbstract { @@ -485,8 +486,14 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload )); $this->addImport(UnexpectedResponseBodyException::class); + $caughtExceptions = [$this->builder->className('UnexpectedResponseBodyException')]; + if ($this->phpVersion->isEnumSupported() && $this->isMappedWithEnums($field)) { + // ::from() throws a ValueError for a value of another alternative + $this->addImport(ValueError::class); + $caughtExceptions[] = $this->builder->className('ValueError'); + } $catchStatement = $this->builder->catch( - [$this->builder->className('UnexpectedResponseBodyException')], + $caughtExceptions, $this->builder->var('exception'), [] ); @@ -686,4 +693,31 @@ private function resolveSchemaNameFromReference(string $reference): string return (string)end($segments); } + + /** + * Whether mapping the field (or anything nested in it) can throw the ValueError of an enum. + */ + private function isMappedWithEnums(Field $field): bool + { + if ($field->isEnum()) { + return true; + } + + if ($field->isArray()) { + return $this->isMappedWithEnums($field->getArrayItem()); + } + + if (($field->hasOneOf() || $field->hasAnyOf()) && !$field->getDiscriminator()) { + // its own mapper catches the ValueError of its alternatives + return false; + } + + foreach ($field->getObjectProperties() as $property) { + if ($this->isMappedWithEnums($property)) { + return true; + } + } + + return false; + } } diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper74.php b/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper74.php new file mode 100644 index 0000000..b930b44 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper74.php @@ -0,0 +1,71 @@ +performerUploaderMapper = $performerUploaderMapper; + $this->memberUploaderMapper = $memberUploaderMapper; + $this->systemUploaderMapper = $systemUploaderMapper; + $this->botUploaderMapper = $botUploaderMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): Uploader + { + $schema = new Uploader(); + $matches = 0; + + try { + $schema->setPerformerUploader($this->performerUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException $exception) { + } + + try { + $schema->setMemberUploader($this->memberUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException $exception) { + } + + try { + $schema->setSystemUploader($this->systemUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException $exception) { + } + + try { + $schema->setBotUploader($this->botUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException $exception) { + } + if ($matches !== 1) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper81.php new file mode 100644 index 0000000..6b14575 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper81.php @@ -0,0 +1,60 @@ +setPerformerUploader($this->performerUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException|ValueError $exception) { + } + + try { + $schema->setMemberUploader($this->memberUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException|ValueError $exception) { + } + + try { + $schema->setSystemUploader($this->systemUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException $exception) { + } + + try { + $schema->setBotUploader($this->botUploaderMapper->toSchema($payload)); + $matches = $matches + 1; + } catch (UnexpectedResponseBodyException|ValueError $exception) { + } + if ($matches !== 1) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml b/test/suite/functional/Generator/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml new file mode 100644 index 0000000..b10a9c9 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml @@ -0,0 +1,86 @@ +openapi: 3.0.0 +info: + title: OneOf without discriminator, alternatives told apart by an enum + version: 1.0.0 + +paths: + /attachments/{attachmentId}: + get: + operationId: getAttachment + parameters: + - in: path + name: attachmentId + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Attachment' + +components: + schemas: + Attachment: + type: object + required: + - id + - uploader + properties: + id: + type: integer + uploader: + oneOf: + - $ref: '#/components/schemas/PerformerUploader' + - $ref: '#/components/schemas/MemberUploader' + - $ref: '#/components/schemas/SystemUploader' + - $ref: '#/components/schemas/BotUploader' + PerformerUploader: + type: object + required: + - type + - id + properties: + type: + type: string + enum: + - performer + id: + type: integer + MemberUploader: + type: object + required: + - type + - id + properties: + type: + type: string + enum: + - member + id: + type: integer + SystemUploader: + type: object + required: + - system + properties: + system: + type: boolean + BotUploader: + type: object + required: + - bot + properties: + bot: + $ref: '#/components/schemas/Bot' + Bot: + type: object + required: + - kind + properties: + kind: + type: string + enum: + - crawler diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 0faf101..69bee6f 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -202,6 +202,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\CookieMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'OneOf response without discriminator told apart by enums with php 7.4' => [ + '/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml', + '/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\UploaderMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'OneOf response without discriminator told apart by enums with php 8.1' => [ + '/SchemaMapper/oneOfWithoutDiscriminatorEnum.yaml', + '/SchemaMapper/OneOfWithoutDiscriminatorEnumMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\UploaderMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } From 927245a08a014f7075ef7bed6fbafceffb862685 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:20:20 +0200 Subject: [PATCH 11/21] fix: serialize request bodies whose schema is not an object A requestBody whose schema is a primitive (e.g. campaign addCampaignMember: `application/json` integer) or an array of non-object items made getBody() return the raw value. The body serializers only accept SerializableInterface, so every call of such an operation failed with a RequestValidationException wrapping a TypeError, and the getBody() return type contradicted RequestInterface. Such a "literal" body is now returned wrapped in the new static Request\LiteralRequestBody, which exposes the value under ContentTypeSerializerInterface::LITERAL_VALUE_KEY (DateTimeInterface values are formatted as RFC 3339, like in schemas). The JSON serializers unwrap that key again, the inverse of how decode() wraps a literal response, so the body is sent as the JSON value itself (`123`, `"text"`, `[1,2]`). LiteralRequestBody is copied into a client only when one of its operations has a literal body. Object, array-of-object and untyped (mixed) bodies are generated exactly as before. The copied AbstractJsonContentTypeSerializer changes for every client; its behaviour is unchanged for all bodies other than the wrapped literal ones. The committed example clients are synced. Co-Authored-By: Claude Opus 5.5 --- .../AbstractJsonContentTypeSerializer.php | 7 +- .../AbstractJsonContentTypeSerializer.php | 7 +- src/Command/GenerateCommand.php | 5 ++ src/Entity/RequestFieldRegistry.php | 13 ++++ src/Generator/RequestGenerator.php | 21 ++++- src/Input/Specification.php | 12 +++ .../Copy/Request/LiteralRequestBody.php | 43 +++++++++++ .../AbstractJsonContentTypeSerializer.php | 9 ++- .../Generator/Request/AddMemberRequest72.php | 72 ++++++++++++++++++ .../Generator/Request/AddMemberRequest74.php | 69 +++++++++++++++++ .../Generator/Request/AddMemberRequest81.php | 63 +++++++++++++++ .../Generator/Request/AddMemberRequest83.php | 63 +++++++++++++++ .../Generator/Request/SetExpiryRequest74.php | 68 +++++++++++++++++ .../Generator/Request/SetExpiryRequest83.php | 65 ++++++++++++++++ .../Request/SetNicknameRequest74.php | 66 ++++++++++++++++ .../Request/SetNicknameRequest83.php | 63 +++++++++++++++ .../Generator/Request/SetTagsRequest74.php | 66 ++++++++++++++++ .../Generator/Request/SetTagsRequest83.php | 63 +++++++++++++++ .../Generator/Request/addMember.yaml | 64 ++++++++++++++++ .../Generator/RequestGeneratorTest.php | 60 +++++++++++++++ .../functional/Input/SpecificationTest.php | 53 +++++++++++++ .../Copy/Request/LiteralRequestBodyTest.php | 76 +++++++++++++++++++ .../JsonContentTypeSerializerTest.php | 27 +++++++ .../VdnApiJsonContentTypeSerializerTest.php | 27 +++++++ 24 files changed, 1077 insertions(+), 5 deletions(-) create mode 100644 src/Output/Copy/Request/LiteralRequestBody.php create mode 100644 test/suite/functional/Generator/Request/AddMemberRequest72.php create mode 100644 test/suite/functional/Generator/Request/AddMemberRequest74.php create mode 100644 test/suite/functional/Generator/Request/AddMemberRequest81.php create mode 100644 test/suite/functional/Generator/Request/AddMemberRequest83.php create mode 100644 test/suite/functional/Generator/Request/SetExpiryRequest74.php create mode 100644 test/suite/functional/Generator/Request/SetExpiryRequest83.php create mode 100644 test/suite/functional/Generator/Request/SetNicknameRequest74.php create mode 100644 test/suite/functional/Generator/Request/SetNicknameRequest83.php create mode 100644 test/suite/functional/Generator/Request/SetTagsRequest74.php create mode 100644 test/suite/functional/Generator/Request/SetTagsRequest83.php create mode 100644 test/suite/functional/Generator/Request/addMember.yaml create mode 100644 test/suite/unit/Output/Copy/Request/LiteralRequestBodyTest.php diff --git a/example/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php b/example/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php index f6c6110..0277978 100644 --- a/example/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php +++ b/example/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php @@ -24,7 +24,12 @@ abstract class AbstractJsonContentTypeSerializer implements ContentTypeSerialize */ public function encode(SerializableInterface $body): string { - $encodedData = json_encode($body->toArray(), self::JSON_OPTIONS); + $data = $body->toArray(); + // A literal (non-object) body is wrapped the same way decode() wraps a literal response. + if (array_keys($data) === [ContentTypeSerializerInterface::LITERAL_VALUE_KEY]) { + $data = $data[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + $encodedData = json_encode($data, self::JSON_OPTIONS); $lastErrorCode = json_last_error(); if ($lastErrorCode === JSON_ERROR_NONE && $encodedData !== false) { return $encodedData; diff --git a/example3_1/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php b/example3_1/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php index f6c6110..0277978 100644 --- a/example3_1/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php +++ b/example3_1/PetStoreClient/src/Serializer/ContentType/AbstractJsonContentTypeSerializer.php @@ -24,7 +24,12 @@ abstract class AbstractJsonContentTypeSerializer implements ContentTypeSerialize */ public function encode(SerializableInterface $body): string { - $encodedData = json_encode($body->toArray(), self::JSON_OPTIONS); + $data = $body->toArray(); + // A literal (non-object) body is wrapped the same way decode() wraps a literal response. + if (array_keys($data) === [ContentTypeSerializerInterface::LITERAL_VALUE_KEY]) { + $data = $data[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + $encodedData = json_encode($data, self::JSON_OPTIONS); $lastErrorCode = json_last_error(); if ($lastErrorCode === JSON_ERROR_NONE && $encodedData !== false) { return $encodedData; diff --git a/src/Command/GenerateCommand.php b/src/Command/GenerateCommand.php index afb52ab..6765523 100644 --- a/src/Command/GenerateCommand.php +++ b/src/Command/GenerateCommand.php @@ -13,6 +13,7 @@ use DoclerLabs\ApiClientGenerator\Input\Specification; use DoclerLabs\ApiClientGenerator\MetaTemplateFacade; use DoclerLabs\ApiClientGenerator\Output\Copy\Request\AuthenticationCredentials; +use DoclerLabs\ApiClientGenerator\Output\Copy\Request\LiteralRequestBody; use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\FormUrlencodedContentTypeSerializer; use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\JsonContentTypeSerializer; use DoclerLabs\ApiClientGenerator\Output\Copy\Serializer\ContentType\VdnApiJsonContentTypeSerializer; @@ -294,6 +295,10 @@ private function getUnusedValueObjects(Specification $specification): array $unusedClasses[] = AuthenticationCredentials::class; } + if (!$specification->hasLiteralRequestBody()) { + $unusedClasses[] = LiteralRequestBody::class; + } + return $unusedClasses; } diff --git a/src/Entity/RequestFieldRegistry.php b/src/Entity/RequestFieldRegistry.php index e51720b..06dca02 100644 --- a/src/Entity/RequestFieldRegistry.php +++ b/src/Entity/RequestFieldRegistry.php @@ -87,6 +87,19 @@ public function getBody(): ?Field return current($this->items[self::ORIGIN_BODY]); } + /** + * A literal body has a schema that is not an object: a string, number, boolean, or an array of those. + * Untyped (mixed) bodies are passed through as they are. + */ + public function hasLiteralBody(): bool + { + $body = $this->getBody(); + + return $body !== null + && !$body->isComposite() + && $body->getType()->toSpecificationType() !== null; + } + /** * @return RecursiveIteratorIterator|Field[] */ diff --git a/src/Generator/RequestGenerator.php b/src/Generator/RequestGenerator.php index c1b1b04..e3b16da 100644 --- a/src/Generator/RequestGenerator.php +++ b/src/Generator/RequestGenerator.php @@ -361,7 +361,7 @@ private function generateGetParametersMethods( $methods[] = $this->generateGetRawParametersMethod('getRawQueryParameters', $fields->getQueryFields(), $securityQueryFields); $methods[] = $this->generateGetParametersMethod('getCookies', $fields->getCookieFields(), $securityCookies); $methods[] = $this->generateGetHeadersMethod($request, $fields->getHeaderFields(), $operation, $specification); - $methods[] = $this->generateGetBody($fields->getBody()); + $methods[] = $this->generateGetBody($fields->getBody(), $fields->hasLiteralBody()); return $methods; } @@ -458,8 +458,25 @@ private function generateFieldsArray(array $fields): array return $fieldsArr; } - private function generateGetBody(?Field $body): ClassMethod + private function generateGetBody(?Field $body, bool $isLiteral): ClassMethod { + if ($body !== null && $isLiteral) { + // The body serializers can only encode SerializableInterface, so a literal body gets wrapped. + $returnType = 'LiteralRequestBody'; + $return = $this->builder->new( + $returnType, + [$this->builder->localPropertyFetch($body->getPhpVariableName())] + ); + + return $this + ->builder + ->method('getBody') + ->makePublic() + ->addStmt($this->builder->return($return)) + ->composeDocBlock([], $returnType) + ->getNode(); + } + if ($body !== null) { $returnType = $body->getPhpTypeHint(); diff --git a/src/Input/Specification.php b/src/Input/Specification.php index 505b143..cb0ea9f 100644 --- a/src/Input/Specification.php +++ b/src/Input/Specification.php @@ -121,6 +121,18 @@ public function getAllContentTypes(): array return array_keys(array_filter($allContentTypes)); } + public function hasLiteralRequestBody(): bool + { + /** @var Operation $operation */ + foreach ($this->getOperations() as $operation) { + if ($operation->request->fields->hasLiteralBody()) { + return true; + } + } + + return false; + } + public function requiresIntlExtension(): bool { foreach ($this->getOperations() as $operation) { diff --git a/src/Output/Copy/Request/LiteralRequestBody.php b/src/Output/Copy/Request/LiteralRequestBody.php new file mode 100644 index 0000000..8645c19 --- /dev/null +++ b/src/Output/Copy/Request/LiteralRequestBody.php @@ -0,0 +1,43 @@ +value = $value; + } + + public function toArray(): array + { + return [ContentTypeSerializerInterface::LITERAL_VALUE_KEY => $this->normalize($this->value)]; + } + + private function normalize($value) + { + if ($value instanceof DateTimeInterface) { + return $value->format(DATE_RFC3339); + } + + if (is_array($value)) { + foreach ($value as $key => $item) { + $value[$key] = $this->normalize($item); + } + } + + return $value; + } +} diff --git a/src/Output/Copy/Serializer/ContentType/AbstractJsonContentTypeSerializer.php b/src/Output/Copy/Serializer/ContentType/AbstractJsonContentTypeSerializer.php index e2a8112..8aed19e 100644 --- a/src/Output/Copy/Serializer/ContentType/AbstractJsonContentTypeSerializer.php +++ b/src/Output/Copy/Serializer/ContentType/AbstractJsonContentTypeSerializer.php @@ -18,7 +18,14 @@ abstract class AbstractJsonContentTypeSerializer implements ContentTypeSerialize */ public function encode(SerializableInterface $body): string { - $encodedData = json_encode($body->toArray(), self::JSON_OPTIONS); + $data = $body->toArray(); + + // A literal (non-object) body is wrapped the same way decode() wraps a literal response. + if (array_keys($data) === [ContentTypeSerializerInterface::LITERAL_VALUE_KEY]) { + $data = $data[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; + } + + $encodedData = json_encode($data, self::JSON_OPTIONS); $lastErrorCode = json_last_error(); if ($lastErrorCode === JSON_ERROR_NONE && $encodedData !== false) { diff --git a/test/suite/functional/Generator/Request/AddMemberRequest72.php b/test/suite/functional/Generator/Request/AddMemberRequest72.php new file mode 100644 index 0000000..9c20381 --- /dev/null +++ b/test/suite/functional/Generator/Request/AddMemberRequest72.php @@ -0,0 +1,72 @@ +campaignId = $campaignId; + $this->addMemberRequestBody = $addMemberRequestBody; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return strtr('v1/campaigns/{campaignId}/members', ['{campaignId}' => $this->campaignId]); + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->addMemberRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/AddMemberRequest74.php b/test/suite/functional/Generator/Request/AddMemberRequest74.php new file mode 100644 index 0000000..9ae178d --- /dev/null +++ b/test/suite/functional/Generator/Request/AddMemberRequest74.php @@ -0,0 +1,69 @@ +campaignId = $campaignId; + $this->addMemberRequestBody = $addMemberRequestBody; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return strtr('v1/campaigns/{campaignId}/members', ['{campaignId}' => $this->campaignId]); + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->addMemberRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/AddMemberRequest81.php b/test/suite/functional/Generator/Request/AddMemberRequest81.php new file mode 100644 index 0000000..151fb63 --- /dev/null +++ b/test/suite/functional/Generator/Request/AddMemberRequest81.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return strtr('v1/campaigns/{campaignId}/members', ['{campaignId}' => $this->campaignId]); + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->addMemberRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/AddMemberRequest83.php b/test/suite/functional/Generator/Request/AddMemberRequest83.php new file mode 100644 index 0000000..151fb63 --- /dev/null +++ b/test/suite/functional/Generator/Request/AddMemberRequest83.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'POST'; + } + + public function getRoute(): string + { + return strtr('v1/campaigns/{campaignId}/members', ['{campaignId}' => $this->campaignId]); + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->addMemberRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetExpiryRequest74.php b/test/suite/functional/Generator/Request/SetExpiryRequest74.php new file mode 100644 index 0000000..ab0fbd4 --- /dev/null +++ b/test/suite/functional/Generator/Request/SetExpiryRequest74.php @@ -0,0 +1,68 @@ +setExpiryRequestBody = $setExpiryRequestBody; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/expiry'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setExpiryRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetExpiryRequest83.php b/test/suite/functional/Generator/Request/SetExpiryRequest83.php new file mode 100644 index 0000000..cfc9144 --- /dev/null +++ b/test/suite/functional/Generator/Request/SetExpiryRequest83.php @@ -0,0 +1,65 @@ +contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/expiry'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setExpiryRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetNicknameRequest74.php b/test/suite/functional/Generator/Request/SetNicknameRequest74.php new file mode 100644 index 0000000..359e6d9 --- /dev/null +++ b/test/suite/functional/Generator/Request/SetNicknameRequest74.php @@ -0,0 +1,66 @@ +setNicknameRequestBody = $setNicknameRequestBody; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/nickname'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setNicknameRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetNicknameRequest83.php b/test/suite/functional/Generator/Request/SetNicknameRequest83.php new file mode 100644 index 0000000..1e62b68 --- /dev/null +++ b/test/suite/functional/Generator/Request/SetNicknameRequest83.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/nickname'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setNicknameRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetTagsRequest74.php b/test/suite/functional/Generator/Request/SetTagsRequest74.php new file mode 100644 index 0000000..d2ba732 --- /dev/null +++ b/test/suite/functional/Generator/Request/SetTagsRequest74.php @@ -0,0 +1,66 @@ +setTagsRequestBody = $setTagsRequestBody; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/tags'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setTagsRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/SetTagsRequest83.php b/test/suite/functional/Generator/Request/SetTagsRequest83.php new file mode 100644 index 0000000..8462bee --- /dev/null +++ b/test/suite/functional/Generator/Request/SetTagsRequest83.php @@ -0,0 +1,63 @@ +contentType; + } + + public function getMethod(): string + { + return 'PUT'; + } + + public function getRoute(): string + { + return 'v1/tags'; + } + + public function getQueryParameters(): array + { + return []; + } + + public function getRawQueryParameters(): array + { + return []; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return ['Content-Type' => $this->contentType]; + } + + /** + * @return LiteralRequestBody + */ + public function getBody() + { + return new LiteralRequestBody($this->setTagsRequestBody); + } +} diff --git a/test/suite/functional/Generator/Request/addMember.yaml b/test/suite/functional/Generator/Request/addMember.yaml new file mode 100644 index 0000000..19a1109 --- /dev/null +++ b/test/suite/functional/Generator/Request/addMember.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/campaigns/{campaignId}/members': + post: + description: Literal integer request body + operationId: addMember + parameters: + - name: campaignId + in: path + required: true + schema: + type: integer + requestBody: + content: + application/json: + schema: + type: integer + description: The member id. + responses: + '204': + description: Member added. + '/v1/nickname': + put: + description: Literal nullable string request body + operationId: setNickname + requestBody: + content: + application/json: + schema: + type: string + nullable: true + responses: + '204': + description: Nickname set. + '/v1/tags': + put: + description: Array of strings request body + operationId: setTags + requestBody: + content: + application/json: + schema: + type: array + items: + type: string + responses: + '204': + description: Tags set. + '/v1/expiry': + put: + description: Literal date-time request body + operationId: setExpiry + requestBody: + content: + application/json: + schema: + type: string + format: date-time + responses: + '204': + description: Expiry set. diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 3bcff1e..e7bc3b6 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -346,6 +346,66 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetMessagesRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with literal integer body with php 7.2' => [ + '/Request/addMember.yaml', + '/Request/AddMemberRequest72.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\AddMemberRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP72)->build(), + ], + 'Request with literal integer body with php 7.4' => [ + '/Request/addMember.yaml', + '/Request/AddMemberRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\AddMemberRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with literal integer body with php 8.1' => [ + '/Request/addMember.yaml', + '/Request/AddMemberRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\AddMemberRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with literal integer body with php 8.3' => [ + '/Request/addMember.yaml', + '/Request/AddMemberRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\AddMemberRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], + 'Request with literal nullable string body with php 7.4' => [ + '/Request/addMember.yaml', + '/Request/SetNicknameRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetNicknameRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with literal nullable string body with php 8.3' => [ + '/Request/addMember.yaml', + '/Request/SetNicknameRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetNicknameRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], + 'Request with array of strings body with php 7.4' => [ + '/Request/addMember.yaml', + '/Request/SetTagsRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetTagsRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with array of strings body with php 8.3' => [ + '/Request/addMember.yaml', + '/Request/SetTagsRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetTagsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], + 'Request with literal date-time body with php 7.4' => [ + '/Request/addMember.yaml', + '/Request/SetExpiryRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetExpiryRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with literal date-time body with php 8.3' => [ + '/Request/addMember.yaml', + '/Request/SetExpiryRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\SetExpiryRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], ]; } diff --git a/test/suite/functional/Input/SpecificationTest.php b/test/suite/functional/Input/SpecificationTest.php index 3c129a9..183bc2c 100644 --- a/test/suite/functional/Input/SpecificationTest.php +++ b/test/suite/functional/Input/SpecificationTest.php @@ -269,6 +269,59 @@ public function testGetFromInfo(): void static::assertSame('License name', $specificationWithLicense->getLicenseName()); } + /** + * @dataProvider literalRequestBodyProvider + */ + public function testHasLiteralRequestBody(?array $requestBodySchema, bool $expectedResult): void + { + $operation = [ + 'operationId' => 'updateUser', + 'responses' => [ + '204' => [ + 'description' => 'No content', + ], + ], + ]; + if ($requestBodySchema !== null) { + $operation['requestBody'] = [ + 'content' => [ + 'application/json' => [ + 'schema' => $requestBodySchema, + ], + ], + ]; + } + $data = [ + 'openapi' => '3.0.0', + 'info' => [ + 'title' => 'Sample API', + 'version' => '1.0.0', + ], + 'paths' => [ + '/users' => [ + 'put' => $operation, + ], + ], + ]; + + $specification = $this->sut->parse($data, '/openapi.yaml'); + + static::assertSame($expectedResult, $specification->hasLiteralRequestBody()); + } + + public function literalRequestBodyProvider(): array + { + return [ + 'No request body' => [null, false], + 'Object body' => [['type' => 'object', 'properties' => ['name' => ['type' => 'string']]], false], + 'Array of objects' => [['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['name' => ['type' => 'string']]]], false], + 'Untyped body' => [['description' => 'Anything'], false], + 'Integer body' => [['type' => 'integer'], true], + 'Nullable string body' => [['type' => 'string', 'nullable' => true], true], + 'Array of integers' => [['type' => 'array', 'items' => ['type' => 'integer']], true], + ]; + } + protected function setUp(): void { $container = $this->getContainerWith(ConfigurationBuilder::fake()->build()); diff --git a/test/suite/unit/Output/Copy/Request/LiteralRequestBodyTest.php b/test/suite/unit/Output/Copy/Request/LiteralRequestBodyTest.php new file mode 100644 index 0000000..d86da11 --- /dev/null +++ b/test/suite/unit/Output/Copy/Request/LiteralRequestBodyTest.php @@ -0,0 +1,76 @@ + $expectedValue], $body->toArray()); + } + + public function literalValuesProvider(): array + { + $date = new DateTimeImmutable('2026-09-28T10:11:12+02:00'); + + return [ + 'integer' => [42, 42], + 'float' => [1.5, 1.5], + 'string' => ['text', 'text'], + 'boolean' => [false, false], + 'null' => [null, null], + 'date-time' => [$date, '2026-09-28T10:11:12+02:00'], + 'array of scalar' => [['a' => 1, 'b' => 2], ['a' => 1, 'b' => 2]], + 'array of dates' => [[$date], ['2026-09-28T10:11:12+02:00']], + ]; + } + + /** + * @dataProvider serializedBodiesProvider + */ + public function testBodySerializerEncodesTheLiteralValue(string $contentType, $value, string $expectedBody): void + { + $serializer = (new BodySerializer()) + ->add(new JsonContentTypeSerializer()) + ->add(new VdnApiJsonContentTypeSerializer()); + + $request = $this->createMock(RequestInterface::class); + $request->method('getContentType')->willReturn($contentType); + $request->method('getBody')->willReturn(new LiteralRequestBody($value)); + + self::assertSame($expectedBody, $serializer->serializeRequest($request)); + } + + public function serializedBodiesProvider(): array + { + return [ + 'integer as json' => ['application/json', 123, '123'], + 'string as json' => ['application/json', 'text', '"text"'], + 'boolean as json' => ['application/json', true, 'true'], + 'null as json' => ['application/json', null, 'null'], + 'list as json' => ['application/json', [1, 2], '[1,2]'], + 'integer as json api' => ['application/vnd.api+json', 123, '123'], + 'integer as +json' => ['application/problem+json', 123, '123'], + 'date-time as json' => ['application/json', new DateTimeImmutable('2026-09-28T10:11:12Z'), '"2026-09-28T10:11:12+00:00"'], + ]; + } +} diff --git a/test/suite/unit/Output/Copy/Serializer/ContentType/JsonContentTypeSerializerTest.php b/test/suite/unit/Output/Copy/Serializer/ContentType/JsonContentTypeSerializerTest.php index a81ce7e..bd491b9 100644 --- a/test/suite/unit/Output/Copy/Serializer/ContentType/JsonContentTypeSerializerTest.php +++ b/test/suite/unit/Output/Copy/Serializer/ContentType/JsonContentTypeSerializerTest.php @@ -23,6 +23,8 @@ protected function setUp(): void /** * @dataProvider validCasesProvider + * @dataProvider literalCasesProvider + * @dataProvider literalEncodeCasesProvider */ public function testEncode(array $input, string $expectedResult): void { @@ -89,6 +91,31 @@ public function validCasesProvider(): array ]; } + public function literalEncodeCasesProvider(): array + { + return [ + 'literal float keeps its zero fraction' => [ + [ + '__literalResponseValue' => 1.0, + ], + '1.0', + ], + 'literal list' => [ + [ + '__literalResponseValue' => ['a', 'b'], + ], + '["a","b"]', + ], + 'literal key next to other keys is a regular property' => [ + [ + '__literalResponseValue' => 1, + 'other' => 2, + ], + '{"__literalResponseValue":1,"other":2}', + ], + ]; + } + public function literalCasesProvider(): array { return [ diff --git a/test/suite/unit/Output/Copy/Serializer/ContentType/VdnApiJsonContentTypeSerializerTest.php b/test/suite/unit/Output/Copy/Serializer/ContentType/VdnApiJsonContentTypeSerializerTest.php index 79869b6..c231e07 100644 --- a/test/suite/unit/Output/Copy/Serializer/ContentType/VdnApiJsonContentTypeSerializerTest.php +++ b/test/suite/unit/Output/Copy/Serializer/ContentType/VdnApiJsonContentTypeSerializerTest.php @@ -23,6 +23,8 @@ protected function setUp(): void /** * @dataProvider validCasesProvider + * @dataProvider literalCasesProvider + * @dataProvider literalEncodeCasesProvider */ public function testEncode(array $input, string $expectedResult): void { @@ -89,6 +91,31 @@ public function validCasesProvider(): array ]; } + public function literalEncodeCasesProvider(): array + { + return [ + 'literal float keeps its zero fraction' => [ + [ + '__literalResponseValue' => 1.0, + ], + '1.0', + ], + 'literal list' => [ + [ + '__literalResponseValue' => ['a', 'b'], + ], + '["a","b"]', + ], + 'literal key next to other keys is a regular property' => [ + [ + '__literalResponseValue' => 1, + 'other' => 2, + ], + '{"__literalResponseValue":1,"other":2}', + ], + ]; + } + public function literalCasesProvider(): array { return [ From 3237f1117d2308524487dc9eb8a7ee57425d4026 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:23:12 +0200 Subject: [PATCH 12/21] fix: throw UnexpectedResponseBodyException for unknown discriminator values The default arm of the discriminator switch (and the whole dispatch for a discriminator without mapping) built the setter and mapper names from the raw value: `$this->{$value . 'Mapper'}` and `set . ucfirst($value)`. A value outside the mapping, or the schema name itself (the implicit mapping per OAS), ended in an "Undefined property" warning and an Error (call to an undefined method, or a member function on null) instead of the mapper's exception. Generate explicit cases instead: - the explicit mapping, as before (the alternative is now looked up by its schema name, which is what the mapping references end with); - for every alternative the mapping does not cover, the implicit mapping (the schema name) plus the lcfirst class name that the old value based dispatch resolved, so payloads that mapped before still map; - a default arm that throws UnexpectedResponseBodyException. The anyOf-with-discriminator fixtures were never run because their data set names were duplicated in the provider; they now have unique names. Co-Authored-By: Claude Opus 5.5 --- src/Generator/SchemaMapperGenerator.php | 154 +++++++++--------- .../AnyOfResponseBodyMapper74.php | 10 +- .../AnyOfResponseBodyMapper80.php | 10 +- .../AnyOfResponseBodyMapper81.php | 10 +- ...eOfDiscriminatorPartialMappingMapper74.php | 63 +++++++ ...eOfDiscriminatorPartialMappingMapper81.php | 54 ++++++ ...eOfDiscriminatorWithoutMappingMapper74.php | 53 ++++++ ...eOfDiscriminatorWithoutMappingMapper81.php | 21 ++- .../OneOfResponseBodyMapper74.php | 10 +- .../OneOfResponseBodyMapper80.php | 10 +- .../OneOfResponseBodyMapper81.php | 10 +- .../oneOfDiscriminatorPartialMapping.yaml | 66 ++++++++ .../Generator/SchemaMapperGeneratorTest.php | 24 ++- 13 files changed, 386 insertions(+), 109 deletions(-) create mode 100644 test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 45cb26a..27c3c00 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -12,7 +12,6 @@ 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_; @@ -585,105 +584,114 @@ private function generateDiscriminatorStatement(Field $root, Variable $payloadVa $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') + $cases = $this->generateDiscriminatorCases($root, $mapping, $payloadVariable); + + $this->addImport(UnexpectedResponseBodyException::class); + $cases[] = $this->builder->default( + $this->builder->throw( + 'UnexpectedResponseBodyException', + $this->builder->val( + sprintf( + 'Unknown `%s` discriminator value for `%s` in the response body', + $propertyName, + $root->getPhpClassName() + ) ) ) ); + $this->mapMethodThrownExceptions['UnexpectedResponseBodyException'] = true; - $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]; + return $this->builder->if($ifCondition, [$this->builder->switch($payloadDiscriminator, ...$cases)]); } /** + * One case per discriminator value: the explicit mapping first, then the implicit mapping (the schema name) + * of every alternative the explicit mapping does not cover. The latter also accepts the lcfirst class name, + * which the previous value based dispatch (`$this->{$value . 'Mapper'}`) resolved. + * * @param string[] $mapping * * @return Case_[] */ - private function generateDiscriminatorMappingCases(Field $root, array $mapping, Variable $payloadVariable): array + private function generateDiscriminatorCases(Field $root, array $mapping, Variable $payloadVariable): array { - $childrenByClassName = []; + $childrenBySchemaName = []; foreach ($root->getObjectProperties() as $child) { if ($child->isComposite()) { - $childrenByClassName[$child->getPhpClassName()] = $child; + $childrenBySchemaName[$child->getName()] = $child; } } - $cases = []; + $cases = []; + $usedValues = []; + $explicitlyMapped = []; foreach ($mapping as $discriminatorValue => $reference) { $schemaName = $this->resolveSchemaNameFromReference($reference); - if (!isset($childrenByClassName[$schemaName])) { + if (!isset($childrenBySchemaName[$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() + $cases[] = $this->generateDiscriminatorCase( + [(string)$discriminatorValue], + $childrenBySchemaName[$schemaName], + $payloadVariable + ); + $usedValues[] = (string)$discriminatorValue; + $explicitlyMapped[$schemaName] = true; + } + + foreach ($childrenBySchemaName as $schemaName => $child) { + if (isset($explicitlyMapped[$schemaName])) { + continue; + } + + $implicitValues = array_values( + array_diff(array_unique([(string)$schemaName, lcfirst($child->getPhpClassName())]), $usedValues) ); + if ($implicitValues === []) { + continue; + } + + $cases[] = $this->generateDiscriminatorCase($implicitValues, $child, $payloadVariable); + $usedValues = [...$usedValues, ...$implicitValues]; } + return array_merge([], ...$cases); + } + + /** + * @param string[] $discriminatorValues + * + * @return Case_[] + */ + private function generateDiscriminatorCase(array $discriminatorValues, Field $child, Variable $payloadVariable): array + { + $cases = []; + $lastValue = array_pop($discriminatorValues); + foreach ($discriminatorValues as $discriminatorValue) { + $cases[] = $this->builder->case($this->builder->val($discriminatorValue)); + } + + $cases[] = $this->builder->case( + $this->builder->val($lastValue), + $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; } diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php index e326539..86103fb 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper74.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -24,6 +25,9 @@ public function __construct(AnimalResponseMapper $animalResponseMapper, MachineR $this->machineResponseMapper = $machineResponseMapper; } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -38,11 +42,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php index 5a480e6..dcea7ae 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper80.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -18,6 +19,9 @@ public function __construct(private AnimalResponseMapper $animalResponseMapper, { } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -32,11 +36,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php index 200536d..5fd57ba 100644 --- a/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php +++ b/test/suite/functional/Generator/SchemaMapper/AnyOfResponseBodyMapper81.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -18,6 +19,9 @@ public function __construct(private readonly AnimalResponseMapper $animalRespons { } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -32,11 +36,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper74.php b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper74.php new file mode 100644 index 0000000..376ed33 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper74.php @@ -0,0 +1,63 @@ +catMapper = $catMapper; + $this->dogMapper = $dogMapper; + $this->lizardMapper = $lizardMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): Pet + { + $schema = new Pet(); + if (array_key_exists('petType', $payload)) { + switch ($payload['petType']) { + case 'cat': + $schema->setCat($this->catMapper->toSchema($payload)); + + break; + case 'kitten': + $schema->setCat($this->catMapper->toSchema($payload)); + + break; + case 'dog': + $schema->setDog($this->dogMapper->toSchema($payload)); + + break; + case 'Lizard': + case 'lizard': + $schema->setLizard($this->lizardMapper->toSchema($payload)); + + break; + default: + throw new UnexpectedResponseBodyException('Unknown `petType` discriminator value for `Pet` in the response body'); + } + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper81.php new file mode 100644 index 0000000..b258c11 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorPartialMappingMapper81.php @@ -0,0 +1,54 @@ +setCat($this->catMapper->toSchema($payload)); + + break; + case 'kitten': + $schema->setCat($this->catMapper->toSchema($payload)); + + break; + case 'dog': + $schema->setDog($this->dogMapper->toSchema($payload)); + + break; + case 'Lizard': + case 'lizard': + $schema->setLizard($this->lizardMapper->toSchema($payload)); + + break; + default: + throw new UnexpectedResponseBodyException('Unknown `petType` discriminator value for `Pet` in the response body'); + } + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper74.php b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper74.php new file mode 100644 index 0000000..94516a8 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper74.php @@ -0,0 +1,53 @@ +animalMapper = $animalMapper; + $this->machineMapper = $machineMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): GetExampleResponseBody + { + $schema = new GetExampleResponseBody(); + if (array_key_exists('objectType', $payload)) { + switch ($payload['objectType']) { + case 'Animal': + case 'animal': + $schema->setAnimal($this->animalMapper->toSchema($payload)); + + break; + case 'Machine': + case 'machine': + $schema->setMachine($this->machineMapper->toSchema($payload)); + + break; + default: + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); + } + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php index 156beb8..622e5a1 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper81.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -18,13 +19,27 @@ public function __construct(private readonly AnimalMapper $animalMapper, private { } + /** + * @throws UnexpectedResponseBodyException + */ 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': + case 'animal': + $schema->setAnimal($this->animalMapper->toSchema($payload)); + + break; + case 'Machine': + case 'machine': + $schema->setMachine($this->machineMapper->toSchema($payload)); + + break; + default: + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); + } } return $schema; diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php index e326539..86103fb 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper74.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -24,6 +25,9 @@ public function __construct(AnimalResponseMapper $animalResponseMapper, MachineR $this->machineResponseMapper = $machineResponseMapper; } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -38,11 +42,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php index 5a480e6..dcea7ae 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper80.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -18,6 +19,9 @@ public function __construct(private AnimalResponseMapper $animalResponseMapper, { } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -32,11 +36,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php index 200536d..5fd57ba 100644 --- a/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php +++ b/test/suite/functional/Generator/SchemaMapper/OneOfResponseBodyMapper81.php @@ -10,6 +10,7 @@ namespace Test\Schema\Mapper; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use Test\Schema\GetExampleResponseBody; class GetExampleResponseBodyMapper implements SchemaMapperInterface @@ -18,6 +19,9 @@ public function __construct(private readonly AnimalResponseMapper $animalRespons { } + /** + * @throws UnexpectedResponseBodyException + */ public function toSchema(array $payload): GetExampleResponseBody { $schema = new GetExampleResponseBody(); @@ -32,11 +36,7 @@ public function toSchema(array $payload): GetExampleResponseBody break; default: - $methodName = 'set' . ucfirst($payload['objectType']); - $mapperName = $payload['objectType'] . 'Mapper'; - $schema->$methodName($this->$mapperName->toSchema($payload)); - - break; + throw new UnexpectedResponseBodyException('Unknown `objectType` discriminator value for `GetExampleResponseBody` in the response body'); } } diff --git a/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml b/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml new file mode 100644 index 0000000..a396033 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.0 +info: + title: OneOf with a discriminator mapping that covers only some of the alternatives + version: 1.0.0 + +paths: + /pets/{petId}: + get: + operationId: getPet + parameters: + - in: path + name: petId + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + +components: + schemas: + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Lizard' + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + kitten: '#/components/schemas/Cat' + dog: Dog + Cat: + type: object + required: + - petType + - name + properties: + petType: + type: string + name: + type: string + Dog: + type: object + required: + - petType + - bark + properties: + petType: + type: string + bark: + type: string + Lizard: + type: object + required: + - petType + - lovesRocks + properties: + petType: + type: string + lovesRocks: + type: boolean diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 69bee6f..785f0cd 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -106,12 +106,30 @@ 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 7.4' => [ + '/SchemaMapper/oneOfDiscriminatorWithoutMapping.yaml', + '/SchemaMapper/OneOfDiscriminatorWithoutMappingMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->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 with a discriminator mapping covering some alternatives with php 7.4' => [ + '/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml', + '/SchemaMapper/OneOfDiscriminatorPartialMappingMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\PetMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'OneOf response with a discriminator mapping covering some alternatives with php 8.1' => [ + '/SchemaMapper/oneOfDiscriminatorPartialMapping.yaml', + '/SchemaMapper/OneOfDiscriminatorPartialMappingMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\PetMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], 'OneOf response without discriminator with php 7.4' => [ '/SchemaMapper/oneOfWithoutDiscriminator.yaml', '/SchemaMapper/OneOfResponseBodyMapperWithoutDiscriminator74.php', @@ -148,19 +166,19 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], - 'AnyOf response with php 7.4' => [ + 'AnyOf response without discriminator with php 7.4' => [ '/SchemaMapper/anyOfWithoutDiscriminator.yaml', '/SchemaMapper/AnyOfResponseBodyMapperWithoutDiscriminator74.php', self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), ], - 'AnyOf response with php 8.0' => [ + 'AnyOf response without discriminator with php 8.0' => [ '/SchemaMapper/anyOfWithoutDiscriminator.yaml', '/SchemaMapper/AnyOfResponseBodyMapperWithoutDiscriminator80.php', self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), ], - 'AnyOf response with php 8.1' => [ + 'AnyOf response without discriminator with php 8.1' => [ '/SchemaMapper/anyOfWithoutDiscriminator.yaml', '/SchemaMapper/AnyOfResponseBodyMapperWithoutDiscriminator81.php', self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GetExampleResponseBodyMapper', From 0ad2a88d25a93b1cad6a6113925f2fcf116628bf Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:30:55 +0200 Subject: [PATCH 13/21] fix: initialise optional untyped (mixed) properties with null For CLIENT_PHP_VERSION 8.0 and later, an optional or nullable property with an empty or untyped schema (`{}`, or only `nullable: true`) is typed `mixed`. CodeBuilder::localProperty() skipped the `= null` default for `mixed`, so the property stayed uninitialised and its getter threw "must not be accessed before initialization" (as did getQueryParameters() for an optional untyped query parameter). The default is now set; only the `?` prefix is still omitted, because `mixed` already includes null. A nullable untyped property also aborted the generation with "mixed type cannot be nullable" because ParameterBuilder::setType() produced `?mixed`; it now emits `mixed` for it. Targets before 8.0 are unchanged (such properties are untyped there). Co-Authored-By: Claude Opus 5.5 --- src/Ast/Builder/CodeBuilder.php | 7 +- src/Ast/Builder/ParameterBuilder.php | 4 +- .../GetResourcesByUntypedFilterRequest74.php | 71 ++++++++++++ .../GetResourcesByUntypedFilterRequest80.php | 71 ++++++++++++ .../Request/getResourcesByUntypedFilter.yaml | 26 +++++ .../Generator/RequestGeneratorTest.php | 12 ++ .../Generator/Schema/ItemWithMixedPhp74.php | 98 ++++++++++++++++ .../Generator/Schema/ItemWithMixedPhp80.php | 107 ++++++++++++++++++ .../Generator/Schema/ItemWithMixedPhp81.php | 107 ++++++++++++++++++ .../Generator/Schema/itemWithMixed.yaml | 29 +++++ .../Generator/SchemaGeneratorTest.php | 18 +++ .../unit/Ast/Builder/CodeBuilderTest.php | 44 +++++++ 12 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest74.php create mode 100644 test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest80.php create mode 100644 test/suite/functional/Generator/Request/getResourcesByUntypedFilter.yaml create mode 100644 test/suite/functional/Generator/Schema/ItemWithMixedPhp74.php create mode 100644 test/suite/functional/Generator/Schema/ItemWithMixedPhp80.php create mode 100644 test/suite/functional/Generator/Schema/ItemWithMixedPhp81.php create mode 100644 test/suite/functional/Generator/Schema/itemWithMixed.yaml diff --git a/src/Ast/Builder/CodeBuilder.php b/src/Ast/Builder/CodeBuilder.php index b413b2e..f95ec1d 100644 --- a/src/Ast/Builder/CodeBuilder.php +++ b/src/Ast/Builder/CodeBuilder.php @@ -170,9 +170,12 @@ public function localProperty( ->makePrivate(); if (!empty($type) && $this->phpVersion->isPropertyTypeHintSupported()) { - if ($nullable && $type !== FieldType::PHP_TYPE_MIXED) { + if ($nullable) { $property->setDefault(null); - $type = '?' . $type; + // mixed already includes null and cannot be marked nullable + if ($type !== FieldType::PHP_TYPE_MIXED) { + $type = '?' . $type; + } } $property->setType($type); diff --git a/src/Ast/Builder/ParameterBuilder.php b/src/Ast/Builder/ParameterBuilder.php index ed2bfeb..899823d 100644 --- a/src/Ast/Builder/ParameterBuilder.php +++ b/src/Ast/Builder/ParameterBuilder.php @@ -6,6 +6,7 @@ use DoclerLabs\ApiClientGenerator\Ast\ParameterNode; use DoclerLabs\ApiClientGenerator\Ast\PhpVersion; +use DoclerLabs\ApiClientGenerator\Entity\FieldType; use PhpParser\Builder\Param; use PhpParser\Node\Expr\Variable; @@ -26,7 +27,8 @@ public function setType($type, bool $isNullable = false): self if ($isNullable) { if ($this->phpVersion->isNullableTypeHintSupported() && is_string($type)) { - return parent::setType(sprintf('?%s', $type)); + // mixed already includes null and cannot be marked nullable + return parent::setType($type === FieldType::PHP_TYPE_MIXED ? $type : sprintf('?%s', $type)); } return $this; diff --git a/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest74.php b/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest74.php new file mode 100644 index 0000000..696cb6c --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest74.php @@ -0,0 +1,71 @@ +contentType; + } + + public function setFilter($filter): self + { + $this->filter = $filter; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-untyped-filter'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['filter' => $this->filter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['filter' => $this->filter]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest80.php b/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest80.php new file mode 100644 index 0000000..4bfa5f7 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesByUntypedFilterRequest80.php @@ -0,0 +1,71 @@ +contentType; + } + + public function setFilter(mixed $filter): self + { + $this->filter = $filter; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-untyped-filter'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['filter' => $this->filter], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['filter' => $this->filter]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/getResourcesByUntypedFilter.yaml b/test/suite/functional/Generator/Request/getResourcesByUntypedFilter.yaml new file mode 100644 index 0000000..9c2a370 --- /dev/null +++ b/test/suite/functional/Generator/Request/getResourcesByUntypedFilter.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/resources-by-untyped-filter': + get: + description: Get resources filtered by an untyped (mixed) query parameter + operationId: getResourcesByUntypedFilter + parameters: + - name: filter + in: query + required: false + schema: {} + responses: + '200': + description: list of resources + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 9c867a7..dbf319a 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -250,6 +250,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with optional untyped (mixed) query parameter with php 7.4' => [ + '/Request/getResourcesByUntypedFilter.yaml', + '/Request/GetResourcesByUntypedFilterRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByUntypedFilterRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with optional untyped (mixed) query parameter with php 8.0' => [ + '/Request/getResourcesByUntypedFilter.yaml', + '/Request/GetResourcesByUntypedFilterRequest80.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByUntypedFilterRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/ItemWithMixedPhp74.php b/test/suite/functional/Generator/Schema/ItemWithMixedPhp74.php new file mode 100644 index 0000000..7fbcf9d --- /dev/null +++ b/test/suite/functional/Generator/Schema/ItemWithMixedPhp74.php @@ -0,0 +1,98 @@ + false, 'optionalNullableMixed' => false]; + + public function __construct($mandatoryMixed, $mandatoryNullableMixed) + { + $this->mandatoryMixed = $mandatoryMixed; + $this->mandatoryNullableMixed = $mandatoryNullableMixed; + } + + public function setOptionalMixed($optionalMixed): self + { + $this->optionalMixed = $optionalMixed; + $this->optionalPropertyChanged['optionalMixed'] = true; + + return $this; + } + + public function setOptionalNullableMixed($optionalNullableMixed): self + { + $this->optionalNullableMixed = $optionalNullableMixed; + $this->optionalPropertyChanged['optionalNullableMixed'] = true; + + return $this; + } + + public function hasOptionalMixed(): bool + { + return $this->optionalPropertyChanged['optionalMixed']; + } + + public function hasOptionalNullableMixed(): bool + { + return $this->optionalPropertyChanged['optionalNullableMixed']; + } + + public function getMandatoryMixed() + { + return $this->mandatoryMixed; + } + + public function getOptionalMixed() + { + return $this->optionalMixed; + } + + public function getOptionalNullableMixed() + { + return $this->optionalNullableMixed; + } + + public function getMandatoryNullableMixed() + { + return $this->mandatoryNullableMixed; + } + + public function toArray(): array + { + $fields = []; + $fields['mandatoryMixed'] = $this->mandatoryMixed; + if ($this->hasOptionalMixed()) { + $fields['optionalMixed'] = $this->optionalMixed; + } + if ($this->hasOptionalNullableMixed()) { + $fields['optionalNullableMixed'] = $this->optionalNullableMixed; + } + $fields['mandatoryNullableMixed'] = $this->mandatoryNullableMixed; + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/ItemWithMixedPhp80.php b/test/suite/functional/Generator/Schema/ItemWithMixedPhp80.php new file mode 100644 index 0000000..49891d5 --- /dev/null +++ b/test/suite/functional/Generator/Schema/ItemWithMixedPhp80.php @@ -0,0 +1,107 @@ + false, 'optionalNullableMixed' => false]; + + /** + * @param mixed|null $mandatoryNullableMixed + */ + public function __construct(private mixed $mandatoryMixed, private mixed $mandatoryNullableMixed) + { + } + + public function setOptionalMixed(mixed $optionalMixed): self + { + $this->optionalMixed = $optionalMixed; + $this->optionalPropertyChanged['optionalMixed'] = true; + + return $this; + } + + /** + * @param mixed|null $optionalNullableMixed + */ + public function setOptionalNullableMixed(mixed $optionalNullableMixed): self + { + $this->optionalNullableMixed = $optionalNullableMixed; + $this->optionalPropertyChanged['optionalNullableMixed'] = true; + + return $this; + } + + public function hasOptionalMixed(): bool + { + return $this->optionalPropertyChanged['optionalMixed']; + } + + public function hasOptionalNullableMixed(): bool + { + return $this->optionalPropertyChanged['optionalNullableMixed']; + } + + public function getMandatoryMixed(): mixed + { + return $this->mandatoryMixed; + } + + /** + * @return mixed|null + */ + public function getOptionalMixed() + { + return $this->optionalMixed; + } + + /** + * @return mixed|null + */ + public function getOptionalNullableMixed() + { + return $this->optionalNullableMixed; + } + + /** + * @return mixed|null + */ + public function getMandatoryNullableMixed() + { + return $this->mandatoryNullableMixed; + } + + public function toArray(): array + { + $fields = []; + $fields['mandatoryMixed'] = $this->mandatoryMixed; + if ($this->hasOptionalMixed()) { + $fields['optionalMixed'] = $this->optionalMixed; + } + if ($this->hasOptionalNullableMixed()) { + $fields['optionalNullableMixed'] = $this->optionalNullableMixed; + } + $fields['mandatoryNullableMixed'] = $this->mandatoryNullableMixed; + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/ItemWithMixedPhp81.php b/test/suite/functional/Generator/Schema/ItemWithMixedPhp81.php new file mode 100644 index 0000000..1572e4e --- /dev/null +++ b/test/suite/functional/Generator/Schema/ItemWithMixedPhp81.php @@ -0,0 +1,107 @@ + false, 'optionalNullableMixed' => false]; + + /** + * @param mixed|null $mandatoryNullableMixed + */ + public function __construct(private readonly mixed $mandatoryMixed, private readonly mixed $mandatoryNullableMixed) + { + } + + public function setOptionalMixed(mixed $optionalMixed): self + { + $this->optionalMixed = $optionalMixed; + $this->optionalPropertyChanged['optionalMixed'] = true; + + return $this; + } + + /** + * @param mixed|null $optionalNullableMixed + */ + public function setOptionalNullableMixed(mixed $optionalNullableMixed): self + { + $this->optionalNullableMixed = $optionalNullableMixed; + $this->optionalPropertyChanged['optionalNullableMixed'] = true; + + return $this; + } + + public function hasOptionalMixed(): bool + { + return $this->optionalPropertyChanged['optionalMixed']; + } + + public function hasOptionalNullableMixed(): bool + { + return $this->optionalPropertyChanged['optionalNullableMixed']; + } + + public function getMandatoryMixed(): mixed + { + return $this->mandatoryMixed; + } + + /** + * @return mixed|null + */ + public function getOptionalMixed() + { + return $this->optionalMixed; + } + + /** + * @return mixed|null + */ + public function getOptionalNullableMixed() + { + return $this->optionalNullableMixed; + } + + /** + * @return mixed|null + */ + public function getMandatoryNullableMixed() + { + return $this->mandatoryNullableMixed; + } + + public function toArray(): array + { + $fields = []; + $fields['mandatoryMixed'] = $this->mandatoryMixed; + if ($this->hasOptionalMixed()) { + $fields['optionalMixed'] = $this->optionalMixed; + } + if ($this->hasOptionalNullableMixed()) { + $fields['optionalNullableMixed'] = $this->optionalNullableMixed; + } + $fields['mandatoryNullableMixed'] = $this->mandatoryNullableMixed; + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/itemWithMixed.yaml b/test/suite/functional/Generator/Schema/itemWithMixed.yaml new file mode 100644 index 0000000..d2d7707 --- /dev/null +++ b/test/suite/functional/Generator/Schema/itemWithMixed.yaml @@ -0,0 +1,29 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Untyped (mixed) properties Test API +paths: + '/v1/items': + get: + operationId: getItems + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ItemWithMixed' +components: + schemas: + ItemWithMixed: + type: object + required: + - mandatoryMixed + - mandatoryNullableMixed + properties: + mandatoryMixed: {} + optionalMixed: {} + optionalNullableMixed: + nullable: true + mandatoryNullableMixed: + nullable: true diff --git a/test/suite/functional/Generator/SchemaGeneratorTest.php b/test/suite/functional/Generator/SchemaGeneratorTest.php index 8d2b217..c4da62d 100644 --- a/test/suite/functional/Generator/SchemaGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaGeneratorTest.php @@ -82,6 +82,24 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithArraysOfEnumProperties', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), ], + 'Untyped (mixed) properties with PHP 7.4' => [ + '/Schema/itemWithMixed.yaml', + '/Schema/ItemWithMixedPhp74.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithMixed', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Untyped (mixed) properties with PHP 8.0' => [ + '/Schema/itemWithMixed.yaml', + '/Schema/ItemWithMixedPhp80.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithMixed', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), + ], + 'Untyped (mixed) properties with PHP 8.1' => [ + '/Schema/itemWithMixed.yaml', + '/Schema/ItemWithMixedPhp81.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithMixed', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/unit/Ast/Builder/CodeBuilderTest.php b/test/suite/unit/Ast/Builder/CodeBuilderTest.php index dd0a214..16cebdd 100644 --- a/test/suite/unit/Ast/Builder/CodeBuilderTest.php +++ b/test/suite/unit/Ast/Builder/CodeBuilderTest.php @@ -295,4 +295,48 @@ public function testNotEquals(): void $notEquals = $this->sut->notEquals($left, $right); self::assertEquals('$left !== $right', $this->printer->prettyPrintExpr($notEquals)); } + + /** + * @dataProvider localPropertyProvider + */ + public function testLocalProperty(string $type, bool $nullable, string $expected): void + { + $this->phpVersionResolver->method('isPropertyTypeHintSupported')->willReturn(true); + + $property = $this->sut->localProperty('some', $type, $type, $nullable); + + self::assertEquals($expected, $this->printer->prettyPrint([$property])); + } + + public function localPropertyProvider(): array + { + return [ + 'mandatory' => ['string', false, 'private string $some;'], + 'nullable' => ['string', true, 'private ?string $some = null;'], + 'mandatory mixed' => ['mixed', false, 'private mixed $some;'], + 'nullable mixed' => ['mixed', true, 'private mixed $some = null;'], + ]; + } + + /** + * @dataProvider paramTypeProvider + */ + public function testParamType(string $type, bool $nullable, string $expected): void + { + $this->phpVersionResolver->method('isNullableTypeHintSupported')->willReturn(true); + + $param = $this->sut->param('some')->setType($type, $nullable)->getNode(); + + self::assertEquals($expected, $this->printer->prettyPrint([$this->sut->method('test')->addParam($param)->getNode()])); + } + + public function paramTypeProvider(): array + { + return [ + 'mandatory' => ['string', false, 'function test(string $some)' . PHP_EOL . '{' . PHP_EOL . '}'], + 'nullable' => ['string', true, 'function test(?string $some)' . PHP_EOL . '{' . PHP_EOL . '}'], + 'mandatory mixed' => ['mixed', false, 'function test(mixed $some)' . PHP_EOL . '{' . PHP_EOL . '}'], + 'nullable mixed' => ['mixed', true, 'function test(mixed $some)' . PHP_EOL . '{' . PHP_EOL . '}'], + ]; + } } From 29300c8efdef0c59961037161c51b94ba75a0140 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:31:50 +0200 Subject: [PATCH 14/21] fix: map arrays of arrays of objects to arrays of collections A property like `data: array>` was documented as `ShowSubscriberCollection[]`, but the collection class was never generated (Parser::extractField() did not descend into array items that are arrays) and the mapper passed the raw payload through, so PHPStan reported an unknown class and getData() returned raw arrays instead of schemas. For arrays of arrays (at any depth) of objects: - Parser extracts the innermost array of objects, so its collection, the collection mapper and the container entry are generated; - the schema mapper injects the collection mapper and maps every innermost array with it (array_map with a closure, valid for every PHP target); - the schema's toArray() serializes the collections back to arrays. The documented types are unchanged and now name an existing class. Co-Authored-By: Claude Opus 5.5 --- src/Entity/Field.php | 24 ++++ src/Generator/SchemaGenerator.php | 41 ++++++ src/Generator/SchemaMapperGenerator.php | 62 +++++++++ src/Generator/ServiceProviderGenerator.php | 3 + src/Input/Parser.php | 4 + .../Schema/ArrayOfArraysOfObjects70.php | 119 ++++++++++++++++++ .../Schema/ArrayOfArraysOfObjects74.php | 115 +++++++++++++++++ .../Schema/ArrayOfArraysOfObjects81.php | 112 +++++++++++++++++ .../ShowSubscriberCollection74.php | 67 ++++++++++ .../SchemaCollectionGeneratorTest.php | 6 + .../Generator/SchemaGeneratorTest.php | 18 +++ .../ArrayOfArraysOfObjectsMapper70.php | 57 +++++++++ .../ArrayOfArraysOfObjectsMapper74.php | 56 +++++++++ .../ArrayOfArraysOfObjectsMapper81.php | 53 ++++++++ .../ShowSubscriberCollectionMapper74.php | 33 +++++ .../SchemaMapper/arrayOfArraysOfObjects.yaml | 58 +++++++++ .../Generator/SchemaMapperGeneratorTest.php | 24 ++++ ...rrayOfArraysOfObjectsServiceProvider74.php | 51 ++++++++ .../ServiceProviderGeneratorTest.php | 6 + 19 files changed, 909 insertions(+) create mode 100644 test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects70.php create mode 100644 test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects74.php create mode 100644 test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects81.php create mode 100644 test/suite/functional/Generator/SchemaCollection/ShowSubscriberCollection74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper70.php create mode 100644 test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/ShowSubscriberCollectionMapper74.php create mode 100644 test/suite/functional/Generator/SchemaMapper/arrayOfArraysOfObjects.yaml create mode 100644 test/suite/functional/Generator/ServiceProvider/ArrayOfArraysOfObjectsServiceProvider74.php diff --git a/src/Entity/Field.php b/src/Entity/Field.php index d2aeb56..fa6607f 100644 --- a/src/Entity/Field.php +++ b/src/Entity/Field.php @@ -188,6 +188,30 @@ public function isArrayOfEnums(): bool && $this->getArrayItem()->isEnum(); } + /** + * An array of arrays (at any depth) of objects, e.g. array>: its innermost arrays are collections. + */ + public function isArrayOfArraysOfObjects(): bool + { + if (!$this->isArray()) { + return false; + } + + $item = $this->getArrayItem(); + + return !$item->isNullable() && ($item->isArrayOfObjects() || $item->isArrayOfArraysOfObjects()); + } + + /** + * The innermost array of objects (the collection) of an array of arrays of objects. + */ + public function getInnermostArrayOfObjects(): Field + { + $item = $this->getArrayItem(); + + return $item->isArrayOfObjects() ? $item : $item->getInnermostArrayOfObjects(); + } + public function getDefault(): mixed { return $this->default; diff --git a/src/Generator/SchemaGenerator.php b/src/Generator/SchemaGenerator.php index 9dde972..587a1c5 100644 --- a/src/Generator/SchemaGenerator.php +++ b/src/Generator/SchemaGenerator.php @@ -12,6 +12,8 @@ use DoclerLabs\ApiClientGenerator\Input\Specification; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; use JsonSerializable; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\ClassMethod; @@ -344,6 +346,16 @@ private function collectSerializationFields(Field $root, Variable $arrayVariable $this->builder->val(null) ) : $arrayMapCall; + } elseif ($propertyField->isArrayOfArraysOfObjects()) { + $collectionsToArray = $this->generateArrayOfArraysOfObjectsToArray($propertyField, $value); + + $value = $propertyField->isNullable() + ? $this->builder->ternary( + $this->builder->notEquals($value, $this->builder->val(null)), + $collectionsToArray, + $this->builder->val(null) + ) + : $collectionsToArray; } $fieldName = $this->builder->val($propertyField->getName()); @@ -366,4 +378,33 @@ private function collectSerializationFields(Field $root, Variable $arrayVariable return $statements; } + + /** + * Serializes every collection of an array of arrays of objects. + */ + private function generateArrayOfArraysOfObjectsToArray(Field $field, Expr $value): FuncCall + { + $item = $field->getArrayItem(); + $itemVar = $this->builder->var('item'); + if ($item->isArrayOfObjects()) { + $itemToArray = $this->builder->methodCall($itemVar, 'toArray'); + $itemType = $item->getPhpClassName(); + } else { + $itemToArray = $this->generateArrayOfArraysOfObjectsToArray($item, $itemVar); + $itemType = FieldType::PHP_TYPE_ARRAY; + } + + return $this->builder->funcCall( + 'array_map', + [ + $this->builder->closure( + [$this->builder->return($itemToArray)], + [$this->builder->param('item')->setType($itemType)->getNode()], + [], + FieldType::PHP_TYPE_ARRAY + ), + $value, + ] + ); + } } diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 27c3c00..b8a1567 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -9,9 +9,12 @@ use DoclerLabs\ApiClientGenerator\Ast\Builder\ParameterBuilder; use DoclerLabs\ApiClientGenerator\Ast\ParameterNode; use DoclerLabs\ApiClientGenerator\Entity\Field; +use DoclerLabs\ApiClientGenerator\Entity\FieldType; use DoclerLabs\ApiClientGenerator\Input\Specification; use DoclerLabs\ApiClientGenerator\Naming\SchemaMapperNaming; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Case_; @@ -59,6 +62,9 @@ protected function generateProperties(Field $root): array if ($root->isObject()) { $alreadyInjected = []; foreach ($root->getObjectProperties() as $child) { + if ($child->isArrayOfArraysOfObjects()) { + $child = $child->getInnermostArrayOfObjects(); + } if ($child->isComposite()) { $childClassName = SchemaMapperNaming::getClassName($child); if (!isset($alreadyInjected[$childClassName])) { @@ -96,6 +102,9 @@ protected function generateConstructor(Field $root): ?ClassMethod if ($root->isObject()) { $alreadyInjected = []; foreach ($root->getObjectProperties() as $child) { + if ($child->isArrayOfArraysOfObjects()) { + $child = $child->getInnermostArrayOfObjects(); + } if ($child->isComposite()) { $childClassName = SchemaMapperNaming::getClassName($child); if (!isset($alreadyInjected[$childClassName])) { @@ -387,6 +396,15 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload $this->builder->val(null) ) : $arrayMapCall; + } elseif ($field->isArrayOfArraysOfObjects()) { + $collectionsMapping = $this->generateArrayOfArraysOfObjectsMapping($field, $requiredResponseItems[$i]); + $requiredVars[] = $field->isNullable() + ? $this->builder->ternary( + $this->builder->notEquals($requiredResponseItems[$i], $this->builder->val(null)), + $collectionsMapping, + $this->builder->val(null) + ) + : $collectionsMapping; } else { $requiredVars[] = $requiredResponseItems[$i]; } @@ -464,6 +482,15 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload $this->builder->val(null) ) : $arrayMapCall; + } elseif ($field->isArrayOfArraysOfObjects()) { + $collectionsMapping = $this->generateArrayOfArraysOfObjectsMapping($field, $optionalResponseItems[$i]); + $optionalVar = $field->isNullable() + ? $this->builder->ternary( + $this->builder->notEquals($optionalResponseItems[$i], $this->builder->val(null)), + $collectionsMapping, + $this->builder->val(null) + ) + : $collectionsMapping; } else { $optionalVar = $optionalResponseItems[$i]; } @@ -567,6 +594,41 @@ protected function generateMapStatementsForObject(Field $root, Variable $payload return $statements; } + /** + * Maps every innermost array of an array of arrays of objects to its collection. + */ + private function generateArrayOfArraysOfObjectsMapping(Field $field, Expr $payloadItem): FuncCall + { + $item = $field->getArrayItem(); + $itemVar = $this->builder->var('item'); + if ($item->isArrayOfObjects()) { + $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $item->getPhpClassName())); + $itemMapping = $this->builder->methodCall( + $this->builder->localPropertyFetch(SchemaMapperNaming::getPropertyName($item)), + 'toSchema', + [$itemVar] + ); + $itemType = $item->getPhpClassName(); + } else { + $itemMapping = $this->generateArrayOfArraysOfObjectsMapping($item, $itemVar); + $itemType = FieldType::PHP_TYPE_ARRAY; + } + + return $this->builder->funcCall( + 'array_map', + [ + $this->builder->closure( + [$this->builder->return($itemMapping)], + [$this->builder->param('item')->setType(FieldType::PHP_TYPE_ARRAY)->getNode()], + [], + $itemType, + false + ), + $payloadItem, + ] + ); + } + private function generateDiscriminatorStatement(Field $root, Variable $payloadVariable): Stmt { $discriminator = $root->getDiscriminator(); diff --git a/src/Generator/ServiceProviderGenerator.php b/src/Generator/ServiceProviderGenerator.php index 88ba55a..777c046 100644 --- a/src/Generator/ServiceProviderGenerator.php +++ b/src/Generator/ServiceProviderGenerator.php @@ -277,6 +277,9 @@ private function buildMapperDependencies(Field $field, Variable $containerVariab if ($field->isObject()) { $alreadyInjected = []; foreach ($field->getObjectProperties() as $subfield) { + if ($subfield->isArrayOfArraysOfObjects()) { + $subfield = $subfield->getInnermostArrayOfObjects(); + } if ($subfield->isComposite() && !isset($alreadyInjected[$subfield->getPhpClassName()])) { $getMethodArg = $this->builder->classConstFetch( SchemaMapperNaming::getClassName($subfield), diff --git a/src/Input/Parser.php b/src/Input/Parser.php index 8adcd99..d7044e3 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -86,6 +86,10 @@ private function extractField(Field $field, FieldCollection $allFields): void if ($field->isArrayOfEnums() && $this->phpVersion->isEnumSupported()) { $allFields->add($field->getArrayItem()); } + + if ($field->isArrayOfArraysOfObjects()) { + $this->extractField($field->getArrayItem(), $allFields); + } } private function extractPropertyFields(Field $rootObject, FieldCollection $allFields): void diff --git a/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects70.php b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects70.php new file mode 100644 index 0000000..54204dc --- /dev/null +++ b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects70.php @@ -0,0 +1,119 @@ + false, 'groups' => false]; + + /** + * @param ShowSubscriberCollection[] $data + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * @param ShowSubscriberCollection[]|null $previousData + */ + public function setPreviousData($previousData): self + { + $this->previousData = $previousData; + $this->optionalPropertyChanged['previousData'] = true; + + return $this; + } + + /** + * @param ShowSubscriberCollection[][] $groups + */ + public function setGroups(array $groups): self + { + $this->groups = $groups; + $this->optionalPropertyChanged['groups'] = true; + + return $this; + } + + public function hasPreviousData(): bool + { + return $this->optionalPropertyChanged['previousData']; + } + + public function hasGroups(): bool + { + return $this->optionalPropertyChanged['groups']; + } + + /** + * @return ShowSubscriberCollection[] + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return ShowSubscriberCollection[]|null + */ + public function getPreviousData() + { + return $this->previousData; + } + + /** + * @return ShowSubscriberCollection[][]|null + */ + public function getGroups() + { + return $this->groups; + } + + public function toArray(): array + { + $fields = []; + $fields['data'] = array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->data); + if ($this->hasPreviousData()) { + $fields['previousData'] = $this->previousData !== null ? array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->previousData) : null; + } + if ($this->hasGroups()) { + $fields['groups'] = array_map(static function (array $item): array { + return array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $item); + }, $this->groups); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects74.php b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects74.php new file mode 100644 index 0000000..7cdfdd3 --- /dev/null +++ b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects74.php @@ -0,0 +1,115 @@ + false, 'groups' => false]; + + /** + * @param ShowSubscriberCollection[] $data + */ + public function __construct(array $data) + { + $this->data = $data; + } + + /** + * @param ShowSubscriberCollection[]|null $previousData + */ + public function setPreviousData(?array $previousData): self + { + $this->previousData = $previousData; + $this->optionalPropertyChanged['previousData'] = true; + + return $this; + } + + /** + * @param ShowSubscriberCollection[][] $groups + */ + public function setGroups(array $groups): self + { + $this->groups = $groups; + $this->optionalPropertyChanged['groups'] = true; + + return $this; + } + + public function hasPreviousData(): bool + { + return $this->optionalPropertyChanged['previousData']; + } + + public function hasGroups(): bool + { + return $this->optionalPropertyChanged['groups']; + } + + /** + * @return ShowSubscriberCollection[] + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return ShowSubscriberCollection[]|null + */ + public function getPreviousData(): ?array + { + return $this->previousData; + } + + /** + * @return ShowSubscriberCollection[][]|null + */ + public function getGroups(): ?array + { + return $this->groups; + } + + public function toArray(): array + { + $fields = []; + $fields['data'] = array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->data); + if ($this->hasPreviousData()) { + $fields['previousData'] = $this->previousData !== null ? array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->previousData) : null; + } + if ($this->hasGroups()) { + $fields['groups'] = array_map(static function (array $item): array { + return array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $item); + }, $this->groups); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects81.php b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects81.php new file mode 100644 index 0000000..ac4de1e --- /dev/null +++ b/test/suite/functional/Generator/Schema/ArrayOfArraysOfObjects81.php @@ -0,0 +1,112 @@ + false, 'groups' => false]; + + /** + * @param ShowSubscriberCollection[] $data + */ + public function __construct(private readonly array $data) + { + } + + /** + * @param ShowSubscriberCollection[]|null $previousData + */ + public function setPreviousData(?array $previousData): self + { + $this->previousData = $previousData; + $this->optionalPropertyChanged['previousData'] = true; + + return $this; + } + + /** + * @param ShowSubscriberCollection[][] $groups + */ + public function setGroups(array $groups): self + { + $this->groups = $groups; + $this->optionalPropertyChanged['groups'] = true; + + return $this; + } + + public function hasPreviousData(): bool + { + return $this->optionalPropertyChanged['previousData']; + } + + public function hasGroups(): bool + { + return $this->optionalPropertyChanged['groups']; + } + + /** + * @return ShowSubscriberCollection[] + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return ShowSubscriberCollection[]|null + */ + public function getPreviousData(): ?array + { + return $this->previousData; + } + + /** + * @return ShowSubscriberCollection[][]|null + */ + public function getGroups(): ?array + { + return $this->groups; + } + + public function toArray(): array + { + $fields = []; + $fields['data'] = array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->data); + if ($this->hasPreviousData()) { + $fields['previousData'] = $this->previousData !== null ? array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $this->previousData) : null; + } + if ($this->hasGroups()) { + $fields['groups'] = array_map(static function (array $item): array { + return array_map(static function (ShowSubscriberCollection $item): array { + return $item->toArray(); + }, $item); + }, $this->groups); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/SchemaCollection/ShowSubscriberCollection74.php b/test/suite/functional/Generator/SchemaCollection/ShowSubscriberCollection74.php new file mode 100644 index 0000000..f6e84c7 --- /dev/null +++ b/test/suite/functional/Generator/SchemaCollection/ShowSubscriberCollection74.php @@ -0,0 +1,67 @@ +items = $items; + } + + public function toArray(): array + { + $return = []; + foreach ($this->items as $item) { + $return[] = $item->toArray(); + } + + return $return; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return ShowSubscriber[] + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } + + public function first(): ?ShowSubscriber + { + $first = reset($this->items); + if ($first === false) { + return null; + } + + return $first; + } +} diff --git a/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php b/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php index 419bf8e..99d9f65 100644 --- a/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php @@ -47,6 +47,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemCollection', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Collection nested in an array of arrays of objects with PHP 7.4' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/SchemaCollection/ShowSubscriberCollection74.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ShowSubscriberCollection', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaGeneratorTest.php b/test/suite/functional/Generator/SchemaGeneratorTest.php index 32b9905..05a82d2 100644 --- a/test/suite/functional/Generator/SchemaGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaGeneratorTest.php @@ -94,6 +94,24 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\Profile', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Array of arrays of objects with PHP 7.0' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/Schema/ArrayOfArraysOfObjects70.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribers', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP70)->build(), + ], + 'Array of arrays of objects with PHP 7.4' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/Schema/ArrayOfArraysOfObjects74.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribers', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Array of arrays of objects with PHP 8.1' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/Schema/ArrayOfArraysOfObjects81.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribers', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper70.php b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper70.php new file mode 100644 index 0000000..9e2954a --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper70.php @@ -0,0 +1,57 @@ +showSubscriberCollectionMapper = $showSubscriberCollectionMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): ShowSubscribers + { + $missingFields = implode(', ', array_diff(['data'], array_keys($payload))); + if (!empty($missingFields)) { + throw new UnexpectedResponseBodyException('Required attributes for `ShowSubscribers` missing in the response body: ' . $missingFields); + } + $schema = new ShowSubscribers(array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $payload['data'])); + if (array_key_exists('previousData', $payload)) { + $schema->setPreviousData($payload['previousData'] !== null ? array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $payload['previousData']) : null); + } + if (isset($payload['groups'])) { + $schema->setGroups(array_map(function (array $item): array { + return array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $item); + }, $payload['groups'])); + } + if (empty($schema->toArray())) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper74.php b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper74.php new file mode 100644 index 0000000..ef4969e --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper74.php @@ -0,0 +1,56 @@ +showSubscriberCollectionMapper = $showSubscriberCollectionMapper; + } + + /** + * @throws UnexpectedResponseBodyException + */ + public function toSchema(array $payload): ShowSubscribers + { + $missingFields = implode(', ', array_diff(['data'], array_keys($payload))); + if (!empty($missingFields)) { + throw new UnexpectedResponseBodyException('Required attributes for `ShowSubscribers` missing in the response body: ' . $missingFields); + } + $schema = new ShowSubscribers(array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $payload['data'])); + if (array_key_exists('previousData', $payload)) { + $schema->setPreviousData($payload['previousData'] !== null ? array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $payload['previousData']) : null); + } + if (isset($payload['groups'])) { + $schema->setGroups(array_map(function (array $item): array { + return array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $item); + }, $payload['groups'])); + } + if (empty($schema->toArray())) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper81.php b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper81.php new file mode 100644 index 0000000..b070cfd --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/ArrayOfArraysOfObjectsMapper81.php @@ -0,0 +1,53 @@ +showSubscriberCollectionMapper->toSchema($item); + }, $payload['data'])); + if (array_key_exists('previousData', $payload)) { + $schema->setPreviousData($payload['previousData'] !== null ? array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $payload['previousData']) : null); + } + if (isset($payload['groups'])) { + $schema->setGroups(array_map(function (array $item): array { + return array_map(function (array $item): ShowSubscriberCollection { + return $this->showSubscriberCollectionMapper->toSchema($item); + }, $item); + }, $payload['groups'])); + } + if (empty($schema->toArray())) { + throw new UnexpectedResponseBodyException(); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/ShowSubscriberCollectionMapper74.php b/test/suite/functional/Generator/SchemaMapper/ShowSubscriberCollectionMapper74.php new file mode 100644 index 0000000..0ea51e4 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/ShowSubscriberCollectionMapper74.php @@ -0,0 +1,33 @@ +showSubscriberMapper = $showSubscriberMapper; + } + + public function toSchema(array $payload): ShowSubscriberCollection + { + $items = []; + foreach ($payload as $payloadItem) { + $items[] = $this->showSubscriberMapper->toSchema($payloadItem); + } + + return new ShowSubscriberCollection(...$items); + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/arrayOfArraysOfObjects.yaml b/test/suite/functional/Generator/SchemaMapper/arrayOfArraysOfObjects.yaml new file mode 100644 index 0000000..ef65f37 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/arrayOfArraysOfObjects.yaml @@ -0,0 +1,58 @@ +openapi: 3.0.0 +info: + title: Arrays of arrays of objects + version: 1.0.0 + +paths: + /shows/{showId}/subscribers: + get: + operationId: findShowSubscribers + parameters: + - in: path + name: showId + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/ShowSubscribers' + +components: + schemas: + ShowSubscribers: + type: object + required: + - data + properties: + data: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ShowSubscriber' + previousData: + type: array + nullable: true + items: + type: array + items: + $ref: '#/components/schemas/ShowSubscriber' + groups: + type: array + items: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ShowSubscriber' + ShowSubscriber: + type: object + required: + - id + properties: + id: + type: integer diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 785f0cd..ad43f0e 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -232,6 +232,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\UploaderMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Array of arrays of objects with php 7.0' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/SchemaMapper/ArrayOfArraysOfObjectsMapper70.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribersMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP70)->build(), + ], + 'Array of arrays of objects with php 7.4' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/SchemaMapper/ArrayOfArraysOfObjectsMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribersMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Array of arrays of objects with php 8.1' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/SchemaMapper/ArrayOfArraysOfObjectsMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ShowSubscribersMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Collection nested in an array of arrays of objects with php 7.4' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/SchemaMapper/ShowSubscriberCollectionMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ShowSubscriberCollectionMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/ServiceProvider/ArrayOfArraysOfObjectsServiceProvider74.php b/test/suite/functional/Generator/ServiceProvider/ArrayOfArraysOfObjectsServiceProvider74.php new file mode 100644 index 0000000..dabf55c --- /dev/null +++ b/test/suite/functional/Generator/ServiceProvider/ArrayOfArraysOfObjectsServiceProvider74.php @@ -0,0 +1,51 @@ +add(new JsonContentTypeSerializer()); + }; + $container[QuerySerializer::class] = static function (): QuerySerializer { + return new QuerySerializer(); + }; + $container[ResponseHandler::class] = static function () use ($container): ResponseHandler { + return new ResponseHandler($container[BodySerializer::class], new ResponseExceptionFactory()); + }; + $container[RequestMapperInterface::class] = static function () use ($container): RequestMapperInterface { + return new GuzzleRequestMapper($container[BodySerializer::class], $container[QuerySerializer::class]); + }; + $container[ShowSubscribersMapper::class] = static function () use ($container): ShowSubscribersMapper { + return new ShowSubscribersMapper($container[ShowSubscriberCollectionMapper::class]); + }; + $container[ShowSubscriberCollectionMapper::class] = static function () use ($container): ShowSubscriberCollectionMapper { + return new ShowSubscriberCollectionMapper($container[ShowSubscriberMapper::class]); + }; + $container[ShowSubscriberMapper::class] = static function () use ($container): ShowSubscriberMapper { + return new ShowSubscriberMapper(); + }; + } +} diff --git a/test/suite/functional/Generator/ServiceProviderGeneratorTest.php b/test/suite/functional/Generator/ServiceProviderGeneratorTest.php index 4bbf8ce..03dbeb2 100644 --- a/test/suite/functional/Generator/ServiceProviderGeneratorTest.php +++ b/test/suite/functional/Generator/ServiceProviderGeneratorTest.php @@ -27,6 +27,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . '\\ServiceProvider', ConfigurationBuilder::fake()->build(), ], + 'Array of arrays of objects' => [ + '/SchemaMapper/arrayOfArraysOfObjects.yaml', + '/ServiceProvider/ArrayOfArraysOfObjectsServiceProvider74.php', + self::BASE_NAMESPACE . '\\ServiceProvider', + ConfigurationBuilder::fake()->build(), + ], ]; } From 22ae31f6d56eee0b0c95b3c8608fc857877ed83c Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:41:53 +0200 Subject: [PATCH 15/21] fix: do not generate a mapper for array-of-enum response items For CLIENT_PHP_VERSION 8.1 and later, the Parser adds the item of an array-of-enums response property to the composite response fields, so that EnumGenerator creates the enum. SchemaMapperGenerator and ServiceProviderGenerator also iterated those items and emitted an `{Enum}Mapper` whose `toSchema()` had no body: it was never used (the parent mapper maps the items with `{Enum}::from()`), threw a TypeError when called, and failed static analysis. Both generators now skip enum fields; the enum itself and the array mapping are unchanged. Co-Authored-By: Claude Opus 5.5 --- src/Generator/SchemaMapperGenerator.php | 5 +++ src/Generator/ServiceProviderGenerator.php | 6 ++- .../Generator/SchemaMapperGeneratorTest.php | 17 ++++++++ .../ServiceProviderArrayOfEnumsPhp81.php | 43 +++++++++++++++++++ .../ServiceProviderGeneratorTest.php | 7 +++ 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 test/suite/functional/Generator/ServiceProvider/ServiceProviderArrayOfEnumsPhp81.php diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 916b557..2a88fc5 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -30,6 +30,11 @@ public function generate(Specification $specification, PhpFileCollection $fileRe { foreach ($specification->getCompositeResponseFields() as $field) { /** @var Field $field */ + if ($field->isEnum()) { + // array-of-enum items are mapped by the parent with from(), they need no mapper + continue; + } + $this->generateMapper($fileRegistry, $field); } } diff --git a/src/Generator/ServiceProviderGenerator.php b/src/Generator/ServiceProviderGenerator.php index 88ba55a..6105c37 100644 --- a/src/Generator/ServiceProviderGenerator.php +++ b/src/Generator/ServiceProviderGenerator.php @@ -62,7 +62,11 @@ public function generate(Specification $specification, PhpFileCollection $fileRe ) ); - $compositeFields = $specification->getCompositeResponseFields()->getUniqueByPhpClassName(); + $compositeFields = array_filter( + $specification->getCompositeResponseFields()->getUniqueByPhpClassName(), + // array-of-enum items have no mapper to register + static fn (Field $field): bool => !$field->isEnum() + ); $classBuilder = $this ->builder diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 0caa9c5..ef4b31d 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -181,6 +181,23 @@ public function exampleProvider(): array ]; } + public function testArrayOfEnumsItemsGetNoMapper(): void + { + $this->setUpContainer(ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build()); + $specificationPath = __DIR__ . '/Schema/arrayOfEnums.yaml'; + $specification = $this->specificationParser->parse( + $this->specificationReader->read($specificationPath), + $specificationPath + ); + + $this->sut->generate($specification, $this->fileRegistry); + + self::assertSame( + [self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ItemWithArraysOfEnumPropertiesMapper'], + array_keys(iterator_to_array($this->fileRegistry)) + ); + } + protected function generatorClassName(): string { return SchemaMapperGenerator::class; diff --git a/test/suite/functional/Generator/ServiceProvider/ServiceProviderArrayOfEnumsPhp81.php b/test/suite/functional/Generator/ServiceProvider/ServiceProviderArrayOfEnumsPhp81.php new file mode 100644 index 0000000..7816ee3 --- /dev/null +++ b/test/suite/functional/Generator/ServiceProvider/ServiceProviderArrayOfEnumsPhp81.php @@ -0,0 +1,43 @@ +add(new JsonContentTypeSerializer()); + }; + $container[QuerySerializer::class] = static function (): QuerySerializer { + return new QuerySerializer(); + }; + $container[ResponseHandler::class] = static function () use ($container): ResponseHandler { + return new ResponseHandler($container[BodySerializer::class], new ResponseExceptionFactory()); + }; + $container[RequestMapperInterface::class] = static function () use ($container): RequestMapperInterface { + return new GuzzleRequestMapper($container[BodySerializer::class], $container[QuerySerializer::class]); + }; + $container[ItemWithArraysOfEnumPropertiesMapper::class] = static function () use ($container): ItemWithArraysOfEnumPropertiesMapper { + return new ItemWithArraysOfEnumPropertiesMapper(); + }; + } +} diff --git a/test/suite/functional/Generator/ServiceProviderGeneratorTest.php b/test/suite/functional/Generator/ServiceProviderGeneratorTest.php index 4bbf8ce..48e5492 100644 --- a/test/suite/functional/Generator/ServiceProviderGeneratorTest.php +++ b/test/suite/functional/Generator/ServiceProviderGeneratorTest.php @@ -4,6 +4,7 @@ namespace DoclerLabs\ApiClientGenerator\Test\Functional\Generator; +use DoclerLabs\ApiClientGenerator\Ast\PhpVersion; use DoclerLabs\ApiClientGenerator\Generator\ServiceProviderGenerator; use DoclerLabs\ApiClientGenerator\Test\Functional\ConfigurationBuilder; @@ -27,6 +28,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . '\\ServiceProvider', ConfigurationBuilder::fake()->build(), ], + 'Array of enums with PHP 8.1 (no mapper for the enum items)' => [ + '/Schema/arrayOfEnums.yaml', + '/ServiceProvider/ServiceProviderArrayOfEnumsPhp81.php', + self::BASE_NAMESPACE . '\\ServiceProvider', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } From e24548d3559c9a7808ebd95e472bda1f8befd5f9 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:50:18 +0200 Subject: [PATCH 16/21] fix: name enum values that consist of symbols only instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An enum value without letters or digits (e.g. `*`, `=`, `>=` or the empty string) sanitised to an empty case name, and EnumGenerator silently skipped it: for CLIENT_PHP_VERSION 8.1 and later the value was missing from the enum (`Enum::from('=')` threw a ValueError, an enum could even end up with no cases at all), and before 8.1 a single-symbol value got no constant. Such values now get a name built from the names of their characters: `*` -> ASTERISK, `=` -> EQUALS, `>=` -> GREATER_THAN_EQUALS, `€` -> U20AC, `` -> EMPTY. For the pre-8.1 constants only the values that got no constant before are affected (e.g. `EMBED_ASTERISK`). A symbolic name that coincides with the name of another value of the same enum gets a numeric suffix, so the existing names never change. A parameter default referencing such a value uses the same name. Co-Authored-By: Claude Opus 5.5 --- src/Generator/EnumGenerator.php | 60 +++++++++++-- .../MutatorAccessorClassGeneratorAbstract.php | 39 +++++--- src/Generator/RequestGenerator.php | 2 +- src/Naming/SchemaNaming.php | 89 +++++++++++++++++++ .../Generator/EnumGeneratorTest.php | 12 +++ .../GetResourcesBySymbolsRequest74.php | 89 +++++++++++++++++++ .../GetResourcesBySymbolsRequest81.php | 80 +++++++++++++++++ .../Request/getResourcesBySymbols.yaml | 41 +++++++++ .../Generator/RequestGeneratorTest.php | 12 +++ .../GetResourcesBySymbolsEmbedItemEnum.php | 16 ++++ .../Generator/Schema/OperatorEnum.php | 18 ++++ .../unit/Generator/EnumGeneratorTest.php | 33 ++++++- 12 files changed, 470 insertions(+), 21 deletions(-) create mode 100644 test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest74.php create mode 100644 test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest81.php create mode 100644 test/suite/functional/Generator/Request/getResourcesBySymbols.yaml create mode 100644 test/suite/functional/Generator/Schema/GetResourcesBySymbolsEmbedItemEnum.php create mode 100644 test/suite/functional/Generator/Schema/OperatorEnum.php diff --git a/src/Generator/EnumGenerator.php b/src/Generator/EnumGenerator.php index dcb1a51..a884e39 100644 --- a/src/Generator/EnumGenerator.php +++ b/src/Generator/EnumGenerator.php @@ -6,6 +6,7 @@ use DoclerLabs\ApiClientGenerator\Entity\Field; use DoclerLabs\ApiClientGenerator\Input\Specification; +use DoclerLabs\ApiClientGenerator\Naming\SchemaNaming; use DoclerLabs\ApiClientGenerator\Output\Php\PhpFileCollection; class EnumGenerator extends MutatorAccessorClassGeneratorAbstract @@ -16,7 +17,12 @@ class EnumGenerator extends MutatorAccessorClassGeneratorAbstract public static function getCaseName(string $value): string { - $sanitized = (string)preg_replace('/[^A-Z0-9_]/', '', strtoupper(str_replace([' ', '-', '/', '.'], '_', $value))); + $sanitized = self::sanitizeCaseName($value); + + if ($sanitized === '') { + // no letters, digits or separators, e.g. `*` or `>=` + return SchemaNaming::getSymbolicEnumValueName($value); + } if (preg_match('/^[0-9]/', $sanitized) === 1) { return 'V_' . $sanitized; @@ -25,6 +31,43 @@ public static function getCaseName(string $value): string return $sanitized; } + /** + * Case names of all values of an enum, with the keys of the values. A symbolic name (a value without letters + * or digits) that coincides with the name of another value gets a numeric suffix, so that one keeps its name. + * + * @param array $values + * + * @return array + */ + public static function getCaseNames(array $values): array + { + $caseNames = []; + foreach ($values as $key => $value) { + $caseNames[$key] = self::getCaseName((string)$value); + } + + foreach ($values as $key => $value) { + if (self::sanitizeCaseName((string)$value) === '') { + $caseNames[$key] = SchemaNaming::getUniqueName( + $caseNames[$key], + array_diff_key($caseNames, [$key => true]) + ); + } + } + + return $caseNames; + } + + /** + * @param array $values + */ + public static function getCaseNameOfValue(array $values, int|string $value): string + { + $key = array_search($value, $values, true); + + return $key === false ? self::getCaseName((string)$value) : self::getCaseNames($values)[$key]; + } + public function generate(Specification $specification, PhpFileCollection $fileRegistry): void { if (!$this->phpVersion->isEnumSupported()) { @@ -75,18 +118,19 @@ private function generateEnumConsts(Field $root): array } $statements = []; - foreach ($root->getEnumValues() as $value) { - $caseName = self::getCaseName((string)$value); - if (empty($caseName)) { - continue; - } - + $caseNames = self::getCaseNames($root->getEnumValues()); + foreach ($root->getEnumValues() as $key => $value) { $statements[] = $this ->builder - ->enumCase($caseName) + ->enumCase($caseNames[$key]) ->setValue($value); } return $statements; } + + private static function sanitizeCaseName(string $value): string + { + return (string)preg_replace('/[^A-Z0-9_]/', '', strtoupper(str_replace([' ', '-', '/', '.'], '_', $value))); + } } diff --git a/src/Generator/MutatorAccessorClassGeneratorAbstract.php b/src/Generator/MutatorAccessorClassGeneratorAbstract.php index 7547460..301cc69 100644 --- a/src/Generator/MutatorAccessorClassGeneratorAbstract.php +++ b/src/Generator/MutatorAccessorClassGeneratorAbstract.php @@ -19,6 +19,8 @@ abstract class MutatorAccessorClassGeneratorAbstract extends GeneratorAbstract { + private const CONSTANT_NAME_PATTERN = '/^[a-zA-Z_][a-zA-Z0-9_]*$/'; + protected CodeBuilder $builder; abstract public function generate(Specification $specification, PhpFileCollection $fileRegistry): void; @@ -112,22 +114,37 @@ protected function generateEnumStatements(Field $field): array return []; } - $statements = []; $enumValues = $field->isArrayOfEnums() ? $field->getArrayItem()->getEnumValues() : $field->getEnumValues(); - if (!empty($enumValues)) { - foreach ($enumValues as $enumValue) { - if (is_string($enumValue)) { - $constName = SchemaNaming::getEnumConstName($field, $enumValue); - if (preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $constName)) { - $statements[] = $this->builder->constant( - $constName, - $this->builder->val($enumValue) - ); - } + $enumValues = array_filter($enumValues ?? [], 'is_string'); + + $constNames = []; + foreach ($enumValues as $key => $enumValue) { + $constName = SchemaNaming::getEnumConstName($field, $enumValue); + if (preg_match(self::CONSTANT_NAME_PATTERN, $constName)) { + $constNames[$key] = $constName; + } + } + + // a value that yields no valid name from its letters and digits (e.g. `*`) gets a symbolic one + foreach ($enumValues as $key => $enumValue) { + if (!isset($constNames[$key])) { + $constName = SchemaNaming::getSymbolicEnumConstName($field, $enumValue); + if (preg_match(self::CONSTANT_NAME_PATTERN, $constName)) { + $constNames[$key] = SchemaNaming::getUniqueName($constName, $constNames); } } } + $statements = []; + foreach ($enumValues as $key => $enumValue) { + if (isset($constNames[$key])) { + $statements[] = $this->builder->constant( + $constNames[$key], + $this->builder->val($enumValue) + ); + } + } + return $statements; } diff --git a/src/Generator/RequestGenerator.php b/src/Generator/RequestGenerator.php index c51f203..239aeab 100644 --- a/src/Generator/RequestGenerator.php +++ b/src/Generator/RequestGenerator.php @@ -171,7 +171,7 @@ protected function generateConstructor( )) { $param->setDefault($this->builder->classConstFetch( $field->getPhpClassName(), - EnumGenerator::getCaseName((string)$default) + EnumGenerator::getCaseNameOfValue($field->getEnumValues() ?? [], $default) )); } else { $param->setDefault($field->getDefault()); diff --git a/src/Naming/SchemaNaming.php b/src/Naming/SchemaNaming.php index 8e2288b..b0acdd2 100644 --- a/src/Naming/SchemaNaming.php +++ b/src/Naming/SchemaNaming.php @@ -13,6 +13,44 @@ class SchemaNaming { private const OPENAPI_COMPONENT_TYPES = ['schemas', 'parameters']; + private const EMPTY_ENUM_VALUE_NAME = 'EMPTY'; + + private const SYMBOL_NAMES = [ + ' ' => 'SPACE', + '!' => 'EXCLAMATION', + '"' => 'QUOTE', + '#' => 'HASH', + '$' => 'DOLLAR', + '%' => 'PERCENT', + '&' => 'AMPERSAND', + '\'' => 'APOSTROPHE', + '(' => 'LEFT_PARENTHESIS', + ')' => 'RIGHT_PARENTHESIS', + '*' => 'ASTERISK', + '+' => 'PLUS', + ',' => 'COMMA', + '-' => 'MINUS', + '.' => 'DOT', + '/' => 'SLASH', + ':' => 'COLON', + ';' => 'SEMICOLON', + '<' => 'LESS_THAN', + '=' => 'EQUALS', + '>' => 'GREATER_THAN', + '?' => 'QUESTION_MARK', + '@' => 'AT', + '[' => 'LEFT_BRACKET', + '\\' => 'BACKSLASH', + ']' => 'RIGHT_BRACKET', + '^' => 'CARET', + '_' => 'UNDERSCORE', + '`' => 'BACKTICK', + '{' => 'LEFT_BRACE', + '|' => 'PIPE', + '}' => 'RIGHT_BRACE', + '~' => 'TILDE', + ]; + public static function getClassName(SpecObjectInterface $reference, string $fallbackName = ''): string { if (!($reference instanceof Reference)) { @@ -44,4 +82,55 @@ public static function getEnumConstName(Field $field, string $enum): string CaseCaster::toMacro($enum) ); } + + /** + * Constant name for an enum value that yields no valid name from its letters and digits (e.g. `*`). + */ + public static function getSymbolicEnumConstName(Field $field, string $enum): string + { + return sprintf( + '%s_%s', + CaseCaster::toMacro($field->getName()), + self::getSymbolicEnumValueName($enum) + ); + } + + /** + * Name for an enum value without letters or digits, built from the names of its characters, + * e.g. `*` -> ASTERISK, `>=` -> GREATER_THAN_EQUALS, `€` -> U20AC and the empty string -> EMPTY. + */ + public static function getSymbolicEnumValueName(string $value): string + { + if ($value === '') { + return self::EMPTY_ENUM_VALUE_NAME; + } + + $names = []; + foreach (mb_str_split($value, 1, 'UTF-8') as $character) { + if (isset(self::SYMBOL_NAMES[$character])) { + $names[] = self::SYMBOL_NAMES[$character]; + } elseif (mb_check_encoding($character, 'UTF-8')) { + $names[] = sprintf('U%04X', mb_ord($character, 'UTF-8')); + } else { + $names[] = 'X' . strtoupper(bin2hex($character)); + } + } + + return implode('_', $names); + } + + /** + * Returns the name, or when it is already taken, the name with the first free numeric suffix. + * + * @param string[] $takenNames + */ + public static function getUniqueName(string $name, array $takenNames, string $separator = '_'): string + { + $uniqueName = $name; + for ($suffix = 2; in_array($uniqueName, $takenNames, true); ++$suffix) { + $uniqueName = $name . $separator . $suffix; + } + + return $uniqueName; + } } diff --git a/test/suite/functional/Generator/EnumGeneratorTest.php b/test/suite/functional/Generator/EnumGeneratorTest.php index 35ea966..c69260b 100644 --- a/test/suite/functional/Generator/EnumGeneratorTest.php +++ b/test/suite/functional/Generator/EnumGeneratorTest.php @@ -53,6 +53,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesStatusesItemEnum', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Request parameter enum with a value consisting of symbols only' => [ + '/Request/getResourcesBySymbols.yaml', + '/Schema/OperatorEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\OperatorEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Inline array of enums request parameter with a value consisting of symbols only' => [ + '/Request/getResourcesBySymbols.yaml', + '/Schema/GetResourcesBySymbolsEmbedItemEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetResourcesBySymbolsEmbedItemEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest74.php b/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest74.php new file mode 100644 index 0000000..501556c --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest74.php @@ -0,0 +1,89 @@ +operator = $operator; + } + + public function getContentType(): string + { + return $this->contentType; + } + + /** + * @param string[] $embed + */ + public function setEmbed(array $embed): self + { + $this->embed = $embed; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-symbols'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['operator' => $this->operator, 'embed' => $this->embed], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['operator' => $this->operator, 'embed' => $this->embed]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest81.php b/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest81.php new file mode 100644 index 0000000..c5d2c59 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesBySymbolsRequest81.php @@ -0,0 +1,80 @@ +contentType; + } + + /** + * @param GetResourcesBySymbolsEmbedItemEnum[] $embed + */ + public function setEmbed(array $embed): self + { + $this->embed = $embed; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-symbols'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['operator' => $this->operator->value, 'embed' => $this->embed === null ? null : array_map(static fn (GetResourcesBySymbolsEmbedItemEnum $enum): string => $enum->value, $this->embed)], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['operator' => $this->operator->value, 'embed' => $this->embed === null ? null : array_map(static fn (GetResourcesBySymbolsEmbedItemEnum $enum): string => $enum->value, $this->embed)]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/getResourcesBySymbols.yaml b/test/suite/functional/Generator/Request/getResourcesBySymbols.yaml new file mode 100644 index 0000000..fee5b84 --- /dev/null +++ b/test/suite/functional/Generator/Request/getResourcesBySymbols.yaml @@ -0,0 +1,41 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/resources-by-symbols': + get: + description: Get resources filtered by enum values that consist of symbols only + operationId: getResourcesBySymbols + parameters: + - name: operator + in: query + required: true + schema: + type: string + default: '=' + enum: + - '=' + - BETWEEN + - IN + - name: embed + in: query + required: false + schema: + type: array + items: + type: string + enum: + - '*' + responses: + '200': + description: list of resources + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index dbf319a..0a8f2ec 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -262,6 +262,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByUntypedFilterRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP80)->build(), ], + 'Request with enum values consisting of symbols only with php 7.4' => [ + '/Request/getResourcesBySymbols.yaml', + '/Request/GetResourcesBySymbolsRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesBySymbolsRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with enum values consisting of symbols only with php 8.1' => [ + '/Request/getResourcesBySymbols.yaml', + '/Request/GetResourcesBySymbolsRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesBySymbolsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/GetResourcesBySymbolsEmbedItemEnum.php b/test/suite/functional/Generator/Schema/GetResourcesBySymbolsEmbedItemEnum.php new file mode 100644 index 0000000..7e95a28 --- /dev/null +++ b/test/suite/functional/Generator/Schema/GetResourcesBySymbolsEmbedItemEnum.php @@ -0,0 +1,16 @@ + ['3d', 'V_3D'], 'decimal' => ['1.5', 'V_1_5'], 'negative number' => ['-1', '_1'], - 'invalid characters only' => ['+', ''], + 'symbol only' => ['+', 'PLUS'], + 'asterisk' => ['*', 'ASTERISK'], + 'equals sign' => ['=', 'EQUALS'], + 'several symbols' => ['>=', 'GREATER_THAN_EQUALS'], + 'non-ASCII character only' => ['€', 'U20AC'], + 'empty string' => ['', 'EMPTY'], ]; } + + /** + * @dataProvider caseNamesProvider + */ + public function testGetCaseNames(array $values, array $expectedCaseNames): void + { + self::assertSame($expectedCaseNames, EnumGenerator::getCaseNames($values)); + } + + public function caseNamesProvider(): array + { + return [ + 'symbol among words' => [['=', 'BETWEEN', 'IN'], ['EQUALS', 'BETWEEN', 'IN']], + 'symbolic name taken by a later value' => [['*', 'asterisk'], ['ASTERISK_2', 'ASTERISK']], + 'symbolic name taken by an early value' => [['equals', '='], ['EQUALS', 'EQUALS_2']], + 'integers' => [[1, 2], ['V_1', 'V_2']], + ]; + } + + public function testGetCaseNameOfValue(): void + { + self::assertSame('ASTERISK_2', EnumGenerator::getCaseNameOfValue(['*', 'asterisk'], '*')); + self::assertSame('ASTERISK', EnumGenerator::getCaseNameOfValue(['*', 'asterisk'], 'asterisk')); + self::assertSame('V_2', EnumGenerator::getCaseNameOfValue([1, 2], 2)); + self::assertSame('UNKNOWN', EnumGenerator::getCaseNameOfValue(['*'], 'unknown')); + } } From 0ea0a2834dd0711ab888c13412c9bde26638b4e1 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 14:01:12 +0200 Subject: [PATCH 17/21] fix: declare the new throwExpression() message parameter explicitly nullable Co-Authored-By: Claude Opus 5.5 --- src/Ast/Builder/CodeBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ast/Builder/CodeBuilder.php b/src/Ast/Builder/CodeBuilder.php index bfdb1e5..a6750c4 100644 --- a/src/Ast/Builder/CodeBuilder.php +++ b/src/Ast/Builder/CodeBuilder.php @@ -381,7 +381,7 @@ public function throw(string $exceptionClassName, Expr $message = null): Stmt return new Throw_($this->new($exceptionClassName, $args)); } - public function throwExpression(string $exceptionClassName, Expr $message = null): ThrowExpression + public function throwExpression(string $exceptionClassName, ?Expr $message = null): ThrowExpression { $args = []; if ($message !== null) { From 4d75b7dbdba402f3ac0ae7cc559433fe9cb2b55f Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 13:59:49 +0200 Subject: [PATCH 18/21] fix: generate valid class names for schemas named after PHP reserved words PhpNameValidator::isValidClassName() only checks the characters, so a schema named after a keyword or reserved type name (e.g. `Match`, `List`, `Print`, `Mixed`) produced `class Match`, which is a parse error on PHP 8.x (for `match`) or on every version (for `list`, `print`, ...). The generation aborted in the lint step, whatever CLIENT_PHP_VERSION was targeted. The class of such an object schema now gets a `Schema` suffix (`MatchSchema`, so its mapper is `MatchSchemaMapper`) and a warning is printed. The names derived from the schema name are valid and stay as they were (e.g. `MatchCollection`, or `MatchVideo` for an inline property), and enums are not affected, their class names already end in `Enum`. The discriminator mapping of a oneOf/anyOf now also finds an option whose class name differs from its schema name, so such an option keeps its case. An enum value `class` produced `case CLASS`, which PHP reserves for the class name constant; it is now `case V_CLASS`, like values that start with a digit. Only output that could not be generated before changes. Co-Authored-By: Claude Opus 5.5 --- src/Entity/Field.php | 3 +- src/Generator/EnumGenerator.php | 3 +- src/Generator/SchemaCollectionGenerator.php | 2 +- src/Generator/SchemaMapperGenerator.php | 9 +- src/Input/Factory/FieldFactory.php | 17 +++ src/Naming/SchemaCollectionNaming.php | 2 +- src/Naming/SchemaNaming.php | 31 +++++ .../Generator/EnumGeneratorTest.php | 6 + .../Generator/Schema/ClassSchema.php | 53 +++++++++ .../functional/Generator/Schema/ListEnum.php | 18 +++ .../Generator/Schema/MatchSchemaPhp74.php | 72 ++++++++++++ .../Generator/Schema/MatchSchemaPhp81.php | 63 +++++++++++ .../Generator/Schema/Tournament.php | 63 +++++++++++ .../Generator/Schema/reservedWords.yaml | 106 ++++++++++++++++++ .../SchemaCollection/MatchCollection.php | 67 +++++++++++ .../SchemaCollectionGeneratorTest.php | 6 + .../Generator/SchemaGeneratorTest.php | 24 ++++ .../Generator/SchemaMapper/GameMapper81.php | 45 ++++++++ .../SchemaMapper/TournamentMapper81.php | 38 +++++++ .../Generator/SchemaMapperGeneratorTest.php | 12 ++ .../unit/Generator/EnumGeneratorTest.php | 1 + test/suite/unit/Naming/SchemaNamingTest.php | 44 ++++++++ 22 files changed, 678 insertions(+), 7 deletions(-) create mode 100644 test/suite/functional/Generator/Schema/ClassSchema.php create mode 100644 test/suite/functional/Generator/Schema/ListEnum.php create mode 100644 test/suite/functional/Generator/Schema/MatchSchemaPhp74.php create mode 100644 test/suite/functional/Generator/Schema/MatchSchemaPhp81.php create mode 100644 test/suite/functional/Generator/Schema/Tournament.php create mode 100644 test/suite/functional/Generator/Schema/reservedWords.yaml create mode 100644 test/suite/functional/Generator/SchemaCollection/MatchCollection.php create mode 100644 test/suite/functional/Generator/SchemaMapper/GameMapper81.php create mode 100644 test/suite/functional/Generator/SchemaMapper/TournamentMapper81.php create mode 100644 test/suite/unit/Naming/SchemaNamingTest.php diff --git a/src/Entity/Field.php b/src/Entity/Field.php index d2aeb56..371785d 100644 --- a/src/Entity/Field.php +++ b/src/Entity/Field.php @@ -8,6 +8,7 @@ use DoclerLabs\ApiClientGenerator\Entity\Constraint\ConstraintCollection; use DoclerLabs\ApiClientGenerator\Naming\CaseCaster; use DoclerLabs\ApiClientGenerator\Naming\SchemaCollectionNaming; +use DoclerLabs\ApiClientGenerator\Naming\SchemaNaming; use RuntimeException; class Field @@ -220,7 +221,7 @@ public function getPhpVariableName(): string public function getPhpClassName(): string { if ($this->type->isObject()) { - return $this->referenceName; + return SchemaNaming::getSchemaClassName($this->referenceName); } if ( diff --git a/src/Generator/EnumGenerator.php b/src/Generator/EnumGenerator.php index a884e39..f7308ad 100644 --- a/src/Generator/EnumGenerator.php +++ b/src/Generator/EnumGenerator.php @@ -24,7 +24,8 @@ public static function getCaseName(string $value): string return SchemaNaming::getSymbolicEnumValueName($value); } - if (preg_match('/^[0-9]/', $sanitized) === 1) { + // a case name cannot start with a digit, and `class` is reserved for the class name constant + if (preg_match('/^[0-9]/', $sanitized) === 1 || $sanitized === 'CLASS') { return 'V_' . $sanitized; } diff --git a/src/Generator/SchemaCollectionGenerator.php b/src/Generator/SchemaCollectionGenerator.php index ceee4e5..bde3219 100644 --- a/src/Generator/SchemaCollectionGenerator.php +++ b/src/Generator/SchemaCollectionGenerator.php @@ -174,7 +174,7 @@ protected function generateFirst(Field $arrayItem): ClassMethod ->addStmt($if) ->addStmt($return) ->setReturnType($arrayItem->getPhpTypeHint(), true) - ->composeDocBlock([], $arrayItem->getReferenceName() . '|null') + ->composeDocBlock([], $arrayItem->getPhpClassName() . '|null') ->getNode(); } } diff --git a/src/Generator/SchemaMapperGenerator.php b/src/Generator/SchemaMapperGenerator.php index 2a88fc5..d1e08fc 100644 --- a/src/Generator/SchemaMapperGenerator.php +++ b/src/Generator/SchemaMapperGenerator.php @@ -641,21 +641,24 @@ private function generateDiscriminatorFallbackStatements(Expr $payloadDiscrimina */ private function generateDiscriminatorMappingCases(Field $root, array $mapping, Variable $payloadVariable): array { - $childrenByClassName = []; + $childrenByClassName = []; + $childrenBySchemaName = []; foreach ($root->getObjectProperties() as $child) { if ($child->isComposite()) { $childrenByClassName[$child->getPhpClassName()] = $child; + // the class name can differ from the schema name, e.g. for a reserved word + $childrenBySchemaName[$child->getName()] = $child; } } $cases = []; foreach ($mapping as $discriminatorValue => $reference) { $schemaName = $this->resolveSchemaNameFromReference($reference); - if (!isset($childrenByClassName[$schemaName])) { + $child = $childrenByClassName[$schemaName] ?? $childrenBySchemaName[$schemaName] ?? null; + if ($child === null) { continue; } - $child = $childrenByClassName[$schemaName]; $cases[] = $this->builder->case( $this->builder->val((string)$discriminatorValue), $this->builder->expr( diff --git a/src/Input/Factory/FieldFactory.php b/src/Input/Factory/FieldFactory.php index 370d7dd..733b9a2 100644 --- a/src/Input/Factory/FieldFactory.php +++ b/src/Input/Factory/FieldFactory.php @@ -27,6 +27,9 @@ class FieldFactory { + /** @var string[] reserved words already reported as schema names */ + private array $reservedSchemaNames = []; + public function __construct(private PhpNameValidator $nameValidator, private PhpVersion $phpVersion) { } @@ -155,6 +158,20 @@ public function create( } } + if ( + FieldType::isSpecificationTypeObject($type) + && SchemaNaming::isReservedClassName($referenceName) + && !in_array($referenceName, $this->reservedSchemaNames, true) + ) { + $this->reservedSchemaNames[] = $referenceName; + $warningMessage = sprintf( + 'Schema name %s is a reserved word in PHP, %s is used as class name instead.', + $referenceName, + SchemaNaming::getSchemaClassName($referenceName) + ); + trigger_error($warningMessage, E_USER_WARNING); + } + $fieldType = new FieldType($type, $this->phpVersion); $field = new Field( $this->phpVersion, diff --git a/src/Naming/SchemaCollectionNaming.php b/src/Naming/SchemaCollectionNaming.php index 0279d97..d51426e 100644 --- a/src/Naming/SchemaCollectionNaming.php +++ b/src/Naming/SchemaCollectionNaming.php @@ -17,6 +17,6 @@ public static function getClassName(string $schemaName): string public static function getArrayDocType(Field $arrayItem): string { - return sprintf('%s[]', $arrayItem->getReferenceName()); + return sprintf('%s[]', $arrayItem->getPhpClassName()); } } diff --git a/src/Naming/SchemaNaming.php b/src/Naming/SchemaNaming.php index b0acdd2..3cf0c04 100644 --- a/src/Naming/SchemaNaming.php +++ b/src/Naming/SchemaNaming.php @@ -13,6 +13,23 @@ class SchemaNaming { private const OPENAPI_COMPONENT_TYPES = ['schemas', 'parameters']; + private const RESERVED_CLASS_NAME_SUFFIX = 'Schema'; + + /** + * Words that cannot be used as a class name (keywords and reserved type names) in at least one PHP version. + * The soft reserved words (enum, resource, numeric) are still valid class names, so they are not listed. + */ + private const RESERVED_CLASS_NAMES = [ + 'abstract', 'and', 'array', 'as', 'bool', 'break', 'callable', 'case', 'catch', 'class', 'clone', 'const', + 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', + 'endforeach', 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'false', 'final', 'finally', + 'float', 'fn', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once', + 'instanceof', 'insteadof', 'int', 'interface', 'isset', 'iterable', 'list', 'match', 'mixed', 'namespace', + 'never', 'new', 'null', 'object', 'or', 'parent', 'print', 'private', 'protected', 'public', 'readonly', + 'require', 'require_once', 'return', 'self', 'static', 'string', 'switch', 'throw', 'trait', 'true', 'try', + 'unset', 'use', 'var', 'void', 'while', 'xor', 'yield', + ]; + private const EMPTY_ENUM_VALUE_NAME = 'EMPTY'; private const SYMBOL_NAMES = [ @@ -74,6 +91,20 @@ public static function getClassName(SpecObjectInterface $reference, string $fall return CaseCaster::toPascal($referencePath[0]); } + /** + * Class name of an object schema. A schema name that is a reserved word in PHP (e.g. `Match`) gets a `Schema` + * suffix; the names derived from the schema name (e.g. `MatchCollection`) are valid and stay unchanged. + */ + public static function getSchemaClassName(string $schemaName): string + { + return self::isReservedClassName($schemaName) ? $schemaName . self::RESERVED_CLASS_NAME_SUFFIX : $schemaName; + } + + public static function isReservedClassName(string $name): bool + { + return in_array(strtolower($name), self::RESERVED_CLASS_NAMES, true); + } + public static function getEnumConstName(Field $field, string $enum): string { return sprintf( diff --git a/test/suite/functional/Generator/EnumGeneratorTest.php b/test/suite/functional/Generator/EnumGeneratorTest.php index c69260b..c53b89d 100644 --- a/test/suite/functional/Generator/EnumGeneratorTest.php +++ b/test/suite/functional/Generator/EnumGeneratorTest.php @@ -65,6 +65,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetResourcesBySymbolsEmbedItemEnum', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Enum named after a reserved word, with the value class' => [ + '/Schema/reservedWords.yaml', + '/Schema/ListEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ListEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/ClassSchema.php b/test/suite/functional/Generator/Schema/ClassSchema.php new file mode 100644 index 0000000..b34d1c5 --- /dev/null +++ b/test/suite/functional/Generator/Schema/ClassSchema.php @@ -0,0 +1,53 @@ + false]; + + public function setName(string $name): self + { + $this->name = $name; + $this->optionalPropertyChanged['name'] = true; + + return $this; + } + + public function hasName(): bool + { + return $this->optionalPropertyChanged['name']; + } + + public function getName(): ?string + { + return $this->name; + } + + public function toArray(): array + { + $fields = []; + if ($this->hasName()) { + $fields['name'] = $this->name; + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/ListEnum.php b/test/suite/functional/Generator/Schema/ListEnum.php new file mode 100644 index 0000000..5636ae6 --- /dev/null +++ b/test/suite/functional/Generator/Schema/ListEnum.php @@ -0,0 +1,18 @@ + false]; + + public function __construct(int $id) + { + $this->id = $id; + } + + public function setList(string $list): self + { + $this->list = $list; + $this->optionalPropertyChanged['list'] = true; + + return $this; + } + + public function hasList(): bool + { + return $this->optionalPropertyChanged['list']; + } + + public function getId(): int + { + return $this->id; + } + + public function getList(): ?string + { + return $this->list; + } + + public function toArray(): array + { + $fields = []; + $fields['id'] = $this->id; + if ($this->hasList()) { + $fields['list'] = $this->list; + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/MatchSchemaPhp81.php b/test/suite/functional/Generator/Schema/MatchSchemaPhp81.php new file mode 100644 index 0000000..635a595 --- /dev/null +++ b/test/suite/functional/Generator/Schema/MatchSchemaPhp81.php @@ -0,0 +1,63 @@ + false]; + + public function __construct(private readonly int $id) + { + } + + public function setList(ListEnum $list): self + { + $this->list = $list; + $this->optionalPropertyChanged['list'] = true; + + return $this; + } + + public function hasList(): bool + { + return $this->optionalPropertyChanged['list']; + } + + public function getId(): int + { + return $this->id; + } + + public function getList(): ?ListEnum + { + return $this->list; + } + + public function toArray(): array + { + $fields = []; + $fields['id'] = $this->id; + if ($this->hasList()) { + $fields['list'] = $this->list->value; + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/Tournament.php b/test/suite/functional/Generator/Schema/Tournament.php new file mode 100644 index 0000000..65bb84e --- /dev/null +++ b/test/suite/functional/Generator/Schema/Tournament.php @@ -0,0 +1,63 @@ + false]; + + public function __construct(private readonly MatchCollection $matches) + { + } + + public function setClass(ClassSchema $class): self + { + $this->class = $class; + $this->optionalPropertyChanged['class'] = true; + + return $this; + } + + public function hasClass(): bool + { + return $this->optionalPropertyChanged['class']; + } + + public function getMatches(): MatchCollection + { + return $this->matches; + } + + public function getClass(): ?ClassSchema + { + return $this->class; + } + + public function toArray(): array + { + $fields = []; + $fields['matches'] = $this->matches->toArray(); + if ($this->hasClass()) { + $fields['class'] = $this->class->toArray(); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/reservedWords.yaml b/test/suite/functional/Generator/Schema/reservedWords.yaml new file mode 100644 index 0000000..34af400 --- /dev/null +++ b/test/suite/functional/Generator/Schema/reservedWords.yaml @@ -0,0 +1,106 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Reserved word schema names Test API +paths: + '/v1/matches': + get: + operationId: findMatches + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Match' + '/v1/matches/{matchId}': + get: + operationId: getMatch + parameters: + - name: matchId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Match' + '/v1/tournaments/{tournamentId}': + get: + operationId: getTournament + parameters: + - name: tournamentId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Tournament' + '/v1/games/{gameId}': + get: + operationId: getGame + parameters: + - name: gameId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Game' +components: + schemas: + Game: + oneOf: + - $ref: '#/components/schemas/Match' + - $ref: '#/components/schemas/Tournament' + discriminator: + propertyName: type + mapping: + match: '#/components/schemas/Match' + tournament: '#/components/schemas/Tournament' + List: + type: string + enum: + - group + - knockout + - class + Match: + type: object + required: + - id + properties: + id: + type: integer + list: + $ref: '#/components/schemas/List' + Tournament: + allOf: + - type: object + required: + - matches + properties: + matches: + type: array + items: + $ref: '#/components/schemas/Match' + class: + type: object + properties: + name: + type: string diff --git a/test/suite/functional/Generator/SchemaCollection/MatchCollection.php b/test/suite/functional/Generator/SchemaCollection/MatchCollection.php new file mode 100644 index 0000000..e460d8a --- /dev/null +++ b/test/suite/functional/Generator/SchemaCollection/MatchCollection.php @@ -0,0 +1,67 @@ +items = $items; + } + + public function toArray(): array + { + $return = []; + foreach ($this->items as $item) { + $return[] = $item->toArray(); + } + + return $return; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * @return MatchSchema[] + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->items); + } + + public function count(): int + { + return count($this->items); + } + + public function first(): ?MatchSchema + { + $first = reset($this->items); + if ($first === false) { + return null; + } + + return $first; + } +} diff --git a/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php b/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php index 419bf8e..ccf9be2 100644 --- a/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaCollectionGeneratorTest.php @@ -47,6 +47,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemCollection', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Collection of a schema named after a reserved word' => [ + '/Schema/reservedWords.yaml', + '/SchemaCollection/MatchCollection.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\MatchCollection', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaGeneratorTest.php b/test/suite/functional/Generator/SchemaGeneratorTest.php index c4da62d..2c96abc 100644 --- a/test/suite/functional/Generator/SchemaGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaGeneratorTest.php @@ -100,6 +100,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ItemWithMixed', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Schema named after a reserved word with PHP 7.4' => [ + '/Schema/reservedWords.yaml', + '/Schema/MatchSchemaPhp74.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\MatchSchema', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Schema named after a reserved word with PHP 8.1' => [ + '/Schema/reservedWords.yaml', + '/Schema/MatchSchemaPhp81.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\MatchSchema', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Schema referencing schemas named after reserved words' => [ + '/Schema/reservedWords.yaml', + '/Schema/Tournament.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\Tournament', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Inline schema with a reserved word fallback name' => [ + '/Schema/reservedWords.yaml', + '/Schema/ClassSchema.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ClassSchema', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaMapper/GameMapper81.php b/test/suite/functional/Generator/SchemaMapper/GameMapper81.php new file mode 100644 index 0000000..bc24fc6 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/GameMapper81.php @@ -0,0 +1,45 @@ +setMatch($this->matchSchemaMapper->toSchema($payload)); + + break; + case 'tournament': + $schema->setTournament($this->tournamentMapper->toSchema($payload)); + + break; + default: + $methodName = 'set' . ucfirst($payload['type']); + $mapperName = $payload['type'] . 'Mapper'; + $schema->$methodName($this->$mapperName->toSchema($payload)); + + break; + } + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapper/TournamentMapper81.php b/test/suite/functional/Generator/SchemaMapper/TournamentMapper81.php new file mode 100644 index 0000000..cf3f53c --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/TournamentMapper81.php @@ -0,0 +1,38 @@ +matchCollectionMapper->toSchema($payload['matches'])); + if (isset($payload['class'])) { + $schema->setClass($this->classSchemaMapper->toSchema($payload['class'])); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index ef4b31d..1fe6755 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -178,6 +178,18 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\ItemWithArraysOfEnumPropertiesMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), ], + 'Mapper referencing schemas named after reserved words' => [ + '/Schema/reservedWords.yaml', + '/SchemaMapper/TournamentMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\TournamentMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Discriminator mapping to a schema named after a reserved word' => [ + '/Schema/reservedWords.yaml', + '/SchemaMapper/GameMapper81.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GameMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/unit/Generator/EnumGeneratorTest.php b/test/suite/unit/Generator/EnumGeneratorTest.php index afd616c..1b1351b 100644 --- a/test/suite/unit/Generator/EnumGeneratorTest.php +++ b/test/suite/unit/Generator/EnumGeneratorTest.php @@ -36,6 +36,7 @@ public function caseNameProvider(): array 'several symbols' => ['>=', 'GREATER_THAN_EQUALS'], 'non-ASCII character only' => ['€', 'U20AC'], 'empty string' => ['', 'EMPTY'], + 'reserved constant name' => ['class', 'V_CLASS'], ]; } diff --git a/test/suite/unit/Naming/SchemaNamingTest.php b/test/suite/unit/Naming/SchemaNamingTest.php new file mode 100644 index 0000000..40fc2b1 --- /dev/null +++ b/test/suite/unit/Naming/SchemaNamingTest.php @@ -0,0 +1,44 @@ + ['Match', true, 'MatchSchema'], + 'keyword in capitals' => ['LIST', true, 'LISTSchema'], + 'reserved type name' => ['Mixed', true, 'MixedSchema'], + 'reserved class name' => ['Self', true, 'SelfSchema'], + 'soft reserved word' => ['Enum', false, 'Enum'], + 'contains a keyword' => ['MatchResult', false, 'MatchResult'], + 'regular name' => ['Player', false, 'Player'], + ]; + } + + public function testGetUniqueName(): void + { + self::assertSame('ASTERISK', SchemaNaming::getUniqueName('ASTERISK', ['EQUALS'])); + self::assertSame('ASTERISK_2', SchemaNaming::getUniqueName('ASTERISK', ['ASTERISK'])); + self::assertSame('ASTERISK_3', SchemaNaming::getUniqueName('ASTERISK', ['ASTERISK', 'ASTERISK_2'])); + self::assertSame('Foo2', SchemaNaming::getUniqueName('Foo', ['Foo'], '')); + } +} From 09b83fd6f5bb3dcaf6ceb6ee363cbbeed859c5e0 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 14:19:49 +0200 Subject: [PATCH 19/21] fix: give different schemas that share a class name distinct class names Class names are derived from component, parent, property and parameter names, so different schemas can get the same one: an enum property `Call.status` and an enum query parameter `callStatus` are both `CallStatusEnum`, the same enum parameter can have different values in two operations, and an inline object can get the fallback name of a component (`HotDeal`). Only one class was generated per name (the last one registered won), so the other schema was mapped with the wrong class: valid values threw a ValueError or UnexpectedResponseBodyException, and a request could not express the values of its own parameter. After parsing, a new ClassNameCollisionResolver groups the schema and enum fields by class name and compares their definitions. Identical definitions keep sharing their class. Otherwise one definition keeps the name and each other one gets a numeric suffix (`CallStatus2Enum`, `HotDeal2`), with a warning naming the operations involved. The name is kept by the component schema, when one of the definitions is a component referenced by its name; otherwise by the definition the generators used to emit, so the classes that were generated before keep their name and content, and only the references that pointed to a class of another schema change. Co-Authored-By: Claude Opus 5.5 --- src/Entity/Field.php | 24 ++ src/Input/ClassNameCollisionResolver.php | 269 ++++++++++++++++++ src/Input/Factory/FieldFactory.php | 4 +- src/Input/Parser.php | 9 +- src/ServiceProvider.php | 6 + .../Generator/EnumGeneratorTest.php | 24 ++ .../Generator/Request/FindHostsRequest81.php | 92 ++++++ .../Generator/RequestGeneratorTest.php | 6 + .../Generator/Schema/CallStatus2Enum.php | 18 ++ .../Generator/Schema/CallStatusEnum.php | 17 ++ .../Schema/CallWithCollidingEnum.php | 44 +++ .../Schema/CriterionStatusEq2Enum.php | 17 ++ .../Schema/CriterionStatusEqEnum.php | 17 ++ .../functional/Generator/Schema/HotDeal.php | 66 +++++ .../functional/Generator/Schema/HotDeal2.php | 41 +++ .../functional/Generator/Schema/PageItem.php | 53 ++++ .../Generator/Schema/classNameCollisions.yaml | 144 ++++++++++ .../Generator/SchemaGeneratorTest.php | 24 ++ .../SchemaMapper/PageItemMapper74.php | 33 +++ .../Generator/SchemaMapperGeneratorTest.php | 6 + .../Input/ClassNameCollisionResolverTest.php | 103 +++++++ 21 files changed, 1014 insertions(+), 3 deletions(-) create mode 100644 src/Input/ClassNameCollisionResolver.php create mode 100644 test/suite/functional/Generator/Request/FindHostsRequest81.php create mode 100644 test/suite/functional/Generator/Schema/CallStatus2Enum.php create mode 100644 test/suite/functional/Generator/Schema/CallStatusEnum.php create mode 100644 test/suite/functional/Generator/Schema/CallWithCollidingEnum.php create mode 100644 test/suite/functional/Generator/Schema/CriterionStatusEq2Enum.php create mode 100644 test/suite/functional/Generator/Schema/CriterionStatusEqEnum.php create mode 100644 test/suite/functional/Generator/Schema/HotDeal.php create mode 100644 test/suite/functional/Generator/Schema/HotDeal2.php create mode 100644 test/suite/functional/Generator/Schema/PageItem.php create mode 100644 test/suite/functional/Generator/Schema/classNameCollisions.yaml create mode 100644 test/suite/functional/Generator/SchemaMapper/PageItemMapper74.php create mode 100644 test/suite/functional/Input/ClassNameCollisionResolverTest.php diff --git a/src/Entity/Field.php b/src/Entity/Field.php index 371785d..0d7fbae 100644 --- a/src/Entity/Field.php +++ b/src/Entity/Field.php @@ -31,6 +31,8 @@ class Field private mixed $discriminator = null; + private bool $explicitReferenceName = false; + public function __construct( private PhpVersion $phpVersion, private string $name, @@ -123,6 +125,28 @@ public function getReferenceName(): string return $this->referenceName; } + public function setReferenceName(string $referenceName): self + { + $this->referenceName = $referenceName; + + return $this; + } + + /** + * Whether the reference name is the name of the referenced component, rather than a fallback name. + */ + public function hasExplicitReferenceName(): bool + { + return $this->explicitReferenceName; + } + + public function setExplicitReferenceName(bool $explicitReferenceName): self + { + $this->explicitReferenceName = $explicitReferenceName; + + return $this; + } + public function isRequired(): bool { return $this->required; diff --git a/src/Input/ClassNameCollisionResolver.php b/src/Input/ClassNameCollisionResolver.php new file mode 100644 index 0000000..c12a0f7 --- /dev/null +++ b/src/Input/ClassNameCollisionResolver.php @@ -0,0 +1,269 @@ +getOperations() as $operation) { + foreach ($operation->request->fields as $field) { + $this->collectNamedFields($field, $operation->name, $fieldsByClassName, $operationsByField); + } + foreach ($operation->successfulResponses as $response) { + if ($response->body !== null) { + $this->collectNamedFields($response->body, $operation->name, $fieldsByClassName, $operationsByField); + } + } + } + + $takenClassNames = $this->getTakenClassNames($fieldsByClassName); + $emittedFields = $this->getEmittedFields($specification); + foreach ($fieldsByClassName as $className => $fields) { + if (count($fields) < 2) { + continue; + } + + $definitions = []; + foreach ($fields as $field) { + $definitions[$this->getSignature($field)][] = $field; + } + + if (count($definitions) < 2) { + continue; + } + + $keptSignature = $this->getKeptSignature($definitions, $emittedFields[$className] ?? null); + foreach ($definitions as $signature => $definitionFields) { + if ($signature === $keptSignature) { + continue; + } + + $newClassName = $this->getUniqueClassName((string)$className, $takenClassNames); + $takenClassNames[] = $newClassName; + $operationNames = []; + foreach ($definitionFields as $field) { + $field->setReferenceName($newClassName); + $operationNames[] = $operationsByField[spl_object_id($field)]; + } + + $warningMessage = sprintf( + 'Class name %s is shared by different schemas, %s is used for the one in %s. Consider giving the schemas distinct names.', + $className, + $newClassName, + implode(', ', array_unique($operationNames)) + ); + trigger_error($warningMessage, E_USER_WARNING); + } + } + } + + /** + * @param array $fieldsByClassName + * @param array $operationsByField + */ + private function collectNamedFields( + Field $field, + string $operationName, + array &$fieldsByClassName, + array &$operationsByField + ): void { + if ($field->isObject() || ($field->isEnum() && $this->phpVersion->isEnumSupported())) { + $fieldsByClassName[$field->getPhpClassName()][] = $field; + $operationsByField[spl_object_id($field)] = $operationName; + } + + if ($field->isArray()) { + $this->collectNamedFields($field->getArrayItem(), $operationName, $fieldsByClassName, $operationsByField); + } + + foreach ($field->getObjectProperties() as $property) { + $this->collectNamedFields($property, $operationName, $fieldsByClassName, $operationsByField); + } + } + + /** + * The fields whose definition the generators emit for a class name when names collide, see the generators. + * + * @return array + */ + private function getEmittedFields(Specification $specification): array + { + $emittedFields = []; + $compositeFields = $specification->getCompositeFields()->getUniqueByPhpClassName(); + if ($this->phpVersion->isEnumSupported()) { + // EnumGenerator + foreach ($compositeFields as $field) { + if ($field->isObject() && !$field->isFreeFormObject()) { + foreach ($field->getObjectProperties() as $propertyField) { + if ($propertyField->isEnum()) { + $emittedFields[$propertyField->getPhpClassName()] = $propertyField; + } + } + } + if ($field->isEnum()) { + $emittedFields[$field->getPhpClassName()] = $field; + } + } + foreach ($specification->getOperations() as $operation) { + foreach ($operation->request->fields as $field) { + if ($field->isEnum()) { + $emittedFields[$field->getPhpClassName()] = $field; + } + } + } + } + + // SchemaGenerator and FreeFormSchemaGenerator, registered after the enums + foreach ($compositeFields as $className => $field) { + if ($field->isObject()) { + $emittedFields[$className] = $field; + } + } + + return $emittedFields; + } + + /** + * @param array $definitions + */ + private function getKeptSignature(array $definitions, ?Field $emittedField): string + { + $explicitSignatures = []; + foreach ($definitions as $signature => $fields) { + foreach ($fields as $field) { + if ($field->hasExplicitReferenceName()) { + $explicitSignatures[] = (string)$signature; + + break; + } + } + } + + $candidates = empty($explicitSignatures) ? array_map('strval', array_keys($definitions)) : $explicitSignatures; + $emittedSignature = $emittedField === null ? null : $this->getSignature($emittedField); + if ($emittedSignature !== null && in_array($emittedSignature, $candidates, true)) { + return $emittedSignature; + } + + return (string)end($candidates); + } + + /** + * @param string[] $takenClassNames + */ + private function getUniqueClassName(string $className, array $takenClassNames): string + { + $suffix = str_ends_with($className, self::ENUM_SUFFIX) ? self::ENUM_SUFFIX : ''; + $base = substr($className, 0, strlen($className) - strlen($suffix)); + + $number = 2; + while (in_array($base . $number . $suffix, $takenClassNames, true)) { + ++$number; + } + + return $base . $number . $suffix; + } + + /** + * @param array $fieldsByClassName + * + * @return string[] + */ + private function getTakenClassNames(array $fieldsByClassName): array + { + $takenClassNames = []; + foreach ($fieldsByClassName as $className => $fields) { + $takenClassNames[] = (string)$className; + foreach ($fields as $field) { + $takenClassNames[] = SchemaCollectionNaming::getClassName($field->getReferenceName()); + } + } + + return array_values(array_unique($takenClassNames)); + } + + /** + * Identifies the definition of the class that a field is generated to: fields with the same signature are + * generated to the same code (apart from the names of the classes they refer to). + */ + private function getSignature(Field $field): string + { + return serialize($this->describeDefinition($field)); + } + + private function describeDefinition(Field $field): array + { + if ($field->isEnum()) { + $values = $field->getEnumValues() ?? []; + sort($values); + + return ['enum', $field->getType()->toSpecificationType(), $values]; + } + + if ($field->isObject()) { + $discriminator = $field->getDiscriminator(); + + return [ + 'object', + $field->isFreeFormObject(), + $field->hasOneOf(), + $field->hasAnyOf(), + $discriminator instanceof SpecObjectInterface ? $discriminator->getSerializableData() : $discriminator, + array_map(fn (Field $property): array => $this->describeProperty($property), $field->getObjectProperties()), + ]; + } + + if ($field->isArray()) { + // the name of an inline item depends on the operation, e.g. `{Operation}{Field}Item` + return ['array', $this->describeProperty($field->getArrayItem(), false)]; + } + + return [$field->getType()->toSpecificationType(), $field->getFormat()]; + } + + private function describeProperty(Field $field, bool $withName = true): array + { + $constraints = []; + foreach ($field->getConstraints() as $constraint) { + /** @var ConstraintInterface $constraint */ + if ($constraint->exists()) { + $constraints[] = [get_class($constraint), $constraint->getExceptionMessage()]; + } + } + + return [ + $withName ? $field->getName() : null, + $field->isRequired(), + $field->isNullable(), + $field->getDefault(), + $constraints, + $this->describeDefinition($field), + ]; + } +} diff --git a/src/Input/Factory/FieldFactory.php b/src/Input/Factory/FieldFactory.php index 733b9a2..f02d3ac 100644 --- a/src/Input/Factory/FieldFactory.php +++ b/src/Input/Factory/FieldFactory.php @@ -125,7 +125,7 @@ public function create( $arrayItem = $this->create( $operationName, lcfirst($itemReferenceName), - $sibling, + $itemsReference, true, $itemReferenceName ); @@ -194,6 +194,8 @@ public function create( !empty($anyOf) ); + $field->setExplicitReferenceName($schemaOrReference instanceof Reference); + if ($arrayItem !== null) { $field->setArrayItem($arrayItem); } elseif (!empty($objectProperties)) { diff --git a/src/Input/Parser.php b/src/Input/Parser.php index 8adcd99..af0c865 100644 --- a/src/Input/Parser.php +++ b/src/Input/Parser.php @@ -16,7 +16,8 @@ class Parser { public function __construct( private OperationCollectionFactory $operationCollectionFactory, - private PhpVersion $phpVersion + private PhpVersion $phpVersion, + private ClassNameCollisionResolver $classNameCollisionResolver ) { } @@ -34,12 +35,16 @@ public function parse(array $data, string $contextUri): Specification $compositeRequestFields = $this->extractCompositeRequestFields($operations); $compositeResponseFields = $this->extractCompositeResponseFields($operations); - return new Specification( + $specification = new Specification( $openApi, $operations, $compositeRequestFields, $compositeResponseFields, ); + + $this->classNameCollisionResolver->resolve($specification); + + return $specification; } private function extractCompositeRequestFields(OperationCollection $operations): FieldCollection diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index b69dd9a..1eb9d19 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -26,6 +26,7 @@ use DoclerLabs\ApiClientGenerator\Generator\Security\BearerAuthenticationSecurityStrategy; use DoclerLabs\ApiClientGenerator\Generator\Security\XWSSESecurityStrategy; use DoclerLabs\ApiClientGenerator\Generator\ServiceProviderGenerator; +use DoclerLabs\ApiClientGenerator\Input\ClassNameCollisionResolver; use DoclerLabs\ApiClientGenerator\Input\Configuration; use DoclerLabs\ApiClientGenerator\Input\Factory\FieldFactory; use DoclerLabs\ApiClientGenerator\Input\Factory\OperationCollectionFactory; @@ -144,6 +145,11 @@ public function register(Container $pimple): void $pimple[OpenApiParser::class] = static fn (Container $container) => new OpenApiParser( $container[OperationCollectionFactory::class], + $container[PhpVersion::class], + $container[ClassNameCollisionResolver::class] + ); + + $pimple[ClassNameCollisionResolver::class] = static fn (Container $container) => new ClassNameCollisionResolver( $container[PhpVersion::class] ); diff --git a/test/suite/functional/Generator/EnumGeneratorTest.php b/test/suite/functional/Generator/EnumGeneratorTest.php index c53b89d..fbdf2ed 100644 --- a/test/suite/functional/Generator/EnumGeneratorTest.php +++ b/test/suite/functional/Generator/EnumGeneratorTest.php @@ -71,6 +71,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ListEnum', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Enum property colliding with an enum query parameter keeps the emitted definition' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/CallStatusEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\CallStatusEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Enum property colliding with an enum query parameter gets a numeric suffix' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/CallStatus2Enum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\CallStatus2Enum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Same enum query parameter with different values, the last one keeps the name' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/CriterionStatusEqEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\CriterionStatusEqEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Same enum query parameter with different values, the other one gets a numeric suffix' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/CriterionStatusEq2Enum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\CriterionStatusEq2Enum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Request/FindHostsRequest81.php b/test/suite/functional/Generator/Request/FindHostsRequest81.php new file mode 100644 index 0000000..692fe1b --- /dev/null +++ b/test/suite/functional/Generator/Request/FindHostsRequest81.php @@ -0,0 +1,92 @@ +contentType; + } + + public function setCallStatus(CallStatusEnum $callStatus): self + { + $this->callStatus = $callStatus; + + return $this; + } + + public function setStatus(StatusEnum $status): self + { + $this->status = $status; + + return $this; + } + + public function setCriterionStatusEq(CriterionStatusEq2Enum $criterionStatusEq): self + { + $this->criterionStatusEq = $criterionStatusEq; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/hosts'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['callStatus' => $this->callStatus?->value, 'status' => $this->status?->value, 'criterion_status_eq' => $this->criterionStatusEq?->value], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['callStatus' => $this->callStatus?->value, 'status' => $this->status?->value, 'criterion_status_eq' => $this->criterionStatusEq?->value]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/RequestGeneratorTest.php b/test/suite/functional/Generator/RequestGeneratorTest.php index 0a8f2ec..c5c45fd 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -274,6 +274,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesBySymbolsRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Request with enum query parameters whose class names collide with php 8.1' => [ + '/Schema/classNameCollisions.yaml', + '/Request/FindHostsRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\FindHostsRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/CallStatus2Enum.php b/test/suite/functional/Generator/Schema/CallStatus2Enum.php new file mode 100644 index 0000000..9d9df15 --- /dev/null +++ b/test/suite/functional/Generator/Schema/CallStatus2Enum.php @@ -0,0 +1,18 @@ +callId; + } + + public function getStatus(): CallStatus2Enum + { + return $this->status; + } + + public function toArray(): array + { + $fields = []; + $fields['callId'] = $this->callId; + $fields['status'] = $this->status->value; + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/CriterionStatusEq2Enum.php b/test/suite/functional/Generator/Schema/CriterionStatusEq2Enum.php new file mode 100644 index 0000000..cb09b87 --- /dev/null +++ b/test/suite/functional/Generator/Schema/CriterionStatusEq2Enum.php @@ -0,0 +1,17 @@ + false]; + + public function __construct(bool $isActive) + { + $this->isActive = $isActive; + } + + public function setDiscountedPrice(?float $discountedPrice): self + { + $this->discountedPrice = $discountedPrice; + $this->optionalPropertyChanged['discountedPrice'] = true; + + return $this; + } + + public function hasDiscountedPrice(): bool + { + return $this->optionalPropertyChanged['discountedPrice']; + } + + public function getIsActive(): bool + { + return $this->isActive; + } + + public function getDiscountedPrice(): ?float + { + return $this->discountedPrice; + } + + public function toArray(): array + { + $fields = []; + $fields['isActive'] = $this->isActive; + if ($this->hasDiscountedPrice()) { + $fields['discountedPrice'] = $this->discountedPrice; + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/HotDeal2.php b/test/suite/functional/Generator/Schema/HotDeal2.php new file mode 100644 index 0000000..6412651 --- /dev/null +++ b/test/suite/functional/Generator/Schema/HotDeal2.php @@ -0,0 +1,41 @@ +discountedPrice = $discountedPrice; + } + + public function getDiscountedPrice(): float + { + return $this->discountedPrice; + } + + public function toArray(): array + { + $fields = []; + $fields['discountedPrice'] = $this->discountedPrice; + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/PageItem.php b/test/suite/functional/Generator/Schema/PageItem.php new file mode 100644 index 0000000..7cd4256 --- /dev/null +++ b/test/suite/functional/Generator/Schema/PageItem.php @@ -0,0 +1,53 @@ + false]; + + public function setHotDeal(HotDeal2 $hotDeal): self + { + $this->hotDeal = $hotDeal; + $this->optionalPropertyChanged['hotDeal'] = true; + + return $this; + } + + public function hasHotDeal(): bool + { + return $this->optionalPropertyChanged['hotDeal']; + } + + public function getHotDeal(): ?HotDeal2 + { + return $this->hotDeal; + } + + public function toArray(): array + { + $fields = []; + if ($this->hasHotDeal()) { + $fields['hotDeal'] = $this->hotDeal->toArray(); + } + + return $fields; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/test/suite/functional/Generator/Schema/classNameCollisions.yaml b/test/suite/functional/Generator/Schema/classNameCollisions.yaml new file mode 100644 index 0000000..81733a9 --- /dev/null +++ b/test/suite/functional/Generator/Schema/classNameCollisions.yaml @@ -0,0 +1,144 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Class name collisions Test API +paths: + '/v1/hosts': + get: + operationId: findHosts + parameters: + - name: callStatus + in: query + schema: + type: string + enum: + - in_progress + - none + - name: status + in: query + schema: + type: string + enum: + - online + - offline + - name: criterion_status_eq + in: query + schema: + type: string + enum: + - pending + - activated + responses: + '204': + description: No content + '/v1/calls/{callId}': + get: + operationId: getCall + parameters: + - name: callId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Call' + '/v1/host-stats': + get: + operationId: findHostStats + parameters: + - name: status + in: query + schema: + type: string + enum: + - online + - offline + - name: criterion_status_eq + in: query + schema: + type: string + enum: + - expiring + - expired + responses: + '204': + description: No content + '/v1/sections/{sectionId}': + get: + operationId: getSection + parameters: + - name: sectionId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Section' + '/v1/pages/{pageId}': + get: + operationId: getPage + parameters: + - name: pageId + in: path + required: true + schema: + type: integer + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PageItem' +components: + schemas: + Call: + type: object + required: + - callId + - status + properties: + callId: + type: integer + status: + type: string + enum: + - created + - started + - finished + HotDeal: + type: object + required: + - isActive + properties: + isActive: + type: boolean + discountedPrice: + type: number + nullable: true + Section: + type: object + properties: + hotDeal: + $ref: '#/components/schemas/HotDeal' + PageItem: + allOf: + - type: object + properties: + hotDeal: + type: object + required: + - discountedPrice + properties: + discountedPrice: + type: number diff --git a/test/suite/functional/Generator/SchemaGeneratorTest.php b/test/suite/functional/Generator/SchemaGeneratorTest.php index 2c96abc..02baa5c 100644 --- a/test/suite/functional/Generator/SchemaGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaGeneratorTest.php @@ -124,6 +124,30 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\ClassSchema', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Schema with an enum property whose class name collides' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/CallWithCollidingEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\Call', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Component schema keeps its name when an inline schema collides with it' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/HotDeal.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\HotDeal', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Inline schema colliding with a component schema gets a numeric suffix' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/HotDeal2.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\HotDeal2', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], + 'Schema with an inline property whose class name collides' => [ + '/Schema/classNameCollisions.yaml', + '/Schema/PageItem.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\PageItem', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/SchemaMapper/PageItemMapper74.php b/test/suite/functional/Generator/SchemaMapper/PageItemMapper74.php new file mode 100644 index 0000000..b29c5b1 --- /dev/null +++ b/test/suite/functional/Generator/SchemaMapper/PageItemMapper74.php @@ -0,0 +1,33 @@ +hotDeal2Mapper = $hotDeal2Mapper; + } + + public function toSchema(array $payload): PageItem + { + $schema = new PageItem(); + if (isset($payload['hotDeal'])) { + $schema->setHotDeal($this->hotDeal2Mapper->toSchema($payload['hotDeal'])); + } + + return $schema; + } +} diff --git a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php index 1fe6755..e9628ec 100644 --- a/test/suite/functional/Generator/SchemaMapperGeneratorTest.php +++ b/test/suite/functional/Generator/SchemaMapperGeneratorTest.php @@ -190,6 +190,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\GameMapper', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Mapper of a schema with an inline property whose class name collides' => [ + '/Schema/classNameCollisions.yaml', + '/SchemaMapper/PageItemMapper74.php', + self::BASE_NAMESPACE . SchemaMapperGenerator::NAMESPACE_SUBPATH . '\\PageItemMapper', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP74)->build(), + ], ]; } diff --git a/test/suite/functional/Input/ClassNameCollisionResolverTest.php b/test/suite/functional/Input/ClassNameCollisionResolverTest.php new file mode 100644 index 0000000..a6f53b6 --- /dev/null +++ b/test/suite/functional/Input/ClassNameCollisionResolverTest.php @@ -0,0 +1,103 @@ +getContainerWith(ConfigurationBuilder::fake()->withPhpVersion($phpVersion)->build()); + + $warnings = []; + set_error_handler( + static function (int $level, string $message) use (&$warnings): bool { + if (str_starts_with($message, 'Class name')) { + $warnings[] = $message; + } + + return true; + }, + E_USER_WARNING + ); + + try { + $specification = $container[Parser::class]->parse( + $container[FileReader::class]->read(self::SPECIFICATION), + self::SPECIFICATION + ); + } finally { + restore_error_handler(); + } + + $fileRegistry = new PhpFileCollection(); + $container[EnumGenerator::class]->generate($specification, $fileRegistry); + $container[SchemaGenerator::class]->generate($specification, $fileRegistry); + $classNames = array_map( + static fn (string $className): string => substr($className, strlen('Test\\Schema\\')), + array_keys(iterator_to_array($fileRegistry)) + ); + sort($classNames); + + self::assertSame($expectedClassNames, $classNames); + self::assertSame($expectedWarnings, $warnings); + } + + public function phpVersionProvider(): array + { + $hotDealWarning = 'Class name HotDeal is shared by different schemas, HotDeal2 is used for the one in getPage. Consider giving the schemas distinct names.'; + + return [ + 'Enums and schemas with PHP 8.1' => [ + PhpVersion::VERSION_PHP81, + [ + 'Call', + 'CallStatus2Enum', + 'CallStatusEnum', + 'CriterionStatusEq2Enum', + 'CriterionStatusEqEnum', + 'HotDeal', + 'HotDeal2', + 'PageItem', + 'Section', + // identical definitions share their class + 'StatusEnum', + ], + [ + 'Class name CallStatusEnum is shared by different schemas, CallStatus2Enum is used for the one in getCall. Consider giving the schemas distinct names.', + 'Class name CriterionStatusEqEnum is shared by different schemas, CriterionStatusEq2Enum is used for the one in findHosts. Consider giving the schemas distinct names.', + $hotDealWarning, + ], + ], + 'Schemas with PHP 7.4, enums are constants' => [ + PhpVersion::VERSION_PHP74, + ['Call', 'HotDeal', 'HotDeal2', 'PageItem', 'Section'], + [$hotDealWarning], + ], + ]; + } +} From 06ec6d4081a609a8ef9fc697e3956c7ab04c137a Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 14:57:02 +0200 Subject: [PATCH 20/21] chore: sync committed example clients with current generator output The acceptance tests regenerate example/ and example3_1/ PetStoreClient; loginUser (a literal string response) now guards against an empty body. Co-Authored-By: Claude Opus 5.5 --- example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php | 4 ++++ .../PetStoreClient/src/SwaggerPetstoreOpenAPI31Client.php | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php b/example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php index b394423..1a5c1a3 100644 --- a/example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php +++ b/example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php @@ -10,6 +10,7 @@ namespace OpenApi\PetStoreClient; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use OpenApi\PetStoreClient\Request\AddPetRequest; use OpenApi\PetStoreClient\Request\CreateUserRequest; use OpenApi\PetStoreClient\Request\CreateUsersWithListInputRequest; @@ -149,6 +150,9 @@ public function createUsersWithListInput(CreateUsersWithListInputRequest $reques public function loginUser(LoginUserRequest $request): string { $response = $this->handleResponse($this->sendRequest($request)); + if (!isset($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY])) { + throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; } diff --git a/example3_1/PetStoreClient/src/SwaggerPetstoreOpenAPI31Client.php b/example3_1/PetStoreClient/src/SwaggerPetstoreOpenAPI31Client.php index 7c020a7..8f222d3 100644 --- a/example3_1/PetStoreClient/src/SwaggerPetstoreOpenAPI31Client.php +++ b/example3_1/PetStoreClient/src/SwaggerPetstoreOpenAPI31Client.php @@ -10,6 +10,7 @@ namespace OpenApi\PetStoreClient; +use DoclerLabs\ApiClientException\UnexpectedResponseBodyException; use OpenApi\PetStoreClient\Request\AddPetRequest; use OpenApi\PetStoreClient\Request\CreateUserRequest; use OpenApi\PetStoreClient\Request\CreateUsersWithListInputRequest; @@ -149,6 +150,9 @@ public function createUsersWithListInput(CreateUsersWithListInputRequest $reques public function loginUser(LoginUserRequest $request): string { $response = $this->handleResponse($this->sendRequest($request)); + if (!isset($response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY])) { + throw new UnexpectedResponseBodyException('Response body does not contain the expected non-null value.'); + } return $response[ContentTypeSerializerInterface::LITERAL_VALUE_KEY]; } From 2bae4782ed6ec6e4e7fc01b5ee25202aa41b0961 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 14:57:28 +0200 Subject: [PATCH 21/21] docs: add changelog entry for 11.4.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86331be..2771dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ 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.4.0] - 2026-09-28 +### Fixed +Generation failures: +- A nullable optional path, query or header parameter no longer aborts generation with "Nullable optional parameter is not supported". Setting it to `null` leaves it out, the same as leaving it unset +- A request body media type without a `schema` (e.g. `application/x-www-form-urlencoded: {}`) no longer crashes the generator. The request sends that `Content-Type` with no body. With several media types, the body is now taken from the one that has a schema, whatever their order +- A schema named after a PHP reserved word (`Match`, `List`, `Class`, ...) now gets a `Schema` suffix (`MatchSchema`, with a warning) instead of producing a class that doesn't parse on PHP 8. The enum value `class` becomes `V_CLASS` +- A literal (scalar) body among several 2xx responses no longer aborts generation + +Generated code that threw or misbehaved at runtime: +- Integer, number and boolean header parameters (including integer-backed enums) are now sent as strings; `guzzlehttp/psr7` 2.11+ raised `E_USER_DEPRECATED` for them. Booleans are sent as `1`/`0`, like query and cookie parameters +- Request bodies whose schema is a primitive or an array of non-objects are now serialized, through the new copied `Request\LiteralRequestBody`. They used to fail on every call +- A `nullable: true` property defined as `allOf: [$ref]` (the OpenAPI 3.0 nullable-reference idiom) now keeps its nullability instead of throwing a `TypeError` on `null` +- Optional nullable enum properties map `null` to `null` instead of calling `Enum::from(null)` (8.1+) +- oneOf/anyOf alternatives without a discriminator that differ only by an enum value are matched correctly: the enum `ValueError` is caught like `UnexpectedResponseBodyException` (8.1+) +- A discriminator value that the mapping (or implicit schema-name mapping) doesn't cover now throws `UnexpectedResponseBodyException` instead of a PHP `Error` from a dynamic mapper call +- Arrays of arrays of objects are mapped to arrays of their collections, as the docblocks already declared, instead of staying raw arrays +- A 2xx status code not listed in the specification now throws `RuntimeException` on 8.0+ targets too, as the pre-8.0 `switch` already did, instead of `UnhandledMatchError` +- An empty or `null` body for a non-nullable literal (scalar) response throws `UnexpectedResponseBodyException` instead of an "Undefined array key" warning followed by a `TypeError`; a nullable one returns `null` +- Inline enum response bodies get their enum class generated (8.1+). The return type used to name a class that did not exist +- Optional untyped (`mixed`) properties are initialised with `null` (8.0+) instead of failing with "must not be accessed before initialization"; `nullable: true` without a type no longer produces the invalid `?mixed` +- Enum values made only of symbols (`*`, `=`, `>=`, ...) get names such as `ASTERISK` and `EQUALS` instead of being dropped, which could leave an enum with no cases +- Different schemas that derive the same class name, such as an enum property and an enum parameter, or an inline schema and a component, get distinct classes with a numeric suffix (`CallStatus2Enum`, `HotDeal2`) and a warning, instead of silently merging into one. Identical definitions still share a class + +### Changed +These are the changes regenerated clients can notice: +- The copied `AbstractJsonContentTypeSerializer` unwraps `LiteralRequestBody` values, so it changes in every generated client that uses JSON +- A `false` boolean header is sent as `0` instead of an empty value +- Properties using the nullable `allOf` idiom become nullable in their constructor, getter and property type +- Arrays of arrays of objects now return collections (`FooCollection[]`) instead of raw arrays +- In a specification with a discriminator mapping, a value that is not a mapping key no longer maps by accident when it equals the lcfirst class name of a mapped alternative; it throws `UnexpectedResponseBodyException` +- Array-of-enum response items no longer get an `{X}EnumMapper` class and service entry (8.1+); it had an empty `toSchema()` and was never used +- Schemas named after reserved words and colliding schemas get new class names as described above. Existing, working class names are kept + ## [11.3.0] - 2026-09-28 ### Fixed These affect `CLIENT_PHP_VERSION` 8.1 and later, where enums are generated as native PHP enums.