From e514cb49744f9a47cf9cba6b87174bb05ea176f4 Mon Sep 17 00:00:00 2001 From: tommey Date: Mon, 28 Sep 2026 12:13:34 +0200 Subject: [PATCH] fix: generation errors for PHP 8.1+ targets with enums - Enum case names that start with a digit (e.g. '+7 days') are prefixed with V_ like numeric values, instead of producing an invalid 'case 7_DAYS' that aborted the generation. - Optional or nullable array-of-enum request parameters are only mapped to their values when set; an unset parameter threw a TypeError from array_map() on null. - Enum header parameters are sent as their values, like query, path and cookie parameters; the enum object itself made PSR-7 throw. - Inline array-of-enum items are named {Operation}{Field}ItemEnum, like inline array-of-object items, instead of all collapsing into one Schema\Enum class with the values of only one of them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++ src/Generator/EnumGenerator.php | 6 +- src/Generator/RequestGenerator.php | 17 +++- src/Input/Factory/FieldFactory.php | 6 +- .../Generator/EnumGeneratorTest.php | 6 ++ .../GetResourcesByStatusesRequest74.php | 91 +++++++++++++++++++ .../GetResourcesByStatusesRequest81.php | 80 ++++++++++++++++ .../GetResourcesByStatusesRequest83.php | 80 ++++++++++++++++ .../Request/PatchResourceRequest81.php | 2 +- .../Request/PatchResourceRequest83.php | 2 +- .../Request/PostResourceByIdRequest81.php | 2 +- .../Request/PostResourceByIdRequest83.php | 2 +- .../Request/getResourcesByStatuses.yaml | 43 +++++++++ .../Generator/RequestGeneratorTest.php | 18 ++++ ...GetResourcesByStatusesStatusesItemEnum.php | 18 ++++ .../unit/Generator/EnumGeneratorTest.php | 36 ++++++++ 16 files changed, 406 insertions(+), 13 deletions(-) create mode 100644 test/suite/functional/Generator/Request/GetResourcesByStatusesRequest74.php create mode 100644 test/suite/functional/Generator/Request/GetResourcesByStatusesRequest81.php create mode 100644 test/suite/functional/Generator/Request/GetResourcesByStatusesRequest83.php create mode 100644 test/suite/functional/Generator/Request/getResourcesByStatuses.yaml create mode 100644 test/suite/functional/Generator/Schema/GetResourcesByStatusesStatusesItemEnum.php create mode 100644 test/suite/unit/Generator/EnumGeneratorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e79f4d..86331be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.3.0] - 2026-09-28 +### Fixed +These affect `CLIENT_PHP_VERSION` 8.1 and later, where enums are generated as native PHP enums. +- Enum case names that start with a digit (e.g. the value `+7 days`) are now prefixed with `V_`, as numeric values already were (`case V_7_DAYS`). They used to produce an invalid `case 7_DAYS` that aborted the generation +- Optional and nullable array-of-enum request parameters are now only mapped to their values when set. Leaving one unset used to throw a `TypeError` from `array_map()` when the request was sent +- Enum header parameters are now sent as their values, like query, path and cookie parameters. The enum object itself used to reach the PSR-7 message, which threw an `InvalidArgumentException` + +### Changed +- Inline array-of-enum items now get their own enum per operation and field, `{Operation}{Field}ItemEnum` (e.g. `FindServicesCriterionStatusInItemEnum`), following the naming of inline array-of-object items. They used to share a single `Schema\Enum` class, which held the values of only one of them. Regenerating a client that already contains `Schema\Enum` renames that class + ## [11.2.3] - 2026-09-28 ### Fixed - Generated clients can no longer resolve dependency versions that raise PHP 8.4+ deprecations: the generated `composer.json` now requires `docler-labs/api-client-exception` `^1.0 || ^2.3.1` (was `^1.0 || ^2.0`) and, for the Guzzle HTTP message implementation, `guzzlehttp/psr7` `^1.6 || ^2.7` (was `^1.6 || ^2.6`). The 1.x lines stay allowed, so every `CLIENT_PHP_VERSION` target keeps resolving; on PHP 7.4+ Composer now picks only releases that run deprecation-free on PHP 8.5 diff --git a/src/Generator/EnumGenerator.php b/src/Generator/EnumGenerator.php index 373f62a..dcb1a51 100644 --- a/src/Generator/EnumGenerator.php +++ b/src/Generator/EnumGenerator.php @@ -16,13 +16,13 @@ class EnumGenerator extends MutatorAccessorClassGeneratorAbstract public static function getCaseName(string $value): string { - $sanitized = preg_replace('/[^A-Z0-9_]/', '', strtoupper(str_replace([' ', '-', '/', '.'], '_', $value))); + $sanitized = (string)preg_replace('/[^A-Z0-9_]/', '', strtoupper(str_replace([' ', '-', '/', '.'], '_', $value))); - if (is_numeric($sanitized)) { + if (preg_match('/^[0-9]/', $sanitized) === 1) { return 'V_' . $sanitized; } - return (string)$sanitized; + return $sanitized; } public function generate(Specification $specification, PhpFileCollection $fileRegistry): void diff --git a/src/Generator/RequestGenerator.php b/src/Generator/RequestGenerator.php index 596102e..c51f203 100644 --- a/src/Generator/RequestGenerator.php +++ b/src/Generator/RequestGenerator.php @@ -431,7 +431,7 @@ private function generateFieldsArray(array $fields): array } elseif ($field->isArrayOfEnums()) { $enumField = $field->getArrayItem(); $this->addImport($this->fqdn($this->withSubNamespace(SchemaGenerator::NAMESPACE_SUBPATH), $enumField->getPhpClassName())); - $fieldsArr[$field->getName()] = $this->builder->funcCall( + $enumValues = $this->builder->funcCall( 'array_map', [ $this->builder->arrowFunction( @@ -442,6 +442,16 @@ private function generateFieldsArray(array $fields): array $fieldsArr[$field->getName()], ] ); + + if ($field->isNullable() || $field->isOptional()) { + $enumValues = $this->builder->ternary( + $this->builder->equals($fieldsArr[$field->getName()], $this->builder->val(null)), + $this->builder->val(null), + $enumValues + ); + } + + $fieldsArr[$field->getName()] = $enumValues; } } } @@ -490,11 +500,8 @@ private function generateGetHeadersMethod( } $returnVal = $this->builder->array($headers); - $fieldsArr = []; + $fieldsArr = $this->generateFieldsArray($fields); $returnType = 'array'; - foreach ($fields as $field) { - $fieldsArr[$field->getName()] = $this->builder->localPropertyFetch($field->getPhpVariableName()); - } if (!empty($fieldsArr)) { $returnVal = $this->builder->funcCall( diff --git a/src/Input/Factory/FieldFactory.php b/src/Input/Factory/FieldFactory.php index 4dc3297..370d7dd 100644 --- a/src/Input/Factory/FieldFactory.php +++ b/src/Input/Factory/FieldFactory.php @@ -107,7 +107,11 @@ public function create( } $sibling = $this->resolveReference($schema->items); $itemsReference = $schema->items; - if (isset($sibling->allOf) || FieldType::isSpecificationTypeObject($sibling->type)) { + if ( + isset($sibling->allOf) + || FieldType::isSpecificationTypeObject($sibling->type) + || (!($itemsReference instanceof Reference) && !empty($sibling->enum)) + ) { $itemReferenceName = SchemaNaming::getClassName( $itemsReference, $operationName . ucfirst($fieldName) . 'Item' diff --git a/test/suite/functional/Generator/EnumGeneratorTest.php b/test/suite/functional/Generator/EnumGeneratorTest.php index 4a8725d..35ea966 100644 --- a/test/suite/functional/Generator/EnumGeneratorTest.php +++ b/test/suite/functional/Generator/EnumGeneratorTest.php @@ -47,6 +47,12 @@ public function exampleProvider(): array self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\IntParamEnum', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), ], + 'Inline array of enums request parameter, value starting with a digit' => [ + '/Request/getResourcesByStatuses.yaml', + '/Schema/GetResourcesByStatusesStatusesItemEnum.php', + self::BASE_NAMESPACE . SchemaGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesStatusesItemEnum', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest74.php b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest74.php new file mode 100644 index 0000000..3df0bbc --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest74.php @@ -0,0 +1,91 @@ +types = $types; + } + + public function getContentType(): string + { + return $this->contentType; + } + + /** + * @param string[] $statuses + */ + public function setStatuses(array $statuses): self + { + $this->statuses = $statuses; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-statuses'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['types' => $this->types, 'statuses' => $this->statuses], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['types' => $this->types, 'statuses' => $this->statuses]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest81.php b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest81.php new file mode 100644 index 0000000..899a469 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest81.php @@ -0,0 +1,80 @@ +contentType; + } + + /** + * @param GetResourcesByStatusesStatusesItemEnum[] $statuses + */ + public function setStatuses(array $statuses): self + { + $this->statuses = $statuses; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-statuses'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['types' => array_map(static fn (GetResourcesByStatusesTypesItemEnum $enum): string => $enum->value, $this->types), 'statuses' => $this->statuses === null ? null : array_map(static fn (GetResourcesByStatusesStatusesItemEnum $enum): string => $enum->value, $this->statuses)], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['types' => array_map(static fn (GetResourcesByStatusesTypesItemEnum $enum): string => $enum->value, $this->types), 'statuses' => $this->statuses === null ? null : array_map(static fn (GetResourcesByStatusesStatusesItemEnum $enum): string => $enum->value, $this->statuses)]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest83.php b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest83.php new file mode 100644 index 0000000..899a469 --- /dev/null +++ b/test/suite/functional/Generator/Request/GetResourcesByStatusesRequest83.php @@ -0,0 +1,80 @@ +contentType; + } + + /** + * @param GetResourcesByStatusesStatusesItemEnum[] $statuses + */ + public function setStatuses(array $statuses): self + { + $this->statuses = $statuses; + + return $this; + } + + public function getMethod(): string + { + return 'GET'; + } + + public function getRoute(): string + { + return 'v1/resources-by-statuses'; + } + + public function getQueryParameters(): array + { + return array_map(static function ($value) { + return $value instanceof SerializableInterface ? $value->toArray() : $value; + }, array_filter(['types' => array_map(static fn (GetResourcesByStatusesTypesItemEnum $enum): string => $enum->value, $this->types), 'statuses' => $this->statuses === null ? null : array_map(static fn (GetResourcesByStatusesStatusesItemEnum $enum): string => $enum->value, $this->statuses)], static function ($value) { + return null !== $value; + })); + } + + public function getRawQueryParameters(): array + { + return ['types' => array_map(static fn (GetResourcesByStatusesTypesItemEnum $enum): string => $enum->value, $this->types), 'statuses' => $this->statuses === null ? null : array_map(static fn (GetResourcesByStatusesStatusesItemEnum $enum): string => $enum->value, $this->statuses)]; + } + + public function getCookies(): array + { + return []; + } + + public function getHeaders(): array + { + return []; + } + + public function getBody() + { + return null; + } +} diff --git a/test/suite/functional/Generator/Request/PatchResourceRequest81.php b/test/suite/functional/Generator/Request/PatchResourceRequest81.php index 1836dc0..641536d 100644 --- a/test/suite/functional/Generator/Request/PatchResourceRequest81.php +++ b/test/suite/functional/Generator/Request/PatchResourceRequest81.php @@ -56,7 +56,7 @@ public function getHeaders(): array { return array_merge(['X-API-KEY' => $this->apiKey, 'Content-Type' => $this->contentType], array_map(static function ($value) { return $value instanceof SerializableInterface ? $value->toArray() : $value; - }, array_filter(['Accept' => $this->accept], static function ($value) { + }, array_filter(['Accept' => $this->accept->value], static function ($value) { return null !== $value; }))); } diff --git a/test/suite/functional/Generator/Request/PatchResourceRequest83.php b/test/suite/functional/Generator/Request/PatchResourceRequest83.php index 1836dc0..641536d 100644 --- a/test/suite/functional/Generator/Request/PatchResourceRequest83.php +++ b/test/suite/functional/Generator/Request/PatchResourceRequest83.php @@ -56,7 +56,7 @@ public function getHeaders(): array { return array_merge(['X-API-KEY' => $this->apiKey, 'Content-Type' => $this->contentType], array_map(static function ($value) { return $value instanceof SerializableInterface ? $value->toArray() : $value; - }, array_filter(['Accept' => $this->accept], static function ($value) { + }, array_filter(['Accept' => $this->accept->value], static function ($value) { return null !== $value; }))); } diff --git a/test/suite/functional/Generator/Request/PostResourceByIdRequest81.php b/test/suite/functional/Generator/Request/PostResourceByIdRequest81.php index b5725a8..001c7b0 100644 --- a/test/suite/functional/Generator/Request/PostResourceByIdRequest81.php +++ b/test/suite/functional/Generator/Request/PostResourceByIdRequest81.php @@ -158,7 +158,7 @@ public function getHeaders(): array return array_merge(['X-WSSE' => $xwsse, 'Content-Type' => $this->contentType->value], array_map(static function ($value) { return $value instanceof SerializableInterface ? $value->toArray() : $value; - }, array_filter(['contentType' => $this->contentType, 'X-Request-ID' => $this->xRequestId], static function ($value) { + }, array_filter(['contentType' => $this->contentType->value, 'X-Request-ID' => $this->xRequestId], static function ($value) { return null !== $value; }))); } diff --git a/test/suite/functional/Generator/Request/PostResourceByIdRequest83.php b/test/suite/functional/Generator/Request/PostResourceByIdRequest83.php index b5725a8..001c7b0 100644 --- a/test/suite/functional/Generator/Request/PostResourceByIdRequest83.php +++ b/test/suite/functional/Generator/Request/PostResourceByIdRequest83.php @@ -158,7 +158,7 @@ public function getHeaders(): array return array_merge(['X-WSSE' => $xwsse, 'Content-Type' => $this->contentType->value], array_map(static function ($value) { return $value instanceof SerializableInterface ? $value->toArray() : $value; - }, array_filter(['contentType' => $this->contentType, 'X-Request-ID' => $this->xRequestId], static function ($value) { + }, array_filter(['contentType' => $this->contentType->value, 'X-Request-ID' => $this->xRequestId], static function ($value) { return null !== $value; }))); } diff --git a/test/suite/functional/Generator/Request/getResourcesByStatuses.yaml b/test/suite/functional/Generator/Request/getResourcesByStatuses.yaml new file mode 100644 index 0000000..a31cd1a --- /dev/null +++ b/test/suite/functional/Generator/Request/getResourcesByStatuses.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Example API +paths: + '/v1/resources-by-statuses': + get: + description: Get resources filtered by arrays of enum values + operationId: getResourcesByStatuses + parameters: + - name: types + in: query + required: true + schema: + type: array + items: + type: string + enum: + - basic + - premium + - name: statuses + in: query + required: false + schema: + type: array + items: + type: string + enum: + - active + - inactive + - +7 days + 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 b164896..9c867a7 100644 --- a/test/suite/functional/Generator/RequestGeneratorTest.php +++ b/test/suite/functional/Generator/RequestGeneratorTest.php @@ -232,6 +232,24 @@ public function exampleProvider(): array self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\PostResourceByIdRequest', ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), ], + 'Request with mandatory and optional array of enums query parameters with php 7.4' => [ + '/Request/getResourcesByStatuses.yaml', + '/Request/GetResourcesByStatusesRequest74.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesRequest', + ConfigurationBuilder::fake()->build(), + ], + 'Request with mandatory and optional array of enums query parameters with php 8.1' => [ + '/Request/getResourcesByStatuses.yaml', + '/Request/GetResourcesByStatusesRequest81.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP81)->build(), + ], + 'Request with mandatory and optional array of enums query parameters with php 8.3' => [ + '/Request/getResourcesByStatuses.yaml', + '/Request/GetResourcesByStatusesRequest83.php', + self::BASE_NAMESPACE . RequestGenerator::NAMESPACE_SUBPATH . '\\GetResourcesByStatusesRequest', + ConfigurationBuilder::fake()->withPhpVersion(PhpVersion::VERSION_PHP83)->build(), + ], ]; } diff --git a/test/suite/functional/Generator/Schema/GetResourcesByStatusesStatusesItemEnum.php b/test/suite/functional/Generator/Schema/GetResourcesByStatusesStatusesItemEnum.php new file mode 100644 index 0000000..836be02 --- /dev/null +++ b/test/suite/functional/Generator/Schema/GetResourcesByStatusesStatusesItemEnum.php @@ -0,0 +1,18 @@ + ['active', 'ACTIVE'], + 'separators' => ['in-progress/soon.done now', 'IN_PROGRESS_SOON_DONE_NOW'], + 'integer' => ['2', 'V_2'], + 'leading digit' => ['+7 days', 'V_7_DAYS'], + 'leading digit, no separator' => ['3d', 'V_3D'], + 'decimal' => ['1.5', 'V_1_5'], + 'negative number' => ['-1', '_1'], + 'invalid characters only' => ['+', ''], + ]; + } +}