Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a0443cc
feat: add support for `oneOf` in sttp4 client
plaflamme Feb 7, 2026
4437a9e
fix: pass `Option[String]` directly to .header
plaflamme Feb 7, 2026
9b9f05e
fix: use oneOf mapping to map discriminator values to types
plaflamme Feb 9, 2026
bd2a4bd
fix: remove unecessary whitespaces
plaflamme Feb 9, 2026
e594023
fix: generate samples, make a new one for sttp4-circe
plaflamme Feb 9, 2026
7aba995
fix: make partial function total
plaflamme Feb 9, 2026
921be4b
fix: only use semiauto derivation
plaflamme Feb 10, 2026
74a8c3e
fix: missing comma
plaflamme Feb 10, 2026
4da2654
fix: re-run generate-samples script
plaflamme Feb 11, 2026
1e8c387
docs: update documentation
plaflamme Feb 11, 2026
1e3be60
fix: spaces, not tabs
plaflamme Feb 11, 2026
ac70f60
Merge remote-tracking branch 'upstream/master' into sttp4-client-oneof
plaflamme Feb 11, 2026
545de95
ci: add new samples directory
plaflamme Feb 12, 2026
14227c6
fix: bump scala version
plaflamme Feb 12, 2026
8de5683
fix: url-form-encoded cases
plaflamme Feb 12, 2026
ce5ea99
Merge remote-tracking branch 'upstream/master' into sttp4-client-oneof
plaflamme Mar 6, 2026
cabdbe6
chore: regenerate sttp4 samples after upstream merge
plaflamme Mar 6, 2026
d7355ec
fix: sttp4 json4s JsonSupport uses sealed trait serializers instead o…
plaflamme Mar 6, 2026
2aeb207
fix: sttp4 oneOf with parent properties - resolve aliased children, p…
plaflamme Mar 16, 2026
ac1b84e
Merge remote-tracking branch 'upstream/master' into sttp4-client-oneof
plaflamme Mar 16, 2026
5a3afd8
fix: use wrapper composition for shared oneOf members in sttp4 client
plaflamme Apr 17, 2026
e45e2c0
Merge remote-tracking branch 'upstream/master' into sttp4-client-oneof
plaflamme Apr 17, 2026
79e738b
fix: new issues after merging master branch
plaflamme Apr 17, 2026
21efd98
fix: use propertyBaseName for discriminator in sttp4 serialization
plaflamme Apr 21, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ public ScalaSttp4ClientCodegen() {
)
);

// Enable oneOf interface generation
useOneOfInterfaces = true;
supportsMultipleInheritance = true;
supportsInheritance = true;
addOneOfInterfaceImports = true;

outputFolder = "generated-code/scala-sttp4";
modelTemplateFiles.put("model.mustache", ".scala");
apiTemplateFiles.put("api.mustache", ".scala");
embeddedTemplateDir = templateDir = "scala-sttp4";

String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);

String jsonValueClass = "circe".equals(jsonLibrary) ? "io.circe.Json" : "org.json4s.JValue";

additionalProperties.put(CodegenConstants.GROUP_ID, groupId);
additionalProperties.put(CodegenConstants.ARTIFACT_ID, artifactId);
additionalProperties.put(CodegenConstants.ARTIFACT_VERSION, artifactVersion);
Expand Down Expand Up @@ -124,13 +126,12 @@ public ScalaSttp4ClientCodegen() {
typeMapping.put("short", "Short");
typeMapping.put("char", "Char");
typeMapping.put("double", "Double");
typeMapping.put("object", "Any");
typeMapping.put("file", "File");
typeMapping.put("binary", "File");
typeMapping.put("number", "Double");
typeMapping.put("decimal", "BigDecimal");
typeMapping.put("ByteArray", "Array[Byte]");
typeMapping.put("AnyType", jsonValueClass);
// AnyType and object mapping will be set in processOpts() based on jsonLibrary

instantiationTypes.put("array", "ListBuffer");
instantiationTypes.put("map", "Map");
Expand All @@ -149,6 +150,20 @@ public void processOpts() {
apiPackage = PACKAGE_PROPERTY.getApiPackage(additionalProperties);
modelPackage = PACKAGE_PROPERTY.getModelPackage(additionalProperties);

// Set AnyType and object mapping based on jsonLibrary
String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);
if ("circe".equals(jsonLibrary)) {
typeMapping.put("AnyType", "io.circe.Json");
typeMapping.put("object", "io.circe.JsonObject");
importMapping.put("io.circe.Json", "io.circe.Json");
importMapping.put("io.circe.JsonObject", "io.circe.JsonObject");
} else {
typeMapping.put("AnyType", "org.json4s.JValue");
typeMapping.put("object", "org.json4s.JObject");
importMapping.put("org.json4s.JValue", "org.json4s.JValue");
importMapping.put("org.json4s.JObject", "org.json4s.JObject");
}

supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
supportingFiles.add(new SupportingFile("build.sbt.mustache", "", "build.sbt"));
final String invokerFolder = (sourceFolder + File.separator + invokerPackage).replace(".", File.separator);
Expand Down Expand Up @@ -211,6 +226,16 @@ public ModelsMap postProcessModels(ModelsMap objs) {
return objs;
}

private void setParameterDefaults(CodegenParameter param) {
// Set default values for optional parameters
// Template will handle Option[] wrapping, so all defaults should be None
if (!param.required) {
param.defaultValue = "None";
}
}



/**
* Invoked by {@link DefaultGenerator} after all models have been post-processed,
* allowing for a last pass of codegen-specific model cleanup.
Expand All @@ -221,6 +246,104 @@ public ModelsMap postProcessModels(ModelsMap objs) {
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
final Map<String, ModelsMap> processed = super.postProcessAllModels(objs);

// First pass: count how many oneOf parents each model has
Map<String, Integer> oneOfMemberCount = new HashMap<>();
for (ModelsMap mm : processed.values()) {
for (ModelMap model : mm.getModels()) {
CodegenModel cModel = model.getModel();
if (!cModel.oneOf.isEmpty()) {
for (String childName : cModel.oneOf) {
oneOfMemberCount.put(childName, oneOfMemberCount.getOrDefault(childName, 0) + 1);
}
}
}
}

// Second pass: process models
for (ModelsMap mm : processed.values()) {
for (ModelMap model : mm.getModels()) {
CodegenModel cModel = model.getModel();

if (!cModel.oneOf.isEmpty()) {
cModel.getVendorExtensions().put("x-isSealedTrait", true);

// Collect child models for inline generation
// Only inline if they are used exclusively by this oneOf parent
List<CodegenModel> childModels = new ArrayList<>();

for (String childName : cModel.oneOf) {
CodegenModel childModel = ModelUtils.getModelByName(childName, processed);
if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
// This child is only used by this parent - can be inlined
childModel.getVendorExtensions().put("x-isOneOfMember", true);
childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname);

// Add discriminator mapping value if present
if (cModel.discriminator != null) {
String discriminatorName = cModel.discriminator.getPropertyName();

// Find the mapping value for this child model
String discriminatorValue = null;
if (cModel.discriminator.getMappedModels() != null) {
for (CodegenDiscriminator.MappedModel mappedModel : cModel.discriminator.getMappedModels()) {
if (mappedModel.getModelName().equals(childName)) {
discriminatorValue = mappedModel.getMappingName();
break;
}
}
}

if (discriminatorValue != null) {
childModel.getVendorExtensions().put("x-discriminator-value", discriminatorValue);
}

// Remove discriminator field from child
// (circe-generic-extras adds it automatically)
childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
childModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
}

childModels.add(childModel);
}
}
cModel.getVendorExtensions().put("x-oneOfMembers", childModels);
} else if (cModel.isEnum) {
cModel.getVendorExtensions().put("x-isEnum", true);
} else {
cModel.getVendorExtensions().put("x-isRegularModel", true);
}

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

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

// Remove discriminator property from models that extend a oneOf parent
// (circe-generic-extras adds it automatically)
if (cModel.parent != null && cModel.parentModel != null && cModel.parentModel.discriminator != null) {
String discriminatorName = cModel.parentModel.discriminator.getPropertyName();
cModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
cModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
cModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
cModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
}
}
}

// Third pass: remove oneOf members from the map to skip file generation
// (they are already inlined in their parent sealed trait)
processed.entrySet().removeIf(entry -> {
ModelsMap mm = entry.getValue();
return mm.getModels().stream()
.anyMatch(model -> model.getModel().getVendorExtensions().containsKey("x-isOneOfMember"));
});

postProcessUpdateImports(processed);
return processed;
}
Expand Down Expand Up @@ -349,6 +472,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<Mo
}
objs.setImports(newImports);

// Fix parameter types and defaults
OperationMap opsMap = objs.getOperations();
for (CodegenOperation operation : opsMap.getOperation()) {
for (CodegenParameter param : operation.allParams) {
setParameterDefaults(param);
}
}

return super.postProcessOperationsWithModels(objs, allModels);
}

Expand Down Expand Up @@ -402,11 +533,11 @@ public String toDefaultValue(Schema p) {
String inner = getSchemaType(ModelUtils.getAdditionalProperties(p));
return "Map[String, " + inner + "].empty ";
} else if (ModelUtils.isArraySchema(p)) {
String inner = getSchemaType(ModelUtils.getSchemaItems(p));
// Use simple Seq.empty for cleaner code
if (ModelUtils.isSet(p)) {
return "Set[" + inner + "].empty ";
return "Set.empty";
}
return "Seq[" + inner + "].empty ";
return "Seq.empty";
} else if (ModelUtils.isStringSchema(p)) {
return null;
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class {{classname}}(baseUrl: String) {
{{>javadoc}}

{{/javadocRenderer}}
def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String, Exception], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
basicRequest
.method(Method.{{httpMethod.toUpperCase}}, uri"$baseUrl{{{path}}}{{#queryParams.0}}?{{/queryParams.0}}{{#queryParams}}{{baseName}}=${ {{paramName}} }{{^-last}}&{{/-last}}{{/queryParams}}{{#authMethods}}{{#isApiKey}}{{#isKeyInQuery}}{{#queryParams.0}}&{{/queryParams.0}}{{^queryParams.0}}?{{/queryParams.0}}{{keyParamName}}=${apiKeyQuery}{{/isKeyInQuery}}{{/isApiKey}}{{/authMethods}}")
.contentType({{#consumes.0}}"{{{mediaType}}}"{{/consumes.0}}{{^consumes}}"application/json"{{/consumes}}){{#headerParams}}
Expand All @@ -35,7 +35,7 @@ class {{classname}}(baseUrl: String) {
.multipartBody(Seq({{#formParams}}
{{>paramMultipartCreation}}{{^-last}}, {{/-last}}{{/formParams}}
).flatten){{/isMultipart}}{{/formParams.0}}{{#bodyParam}}
.body({{paramName}}){{/bodyParam}}
.body(asJson({{paramName}})){{/bodyParam}}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.response({{#separateErrorChannel}}{{^returnType}}asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(()))){{/returnType}}{{#returnType}}asJson[{{>operationReturnType}}]{{/returnType}}{{/separateErrorChannel}}{{^separateErrorChannel}}{{^returnType}}asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(()))).getRight{{/returnType}}{{#returnType}}asJson[{{>operationReturnType}}].getRight{{/returnType}}{{/separateErrorChannel}})

{{/operation}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,9 @@ object JsonSupport extends SttpJson4sApi {
{{#circe}}
import io.circe.{Decoder, Encoder}
import io.circe.generic.AutoDerivation
import sttp.client3.circe.SttpCirceApi
import sttp.client4.circe.SttpCirceApi

object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers {

{{#models}}
{{#model}}
{{#isEnum}}
implicit val {{classname}}Decoder: Decoder[{{classname}}.{{classname}}] = Decoder.decodeEnumeration({{classname}})
implicit val {{classname}}Encoder: Encoder[{{classname}}.{{classname}}] = Encoder.encodeEnumeration({{classname}})
{{/isEnum}}
{{/model}}
{{/models}}
// Enum encoders/decoders are defined in their respective companion objects
}
{{/circe}}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{{#authMethods.0}}{{#authMethods}}{{#isApiKey}}{{#isKeyInHeader}}apiKeyHeader: String{{/isKeyInHeader}}{{#isKeyInQuery}}apiKeyQuery: String{{/isKeyInQuery}}{{#isKeyInCookie}}apiKeyCookie: String{{/isKeyInCookie}}{{/isApiKey}}{{#isBasic}}{{#isBasicBasic}}username: String, password: String{{/isBasicBasic}}{{#isBasicBearer}}bearerToken: String{{/isBasicBearer}}{{/isBasic}}{{^-last}}, {{/-last}}{{/authMethods}})({{/authMethods.0}}{{#allParams}}{{paramName}}: {{#required}}{{dataType}}{{/required}}{{^required}}{{#isContainer}}{{dataType}}{{/isContainer}}{{^isContainer}}Option[{{dataType}}]{{/isContainer}}{{/required}}{{^defaultValue}}{{^required}}{{^isContainer}} = None{{/isContainer}}{{/required}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allParams}}
{{#authMethods.0}}{{#authMethods}}{{#isApiKey}}{{#isKeyInHeader}}apiKeyHeader: String{{/isKeyInHeader}}{{#isKeyInQuery}}apiKeyQuery: String{{/isKeyInQuery}}{{#isKeyInCookie}}apiKeyCookie: String{{/isKeyInCookie}}{{/isApiKey}}{{#isBasic}}{{#isBasicBasic}}username: String, password: String{{/isBasicBasic}}{{#isBasicBearer}}bearerToken: String{{/isBasicBearer}}{{/isBasic}}{{^-last}}, {{/-last}}{{/authMethods}})({{/authMethods.0}}{{#allParams}}{{paramName}}: {{#required}}{{dataType}}{{/required}}{{^required}}Option[{{dataType}}]{{/required}}{{#defaultValue}} = {{{defaultValue}}}{{/defaultValue}}{{^-last}}, {{/-last}}{{/allParams}}
Loading