Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
43b94b1
update codegen
ckoegel Jul 9, 2026
f69799d
update test codegen
ckoegel Jul 9, 2026
3843c5c
mustache templates
ckoegel Jul 9, 2026
64d4a0f
generate samples
ckoegel Jul 9, 2026
17bcfc7
update model doc template
ckoegel Jul 14, 2026
c788027
regenerate samples
ckoegel Jul 14, 2026
24f6b9b
add tests
ckoegel Jul 14, 2026
8161b24
regenerate samples
ckoegel Jul 14, 2026
72feca0
fix comment per cubic review
ckoegel Jul 15, 2026
3c6eb0f
update composed model tests
ckoegel Jul 15, 2026
da12fc3
use Throwable to preserve error catch
ckoegel Jul 15, 2026
0a3d632
update model serializer per cubic review
ckoegel Jul 15, 2026
9a21acf
regenerate samples
ckoegel Jul 15, 2026
b86c6fc
regenerate tests
ckoegel Jul 15, 2026
d95922f
fix object serialization per cubic review
ckoegel Jul 15, 2026
ba73003
add function to flatten type hints
ckoegel Jul 15, 2026
d04e7bc
generate samples
ckoegel Jul 15, 2026
3a01c20
add tests
ckoegel Jul 15, 2026
9d1ce0b
regenerate
ckoegel Jul 15, 2026
fe2f126
split composed into oneOf/anyOf
ckoegel Jul 21, 2026
c31c074
regenerate samples
ckoegel Jul 21, 2026
a3fad7b
Merge branch 'master' of https://github.com/ckoegel/openapi-generator…
ckoegel Jul 21, 2026
1582a69
Merge branch 'php-nextgen-anyof' into php-nextgen-nested-composed
ckoegel Jul 21, 2026
457f0f4
samples again
ckoegel Jul 21, 2026
cbaf8c7
address cubic json decoding feedback
ckoegel Jul 21, 2026
b2a7e37
address cubic type validation feedback
ckoegel Jul 22, 2026
1ac0a88
Update modules/openapi-generator/src/main/resources/php-nextgen/Objec…
ckoegel Jul 22, 2026
9add223
generate samples
ckoegel Jul 22, 2026
c3cf715
Merge branch 'php-nextgen-anyof' of https://github.com/ckoegel/openap…
ckoegel Jul 22, 2026
0c00fc5
generate samples
ckoegel Jul 22, 2026
c0c811e
Merge branch 'master' into php-nextgen-nested-composed
ckoegel Jul 23, 2026
24aee7c
address cubic cyclic feedback
ckoegel Jul 23, 2026
4b1cda7
address cubic docs feedback
ckoegel Jul 23, 2026
0194b12
Update modules/openapi-generator/src/main/java/org/openapitools/codeg…
ckoegel Jul 23, 2026
c6e09a0
address cubic invalid php feedback
ckoegel Jul 23, 2026
3a17e3b
Merge branch 'php-nextgen-nested-composed' of https://github.com/ckoe…
ckoegel Jul 23, 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
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
collectComposedTypeHint(m.getModel(), composedTypeHints);
}
}
flattenComposedTypeHints(composedTypeHints);

for (Map.Entry<String, ModelsMap> entry : processed.entrySet()) {
entry.setValue(postProcessModelsMap(entry.getValue(), composedTypeHints));
Expand All @@ -163,10 +164,24 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
* used wherever the model is referenced. A model that declares both contributes all members.
*/
private void collectComposedTypeHint(CodegenModel model, Map<String, String> composedTypeHints) {
if (model == null || model.getComposedSchemas() == null) {
Set<String> memberTypes = directComposedMemberTypes(model);
if (memberTypes.isEmpty()) {
return;
}

composedTypeHints.put("\\" + modelPackage + "\\" + model.classname, String.join("|", memberTypes));
}

/**
* The immediate (non-recursive) oneOf/anyOf member types of a composed model, containers
* collapsed to {@code array}. Empty when the model is not a composition.
*/
private Set<String> directComposedMemberTypes(CodegenModel model) {
Set<String> memberTypes = new LinkedHashSet<>();
if (model == null || model.getComposedSchemas() == null) {
return memberTypes;
}

CodegenComposedSchemas composed = model.getComposedSchemas();
List<CodegenProperty> members = new ArrayList<>();
if (composed.getOneOf() != null) {
Expand All @@ -175,16 +190,65 @@ private void collectComposedTypeHint(CodegenModel model, Map<String, String> com
if (composed.getAnyOf() != null) {
members.addAll(composed.getAnyOf());
}
if (members.isEmpty()) {
return;
}

Set<String> memberTypes = new LinkedHashSet<>();
for (CodegenProperty member : members) {
memberTypes.add((member.isArray || member.isMap) ? "array" : member.dataType);
}
return memberTypes;
}

composedTypeHints.put("\\" + modelPackage + "\\" + model.classname, String.join("|", memberTypes));
/**
* Split a flattened union into doc-link entries. A class member (starts with {@code \}) gets a
* {@code complexType} - its bare class name - for the {@code .md} link; a primitive gets none.
*/
private List<Map<String, String>> composedLeafDocEntries(String union) {
List<Map<String, String>> entries = new ArrayList<>();
for (String leaf : union.split("\\|")) {
Map<String, String> entry = new HashMap<>();
entry.put("dataType", leaf);
if (leaf.startsWith("\\")) {
entry.put("complexType", leaf.substring(leaf.lastIndexOf('\\') + 1));
}
entries.add(entry);
}
return entries;
}

/**
* Expand each composed type's union hint transitively: a member that is itself a composed type
* is replaced by its own leaf members. The generated {@code ObjectSerializer} dispatches
* nested composition down to the leaf instance, so a property typed with an intermediate
* composed member would otherwise reject the leaf the deserializer actually returns.
*/
private void flattenComposedTypeHints(Map<String, String> composedTypeHints) {
Map<String, String> resolved = new HashMap<>();
for (String composedType : composedTypeHints.keySet()) {
Set<String> leaves = new LinkedHashSet<>();
collectLeafTypes(composedType, composedTypeHints, new LinkedHashSet<>(), leaves);
// No leaves means a fully cyclic composition; keep the original hint, not an empty type.
if (!leaves.isEmpty()) {
resolved.put(composedType, String.join("|", leaves));
}
}
composedTypeHints.putAll(resolved);
}

/**
* Accumulate into {@code leaves} the non-composed member types reachable from {@code type}. A
* member that is itself a composed type (a key in {@code composedTypeHints}) is expanded
* recursively; {@code visiting} guards against cycles in self-referential schemas.
*/
private void collectLeafTypes(String type, Map<String, String> composedTypeHints, Set<String> visiting, Set<String> leaves) {
if (!composedTypeHints.containsKey(type)) {
leaves.add(type);
return;
}
if (!visiting.add(type)) {
return;
}
for (String member : composedTypeHints.get(type).split("\\|")) {
collectLeafTypes(member, composedTypeHints, visiting, leaves);
}
visiting.remove(type);
}

/**
Expand Down Expand Up @@ -309,6 +373,14 @@ private ModelsMap postProcessModelsMap(ModelsMap objs, Map<String, String> compo
for (ModelMap m : objs.getModels()) {
CodegenModel model = m.getModel();

// Surface a composed model's flattened leaf types so the doc page lists the concrete
// types users actually work with, matching the generated method signatures.
String composedKey = "\\" + modelPackage + "\\" + model.classname;
if (composedTypeHints.containsKey(composedKey)) {
model.vendorExtensions.putIfAbsent("x-php-composed-leaves",
composedLeafDocEntries(composedTypeHints.get(composedKey)));
}

for (CodegenProperty prop : model.vars) {
prop.vendorExtensions.putIfAbsent("x-php-prop-type",
phpSignatureType(prop.dataType, prop.isArray || prop.isMap, prop.notRequiredOrIsNullable(), composedTypeHints));
Expand All @@ -326,6 +398,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
for (ModelMap m : allModels) {
collectComposedTypeHint(m.getModel(), composedTypeHints);
}
flattenComposedTypeHints(composedTypeHints);

OperationMap operations = objs.getOperations();
for (CodegenOperation operation : operations.getOperation()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,9 @@ It is never instantiated directly — use one of the concrete types.

## anyOf

{{#composedSchemas}}
{{#anyOf}}
{{#vendorExtensions.x-php-composed-leaves}}
- {{#complexType}}[**{{{dataType}}}**]({{complexType}}.md){{/complexType}}{{^complexType}}**{{{dataType}}}**{{/complexType}}
{{/anyOf}}
{{/composedSchemas}}
{{/vendorExtensions.x-php-composed-leaves}}
{{#discriminator}}

The concrete type is selected by the `{{propertyName}}` discriminator property.
Expand All @@ -34,11 +32,9 @@ It is never instantiated directly — use one of the concrete types.

## oneOf

{{#composedSchemas}}
{{#oneOf}}
{{#vendorExtensions.x-php-composed-leaves}}
- {{#complexType}}[**{{{dataType}}}**]({{complexType}}.md){{/complexType}}{{^complexType}}**{{{dataType}}}**{{/complexType}}
{{/oneOf}}
{{/composedSchemas}}
{{/vendorExtensions.x-php-composed-leaves}}
{{#discriminator}}

The concrete type is selected by the `{{propertyName}}` discriminator property.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,39 @@ public void testAnyOfAsPropertyType() throws IOException {
"a union type must never use the nullable shorthand `?`");
}

@Test
public void testNestedComposedPropertyTypeIsFlattenedToLeaves() throws IOException {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
output.deleteOnExit();

OpenAPI openAPI = new OpenAPIParser()
.readLocation("src/test/resources/3_0/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml", null, new ParseOptions())
.getOpenAPI();

codegen.setOutputDir(output.getAbsolutePath());
ClientOptInput input = new ClientOptInput()
.openAPI(openAPI)
.config(codegen);

DefaultGenerator generator = new DefaultGenerator();
Map<String, File> files = generator.opts(input).generate().stream()
.collect(Collectors.toMap(File::getName, Function.identity()));

// Zoo.featuredCreature references Creature = anyOf(Mammal, Reptile), and BOTH members are
// themselves composed (Mammal oneOf Whale/Zebra, Reptile anyOf Lizard/Snake).
// deserializeComposed unwraps to the leaf instance, so the property union must flatten the
// intermediate composed types to their leaves: Whale|Zebra|Lizard|Snake.
List<String> zoo = Files.readAllLines(files.get("Zoo.php").toPath())
.stream().map(String::trim).collect(Collectors.toList());

Assert.assertListContains(zoo,
a -> a.equals("public function getFeaturedCreature(): \\OpenAPI\\Client\\Model\\Whale|\\OpenAPI\\Client\\Model\\Zebra|\\OpenAPI\\Client\\Model\\Lizard|\\OpenAPI\\Client\\Model\\Snake|null"),
"nested composed property union flattens intermediate composed members to their leaves");
Assert.assertListNotContains(zoo,
a -> a.contains("FeaturedCreature") && (a.contains("\\OpenAPI\\Client\\Model\\Mammal") || a.contains("\\OpenAPI\\Client\\Model\\Reptile")),
"intermediate composed types (Mammal, Reptile) must not appear in the flattened union");
}

@Test
public void testOneOfAsPropertyType() throws IOException {
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2412,6 +2412,10 @@ components:
drink:
# optional, discriminator-less anyOf -> nullable PHP union
$ref: '#/components/schemas/Smoothie'
featuredCreature:
# optional anyOf whose members are THEMSELVES composed (Mammal, Reptile) -> the union
# must flatten transitively to their leaves, not the intermediate composed types
$ref: '#/components/schemas/Creature'

Lizard:
type: object
Expand Down Expand Up @@ -2448,3 +2452,11 @@ components:
- $ref: '#/components/schemas/Apple'
- $ref: '#/components/schemas/Banana'

Creature:
# anyOf whose members are themselves composed: Mammal (oneOf Whale/Zebra) and Reptile
# (anyOf Lizard/Snake). A property referencing Creature must flatten transitively to the
# leaves (Whale|Zebra|Lizard|Snake), since the deserializer unwraps to the leaf instance.
anyOf:
- $ref: '#/components/schemas/Mammal'
- $ref: '#/components/schemas/Reptile'

Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ docs/Model/Category.md
docs/Model/ChildWithNullable.md
docs/Model/ClassModel.md
docs/Model/Client.md
docs/Model/Creature.md
docs/Model/DeprecatedObject.md
docs/Model/DiscriminatorBase.md
docs/Model/DiscriminatorChild.md
Expand Down Expand Up @@ -106,6 +107,7 @@ src/Model/Category.php
src/Model/ChildWithNullable.php
src/Model/ClassModel.php
src/Model/Client.php
src/Model/Creature.php
src/Model/DeprecatedObject.php
src/Model/DiscriminatorBase.php
src/Model/DiscriminatorChild.php
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ Class | Method | HTTP request | Description
- [ChildWithNullable](docs/Model/ChildWithNullable.md)
- [ClassModel](docs/Model/ClassModel.md)
- [Client](docs/Model/Client.md)
- [Creature](docs/Model/Creature.md)
- [DeprecatedObject](docs/Model/DeprecatedObject.md)
- [DiscriminatorBase](docs/Model/DiscriminatorBase.md)
- [DiscriminatorChild](docs/Model/DiscriminatorChild.md)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Creature

This model is an `anyOf` wrapper: a value is at least one of the member types listed below.
It is never instantiated directly — use one of the concrete types.

## anyOf

- [**\OpenAPI\Client\Model\Whale**](Whale.md)
- [**\OpenAPI\Client\Model\Zebra**](Zebra.md)
- [**\OpenAPI\Client\Model\Lizard**](Lizard.md)
- [**\OpenAPI\Client\Model\Snake**](Snake.md)

[[Back to Model list]](../../README.md#models) [[Back to API list]](../../README.md#endpoints) [[Back to README]](../../README.md)
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ Name | Type | Description | Notes
**mammals** | [**\OpenAPI\Client\Model\Mammal[]**](Mammal.md) | | [optional]
**favorite_reptile** | [**\OpenAPI\Client\Model\Reptile**](Reptile.md) | |
**drink** | [**\OpenAPI\Client\Model\Smoothie**](Smoothie.md) | | [optional]
**featured_creature** | [**\OpenAPI\Client\Model\Creature**](Creature.md) | | [optional]

[[Back to Model list]](../../README.md#models) [[Back to API list]](../../README.md#endpoints) [[Back to README]](../../README.md)
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
/**
* Creature
*
* PHP version 8.1
*
* @package OpenAPI\Client
* @author OpenAPI Generator team
* @link https://openapi-generator.tech
*/

/**
* OpenAPI Petstore
*
* This spec is mainly for testing Petstore server and contains fake endpoints, models. Please do not use this for any other purpose. Special characters: \" \\
*
* The version of the OpenAPI document: 1.0.0
* @generated Generated by: https://openapi-generator.tech
* Generator version: 7.25.0-SNAPSHOT
*/

/**
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/

namespace OpenAPI\Client\Model;

/**
* Creature Class Doc Comment
*
* @package OpenAPI\Client
* @author OpenAPI Generator team
* @link https://openapi-generator.tech
*/
class Creature implements AnyOfInterface
{
/**
* The original name of the model.
*
* @var string
*/
public const MODEL_NAME = 'Creature';

/**
* The discriminator property name, or null when the schema has no discriminator.
*
* @var string|null
*/
public const DISCRIMINATOR = null;

/**
* {@inheritdoc}
*/
public static function getAnyOfTypes(): array
{
return [
'\OpenAPI\Client\Model\Mammal',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Creature::getAnyOfTypes() remains nested, so consumers of the generated anyOf metadata see Mammal|Reptile instead of the promised leaf union Whale|Zebra|Lizard|Snake. Returning the transitive leaf types here keeps direct Creature deserialization/introspection consistent with the flattened property types generated elsewhere.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/php-nextgen/OpenAPIClient-php/src/Model/Creature.php, line 59:

<comment>`Creature::getAnyOfTypes()` remains nested, so consumers of the generated anyOf metadata see `Mammal|Reptile` instead of the promised leaf union `Whale|Zebra|Lizard|Snake`. Returning the transitive leaf types here keeps direct `Creature` deserialization/introspection consistent with the flattened property types generated elsewhere.</comment>

<file context>
@@ -0,0 +1,83 @@
+    public static function getAnyOfTypes(): array
+    {
+        return [
+            '\OpenAPI\Client\Model\Mammal',
+            '\OpenAPI\Client\Model\Reptile'
+        ];
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be ignored, getAnyOfTypes() is meant to return this model's direct anyOf members, not its grandchildren, so Mammal and Reptile are what this should list imo. It's also used in deserializeAnyOf, which already recurses through them to return the leaves. Flattening it would skip their discriminators and make it guess by validity instead, which could result in picking the wrong type if multiple are valid.

'\OpenAPI\Client\Model\Reptile'
];
}

/**
* {@inheritdoc}
*/
public static function getAnyOfDiscriminator(): ?string
{
return self::DISCRIMINATOR;
}

/**
* {@inheritdoc}
*/
public static function getAnyOfDiscriminatorMappings(): array
{
return [

];
}
}


Loading
Loading