diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PhpNextgenClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PhpNextgenClientCodegen.java index 4ac96d53d65a..0abd59367e36 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PhpNextgenClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/PhpNextgenClientCodegen.java @@ -150,6 +150,7 @@ public Map postProcessAllModels(Map objs) collectComposedTypeHint(m.getModel(), composedTypeHints); } } + flattenComposedTypeHints(composedTypeHints); for (Map.Entry entry : processed.entrySet()) { entry.setValue(postProcessModelsMap(entry.getValue(), composedTypeHints)); @@ -163,10 +164,24 @@ public Map postProcessAllModels(Map objs) * used wherever the model is referenced. A model that declares both contributes all members. */ private void collectComposedTypeHint(CodegenModel model, Map composedTypeHints) { - if (model == null || model.getComposedSchemas() == null) { + Set 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 directComposedMemberTypes(CodegenModel model) { + Set memberTypes = new LinkedHashSet<>(); + if (model == null || model.getComposedSchemas() == null) { + return memberTypes; + } + CodegenComposedSchemas composed = model.getComposedSchemas(); List members = new ArrayList<>(); if (composed.getOneOf() != null) { @@ -175,16 +190,65 @@ private void collectComposedTypeHint(CodegenModel model, Map com if (composed.getAnyOf() != null) { members.addAll(composed.getAnyOf()); } - if (members.isEmpty()) { - return; - } - - Set 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> composedLeafDocEntries(String union) { + List> entries = new ArrayList<>(); + for (String leaf : union.split("\\|")) { + Map 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 composedTypeHints) { + Map resolved = new HashMap<>(); + for (String composedType : composedTypeHints.keySet()) { + Set 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 composedTypeHints, Set visiting, Set 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); } /** @@ -309,6 +373,14 @@ private ModelsMap postProcessModelsMap(ModelsMap objs, Map 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)); @@ -326,6 +398,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List 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 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(); diff --git a/modules/openapi-generator/src/test/resources/3_0/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml b/modules/openapi-generator/src/test/resources/3_0/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml index 8afde128fc1b..8de20de092c1 100644 --- a/modules/openapi-generator/src/test/resources/3_0/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/php-nextgen/petstore-with-fake-endpoints-models-for-testing.yaml @@ -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 @@ -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' + diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/.openapi-generator/FILES b/samples/client/petstore/php-nextgen/OpenAPIClient-php/.openapi-generator/FILES index 19d0fbfa91d4..581e0684b053 100644 --- a/samples/client/petstore/php-nextgen/OpenAPIClient-php/.openapi-generator/FILES +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/.openapi-generator/FILES @@ -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 @@ -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 diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/README.md b/samples/client/petstore/php-nextgen/OpenAPIClient-php/README.md index 36b3a7c691a8..e85c70117370 100644 --- a/samples/client/petstore/php-nextgen/OpenAPIClient-php/README.md +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/README.md @@ -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) diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Creature.md b/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Creature.md new file mode 100644 index 000000000000..678c624a9551 --- /dev/null +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Creature.md @@ -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) diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Zoo.md b/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Zoo.md index 9bee60684e57..2c323f5746f2 100644 --- a/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Zoo.md +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/docs/Model/Zoo.md @@ -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) diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/Model/Creature.php b/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/Model/Creature.php new file mode 100644 index 000000000000..dacb8862c5e6 --- /dev/null +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/Model/Creature.php @@ -0,0 +1,83 @@ + '\OpenAPI\Client\Model\Fruit', 'mammals' => '\OpenAPI\Client\Model\Mammal[]', 'favorite_reptile' => '\OpenAPI\Client\Model\Reptile', - 'drink' => '\OpenAPI\Client\Model\Smoothie' + 'drink' => '\OpenAPI\Client\Model\Smoothie', + 'featured_creature' => '\OpenAPI\Client\Model\Creature' ]; /** @@ -78,7 +79,8 @@ class Zoo implements ModelInterface, ArrayAccess, JsonSerializable 'snack' => null, 'mammals' => null, 'favorite_reptile' => null, - 'drink' => null + 'drink' => null, + 'featured_creature' => null ]; /** @@ -92,7 +94,8 @@ class Zoo implements ModelInterface, ArrayAccess, JsonSerializable 'snack' => false, 'mammals' => false, 'favorite_reptile' => false, - 'drink' => false + 'drink' => false, + 'featured_creature' => false ]; /** @@ -176,7 +179,8 @@ public function isNullableSetToNull(string $property): bool 'snack' => 'snack', 'mammals' => 'mammals', 'favorite_reptile' => 'favoriteReptile', - 'drink' => 'drink' + 'drink' => 'drink', + 'featured_creature' => 'featuredCreature' ]; /** @@ -190,7 +194,8 @@ public function isNullableSetToNull(string $property): bool 'snack' => 'setSnack', 'mammals' => 'setMammals', 'favorite_reptile' => 'setFavoriteReptile', - 'drink' => 'setDrink' + 'drink' => 'setDrink', + 'featured_creature' => 'setFeaturedCreature' ]; /** @@ -204,7 +209,8 @@ public function isNullableSetToNull(string $property): bool 'snack' => 'getSnack', 'mammals' => 'getMammals', 'favorite_reptile' => 'getFavoriteReptile', - 'drink' => 'getDrink' + 'drink' => 'getDrink', + 'featured_creature' => 'getFeaturedCreature' ]; /** @@ -260,6 +266,7 @@ public function __construct(?array $data = null) $this->setIfExists('mammals', $data ?? [], null); $this->setIfExists('favorite_reptile', $data ?? [], null); $this->setIfExists('drink', $data ?? [], null); + $this->setIfExists('featured_creature', $data ?? [], null); } /** @@ -466,6 +473,33 @@ public function setDrink(\OpenAPI\Client\Model\Apple|\OpenAPI\Client\Model\Banan return $this; } + + /** + * Gets featured_creature + * + * @return \OpenAPI\Client\Model\Whale|\OpenAPI\Client\Model\Zebra|\OpenAPI\Client\Model\Lizard|\OpenAPI\Client\Model\Snake|null + */ + public function getFeaturedCreature(): \OpenAPI\Client\Model\Whale|\OpenAPI\Client\Model\Zebra|\OpenAPI\Client\Model\Lizard|\OpenAPI\Client\Model\Snake|null + { + return $this->container['featured_creature']; + } + + /** + * Sets featured_creature + * + * @param \OpenAPI\Client\Model\Whale|\OpenAPI\Client\Model\Zebra|\OpenAPI\Client\Model\Lizard|\OpenAPI\Client\Model\Snake|null $featured_creature featured_creature + * + * @return $this + */ + public function setFeaturedCreature(\OpenAPI\Client\Model\Whale|\OpenAPI\Client\Model\Zebra|\OpenAPI\Client\Model\Lizard|\OpenAPI\Client\Model\Snake|null $featured_creature): static + { + if (is_null($featured_creature)) { + throw new InvalidArgumentException('non-nullable featured_creature cannot be null'); + } + $this->container['featured_creature'] = $featured_creature; + + return $this; + } /** * Returns true if offset exists. False otherwise. * diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/Model/CreatureTest.php b/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/Model/CreatureTest.php new file mode 100644 index 000000000000..f50c81721569 --- /dev/null +++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/Model/CreatureTest.php @@ -0,0 +1,79 @@ +