Skip to content

Commit d2b05cd

Browse files
authored
[php-nextgen] oneof polymorphism (#23985)
* [php-nextgen]: Add oneof polymorphism * [php-nextgen]: Regenerate sample with new models * [php-nextgen]: Update generated files file * [php-nextgen]: add polymorphism to docs * [php-nextgen]: Add oneOf properties (currently not working) * [php-nextgen]: Unify doc and signature types for params, properties and returns * [php-nextgen]: Make api responses nullable only on nullable SUCCESS responses * [php-nextgen]: Try to use correct model for api documentation * [php-nextgen] Regenerate docs * [php-nextgen] Improve and link javadocs
1 parent 18ae7c6 commit d2b05cd

63 files changed

Lines changed: 4665 additions & 187 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/generators/php-nextgen.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
241241
| ---- | --------- | ---------- |
242242
|Simple|✓|OAS2,OAS3
243243
|Composite|✓|OAS2,OAS3
244-
|Polymorphism||OAS2,OAS3
244+
|Polymorphism||OAS2,OAS3
245245
|Union|✗|OAS3
246246
|allOf|✗|OAS2,OAS3
247247
|anyOf|✗|OAS3

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

Lines changed: 173 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,6 @@ public PhpNextgenClientCodegen() {
6969
GlobalFeature.LinkObjects,
7070
GlobalFeature.ParameterStyling
7171
)
72-
.excludeSchemaSupportFeatures(
73-
SchemaSupportFeature.Polymorphism
74-
)
7572
);
7673

7774
// clear import mapping (from default generator) as php does not use it
@@ -127,6 +124,7 @@ public void processOpts() {
127124
supportingFiles.add(new SupportingFile("FormDataProcessor.mustache", toSrcPath(invokerPackage, srcBasePath), "FormDataProcessor.php"));
128125
supportingFiles.add(new SupportingFile("ObjectSerializer.mustache", toSrcPath(invokerPackage, srcBasePath), "ObjectSerializer.php"));
129126
supportingFiles.add(new SupportingFile("ModelInterface.mustache", toSrcPath(modelPackage, srcBasePath), "ModelInterface.php"));
127+
supportingFiles.add(new SupportingFile("OneOfInterface.mustache", toSrcPath(modelPackage, srcBasePath), "OneOfInterface.php"));
130128
supportingFiles.add(new SupportingFile("HeaderSelector.mustache", toSrcPath(invokerPackage, srcBasePath), "HeaderSelector.php"));
131129
supportingFiles.add(new SupportingFile("composer.mustache", "", "composer.json"));
132130
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
@@ -145,30 +143,168 @@ public void processOpts() {
145143
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
146144
final Map<String, ModelsMap> processed = super.postProcessAllModels(objs);
147145

146+
Map<String, String> oneOfTypeHints = new HashMap<>();
147+
for (ModelsMap modelsMap : processed.values()) {
148+
for (ModelMap m : modelsMap.getModels()) {
149+
collectOneOfTypeHint(m.getModel(), oneOfTypeHints);
150+
}
151+
}
152+
148153
for (Map.Entry<String, ModelsMap> entry : processed.entrySet()) {
149-
entry.setValue(postProcessModelsMap(entry.getValue()));
154+
entry.setValue(postProcessModelsMap(entry.getValue(), oneOfTypeHints));
150155
}
151156

152157
return processed;
153158
}
154159

155-
private ModelsMap postProcessModelsMap(ModelsMap objs) {
160+
/**
161+
* If the given model is a oneOf composition, record the PHP union type that should be used
162+
* wherever the model is referenced.
163+
*/
164+
private void collectOneOfTypeHint(CodegenModel model, Map<String, String> oneOfTypeHints) {
165+
if (model == null || model.getComposedSchemas() == null) {
166+
return;
167+
}
168+
169+
List<CodegenProperty> oneOf = model.getComposedSchemas().getOneOf();
170+
if (oneOf == null || oneOf.isEmpty()) {
171+
return;
172+
}
173+
174+
Set<String> memberTypes = new LinkedHashSet<>();
175+
for (CodegenProperty member : oneOf) {
176+
memberTypes.add((member.isArray || member.isMap) ? "array" : member.dataType);
177+
}
178+
179+
oneOfTypeHints.put("\\" + modelPackage + "\\" + model.classname, String.join("|", memberTypes));
180+
}
181+
182+
/**
183+
* PHP forbids the nullable shorthand ({@code ?T}) on union types, so a union must instead
184+
* gain an explicit {@code |null} member.
185+
*/
186+
private static String makeNullable(String phpType) {
187+
return phpType.contains("|") ? phpType + "|null" : "?" + phpType;
188+
}
189+
190+
/**
191+
* The base PHP type hint for a single element: a container collapses to {@code array} (PHP
192+
* cannot type-hint {@code Foo[]}), a oneOf alias expands to the union of its members, and
193+
* everything else stays its {@code dataType}.
194+
*/
195+
private String phpBaseType(String dataType, boolean isContainer, Map<String, String> oneOfTypeHints) {
196+
return isContainer ? "array" : oneOfTypeHints.getOrDefault(dataType, dataType);
197+
}
198+
199+
/**
200+
* The PHP signature type hint: the {@link #phpBaseType base type}, made nullable when the
201+
* element is optional or nullable - except {@code mixed}, which already admits null.
202+
*/
203+
private String phpSignatureType(String dataType, boolean isContainer, boolean nullable, Map<String, String> oneOfTypeHints) {
204+
String base = phpBaseType(dataType, isContainer, oneOfTypeHints);
205+
return (nullable && !base.equals("mixed")) ? makeNullable(base) : base;
206+
}
207+
208+
/**
209+
* Wrap an expanded inner union back into container phpdoc notation: {@code (Apple|Banana)[]}
210+
* for arrays (parenthesised so {@code []} binds to the whole union, not just its last member)
211+
* and {@code array<string,Apple|Banana>} for maps. A {@code null} inner propagates, signalling
212+
* "no oneOf in this type".
213+
*/
214+
private static String wrapContainerDoc(boolean isArray, String inner) {
215+
if (inner == null) {
216+
return null;
217+
}
218+
return isArray ? (inner.contains("|") ? "(" + inner + ")[]" : inner + "[]")
219+
: "array<string," + inner + ">";
220+
}
221+
222+
/**
223+
* The phpdoc type with any reference to a oneOf model expanded to the union of its members.
224+
* A oneOf model is only a deserialization dispatcher, so its members do not inherit from it
225+
* and {@code @param Fruit} would be a lie — {@code @param Apple|Banana} is the truth.
226+
* Returns {@code null} when no oneOf is involved, so the caller can leave the original
227+
* {@code dataType} phpdoc untouched.
228+
*/
229+
private String oneOfDocType(CodegenProperty prop, Map<String, String> oneOfTypeHints) {
230+
return docTypeOf(prop.isArray, prop.isMap, prop.items, prop.dataType, oneOfTypeHints);
231+
}
232+
233+
/** @see #oneOfDocType(CodegenProperty, Map) */
234+
private String oneOfDocType(CodegenParameter param, Map<String, String> oneOfTypeHints) {
235+
return docTypeOf(param.isArray, param.isMap, param.items, param.dataType, oneOfTypeHints);
236+
}
237+
238+
/** @see #oneOfDocType(CodegenProperty, Map) */
239+
private String oneOfDocType(CodegenResponse response, Map<String, String> oneOfTypeHints) {
240+
return docTypeOf(response.isArray, response.isMap, response.items, response.dataType, oneOfTypeHints);
241+
}
242+
243+
/**
244+
* The shared core of the {@code oneOfDocType} overloads: expands a oneOf {@code dataType} to the
245+
* union of its members (recursing through array/map items so the expansion reaches nested oneOfs),
246+
* or returns {@code null} when no oneOf is involved. See {@link #oneOfDocType(CodegenProperty, Map)}.
247+
*/
248+
private String docTypeOf(boolean isArray, boolean isMap, CodegenProperty items, String dataType, Map<String, String> oneOfTypeHints) {
249+
if ((isArray || isMap) && items != null) {
250+
return wrapContainerDoc(isArray, oneOfDocType(items, oneOfTypeHints));
251+
}
252+
return oneOfTypeHints.get(dataType);
253+
}
254+
255+
/**
256+
* The final phpdoc type, ready for the template to emit verbatim: the oneOf-expanded type (or the
257+
* unchanged {@code dataType} when no oneOf is involved), with a {@code |null} member appended
258+
* when the element is optional or nullable. phpdoc unions always spell out {@code |null}
259+
* rather than using the {@code ?T} shorthand.
260+
*/
261+
private String phpDocType(CodegenProperty prop, Map<String, String> oneOfTypeHints) {
262+
return bakeDocType(oneOfDocType(prop, oneOfTypeHints), prop.dataType, prop.notRequiredOrIsNullable());
263+
}
264+
265+
private String phpDocType(CodegenParameter param, Map<String, String> oneOfTypeHints) {
266+
return bakeDocType(oneOfDocType(param, oneOfTypeHints), param.dataType, param.notRequiredOrIsNullable());
267+
}
268+
269+
/**
270+
* The shared core of the {@code phpDocType} overloads: uses {@code expandedType}, falling back to
271+
* {@code dataType} when it is {@code null}, and appends {@code |null} when {@code nullable}.
272+
* See {@link #phpDocType(CodegenProperty, Map)}.
273+
*/
274+
private static String bakeDocType(String expandedType, String dataType, boolean nullable) {
275+
String docType = expandedType != null ? expandedType : dataType;
276+
return nullable ? docType + "|null" : docType;
277+
}
278+
279+
/**
280+
* A oneOf model is an abstract dispatcher, so the default doc example ({@code new Mammal()})
281+
* instantiates a type that cannot be used. Rewrite the example to instantiate the first member
282+
* of the union instead ({@code new Whale()}). Handles a oneOf parameter directly as well as a
283+
* container whose items are a oneOf.
284+
*/
285+
private void useFirstOneOfMemberInExample(CodegenParameter param, Map<String, String> oneOfTypeHints) {
286+
if (param.example == null) {
287+
return;
288+
}
289+
String alias = oneOfTypeHints.containsKey(param.dataType) ? param.dataType
290+
: (param.items != null && oneOfTypeHints.containsKey(param.items.dataType) ? param.items.dataType : null);
291+
if (alias == null) {
292+
return;
293+
}
294+
String firstMember = oneOfTypeHints.get(alias).split("\\|", 2)[0];
295+
if (firstMember.startsWith("\\")) { // a concrete class we can instantiate
296+
param.example = param.example.replace(alias, firstMember);
297+
}
298+
}
299+
300+
private ModelsMap postProcessModelsMap(ModelsMap objs, Map<String, String> oneOfTypeHints) {
156301
for (ModelMap m : objs.getModels()) {
157302
CodegenModel model = m.getModel();
158303

159304
for (CodegenProperty prop : model.vars) {
160-
String propType;
161-
if (prop.isArray || prop.isMap) {
162-
propType = "array";
163-
} else {
164-
propType = prop.dataType;
165-
}
166-
167-
if ((!prop.required || prop.isNullable) && !propType.equals("mixed")) { // optional or nullable but not mixed
168-
propType = "?" + propType;
169-
}
170-
171-
prop.vendorExtensions.putIfAbsent("x-php-prop-type", propType);
305+
prop.vendorExtensions.putIfAbsent("x-php-prop-type",
306+
phpSignatureType(prop.dataType, prop.isArray || prop.isMap, prop.notRequiredOrIsNullable(), oneOfTypeHints));
307+
prop.vendorExtensions.putIfAbsent("x-php-prop-doc-type", phpDocType(prop, oneOfTypeHints));
172308
}
173309
}
174310
return objs;
@@ -177,6 +313,12 @@ private ModelsMap postProcessModelsMap(ModelsMap objs) {
177313
@Override
178314
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
179315
objs = super.postProcessOperationsWithModels(objs, allModels);
316+
317+
Map<String, String> oneOfTypeHints = new HashMap<>();
318+
for (ModelMap m : allModels) {
319+
collectOneOfTypeHint(m.getModel(), oneOfTypeHints);
320+
}
321+
180322
OperationMap operations = objs.getOperations();
181323
for (CodegenOperation operation : operations.getOperation()) {
182324
Set<String> phpReturnTypeOptions = new LinkedHashSet<>();
@@ -185,16 +327,15 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
185327

186328
for (CodegenResponse response : operation.responses) {
187329
if (response.dataType != null) {
188-
String returnType = response.dataType;
189-
if (response.isArray || response.isMap) {
190-
// PHP does not understand array type hinting so we strip it
191-
// The phpdoc will still contain the array type hinting
192-
returnType = "array";
193-
}
194-
195-
phpReturnTypeOptions.add(returnType);
196-
docReturnTypeOptions.add(response.dataType);
197-
} else {
330+
// The signature collapses a container to `array` (PHP cannot type-hint Foo[]);
331+
// the phpdoc keeps the full notation, with any oneOf alias expanded to its union.
332+
phpReturnTypeOptions.add(phpBaseType(response.dataType, response.isArray || response.isMap, oneOfTypeHints));
333+
String responseDocType = oneOfDocType(response, oneOfTypeHints);
334+
docReturnTypeOptions.add(responseDocType != null ? responseDocType : response.dataType);
335+
} else if (response.is2xx) {
336+
// Only a body-less *success* response makes the method return null. A body-less
337+
// error response throws an ApiException instead, so it must not make the return
338+
// type nullable.
198339
hasEmptyResponse = true;
199340
}
200341
}
@@ -207,11 +348,7 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
207348
String phpReturnType = String.join("|", phpReturnTypeOptions);
208349
String docReturnType = String.join("|", docReturnTypeOptions);
209350
if (hasEmptyResponse) {
210-
if (phpReturnTypeOptions.size() > 1) {
211-
phpReturnType = phpReturnType + "|null";
212-
} else {
213-
phpReturnType = "?" + phpReturnType;
214-
}
351+
phpReturnType = makeNullable(phpReturnType);
215352
docReturnType = docReturnType + "|null";
216353
}
217354

@@ -221,16 +358,10 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
221358
}
222359

223360
for (CodegenParameter param : operation.allParams) {
224-
String paramType;
225-
if (param.isArray || param.isMap) {
226-
paramType = "array";
227-
} else {
228-
paramType = param.dataType;
229-
}
230-
if ((!param.required || param.isNullable) && !paramType.equals("mixed")) { // optional or nullable but not mixed
231-
paramType = "?" + paramType;
232-
}
233-
param.vendorExtensions.putIfAbsent("x-php-param-type", paramType);
361+
param.vendorExtensions.putIfAbsent("x-php-param-type",
362+
phpSignatureType(param.dataType, param.isArray || param.isMap, param.notRequiredOrIsNullable(), oneOfTypeHints));
363+
param.vendorExtensions.putIfAbsent("x-php-param-doc-type", phpDocType(param, oneOfTypeHints));
364+
useFirstOneOfMemberInExample(param, oneOfTypeHints);
234365
}
235366
}
236367

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,11 @@ class ObjectSerializer
496496
$data = (object) $data;
497497
}
498498

499+
// A oneOf schema is not a value object: resolve the data to one of its member types.
500+
if (is_subclass_of($class, '\{{modelPackage}}\OneOfInterface')) {
501+
return self::deserializeOneOf($data, $class, $httpHeaders);
502+
}
503+
499504
// If a discriminator is defined and points to a valid subclass, use it.
500505
$discriminator = $class::DISCRIMINATOR;
501506
if (!empty($discriminator) && isset($data->{$discriminator}) && is_string($data->{$discriminator})) {
@@ -531,6 +536,49 @@ class ObjectSerializer
531536
}
532537
}
533538

539+
/**
540+
* Deserialize data into one of the member types of a `oneOf` schema.
541+
*
542+
* When the schema declares a discriminator, its value selects the member type directly.
543+
* Otherwise each member type is tried in turn and the first one that yields a valid model
544+
* (or a non-null primitive) is returned.
545+
*
546+
* @param mixed $data the data already decoded to an object
547+
* @param string $class a class name implementing OneOfInterface
548+
* @param string[]|null $httpHeaders HTTP headers
549+
*
550+
* @return mixed an instance of one of the `oneOf` member types
551+
*/
552+
private static function deserializeOneOf(mixed $data, string $class, ?array $httpHeaders): mixed
553+
{
554+
$discriminator = $class::getOneOfDiscriminator();
555+
if ($discriminator !== null && isset($data->{$discriminator}) && is_string($data->{$discriminator})) {
556+
$mappings = $class::getOneOfDiscriminatorMappings();
557+
if (isset($mappings[$data->{$discriminator}])) {
558+
return self::deserialize($data, $mappings[$data->{$discriminator}], $httpHeaders);
559+
}
560+
}
561+
562+
foreach ($class::getOneOfTypes() as $type) {
563+
try {
564+
$instance = self::deserialize($data, $type, $httpHeaders);
565+
} catch (\Throwable $e) {
566+
// The data does not match this member type, try the next one.
567+
continue;
568+
}
569+
570+
if ($instance instanceof ModelInterface) {
571+
if ($instance->valid()) {
572+
return $instance;
573+
}
574+
} elseif ($instance !== null) {
575+
return $instance;
576+
}
577+
}
578+
579+
throw new \InvalidArgumentException(sprintf('No matching schema in oneOf %s for the given data', $class));
580+
}
581+
534582
/**
535583
* Build a query string from an array of key value pairs.
536584
*

0 commit comments

Comments
 (0)