Skip to content

Commit d9c082f

Browse files
LTSCommerceclaude
andcommitted
Fix oneOf path-parameter type resolution generating mixed instead of the real type
anyOf was already resolved to a union/single native PHP type on endpoint constructor parameters; oneOf fell all the way through ChainGuesser's fallback to `mixed`, while the assigned property (independently defaulted to `string` for path parameters) stayed typed `string` — a guaranteed PHPStan assign.propertyType error on every oneOf path parameter. Extracted NonBodyParameterGenerator::resolveBranchTypedParameter() so anyOf and oneOf share one branch-to-native-type resolution path, and extended GetConstructorTrait's existing strval() safety net (already used for anyOf/multi-type) to cover oneOf too. New fixture one-of-string-path-parameter proves both the same-type-collapses-to-string case and the differing-types-collapses-to-a-union case. Found via Zoho CRM's modules.json spec (settings/modules/{moduleIdentifier}), whose moduleIdentifier parameter is a oneOf of two differently-formatted strings. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 944bc15 commit d9c082f

33 files changed

Lines changed: 1459 additions & 51 deletions

src/Component/OpenApi3/Generator/Endpoint/GetConstructorTrait.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,21 @@ public function getConstructor(OperationGuess $operation, Context $context, Gues
7575

7676
$paramName = $paramNameRaw;
7777

78-
// When anyOf is present or the schema type is an OAS 3.1 array of types,
79-
// the constructor parameter will be a union type (e.g. string|int|null).
80-
// Path parameters always become URL strings, so use strval() to safely
81-
// convert to string, avoiding assign.propertyType errors.
78+
// When anyOf/oneOf is present or the schema type is an OAS 3.1 array of
79+
// types, the constructor parameter will be a union type (e.g. string|int|null)
80+
// — or, if every branch resolves to the same PHP type (e.g. a oneOf of two
81+
// differently-formatted strings), the resolved type on its own. Either way
82+
// the assigned property below is always declared `string` (path parameters
83+
// always become URL strings), so use strval() to safely convert, avoiding
84+
// assign.propertyType errors.
8285
$schemaAnyOf = $paramSchema instanceof Schema ? $paramSchema->getAnyOf() : null;
8386
$hasAnyOf = null !== $schemaAnyOf && [] !== $schemaAnyOf;
87+
$schemaOneOf = $paramSchema instanceof Schema ? $paramSchema->getOneOf() : null;
88+
$hasOneOf = null !== $schemaOneOf && [] !== $schemaOneOf;
8489
$schemaType = $paramSchema instanceof Schema ? $paramSchema->getType() : null;
8590
$hasMultiType = \is_array($schemaType);
8691

87-
$assignValue = ($hasAnyOf || $hasMultiType)
92+
$assignValue = ($hasAnyOf || $hasOneOf || $hasMultiType)
8893
? new Expr\FuncCall(new Name('strval'), [new Node\Arg(new Expr\Variable($this->getInflector()->camelize($paramName)))])
8994
: new Expr\Variable($this->getInflector()->camelize($paramName));
9095

src/Component/OpenApi3/Generator/Parameter/NonBodyParameterGenerator.php

Lines changed: 69 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -63,41 +63,16 @@ public function generateMethodParameter(mixed $parameter, Context $context, stri
6363
$methodParameter->default = $this->getDefaultAsExpr($parameter);
6464
}
6565

66-
$anyOf = $schema->getAnyOf();
67-
if (null !== $anyOf && [] !== $anyOf) {
68-
// Resolve anyOf types to build a union type (e.g., int|string)
69-
$anyOfTypes = [];
70-
foreach ($anyOf as $anyOfSchema) {
71-
if ($anyOfSchema instanceof Reference) {
72-
[, $anyOfSchema] = $this->guessClass->resolve($anyOfSchema, Schema::class);
73-
}
74-
75-
if ($anyOfSchema instanceof Schema) {
76-
foreach ($this->convertParameterType($anyOfSchema) as $t) {
77-
$anyOfTypes[$t] = true;
78-
}
79-
}
80-
}
81-
82-
$uniqueTypes = array_keys($anyOfTypes);
83-
84-
// If any type is 'mixed' or no types resolved, leave parameter untyped
85-
if ([] === $uniqueTypes || isset($anyOfTypes['mixed'])) {
86-
return $methodParameter;
87-
}
88-
89-
if (1 === \count($uniqueTypes)) {
90-
$methodParameter->type = new Node\Name($uniqueTypes[0]);
91-
} else {
92-
$methodParameter->type = new Node\UnionType(array_map(
93-
static fn (string $t): Node\Identifier|Node\Name => \in_array($t, ['int', 'float', 'bool', 'string', 'array', 'null'], true)
94-
? new Node\Identifier($t)
95-
: new Node\Name($t),
96-
$uniqueTypes
97-
));
98-
}
66+
$branches = $schema->getAnyOf();
67+
if (null === $branches || [] === $branches) {
68+
// oneOf is semantically "exactly one" rather than anyOf's "at least one", but for
69+
// native-PHP-type-resolution purposes both are a set of candidate branch schemas —
70+
// resolved identically here.
71+
$branches = $schema->getOneOf();
72+
}
9973

100-
return $methodParameter;
74+
if (null !== $branches && [] !== $branches) {
75+
return $this->resolveBranchTypedParameter($branches, $methodParameter);
10176
}
10277

10378
$types = $this->convertParameterType($schema);
@@ -116,6 +91,49 @@ public function generateMethodParameter(mixed $parameter, Context $context, stri
11691
return $methodParameter;
11792
}
11893

94+
/**
95+
* Resolves a set of anyOf/oneOf branch schemas to a native PHP type on the given
96+
* parameter — a union type if the branches resolve to more than one distinct type
97+
* (e.g. int|string), or that single type if every branch resolves to the same one
98+
* (e.g. a oneOf of two differently-formatted strings collapses to plain `string`).
99+
* Leaves the parameter untyped if any branch is unresolvable ('mixed').
100+
*
101+
* @param array<Reference|Schema> $branches
102+
*/
103+
private function resolveBranchTypedParameter(array $branches, Node\Param $methodParameter): Node\Param
104+
{
105+
$branchTypes = [];
106+
foreach ($branches as $branchSchema) {
107+
if ($branchSchema instanceof Reference) {
108+
[, $branchSchema] = $this->guessClass->resolve($branchSchema, Schema::class);
109+
}
110+
111+
if ($branchSchema instanceof Schema) {
112+
foreach ($this->convertParameterType($branchSchema) as $t) {
113+
$branchTypes[$t] = true;
114+
}
115+
}
116+
}
117+
118+
$uniqueTypes = array_keys($branchTypes);
119+
120+
// If any type is 'mixed' or no types resolved, leave parameter untyped
121+
if ([] === $uniqueTypes || isset($branchTypes['mixed'])) {
122+
return $methodParameter;
123+
}
124+
125+
$methodParameter->type = 1 === \count($uniqueTypes)
126+
? new Node\Name($uniqueTypes[0])
127+
: new Node\UnionType(array_map(
128+
static fn (string $t): Node\Identifier|Node\Name => \in_array($t, ['int', 'float', 'bool', 'string', 'array', 'null'], true)
129+
? new Node\Identifier($t)
130+
: new Node\Name($t),
131+
$uniqueTypes
132+
));
133+
134+
return $methodParameter;
135+
}
136+
119137
/**
120138
* @param Parameter[] $parameters
121139
* @param array<string, mixed> $genericResolver
@@ -214,26 +232,31 @@ public function generateMethodDocParameter(mixed $parameter, Context $context, s
214232

215233
$schema = $parameter->getSchema();
216234
if ($schema instanceof Schema) {
217-
$anyOf = $schema->getAnyOf();
218-
if (null === $anyOf || [] === $anyOf) {
235+
$branches = $schema->getAnyOf();
236+
if (null === $branches || [] === $branches) {
237+
$branches = $schema->getOneOf();
238+
}
239+
240+
if (null === $branches || [] === $branches) {
219241
$type = implode('|', $this->convertParameterTypeForDoc($schema));
220242
} else {
221-
// Resolve anyOf types for PHPDoc (must match native union type)
222-
$anyOfTypes = [];
223-
foreach ($anyOf as $anyOfSchema) {
224-
if ($anyOfSchema instanceof Reference) {
225-
[, $anyOfSchema] = $this->guessClass->resolve($anyOfSchema, Schema::class);
243+
// Resolve anyOf/oneOf types for PHPDoc (must match native union type
244+
// resolved by resolveBranchTypedParameter())
245+
$branchTypes = [];
246+
foreach ($branches as $branchSchema) {
247+
if ($branchSchema instanceof Reference) {
248+
[, $branchSchema] = $this->guessClass->resolve($branchSchema, Schema::class);
226249
}
227250

228-
if ($anyOfSchema instanceof Schema) {
229-
foreach ($this->convertParameterType($anyOfSchema) as $t) {
230-
$anyOfTypes[$t] = true;
251+
if ($branchSchema instanceof Schema) {
252+
foreach ($this->convertParameterType($branchSchema) as $t) {
253+
$branchTypes[$t] = true;
231254
}
232255
}
233256
}
234257

235-
$uniqueTypes = array_keys($anyOfTypes);
236-
if ([] !== $uniqueTypes && !isset($anyOfTypes['mixed'])) {
258+
$uniqueTypes = array_keys($branchTypes);
259+
if ([] !== $uniqueTypes && !isset($branchTypes['mixed'])) {
237260
$type = implode('|', $uniqueTypes);
238261
}
239262
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?php
2+
3+
return [
4+
'openapi-file' => __DIR__ . '/openapi.json',
5+
'namespace' => 'LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter',
6+
'directory' => \dirname(__DIR__, 6) . '/var/test-generated/' . \basename(__DIR__),
7+
];
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter;
9+
10+
/**
11+
* @internal
12+
*/
13+
class Client extends \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Runtime\Client\Client
14+
{
15+
/**
16+
* @param string $identifier
17+
* @param int|string $mixedIdentifier
18+
* @param string $fetch Fetch mode to use (can be OBJECT or RESPONSE)
19+
* @throws \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception\UnexpectedStatusCodeException
20+
*/
21+
public function testOneOfStringPathParameter(string $identifier, int|string $mixedIdentifier, string $fetch = self::FETCH_OBJECT): mixed
22+
{
23+
return $this->executeEndpoint(new \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Endpoint\TestOneOfStringPathParameter($identifier, $mixedIdentifier), $fetch);
24+
}
25+
/**
26+
* @param list<\Http\Client\Common\Plugin> $additionalPlugins
27+
* @param list<\Symfony\Component\Serializer\Normalizer\NormalizerInterface> $additionalNormalizers
28+
*/
29+
public static function create(?\Psr\Http\Client\ClientInterface $httpClient = null, array $additionalPlugins = [], array $additionalNormalizers = []): self
30+
{
31+
if (null === $httpClient) {
32+
$httpClient = \Http\Discovery\Psr18ClientDiscovery::find();
33+
$plugins = [];
34+
if ($additionalPlugins !== []) {
35+
$plugins = array_merge($plugins, $additionalPlugins);
36+
}
37+
$httpClient = new \Http\Client\Common\PluginClient($httpClient, $plugins);
38+
}
39+
$requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory();
40+
$streamFactory = \Http\Discovery\Psr17FactoryDiscovery::findStreamFactory();
41+
$normalizers = [new \Symfony\Component\Serializer\Normalizer\ArrayDenormalizer(), new \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Normalizer\JaneObjectNormalizer()];
42+
if ($additionalNormalizers !== []) {
43+
$normalizers = array_merge($normalizers, $additionalNormalizers);
44+
}
45+
$serializer = new \Symfony\Component\Serializer\Serializer($normalizers, [new \Symfony\Component\Serializer\Encoder\JsonEncoder(new \Symfony\Component\Serializer\Encoder\JsonEncode(), new \Symfony\Component\Serializer\Encoder\JsonDecode(['json_decode_associative' => true]))]);
46+
return new self($httpClient, $requestFactory, $serializer, $streamFactory);
47+
}
48+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Endpoint;
9+
10+
/**
11+
* @internal
12+
*/
13+
class TestOneOfStringPathParameter extends \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Runtime\Client\BaseEndpoint implements \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Runtime\Client\Endpoint
14+
{
15+
protected string $identifier;
16+
protected string $mixedIdentifier;
17+
/**
18+
* @param string $identifier
19+
* @param int|string $mixedIdentifier
20+
*/
21+
public function __construct(string $identifier, int|string $mixedIdentifier)
22+
{
23+
$this->identifier = strval($identifier);
24+
$this->mixedIdentifier = strval($mixedIdentifier);
25+
}
26+
use \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Runtime\Client\EndpointTrait;
27+
public function getMethod(): string
28+
{
29+
return 'GET';
30+
}
31+
public function getUri(): string
32+
{
33+
return str_replace(['{identifier}', '{mixedIdentifier}'], [$this->identifier, $this->mixedIdentifier], '/test-one-of-string-path-parameter/{identifier}/{mixedIdentifier}');
34+
}
35+
/**
36+
* @return array<int, mixed>
37+
*/
38+
public function getBody(\Symfony\Component\Serializer\SerializerInterface $serializer, ?\Psr\Http\Message\StreamFactoryInterface $streamFactory = null): array
39+
{
40+
return [[], null];
41+
}
42+
/**
43+
* {@inheritdoc}
44+
*
45+
* @throws \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception\UnexpectedStatusCodeException
46+
*/
47+
protected function transformResponseBody(\Psr\Http\Message\ResponseInterface $response, \Symfony\Component\Serializer\SerializerInterface $serializer, ?string $contentType = null): never
48+
{
49+
$status = $response->getStatusCode();
50+
$body = (string) $response->getBody();
51+
throw new \LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception\UnexpectedStatusCodeException($status, $body, 'GET /test-one-of-string-path-parameter/{identifier}/{mixedIdentifier}');
52+
}
53+
/**
54+
* @return list<string>
55+
*/
56+
public function getAuthenticationScopes(): array
57+
{
58+
return [];
59+
}
60+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception;
9+
10+
/**
11+
* @internal
12+
*/
13+
interface ApiException extends \Throwable
14+
{
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception;
9+
10+
/**
11+
* @internal
12+
*/
13+
interface ClientException extends ApiException
14+
{
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception;
9+
10+
/**
11+
* @internal
12+
*/
13+
interface ServerException extends ApiException
14+
{
15+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is auto-generated by PHP OpenAPI Generator.
5+
* DO NOT EDIT MANUALLY — changes will be overwritten when the generator is re-run.
6+
*/
7+
declare (strict_types=1);
8+
namespace LongTermSupport\OpenApiGenerator\Component\OpenApi3\Tests\Expected\OneOfStringPathParameter\Exception;
9+
10+
/**
11+
* @internal
12+
*/
13+
final class UnexpectedStatusCodeException extends \RuntimeException implements ClientException
14+
{
15+
private string $body;
16+
public function __construct(int $status, string $body = '', string $endpointHint = '')
17+
{
18+
parent::__construct(\sprintf('Unexpected HTTP %d from %s — body: %s. This status code is not in the OpenAPI spec. To document it, run: bin/console zoho:sdk:spec:patch-scaffold', $status, $endpointHint, $body), $status);
19+
$this->body = $body;
20+
}
21+
public function getBody(): string
22+
{
23+
return $this->body;
24+
}
25+
}

0 commit comments

Comments
 (0)