Skip to content

Commit 578c32d

Browse files
[php-nextgen] Add anyof Support (#24306)
* update codegen * update test codegen * mustache templates * generate samples * update model doc template * regenerate samples * add tests * regenerate samples * fix comment per cubic review * update composed model tests * use Throwable to preserve error catch * update model serializer per cubic review * regenerate samples * regenerate tests * fix object serialization per cubic review * split composed into oneOf/anyOf * regenerate samples * samples again * address cubic json decoding feedback * address cubic type validation feedback * Update modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * generate samples --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 0eb515c commit 578c32d

36 files changed

Lines changed: 2452 additions & 170 deletions

File tree

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PhpNextgenClientCodegen.java

Lines changed: 65 additions & 57 deletions
Large diffs are not rendered by default.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
/**
3+
* AnyOfInterface
4+
*
5+
* PHP version 8.1
6+
*
7+
* @package {{modelPackage}}
8+
* @author OpenAPI Generator team
9+
* @link https://openapi-generator.tech
10+
*/
11+
12+
{{>partial_header}}
13+
14+
/**
15+
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
16+
* https://openapi-generator.tech
17+
* Do not edit the class manually.
18+
*/
19+
20+
namespace {{modelPackage}};
21+
22+
/**
23+
* Interface implemented by models generated from an `anyOf` schema.
24+
*
25+
* An `anyOf` schema is not represented by a value object; instead a value is one of the
26+
* member types. Classes implementing this interface only carry the metadata that
27+
* {@see ObjectSerializer::deserialize()} needs to resolve the concrete member type.
28+
*
29+
* @package {{modelPackage}}
30+
* @author OpenAPI Generator team
31+
*/
32+
interface AnyOfInterface
33+
{
34+
/**
35+
* List of the types a value of this `anyOf` schema may be. Each entry uses the same
36+
* notation as the values of {@see ModelInterface::openAPITypes()}.
37+
*
38+
* @return string[]
39+
*/
40+
public static function getAnyOfTypes(): array;
41+
42+
/**
43+
* Name of the discriminator property used to resolve the concrete member type, or null
44+
* when the schema has no discriminator.
45+
*
46+
* @return string|null
47+
*/
48+
public static function getAnyOfDiscriminator(): ?string;
49+
50+
/**
51+
* Mapping of discriminator values to the concrete member type. Empty when the schema has
52+
* no discriminator.
53+
*
54+
* @return array<string,string>
55+
*/
56+
public static function getAnyOfDiscriminatorMappings(): array;
57+
}

modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -490,16 +490,24 @@ class ObjectSerializer
490490
}
491491
return $data;
492492
} else {
493-
$data = is_string($data) ? json_decode($data) : $data;
494-
495-
if (is_array($data)) {
496-
$data = (object) $data;
493+
if (is_string($data)) {
494+
$decoded = json_decode($data);
495+
// Keep the original string if decode fails (nested values are already PHP strings).
496+
$data = ($decoded !== null || $data === 'null') ? $decoded : $data;
497497
}
498498

499-
// A oneOf schema is not a value object: resolve the data to one of its member types.
499+
// A oneOf/anyOf schema is not a value object: dispatch here, after json_decode (so
500+
// members get the decoded value) but before the array-to-object cast (which breaks arrays).
500501
if (is_subclass_of($class, '\{{modelPackage}}\OneOfInterface')) {
501502
return self::deserializeOneOf($data, $class, $httpHeaders);
502503
}
504+
if (is_subclass_of($class, '\{{modelPackage}}\AnyOfInterface')) {
505+
return self::deserializeAnyOf($data, $class, $httpHeaders);
506+
}
507+
508+
if (is_array($data)) {
509+
$data = (object) $data;
510+
}
503511

504512
// If a discriminator is defined and points to a valid subclass, use it.
505513
$discriminator = $class::DISCRIMINATOR;
@@ -536,34 +544,63 @@ class ObjectSerializer
536544
}
537545
}
538546

547+
/**
548+
* Returns true when $data's PHP type is compatible with the OpenAPI primitive $type.
549+
* Prevents settype() coercion (e.g. "abc" → 0 for integer) from producing a false match
550+
* when iterating over oneOf/anyOf candidate types.
551+
* Non-primitive types always return true and are validated downstream by ModelInterface::valid().
552+
*/
553+
private static function isPrimitiveTypeCompatible(mixed $data, string $type): bool
554+
{
555+
return match($type) {
556+
'int', 'integer' => is_int($data),
557+
'float', 'double', 'number' => is_int($data) || is_float($data),
558+
'bool', 'boolean' => is_bool($data),
559+
'string' => is_string($data),
560+
'array' => is_array($data),
561+
'object' => is_object($data),
562+
default => true,
563+
};
564+
}
565+
539566
/**
540567
* Deserialize data into one of the member types of a `oneOf` schema.
541568
*
542569
* When the schema declares a discriminator, its value selects the member type directly.
543570
* Otherwise each member type is tried in turn and the first one that yields a valid model
544571
* (or a non-null primitive) is returned.
545572
*
546-
* @param mixed $data the data already decoded to an object
573+
* @param mixed $data the decoded data to resolve to a member type
547574
* @param string $class a class name implementing OneOfInterface
548575
* @param string[]|null $httpHeaders HTTP headers
549576
*
550577
* @return mixed an instance of one of the `oneOf` member types
551578
*/
552579
private static function deserializeOneOf(mixed $data, string $class, ?array $httpHeaders): mixed
553580
{
581+
// $data is already decoded (see deserialize()). Read the discriminator from an object view
582+
// of it, but deserialize each member from the original $data so array and scalar members
583+
// keep their own type handling instead of being coerced to an object here.
584+
$probe = is_array($data) ? (object) $data : $data;
585+
554586
$discriminator = $class::getOneOfDiscriminator();
555-
if ($discriminator !== null && isset($data->{$discriminator}) && is_string($data->{$discriminator})) {
587+
if ($discriminator !== null && is_object($probe) && isset($probe->{$discriminator}) && is_string($probe->{$discriminator})) {
556588
$mappings = $class::getOneOfDiscriminatorMappings();
557-
if (isset($mappings[$data->{$discriminator}])) {
558-
return self::deserialize($data, $mappings[$data->{$discriminator}], $httpHeaders);
589+
if (isset($mappings[$probe->{$discriminator}])) {
590+
return self::deserialize($data, $mappings[$probe->{$discriminator}], $httpHeaders);
559591
}
560592
}
561593

562594
foreach ($class::getOneOfTypes() as $type) {
595+
if (!self::isPrimitiveTypeCompatible($data, $type)) {
596+
continue;
597+
}
563598
try {
564599
$instance = self::deserialize($data, $type, $httpHeaders);
565600
} catch (\Throwable $e) {
566-
// The data does not match this member type, try the next one.
601+
// Any failure means the data does not match this member type; try the next one.
602+
// The broad catch is intentional: a real failure is not hidden, since the loop
603+
// throws below when no member type matches.
567604
continue;
568605
}
569606

@@ -579,6 +616,59 @@ class ObjectSerializer
579616
throw new \InvalidArgumentException(sprintf('No matching schema in oneOf %s for the given data', $class));
580617
}
581618

619+
/**
620+
* Deserialize data into one of the member types of an `anyOf` schema.
621+
*
622+
* When the schema declares a discriminator, its value selects the member type directly.
623+
* Otherwise each member type is tried in turn and the first one that yields a valid model
624+
* (or a non-null primitive) is returned.
625+
*
626+
* @param mixed $data the decoded data to resolve to a member type
627+
* @param string $class a class name implementing AnyOfInterface
628+
* @param string[]|null $httpHeaders HTTP headers
629+
*
630+
* @return mixed an instance of one of the `anyOf` member types
631+
*/
632+
private static function deserializeAnyOf(mixed $data, string $class, ?array $httpHeaders): mixed
633+
{
634+
// $data is already decoded (see deserialize()). Read the discriminator from an object view
635+
// of it, but deserialize each member from the original $data so array and scalar members
636+
// keep their own type handling instead of being coerced to an object here.
637+
$probe = is_array($data) ? (object) $data : $data;
638+
639+
$discriminator = $class::getAnyOfDiscriminator();
640+
if ($discriminator !== null && is_object($probe) && isset($probe->{$discriminator}) && is_string($probe->{$discriminator})) {
641+
$mappings = $class::getAnyOfDiscriminatorMappings();
642+
if (isset($mappings[$probe->{$discriminator}])) {
643+
return self::deserialize($data, $mappings[$probe->{$discriminator}], $httpHeaders);
644+
}
645+
}
646+
647+
foreach ($class::getAnyOfTypes() as $type) {
648+
if (!self::isPrimitiveTypeCompatible($data, $type)) {
649+
continue;
650+
}
651+
try {
652+
$instance = self::deserialize($data, $type, $httpHeaders);
653+
} catch (\Throwable $e) {
654+
// Any failure means the data does not match this member type; try the next one.
655+
// The broad catch is intentional: a real failure is not hidden, since the loop
656+
// throws below when no member type matches.
657+
continue;
658+
}
659+
660+
if ($instance instanceof ModelInterface) {
661+
if ($instance->valid()) {
662+
return $instance;
663+
}
664+
} elseif ($instance !== null) {
665+
return $instance;
666+
}
667+
}
668+
669+
throw new \InvalidArgumentException(sprintf('No matching schema in anyOf %s for the given data', $class));
670+
}
671+
582672
/**
583673
* Build a query string from an array of key value pairs.
584674
*

modules/openapi-generator/src/main/resources/php-nextgen/model.mustache

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
namespace {{modelPackage}};
2323
{{^isEnum}}
2424
{{^oneOf}}
25+
{{^anyOf}}
2526

2627
{{^parentSchema}}
2728
use ArrayAccess;
@@ -30,6 +31,7 @@ use JsonSerializable;
3031
use InvalidArgumentException;
3132
use ReturnTypeWillChange;
3233
use {{invokerPackage}}\ObjectSerializer;
34+
{{/anyOf}}
3335
{{/oneOf}}
3436
{{/isEnum}}
3537

@@ -45,10 +47,12 @@ use {{invokerPackage}}\ObjectSerializer;
4547
{{^parentSchema}}
4648
{{^isEnum}}
4749
{{^oneOf}}
50+
{{^anyOf}}
4851
* @implements ArrayAccess<string, mixed>
52+
{{/anyOf}}
4953
{{/oneOf}}
5054
{{/isEnum}}
5155
{{/parentSchema}}
5256
*/
53-
{{#isEnum}}{{>model_enum}}{{/isEnum}}{{^isEnum}}{{#oneOf}}{{#-first}}{{>model_oneof}}{{/-first}}{{/oneOf}}{{^oneOf}}{{>model_generic}}{{/oneOf}}{{/isEnum}}
57+
{{#isEnum}}{{>model_enum}}{{/isEnum}}{{^isEnum}}{{#oneOf}}{{#-first}}{{>model_oneof}}{{/-first}}{{/oneOf}}{{^oneOf}}{{#anyOf}}{{#-first}}{{>model_anyof}}{{/-first}}{{/anyOf}}{{^anyOf}}{{>model_generic}}{{/anyOf}}{{/oneOf}}{{/isEnum}}
5458
{{/model}}{{/models}}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
class {{classname}} implements AnyOfInterface
2+
{
3+
/**
4+
* The original name of the model.
5+
*
6+
* @var string
7+
*/
8+
public const MODEL_NAME = '{{name}}';
9+
10+
/**
11+
* The discriminator property name, or null when the schema has no discriminator.
12+
*
13+
* @var string|null
14+
*/
15+
public const DISCRIMINATOR = {{#discriminator}}'{{propertyBaseName}}'{{/discriminator}}{{^discriminator}}null{{/discriminator}};
16+
17+
/**
18+
* {@inheritdoc}
19+
*/
20+
public static function getAnyOfTypes(): array
21+
{
22+
return [
23+
{{#composedSchemas}}{{#anyOf}}'{{{dataType}}}'{{^-last}},
24+
{{/-last}}{{/anyOf}}{{/composedSchemas}}
25+
];
26+
}
27+
28+
/**
29+
* {@inheritdoc}
30+
*/
31+
public static function getAnyOfDiscriminator(): ?string
32+
{
33+
return self::DISCRIMINATOR;
34+
}
35+
36+
/**
37+
* {@inheritdoc}
38+
*/
39+
public static function getAnyOfDiscriminatorMappings(): array
40+
{
41+
return [
42+
{{#discriminator}}{{#mappedModels}}'{{mappingName}}' => '\{{modelPackage}}\{{modelName}}'{{^-last}},
43+
{{/-last}}{{/mappedModels}}{{/discriminator}}
44+
];
45+
}
46+
}

modules/openapi-generator/src/main/resources/php-nextgen/model_doc.mustache

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,31 @@
11
{{#models}}{{#model}}# {{classname}}
22
{{#oneOf.isEmpty}}
3+
{{#anyOf.isEmpty}}
34

45
## Properties
56

67
Name | Type | Description | Notes
78
------------ | ------------- | ------------- | -------------
89
{{#vars}}**{{name}}** | {{#isPrimitiveType}}**{{{dataType}}}**{{/isPrimitiveType}}{{^isPrimitiveType}}[**{{{dataType}}}**]({{complexType}}.md){{/isPrimitiveType}} | {{description}} |{{^required}} [optional]{{/required}}{{#isReadOnly}} [readonly]{{/isReadOnly}}{{#defaultValue}} [default to {{{.}}}]{{/defaultValue}}
910
{{/vars}}
11+
{{/anyOf.isEmpty}}
12+
{{^anyOf.isEmpty}}
13+
14+
This model is an `anyOf` wrapper: a value is at least one of the member types listed below.
15+
It is never instantiated directly — use one of the concrete types.
16+
17+
## anyOf
18+
19+
{{#composedSchemas}}
20+
{{#anyOf}}
21+
- {{#complexType}}[**{{{dataType}}}**]({{complexType}}.md){{/complexType}}{{^complexType}}**{{{dataType}}}**{{/complexType}}
22+
{{/anyOf}}
23+
{{/composedSchemas}}
24+
{{#discriminator}}
25+
26+
The concrete type is selected by the `{{propertyName}}` discriminator property.
27+
{{/discriminator}}
28+
{{/anyOf.isEmpty}}
1029
{{/oneOf.isEmpty}}
1130
{{^oneOf.isEmpty}}
1231

modules/openapi-generator/src/main/resources/php-nextgen/model_test.mustache

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ class {{classname}}Test extends TestCase
7070
// TODO: implement
7171
self::markTestIncomplete('Not implemented');
7272
}
73+
{{! Composed (oneOf/anyOf) models are dispatchers with no own properties: their `vars` are the }}
74+
{{! members' flattened properties, so per-property test stubs would be meaningless. Skip them. }}
75+
{{^oneOf}}
76+
{{^anyOf}}
7377
{{#vars}}
7478
7579
/**
@@ -81,6 +85,8 @@ class {{classname}}Test extends TestCase
8185
self::markTestIncomplete('Not implemented');
8286
}
8387
{{/vars}}
88+
{{/anyOf}}
89+
{{/oneOf}}
8490
}
8591
{{/model}}
8692
{{/models}}

0 commit comments

Comments
 (0)