Skip to content

Commit a0443cc

Browse files
committed
feat: add support for oneOf in sttp4 client
1 parent 459f359 commit a0443cc

File tree

8 files changed

+622
-40
lines changed

8 files changed

+622
-40
lines changed

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

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,17 @@ public ScalaSttp4ClientCodegen() {
8686
)
8787
);
8888

89+
// Enable oneOf interface generation
90+
useOneOfInterfaces = true;
91+
supportsMultipleInheritance = true;
92+
supportsInheritance = true;
93+
addOneOfInterfaceImports = true;
94+
8995
outputFolder = "generated-code/scala-sttp4";
9096
modelTemplateFiles.put("model.mustache", ".scala");
9197
apiTemplateFiles.put("api.mustache", ".scala");
9298
embeddedTemplateDir = templateDir = "scala-sttp4";
9399

94-
String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);
95-
96-
String jsonValueClass = "circe".equals(jsonLibrary) ? "io.circe.Json" : "org.json4s.JValue";
97-
98100
additionalProperties.put(CodegenConstants.GROUP_ID, groupId);
99101
additionalProperties.put(CodegenConstants.ARTIFACT_ID, artifactId);
100102
additionalProperties.put(CodegenConstants.ARTIFACT_VERSION, artifactVersion);
@@ -124,13 +126,12 @@ public ScalaSttp4ClientCodegen() {
124126
typeMapping.put("short", "Short");
125127
typeMapping.put("char", "Char");
126128
typeMapping.put("double", "Double");
127-
typeMapping.put("object", "Any");
128129
typeMapping.put("file", "File");
129130
typeMapping.put("binary", "File");
130131
typeMapping.put("number", "Double");
131132
typeMapping.put("decimal", "BigDecimal");
132133
typeMapping.put("ByteArray", "Array[Byte]");
133-
typeMapping.put("AnyType", jsonValueClass);
134+
// AnyType and object mapping will be set in processOpts() based on jsonLibrary
134135

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

153+
// Set AnyType and object mapping based on jsonLibrary
154+
String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);
155+
if ("circe".equals(jsonLibrary)) {
156+
typeMapping.put("AnyType", "io.circe.Json");
157+
typeMapping.put("object", "io.circe.JsonObject");
158+
importMapping.put("io.circe.Json", "io.circe.Json");
159+
importMapping.put("io.circe.JsonObject", "io.circe.JsonObject");
160+
} else {
161+
typeMapping.put("AnyType", "org.json4s.JValue");
162+
typeMapping.put("object", "org.json4s.JObject");
163+
importMapping.put("org.json4s.JValue", "org.json4s.JValue");
164+
importMapping.put("org.json4s.JObject", "org.json4s.JObject");
165+
}
166+
152167
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
153168
supportingFiles.add(new SupportingFile("build.sbt.mustache", "", "build.sbt"));
154169
final String invokerFolder = (sourceFolder + File.separator + invokerPackage).replace(".", File.separator);
@@ -221,6 +236,87 @@ public ModelsMap postProcessModels(ModelsMap objs) {
221236
@Override
222237
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
223238
final Map<String, ModelsMap> processed = super.postProcessAllModels(objs);
239+
240+
// First pass: count how many oneOf parents each model has
241+
Map<String, Integer> oneOfMemberCount = new HashMap<>();
242+
for (ModelsMap mm : processed.values()) {
243+
for (ModelMap model : mm.getModels()) {
244+
CodegenModel cModel = model.getModel();
245+
if (!cModel.oneOf.isEmpty()) {
246+
for (String childName : cModel.oneOf) {
247+
oneOfMemberCount.put(childName, oneOfMemberCount.getOrDefault(childName, 0) + 1);
248+
}
249+
}
250+
}
251+
}
252+
253+
// Second pass: process models
254+
for (ModelsMap mm : processed.values()) {
255+
for (ModelMap model : mm.getModels()) {
256+
CodegenModel cModel = model.getModel();
257+
258+
if (!cModel.oneOf.isEmpty()) {
259+
cModel.getVendorExtensions().put("x-isSealedTrait", true);
260+
261+
// Collect child models for inline generation
262+
// Only inline if they are used exclusively by this oneOf parent
263+
List<CodegenModel> childModels = new ArrayList<>();
264+
265+
for (String childName : cModel.oneOf) {
266+
CodegenModel childModel = ModelUtils.getModelByName(childName, processed);
267+
if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) {
268+
// This child is only used by this parent - can be inlined
269+
childModel.getVendorExtensions().put("x-isOneOfMember", true);
270+
childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname);
271+
272+
// Remove discriminator field from child if parent has discriminator
273+
// (circe-generic-extras adds it automatically)
274+
if (cModel.discriminator != null) {
275+
String discriminatorName = cModel.discriminator.getPropertyName();
276+
childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
277+
childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
278+
childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
279+
childModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
280+
}
281+
282+
childModels.add(childModel);
283+
}
284+
}
285+
cModel.getVendorExtensions().put("x-oneOfMembers", childModels);
286+
} else if (cModel.isEnum) {
287+
cModel.getVendorExtensions().put("x-isEnum", true);
288+
} else {
289+
cModel.getVendorExtensions().put("x-isRegularModel", true);
290+
}
291+
292+
if (cModel.discriminator != null) {
293+
cModel.getVendorExtensions().put("x-use-discr", true);
294+
295+
if (cModel.discriminator.getMapping() != null) {
296+
cModel.getVendorExtensions().put("x-use-discr-mapping", true);
297+
}
298+
}
299+
300+
// Remove discriminator property from models that extend a oneOf parent
301+
// (circe-generic-extras adds it automatically)
302+
if (cModel.parent != null && cModel.parentModel != null && cModel.parentModel.discriminator != null) {
303+
String discriminatorName = cModel.parentModel.discriminator.getPropertyName();
304+
cModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName));
305+
cModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
306+
cModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
307+
cModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName));
308+
}
309+
}
310+
}
311+
312+
// Third pass: remove oneOf members from the map to skip file generation
313+
// (they are already inlined in their parent sealed trait)
314+
processed.entrySet().removeIf(entry -> {
315+
ModelsMap mm = entry.getValue();
316+
return mm.getModels().stream()
317+
.anyMatch(model -> model.getModel().getVendorExtensions().containsKey("x-isOneOfMember"));
318+
});
319+
224320
postProcessUpdateImports(processed);
225321
return processed;
226322
}

modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class {{classname}}(baseUrl: String) {
2020
{{>javadoc}}
2121

2222
{{/javadocRenderer}}
23-
def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String, Exception], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
23+
def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] =
2424
basicRequest
2525
.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}}")
2626
.contentType({{#consumes.0}}"{{{mediaType}}}"{{/consumes.0}}{{^consumes}}"application/json"{{/consumes}}){{#headerParams}}
@@ -35,7 +35,7 @@ class {{classname}}(baseUrl: String) {
3535
.multipartBody(Seq({{#formParams}}
3636
{{>paramMultipartCreation}}{{^-last}}, {{/-last}}{{/formParams}}
3737
).flatten){{/isMultipart}}{{/formParams.0}}{{#bodyParam}}
38-
.body({{paramName}}){{/bodyParam}}
38+
.body(asJson({{paramName}})){{/bodyParam}}
3939
.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}})
4040

4141
{{/operation}}

modules/openapi-generator/src/main/resources/scala-sttp4/jsonSupport.mustache

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,9 @@ object JsonSupport extends SttpJson4sApi {
4343
{{#circe}}
4444
import io.circe.{Decoder, Encoder}
4545
import io.circe.generic.AutoDerivation
46-
import sttp.client3.circe.SttpCirceApi
46+
import sttp.client4.circe.SttpCirceApi
4747

4848
object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers {
49-
50-
{{#models}}
51-
{{#model}}
52-
{{#isEnum}}
53-
implicit val {{classname}}Decoder: Decoder[{{classname}}.{{classname}}] = Decoder.decodeEnumeration({{classname}})
54-
implicit val {{classname}}Encoder: Encoder[{{classname}}.{{classname}}] = Encoder.encodeEnumeration({{classname}})
55-
{{/isEnum}}
56-
{{/model}}
57-
{{/models}}
49+
// Enum encoders/decoders are defined in their respective companion objects
5850
}
5951
{{/circe}}

0 commit comments

Comments
 (0)