Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion docs/generators/scala-http4s.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
|Union|✗|OAS3
|allOf|✗|OAS2,OAS3
|anyOf|✗|OAS3
|oneOf||OAS3
|oneOf||OAS3
|not|✗|OAS3

### Security Feature
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import org.openapitools.codegen.*;
import org.openapitools.codegen.meta.features.*;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.utils.ModelUtils;
Expand All @@ -35,6 +36,8 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.openapitools.codegen.CodegenConstants.X_IMPLEMENTS;

public class ScalaHttp4sClientCodegen extends AbstractScalaCodegen implements CodegenConfig {
private final Logger LOGGER = LoggerFactory.getLogger(ScalaHttp4sClientCodegen.class);

Expand Down Expand Up @@ -354,6 +357,162 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
return super.postProcessOperationsWithModels(objs, allModels);
}

@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
Map<String, ModelsMap> modelsMap = super.postProcessAllModels(objs);

// First pass: Count how many oneOf parents reference each child model
Map<String, Integer> oneOfMemberCount = new HashMap<>();
Map<String, CodegenModel> allModels = new HashMap<>();

for (ModelsMap mm : modelsMap.values()) {
for (ModelMap model : mm.getModels()) {
CodegenModel cModel = model.getModel();
allModels.put(cModel.classname, cModel);

if (!cModel.oneOf.isEmpty()) {
for (String childName : cModel.oneOf) {
oneOfMemberCount.put(childName, oneOfMemberCount.getOrDefault(childName, 0) + 1);
}
}
}
}

// Second pass: Mark and configure models
for (ModelsMap mm : modelsMap.values()) {
for (ModelMap model : mm.getModels()) {
CodegenModel cModel = model.getModel();

// Mark models with oneOf as sealed traits (or regular traits for edge cases)
if (!cModel.oneOf.isEmpty()) {
// Collect oneOf members for inlining
List<CodegenModel> oneOfMembers = new ArrayList<>();
Set<String> additionalImports = new HashSet<>();
for (String childName : cModel.oneOf) {
CodegenModel childModel = allModels.get(childName);
if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 13, 2026

Choose a reason for hiding this comment

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

P2: Nested oneOf models can be incorrectly inlined as case classes because the inlining condition doesn’t exclude child models that are oneOf containers, causing nested oneOf traits to be dropped from output and generating incorrect models.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaHttp4sClientCodegen.java, line 393:

<comment>Nested oneOf models can be incorrectly inlined as case classes because the inlining condition doesn’t exclude child models that are oneOf containers, causing nested oneOf traits to be dropped from output and generating incorrect models.</comment>

<file context>
@@ -354,6 +357,162 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
+                    Set<String> additionalImports = new HashSet<>();
+                    for (String childName : cModel.oneOf) {
+                        CodegenModel childModel = allModels.get(childName);
+                        if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
+                            // Mark for inlining (only used by this one parent)
+                            childModel.getVendorExtensions().put("x-isOneOfMember", true);
</file context>
Suggested change
if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
if (childModel != null
+ && (childModel.oneOf == null || childModel.oneOf.isEmpty())
+ && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
Fix with Cubic

// Mark for inlining (only used by this one parent)
childModel.getVendorExtensions().put("x-isOneOfMember", true);
childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname);
// Store parent's discriminator info for use in template
if (cModel.discriminator != null) {
childModel.getVendorExtensions().put("x-parentDiscriminatorName", cModel.discriminator.getPropertyName());
}
oneOfMembers.add(childModel);

// Collect imports from inlined members
if (childModel.imports != null) {
additionalImports.addAll(childModel.imports);
}
}
}

// Decide between sealed trait (with inlined members) vs regular trait (edge cases)
// Use sealed trait ONLY if ALL oneOf members can be inlined
// If some are inlined and some aren't (mixed case), use regular trait
boolean allMembersInlined = oneOfMembers.size() == cModel.oneOf.size();

if (!oneOfMembers.isEmpty() && allMembersInlined) {
// Normal case: can inline ALL members, use sealed trait
cModel.getVendorExtensions().put("x-isSealedTrait", true);
cModel.getVendorExtensions().put("x-oneOfMembers", oneOfMembers);

// Add child imports to parent (excluding already present imports)
if (!additionalImports.isEmpty()) {
Set<String> parentImports = cModel.imports != null ? new HashSet<>(cModel.imports) : new HashSet<>();
additionalImports.removeAll(parentImports);
if (!additionalImports.isEmpty()) {
if (cModel.imports == null) {
cModel.imports = new HashSet<>();
}
cModel.imports.addAll(additionalImports);
}
}
} else {
// Edge case: nested oneOf, shared members, or mixed case - use regular trait
// Implementations will be in separate files
cModel.getVendorExtensions().put("x-isRegularTrait", true);

// For mixed cases, unmark members for inlining - they need to be separate files
for (CodegenModel member : oneOfMembers) {
member.getVendorExtensions().remove("x-isOneOfMember");
member.getVendorExtensions().remove("x-oneOfParent");
member.getVendorExtensions().remove("x-parentDiscriminatorName");
}

if (oneOfMembers.isEmpty()) {
LOGGER.warn("Model '{}' has oneOf with no inlineable members (likely nested oneOf). " +
"Generating as regular trait instead of sealed trait.", cModel.classname);
} else {
LOGGER.warn("Model '{}' has mixed oneOf (some inlineable, some not). " +
"Generating as regular trait instead of sealed trait.", cModel.classname);
}
}
} else if (cModel.isEnum) {
cModel.getVendorExtensions().put("x-isEnum", true);
} else {
cModel.getVendorExtensions().put("x-another", true);
}

// Handle discriminator
if (cModel.discriminator != null) {
cModel.getVendorExtensions().put("x-use-discr", true);

if (cModel.discriminator.getMapping() != null) {
cModel.getVendorExtensions().put("x-use-discr-mapping", true);
}
}

// Handle X_IMPLEMENTS extension (for extends/with separation)
try {
List<String> exts = (List<String>) cModel.getVendorExtensions().get(X_IMPLEMENTS);
if (exts != null) {
cModel.getVendorExtensions().put("x-extends", exts.subList(0, 1));
cModel.getVendorExtensions().put("x-extendsWith", exts.subList(1, exts.size()));
}
} catch (IndexOutOfBoundsException ignored) {
}
}
}

// Third pass: Clear X_IMPLEMENTS for models extending multiple SEALED traits
// (Regular traits can be extended from separate files, but sealed traits cannot)
for (ModelsMap mm : modelsMap.values()) {
for (ModelMap model : mm.getModels()) {
CodegenModel cModel = model.getModel();

// Check if this model extends multiple sealed traits
List<String> exts = (List<String>) cModel.getVendorExtensions().get(X_IMPLEMENTS);
if (exts != null && exts.size() > 1) {
// Count how many of the parents are sealed traits
int sealedParentCount = 0;
for (String parentName : exts) {
CodegenModel parentModel = allModels.get(parentName);
if (parentModel != null && parentModel.getVendorExtensions().containsKey("x-isSealedTrait")) {
sealedParentCount++;
}
}

// If extending multiple sealed traits, clear all extends (impossible in Scala)
if (sealedParentCount > 1) {
cModel.getVendorExtensions().remove(X_IMPLEMENTS);
LOGGER.warn("Model '{}' cannot extend multiple sealed traits. Generating as standalone class.",
cModel.classname);
}
}
}
}

// Fourth pass: Remove inlined members from output (no separate file generation)
modelsMap.entrySet().removeIf(entry -> {
ModelsMap mm = entry.getValue();
return mm.getModels().stream()
.anyMatch(model -> model.getModel().getVendorExtensions().containsKey("x-isOneOfMember"));
});

return modelsMap;
}

@Override
public List<CodegenSecurity> fromSecurity(Map<String, SecurityScheme> schemes) {
final List<CodegenSecurity> codegenSecurities = super.fromSecurity(schemes);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ package {{modelPackage}}

import io.circe.*
import io.circe.syntax.*
import io.circe.{Decoder, Encoder}
import io.circe.{Decoder, DecodingFailure, Encoder}
import cats.syntax.functor.*

{{#imports}}
import {{import}}
Expand All @@ -16,6 +17,173 @@ import {{import}}
* @param {{name}} {{{description}}}
{{/vars}}
*/
{{#vendorExtensions.x-isRegularTrait}}
trait {{classname}}
object {{classname}} {
import io.circe.{ Decoder, Encoder }
import io.circe.syntax.*
import cats.syntax.functor.*

{{^vendorExtensions.x-use-discr}}
// no discriminator
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#oneOf}}
case obj: {{.}} => obj.asJson
{{/oneOf}}
}

given decoder{{classname}}: Decoder[{{classname}}] =
List[Decoder[{{classname}}]](
{{#oneOf}}
Decoder[{{.}}].widen,
{{/oneOf}}
).reduceLeft(_ or _)
{{/vendorExtensions.x-use-discr}}
{{#vendorExtensions.x-use-discr}}
{{^vendorExtensions.x-use-discr-mapping}}
// with discriminator, no mapping
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#oneOf}}
case obj: {{.}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{.}}".asJson) +: _)
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 13, 2026

Choose a reason for hiding this comment

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

P2: Discriminator handling without mapping uses generated class names rather than schema names, which can break decoding/encoding when schema names differ from class names.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/scala-http4s/model.mustache, line 47:

<comment>Discriminator handling without mapping uses generated class names rather than schema names, which can break decoding/encoding when schema names differ from class names.</comment>

<file context>
@@ -16,6 +17,173 @@ import {{import}}
+// with discriminator, no mapping
+  given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
+{{#oneOf}}
+    case obj: {{.}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{.}}".asJson) +: _)
+{{/oneOf}}
+  }
</file context>
Fix with Cubic

{{/oneOf}}
}

given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
cursor.downField("{{discriminator.propertyName}}").as[String].flatMap {
{{#oneOf}}
case "{{.}}" => cursor.as[{{.}}]
{{/oneOf}}
case discriminatorValue =>
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
}
}
{{/vendorExtensions.x-use-discr-mapping}}
{{#vendorExtensions.x-use-discr-mapping}}
// with discriminator mapping
{{#discriminator}}
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#mappedModels}}
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
{{/mappedModels}}
}

given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
cursor.downField("{{propertyName}}").as[String].flatMap {
{{#mappedModels}}
case "{{mappingName}}" => cursor.as[{{model.classname}}]
{{/mappedModels}}
case discriminatorValue =>
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
}
}
{{/discriminator}}
{{/vendorExtensions.x-use-discr-mapping}}
{{/vendorExtensions.x-use-discr}}
}

{{/vendorExtensions.x-isRegularTrait}}
{{#vendorExtensions.x-isSealedTrait}}
sealed trait {{classname}}
object {{classname}} {
import io.circe.{ Decoder, Encoder }
import io.circe.syntax.*
import cats.syntax.functor.*

{{^vendorExtensions.x-use-discr}}
// no discriminator
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#vendorExtensions.x-oneOfMembers}}
case obj: {{classname}} => obj.asJson
{{/vendorExtensions.x-oneOfMembers}}
}

given decoder{{classname}}: Decoder[{{classname}}] =
List[Decoder[{{classname}}]](
{{#vendorExtensions.x-oneOfMembers}}
Decoder[{{classname}}].widen,
{{/vendorExtensions.x-oneOfMembers}}
).reduceLeft(_ or _)
{{/vendorExtensions.x-use-discr}}
{{#vendorExtensions.x-use-discr}}
{{^vendorExtensions.x-use-discr-mapping}}
// with discriminator, no mapping
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#vendorExtensions.x-oneOfMembers}}
case obj: {{classname}} => obj.asJson.mapObject(("{{vendorExtensions.x-parentDiscriminatorName}}" -> "{{classname}}".asJson) +: _)
{{/vendorExtensions.x-oneOfMembers}}
}

given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
cursor.downField("{{discriminator.propertyName}}").as[String].flatMap {
{{#vendorExtensions.x-oneOfMembers}}
case "{{classname}}" => cursor.as[{{classname}}]
{{/vendorExtensions.x-oneOfMembers}}
case discriminatorValue =>
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
}
}
{{/vendorExtensions.x-use-discr-mapping}}
{{#vendorExtensions.x-use-discr-mapping}}
// with discriminator mapping
{{#discriminator}}
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
{{#mappedModels}}
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
{{/mappedModels}}
}

given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { cursor =>
cursor.downField("{{propertyName}}").as[String].flatMap {
{{#mappedModels}}
case "{{mappingName}}" => cursor.as[{{model.classname}}]
{{/mappedModels}}
case discriminatorValue =>
Left(DecodingFailure(s"Unknown discriminator value: $discriminatorValue", cursor.history))
}
}
{{/discriminator}}
{{/vendorExtensions.x-use-discr-mapping}}
{{/vendorExtensions.x-use-discr}}
}

{{#vendorExtensions.x-oneOfMembers}}
/** {{{description}}}
{{#vars}}
* @param {{name}} {{{description}}}
{{/vars}}
*/
case class {{classname}}(
{{#vars}}
{{name}}: {{^required}}Option[{{{dataType}}}] = None{{/required}}{{#required}}{{{dataType}}}{{/required}}{{^-last}},{{/-last}}
{{/vars}}
) extends {{vendorExtensions.x-oneOfParent}}

object {{classname}} {
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
Json.fromFields{
Seq(
{{#vars}}
{{#required}}Some("{{baseName}}" -> t.{{name}}.asJson){{/required}}{{^required}}t.{{name}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
{{/vars}}
).flatten
}
}
given decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
for {
{{#vars}}
{{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}}]
{{/vars}}
} yield {{classname}}(
{{#vars}}
{{name}} = {{name}}{{^-last}},{{/-last}}
{{/vars}}
)
}
}

{{/vendorExtensions.x-oneOfMembers}}
{{/vendorExtensions.x-isSealedTrait}}
{{#vendorExtensions.x-isEnum}}
{{#isEnum}}
enum {{classname}}(val value: String) {
{{#allowableValues}}
Expand All @@ -36,12 +204,14 @@ object {{classname}} {

}
{{/isEnum}}
{{/vendorExtensions.x-isEnum}}
{{#vendorExtensions.x-another}}
{{^isEnum}}
case class {{classname}}(
{{#vars}}
{{name}}: {{^required}}Option[{{{dataType}}}] = None{{/required}}{{#required}}{{{dataType}}}{{/required}}{{^-last}},{{/-last}}
{{/vars}}
)
){{#vendorExtensions.x-extends}} extends {{.}}{{/vendorExtensions.x-extends}}{{#vendorExtensions.x-extendsWith}} with {{.}}{{/vendorExtensions.x-extendsWith}}

object {{classname}} {
given encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
Expand All @@ -66,6 +236,7 @@ object {{classname}} {
}
}
{{/isEnum}}
{{/vendorExtensions.x-another}}
{{/model}}
{{/models}}

Loading
Loading