Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
// 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) +: _)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
{{/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