Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a1b8582
fix: throw on unmapped 2xx status codes in multi-response match
tommey Sep 28, 2026
dc658b1
fix: support nullable optional request parameters
tommey Sep 28, 2026
3fd9a70
fix: handle empty bodies of literal (scalar) responses
tommey Sep 28, 2026
476d604
fix: keep nullable on nullable: true + allOf references
tommey Sep 28, 2026
85f4f7c
fix: accept request body media types without schema
tommey Sep 28, 2026
f36d058
fix: map null for optional nullable enum properties
tommey Sep 28, 2026
7a480c0
fix: generate enums for inline enum response bodies
tommey Sep 28, 2026
0745294
fix: send integer, number and boolean header parameters as strings
tommey Sep 28, 2026
d32f745
fix: support literal bodies in operations with several 2xx responses
tommey Sep 28, 2026
b3e75f0
fix: catch enum ValueError when matching oneOf/anyOf alternatives
tommey Sep 28, 2026
927245a
fix: serialize request bodies whose schema is not an object
tommey Sep 28, 2026
3237f11
fix: throw UnexpectedResponseBodyException for unknown discriminator …
tommey Sep 28, 2026
0ad2a88
fix: initialise optional untyped (mixed) properties with null
tommey Sep 28, 2026
29300c8
fix: map arrays of arrays of objects to arrays of collections
tommey Sep 28, 2026
22ae31f
fix: do not generate a mapper for array-of-enum response items
tommey Sep 28, 2026
e24548d
fix: name enum values that consist of symbols only instead of droppin…
tommey Sep 28, 2026
4ce2395
Merge branch 'fix/client-response-handling' into feature/fix-remainin…
tommey Sep 28, 2026
dc7cee4
Merge branch 'fix/response-mapping' into feature/fix-remaining-genera…
tommey Sep 28, 2026
e42b4ab
Merge branch 'fix/request-generation' into feature/fix-remaining-gene…
tommey Sep 28, 2026
0ea0a28
fix: declare the new throwExpression() message parameter explicitly n…
tommey Sep 28, 2026
4d75b7d
fix: generate valid class names for schemas named after PHP reserved …
tommey Sep 28, 2026
09b83fd
fix: give different schemas that share a class name distinct class names
tommey Sep 28, 2026
6063f6e
Merge branch 'fix/naming-and-validation' into feature/fix-remaining-g…
tommey Sep 28, 2026
06ec6d4
chore: sync committed example clients with current generator output
tommey Sep 28, 2026
2bae478
docs: add changelog entry for 11.4.0
tommey Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions example/PetStoreClient/src/SwaggerPetstoreOpenAPI3Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
}
Expand Down
28 changes: 26 additions & 2 deletions src/Ast/Builder/CodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -170,9 +171,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);
Expand Down Expand Up @@ -260,6 +264,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);
Expand All @@ -280,6 +289,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);
Expand Down Expand Up @@ -370,6 +384,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];
Expand Down
4 changes: 3 additions & 1 deletion src/Ast/Builder/ParameterBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/Ast/PhpVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
5 changes: 5 additions & 0 deletions src/Command/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -294,6 +295,10 @@ private function getUnusedValueObjects(Specification $specification): array
$unusedClasses[] = AuthenticationCredentials::class;
}

if (!$specification->hasLiteralRequestBody()) {
$unusedClasses[] = LiteralRequestBody::class;
}

return $unusedClasses;
}

Expand Down
51 changes: 50 additions & 1 deletion src/Entity/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +31,8 @@ class Field

private mixed $discriminator = null;

private bool $explicitReferenceName = false;

public function __construct(
private PhpVersion $phpVersion,
private string $name,
Expand Down Expand Up @@ -122,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;
Expand Down Expand Up @@ -188,6 +213,30 @@ public function isArrayOfEnums(): bool
&& $this->getArrayItem()->isEnum();
}

/**
* An array of arrays (at any depth) of objects, e.g. array<array<Item>>: 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;
Expand Down Expand Up @@ -220,7 +269,7 @@ public function getPhpVariableName(): string
public function getPhpClassName(): string
{
if ($this->type->isObject()) {
return $this->referenceName;
return SchemaNaming::getSchemaClassName($this->referenceName);
}

if (
Expand Down
13 changes: 13 additions & 0 deletions src/Entity/RequestFieldRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
*/
Expand Down
Loading
Loading