Skip to content

Commit 1d8ef81

Browse files
committed
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.
1 parent a53ebb3 commit 1d8ef81

13 files changed

Lines changed: 371 additions & 12 deletions

File tree

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: 159 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

@@ -354,6 +357,162 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
354357
return super.postProcessOperationsWithModels(objs, allModels);
355358
}
356359

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

modules/openapi-generator/src/main/resources/scala-http4s/model.mustache

Lines changed: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ package {{modelPackage}}
33

44
import io.circe.*
55
import io.circe.syntax.*
6-
import io.circe.{Decoder, Encoder}
6+
import io.circe.{Decoder, DecodingFailure, Encoder}
7+
import cats.syntax.functor.*
78

89
{{#imports}}
910
import {{import}}
@@ -16,6 +17,173 @@ import {{import}}
1617
* @param {{name}} {{{description}}}
1718
{{/vars}}
1819
*/
20+
{{#vendorExtensions.x-isRegularTrait}}
21+
trait {{classname}}
22+
object {{classname}} {
23+
import io.circe.{ Decoder, Encoder }
24+
import io.circe.syntax.*
25+
import cats.syntax.functor.*
26+
27+
{{^vendorExtensions.x-use-discr}}
28+
// no discriminator
29+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
30+
{{#oneOf}}
31+
case obj: {{.}} => obj.asJson
32+
{{/oneOf}}
33+
}
34+
35+
given decoder{{classname}}: Decoder[{{classname}}] =
36+
List[Decoder[{{classname}}]](
37+
{{#oneOf}}
38+
Decoder[{{.}}].widen,
39+
{{/oneOf}}
40+
).reduceLeft(_ or _)
41+
{{/vendorExtensions.x-use-discr}}
42+
{{#vendorExtensions.x-use-discr}}
43+
{{^vendorExtensions.x-use-discr-mapping}}
44+
// with discriminator, no mapping
45+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
46+
{{#oneOf}}
47+
case obj: {{.}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{.}}".asJson) +: _)
48+
{{/oneOf}}
49+
}
50+
51+
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
52+
cursor.downField("{{discriminator.propertyName}}").as[String].flatMap {
53+
{{#oneOf}}
54+
case "{{.}}" => cursor.as[{{.}}]
55+
{{/oneOf}}
56+
case discriminatorValue =>
57+
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
58+
}
59+
}
60+
{{/vendorExtensions.x-use-discr-mapping}}
61+
{{#vendorExtensions.x-use-discr-mapping}}
62+
// with discriminator mapping
63+
{{#discriminator}}
64+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
65+
{{#mappedModels}}
66+
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
67+
{{/mappedModels}}
68+
}
69+
70+
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
71+
cursor.downField("{{propertyName}}").as[String].flatMap {
72+
{{#mappedModels}}
73+
case "{{mappingName}}" => cursor.as[{{model.classname}}]
74+
{{/mappedModels}}
75+
case discriminatorValue =>
76+
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
77+
}
78+
}
79+
{{/discriminator}}
80+
{{/vendorExtensions.x-use-discr-mapping}}
81+
{{/vendorExtensions.x-use-discr}}
82+
}
83+
84+
{{/vendorExtensions.x-isRegularTrait}}
85+
{{#vendorExtensions.x-isSealedTrait}}
86+
sealed trait {{classname}}
87+
object {{classname}} {
88+
import io.circe.{ Decoder, Encoder }
89+
import io.circe.syntax.*
90+
import cats.syntax.functor.*
91+
92+
{{^vendorExtensions.x-use-discr}}
93+
// no discriminator
94+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
95+
{{#vendorExtensions.x-oneOfMembers}}
96+
case obj: {{classname}} => obj.asJson
97+
{{/vendorExtensions.x-oneOfMembers}}
98+
}
99+
100+
given decoder{{classname}}: Decoder[{{classname}}] =
101+
List[Decoder[{{classname}}]](
102+
{{#vendorExtensions.x-oneOfMembers}}
103+
Decoder[{{classname}}].widen,
104+
{{/vendorExtensions.x-oneOfMembers}}
105+
).reduceLeft(_ or _)
106+
{{/vendorExtensions.x-use-discr}}
107+
{{#vendorExtensions.x-use-discr}}
108+
{{^vendorExtensions.x-use-discr-mapping}}
109+
// with discriminator, no mapping
110+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
111+
{{#vendorExtensions.x-oneOfMembers}}
112+
case obj: {{classname}} => obj.asJson.mapObject(("{{vendorExtensions.x-parentDiscriminatorName}}" -> "{{classname}}".asJson) +: _)
113+
{{/vendorExtensions.x-oneOfMembers}}
114+
}
115+
116+
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
117+
cursor.downField("{{discriminator.propertyName}}").as[String].flatMap {
118+
{{#vendorExtensions.x-oneOfMembers}}
119+
case "{{classname}}" => cursor.as[{{classname}}]
120+
{{/vendorExtensions.x-oneOfMembers}}
121+
case discriminatorValue =>
122+
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
123+
}
124+
}
125+
{{/vendorExtensions.x-use-discr-mapping}}
126+
{{#vendorExtensions.x-use-discr-mapping}}
127+
// with discriminator mapping
128+
{{#discriminator}}
129+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
130+
{{#mappedModels}}
131+
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
132+
{{/mappedModels}}
133+
}
134+
135+
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
136+
cursor.downField("{{propertyName}}").as[String].flatMap {
137+
{{#mappedModels}}
138+
case "{{mappingName}}" => cursor.as[{{model.classname}}]
139+
{{/mappedModels}}
140+
case discriminatorValue =>
141+
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
142+
}
143+
}
144+
{{/discriminator}}
145+
{{/vendorExtensions.x-use-discr-mapping}}
146+
{{/vendorExtensions.x-use-discr}}
147+
}
148+
149+
{{#vendorExtensions.x-oneOfMembers}}
150+
/** {{{description}}}
151+
{{#vars}}
152+
* @param {{name}} {{{description}}}
153+
{{/vars}}
154+
*/
155+
case class {{classname}}(
156+
{{#vars}}
157+
{{name}}: {{^required}}Option[{{{dataType}}}] = None{{/required}}{{#required}}{{{dataType}}}{{/required}}{{^-last}},{{/-last}}
158+
{{/vars}}
159+
) extends {{vendorExtensions.x-oneOfParent}}
160+
161+
object {{classname}} {
162+
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
163+
Json.fromFields{
164+
Seq(
165+
{{#vars}}
166+
{{#required}}Some("{{baseName}}" -> t.{{name}}.asJson){{/required}}{{^required}}t.{{name}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
167+
{{/vars}}
168+
).flatten
169+
}
170+
}
171+
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
172+
for {
173+
{{#vars}}
174+
{{name}} <- {{#isEnumOrRef}}{{^required}}mapEmptyStringToNull(c.downField("{{baseName}}")){{/required}}{{#required}}c.downField("{{baseName}}"){{/required}}{{/isEnumOrRef}}{{^isEnumOrRef}}c.downField("{{baseName}}"){{/isEnumOrRef}}.as[{{^required}}Option[{{{dataType}}}]{{/required}}{{#required}}{{{dataType}}}{{/required}}]
175+
{{/vars}}
176+
} yield {{classname}}(
177+
{{#vars}}
178+
{{name}} = {{name}}{{^-last}},{{/-last}}
179+
{{/vars}}
180+
)
181+
}
182+
}
183+
184+
{{/vendorExtensions.x-oneOfMembers}}
185+
{{/vendorExtensions.x-isSealedTrait}}
186+
{{#vendorExtensions.x-isEnum}}
19187
{{#isEnum}}
20188
enum {{classname}}(val value: String) {
21189
{{#allowableValues}}
@@ -36,12 +204,14 @@ object {{classname}} {
36204
37205
}
38206
{{/isEnum}}
207+
{{/vendorExtensions.x-isEnum}}
208+
{{#vendorExtensions.x-another}}
39209
{{^isEnum}}
40210
case class {{classname}}(
41211
{{#vars}}
42212
{{name}}: {{^required}}Option[{{{dataType}}}] = None{{/required}}{{#required}}{{{dataType}}}{{/required}}{{^-last}},{{/-last}}
43213
{{/vars}}
44-
)
214+
){{#vendorExtensions.x-extends}} extends {{.}}{{/vendorExtensions.x-extends}}{{#vendorExtensions.x-extendsWith}} with {{.}}{{/vendorExtensions.x-extendsWith}}
45215

46216
object {{classname}} {
47217
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
@@ -66,6 +236,7 @@ object {{classname}} {
66236
}
67237
}
68238
{{/isEnum}}
239+
{{/vendorExtensions.x-another}}
69240
{{/model}}
70241
{{/models}}
71242

0 commit comments

Comments
 (0)