Skip to content

Commit fd0d43f

Browse files
authored
feat: Add oneOf for scala-http4s client (#22969)
* feat(scala-http4s): Add oneOf support with sealed traits Implement oneOf schema support for scala-http4s client generator using sealed traits with inlined members. Falls back to regular traits for edge cases (nested oneOf, mixed members). - Add postProcessAllModels to detect and mark oneOf models - Update model.mustache for sealed/regular trait generation - Support all discriminator modes (none, implicit, mapping) - Fix Scala 3 syntax (wildcard imports with *) - Handle shared members and import management - Add test and regenerate samples Common oneOf schemas generate sealed traits for exhaustive pattern matching. Edge cases use regular traits and emit warnings. All generated code compiles. * Enhance oneOf test with comprehensive assertions Add detailed assertions for sealed traits, discriminators, Scala 3 syntax, edge cases, and verify inlined members are not in separate files * Address review findings
1 parent e591aa0 commit fd0d43f

File tree

13 files changed

+457
-12
lines changed

13 files changed

+457
-12
lines changed

docs/generators/scala-http4s.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
247247
|Union|✗|OAS3
248248
|allOf|✗|OAS2,OAS3
249249
|anyOf|✗|OAS3
250-
|oneOf||OAS3
250+
|oneOf||OAS3
251251
|not|✗|OAS3
252252

253253
### Security Feature

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

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.openapitools.codegen.*;
2525
import org.openapitools.codegen.meta.features.*;
2626
import org.openapitools.codegen.model.ModelMap;
27+
import org.openapitools.codegen.model.ModelsMap;
2728
import org.openapitools.codegen.model.OperationMap;
2829
import org.openapitools.codegen.model.OperationsMap;
2930
import org.openapitools.codegen.utils.ModelUtils;
@@ -35,6 +36,8 @@
3536
import java.util.regex.Matcher;
3637
import java.util.regex.Pattern;
3738

39+
import static org.openapitools.codegen.CodegenConstants.X_IMPLEMENTS;
40+
3841
public class ScalaHttp4sClientCodegen extends AbstractScalaCodegen implements CodegenConfig {
3942
private final Logger LOGGER = LoggerFactory.getLogger(ScalaHttp4sClientCodegen.class);
4043

@@ -85,6 +88,9 @@ public ScalaHttp4sClientCodegen() {
8588
SchemaSupportFeature.Polymorphism,
8689
SchemaSupportFeature.not
8790
)
91+
.includeSchemaSupportFeatures(
92+
SchemaSupportFeature.oneOf
93+
)
8894
.excludeParameterFeatures(
8995
ParameterFeature.Cookie,
9096
ParameterFeature.FormMultipart
@@ -356,6 +362,178 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
356362
return super.postProcessOperationsWithModels(objs, allModels);
357363
}
358364

365+
@Override
366+
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
367+
Map<String, ModelsMap> modelsMap = super.postProcessAllModels(objs);
368+
369+
// First pass: Count how many oneOf parents reference each child model
370+
Map<String, Integer> oneOfMemberCount = new HashMap<>();
371+
Map<String, CodegenModel> allModels = new HashMap<>();
372+
373+
for (ModelsMap mm : modelsMap.values()) {
374+
for (ModelMap model : mm.getModels()) {
375+
CodegenModel cModel = model.getModel();
376+
allModels.put(cModel.classname, cModel);
377+
378+
if (!cModel.oneOf.isEmpty()) {
379+
for (String childName : cModel.oneOf) {
380+
oneOfMemberCount.put(childName, oneOfMemberCount.getOrDefault(childName, 0) + 1);
381+
}
382+
}
383+
}
384+
}
385+
386+
// Second pass: Mark and configure models
387+
for (ModelsMap mm : modelsMap.values()) {
388+
for (ModelMap model : mm.getModels()) {
389+
CodegenModel cModel = model.getModel();
390+
391+
// Mark models with oneOf as sealed traits (or regular traits for edge cases)
392+
if (!cModel.oneOf.isEmpty()) {
393+
// Collect oneOf members for inlining
394+
List<CodegenModel> oneOfMembers = new ArrayList<>();
395+
Set<String> additionalImports = new HashSet<>();
396+
for (String childName : cModel.oneOf) {
397+
CodegenModel childModel = allModels.get(childName);
398+
if (childModel != null
399+
&& (childModel.oneOf == null || childModel.oneOf.isEmpty())
400+
&& oneOfMemberCount.getOrDefault(childName, 0) == 1) {
401+
// Mark for inlining (only used by this one parent, and not itself a oneOf container)
402+
childModel.getVendorExtensions().put("x-isOneOfMember", true);
403+
childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname);
404+
// Store parent's discriminator info for use in template
405+
if (cModel.discriminator != null) {
406+
childModel.getVendorExtensions().put("x-parentDiscriminatorName", cModel.discriminator.getPropertyName());
407+
}
408+
oneOfMembers.add(childModel);
409+
410+
// Collect imports from inlined members
411+
if (childModel.imports != null) {
412+
additionalImports.addAll(childModel.imports);
413+
}
414+
}
415+
}
416+
417+
// Create list of discriminator entries with class names and schema names
418+
// When discriminator has no explicit mapping, use schema name (not class name)
419+
List<Map<String, String>> discriminatorEntries = new ArrayList<>();
420+
for (String childName : cModel.oneOf) {
421+
CodegenModel childModel = allModels.get(childName);
422+
if (childModel != null) {
423+
Map<String, String> entry = new HashMap<>();
424+
entry.put("classname", childModel.classname);
425+
entry.put("schemaName", childModel.name);
426+
discriminatorEntries.add(entry);
427+
}
428+
}
429+
cModel.getVendorExtensions().put("x-discriminator-entries", discriminatorEntries);
430+
431+
// Decide between sealed trait (with inlined members) vs regular trait (edge cases)
432+
// Use sealed trait ONLY if ALL oneOf members can be inlined
433+
// If some are inlined and some aren't (mixed case), use regular trait
434+
boolean allMembersInlined = oneOfMembers.size() == cModel.oneOf.size();
435+
436+
if (!oneOfMembers.isEmpty() && allMembersInlined) {
437+
// Normal case: can inline ALL members, use sealed trait
438+
cModel.getVendorExtensions().put("x-isSealedTrait", true);
439+
cModel.getVendorExtensions().put("x-oneOfMembers", oneOfMembers);
440+
441+
// Add child imports to parent (excluding already present imports)
442+
if (!additionalImports.isEmpty()) {
443+
Set<String> parentImports = cModel.imports != null ? new HashSet<>(cModel.imports) : new HashSet<>();
444+
additionalImports.removeAll(parentImports);
445+
if (!additionalImports.isEmpty()) {
446+
if (cModel.imports == null) {
447+
cModel.imports = new HashSet<>();
448+
}
449+
cModel.imports.addAll(additionalImports);
450+
}
451+
}
452+
} else {
453+
// Edge case: nested oneOf, shared members, or mixed case - use regular trait
454+
// Implementations will be in separate files
455+
cModel.getVendorExtensions().put("x-isRegularTrait", true);
456+
457+
// For mixed cases, unmark members for inlining - they need to be separate files
458+
for (CodegenModel member : oneOfMembers) {
459+
member.getVendorExtensions().remove("x-isOneOfMember");
460+
member.getVendorExtensions().remove("x-oneOfParent");
461+
member.getVendorExtensions().remove("x-parentDiscriminatorName");
462+
}
463+
464+
if (oneOfMembers.isEmpty()) {
465+
LOGGER.warn("Model '{}' has oneOf with no inlineable members (likely nested oneOf). " +
466+
"Generating as regular trait instead of sealed trait.", cModel.classname);
467+
} else {
468+
LOGGER.warn("Model '{}' has mixed oneOf (some inlineable, some not). " +
469+
"Generating as regular trait instead of sealed trait.", cModel.classname);
470+
}
471+
}
472+
} else if (cModel.isEnum) {
473+
cModel.getVendorExtensions().put("x-isEnum", true);
474+
} else {
475+
cModel.getVendorExtensions().put("x-another", true);
476+
}
477+
478+
// Handle discriminator
479+
if (cModel.discriminator != null) {
480+
cModel.getVendorExtensions().put("x-use-discr", true);
481+
482+
if (cModel.discriminator.getMapping() != null) {
483+
cModel.getVendorExtensions().put("x-use-discr-mapping", true);
484+
}
485+
}
486+
487+
// Handle X_IMPLEMENTS extension (for extends/with separation)
488+
try {
489+
List<String> exts = (List<String>) cModel.getVendorExtensions().get(X_IMPLEMENTS);
490+
if (exts != null) {
491+
cModel.getVendorExtensions().put("x-extends", exts.subList(0, 1));
492+
cModel.getVendorExtensions().put("x-extendsWith", exts.subList(1, exts.size()));
493+
}
494+
} catch (IndexOutOfBoundsException ignored) {
495+
}
496+
}
497+
}
498+
499+
// Third pass: Clear X_IMPLEMENTS for models extending multiple SEALED traits
500+
// (Regular traits can be extended from separate files, but sealed traits cannot)
501+
for (ModelsMap mm : modelsMap.values()) {
502+
for (ModelMap model : mm.getModels()) {
503+
CodegenModel cModel = model.getModel();
504+
505+
// Check if this model extends multiple sealed traits
506+
List<String> exts = (List<String>) cModel.getVendorExtensions().get(X_IMPLEMENTS);
507+
if (exts != null && exts.size() > 1) {
508+
// Count how many of the parents are sealed traits
509+
int sealedParentCount = 0;
510+
for (String parentName : exts) {
511+
CodegenModel parentModel = allModels.get(parentName);
512+
if (parentModel != null && parentModel.getVendorExtensions().containsKey("x-isSealedTrait")) {
513+
sealedParentCount++;
514+
}
515+
}
516+
517+
// If extending multiple sealed traits, clear all extends (impossible in Scala)
518+
if (sealedParentCount > 1) {
519+
cModel.getVendorExtensions().remove(X_IMPLEMENTS);
520+
LOGGER.warn("Model '{}' cannot extend multiple sealed traits. Generating as standalone class.",
521+
cModel.classname);
522+
}
523+
}
524+
}
525+
}
526+
527+
// Fourth pass: Remove inlined members from output (no separate file generation)
528+
modelsMap.entrySet().removeIf(entry -> {
529+
ModelsMap mm = entry.getValue();
530+
return mm.getModels().stream()
531+
.anyMatch(model -> model.getModel().getVendorExtensions().containsKey("x-isOneOfMember"));
532+
});
533+
534+
return modelsMap;
535+
}
536+
359537
@Override
360538
public List<CodegenSecurity> fromSecurity(Map<String, SecurityScheme> schemes) {
361539
final List<CodegenSecurity> codegenSecurities = super.fromSecurity(schemes);

0 commit comments

Comments
 (0)