Skip to content

Commit 7c9ada8

Browse files
committed
[feat] Adding oneOf and allOf support to scala-sttp-circe generator
1 parent 0ff9625 commit 7c9ada8

20 files changed

Lines changed: 1667 additions & 77 deletions

File tree

bin/configs/scala-sttp-circe.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
generatorName: scala-sttp
22
outputDir: samples/client/petstore/scala-sttp-circe
3-
inputSpec: modules/openapi-generator/src/test/resources/3_0/scala/petstore.yaml
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml
44
templateDir: modules/openapi-generator/src/main/resources/scala-sttp
55
nameMappings:
66
_type: "`underscoreType`"

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

Lines changed: 184 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ public ScalaSttpClientCodegen() {
9494
.excludeSchemaSupportFeatures(
9595
SchemaSupportFeature.Polymorphism
9696
)
97+
.includeSchemaSupportFeatures(
98+
SchemaSupportFeature.oneOf,
99+
SchemaSupportFeature.allOf
100+
)
97101
.excludeParameterFeatures(
98102
ParameterFeature.Cookie
99103
)
@@ -240,9 +244,186 @@ public ModelsMap postProcessModels(ModelsMap objs) {
240244
*/
241245
@Override
242246
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
243-
final Map<String, ModelsMap> processed = super.postProcessAllModels(objs);
244-
postProcessUpdateImports(processed);
245-
return processed;
247+
Map<String, ModelsMap> modelsMap = super.postProcessAllModels(objs);
248+
249+
Map<String, CodegenModel> allModels = collectAllModels(modelsMap);
250+
synthesizeOneOfFromDiscriminator(allModels);
251+
Map<String, Integer> refCounts = countOneOfReferences(allModels);
252+
markOneOfTraits(modelsMap, allModels, refCounts);
253+
removeInlinedModels(modelsMap);
254+
255+
postProcessUpdateImports(modelsMap);
256+
return modelsMap;
257+
}
258+
259+
/**
260+
* Collect all CodegenModels by classname for lookup.
261+
*/
262+
private Map<String, CodegenModel> collectAllModels(Map<String, ModelsMap> modelsMap) {
263+
return modelsMap.values().stream()
264+
.flatMap(mm -> mm.getModels().stream())
265+
.map(ModelMap::getModel)
266+
.collect(java.util.stream.Collectors.toMap(m -> m.classname, m -> m, (a, b) -> a));
267+
}
268+
269+
/**
270+
* For specs that use allOf+discriminator (children reference parent via allOf, parent has
271+
* discriminator.mapping but no oneOf), synthesize the oneOf set from the discriminator mapping.
272+
* This allows the standard oneOf processing logic to handle both patterns uniformly.
273+
*/
274+
private void synthesizeOneOfFromDiscriminator(Map<String, CodegenModel> allModels) {
275+
for (CodegenModel model : allModels.values()) {
276+
if (!model.oneOf.isEmpty() || model.discriminator == null) {
277+
continue;
278+
}
279+
280+
if (model.discriminator.getMappedModels() != null
281+
&& !model.discriminator.getMappedModels().isEmpty()) {
282+
for (CodegenDiscriminator.MappedModel mapped : model.discriminator.getMappedModels()) {
283+
model.oneOf.add(mapped.getModelName());
284+
}
285+
} else if (model.discriminator.getMapping() != null) {
286+
for (String ref : model.discriminator.getMapping().values()) {
287+
String modelName = ref.contains("/") ? ref.substring(ref.lastIndexOf('/') + 1) : ref;
288+
if (allModels.containsKey(modelName)) {
289+
model.oneOf.add(modelName);
290+
}
291+
}
292+
}
293+
294+
if (!model.oneOf.isEmpty()) {
295+
model.getVendorExtensions().put("x-synthesized-oneOf", true);
296+
}
297+
}
298+
}
299+
300+
/**
301+
* Count how many oneOf parents reference each child, used to determine
302+
* whether a child can be inlined (only if referenced by exactly one parent).
303+
*/
304+
private Map<String, Integer> countOneOfReferences(Map<String, CodegenModel> allModels) {
305+
return allModels.values().stream()
306+
.flatMap(m -> m.oneOf.stream())
307+
.collect(java.util.stream.Collectors.toMap(name -> name, name -> 1, Integer::sum));
308+
}
309+
310+
/**
311+
* Mark oneOf parents as sealed/regular traits with discriminator vendor extensions,
312+
* and configure child models for inlining.
313+
*/
314+
private void markOneOfTraits(Map<String, ModelsMap> modelsMap,
315+
Map<String, CodegenModel> allModels,
316+
Map<String, Integer> refCounts) {
317+
for (ModelsMap mm : modelsMap.values()) {
318+
for (ModelMap modelMap : mm.getModels()) {
319+
CodegenModel model = modelMap.getModel();
320+
321+
if (!model.oneOf.isEmpty()) {
322+
configureOneOfModel(model, allModels, refCounts);
323+
}
324+
325+
if (model.discriminator != null) {
326+
model.getVendorExtensions().put("x-use-discr", true);
327+
if (model.discriminator.getMapping() != null) {
328+
model.getVendorExtensions().put("x-use-discr-mapping", true);
329+
}
330+
}
331+
}
332+
}
333+
}
334+
335+
private void configureOneOfModel(CodegenModel parent,
336+
Map<String, CodegenModel> allModels,
337+
Map<String, Integer> refCounts) {
338+
List<CodegenModel> inlineableMembers = new ArrayList<>();
339+
Set<String> childImports = new HashSet<>();
340+
341+
for (String childName : parent.oneOf) {
342+
CodegenModel child = allModels.get(childName);
343+
if (child != null && isInlineable(child, refCounts)) {
344+
markChildForInlining(child, parent);
345+
inlineableMembers.add(child);
346+
if (child.imports != null) {
347+
childImports.addAll(child.imports);
348+
}
349+
}
350+
}
351+
352+
buildDiscriminatorEntries(parent, allModels);
353+
354+
if (!inlineableMembers.isEmpty() && inlineableMembers.size() == parent.oneOf.size()) {
355+
markAsSealedTrait(parent, inlineableMembers, childImports);
356+
} else {
357+
markAsRegularTrait(parent, inlineableMembers);
358+
}
359+
}
360+
361+
private boolean isInlineable(CodegenModel child, Map<String, Integer> refCounts) {
362+
return (child.oneOf == null || child.oneOf.isEmpty())
363+
&& refCounts.getOrDefault(child.classname, 0) == 1;
364+
}
365+
366+
private void markChildForInlining(CodegenModel child, CodegenModel parent) {
367+
child.getVendorExtensions().put("x-isOneOfMember", true);
368+
child.getVendorExtensions().put("x-oneOfParent", parent.classname);
369+
if (parent.discriminator != null) {
370+
child.getVendorExtensions().put("x-parentDiscriminatorName",
371+
parent.discriminator.getPropertyName());
372+
}
373+
}
374+
375+
private void buildDiscriminatorEntries(CodegenModel parent, Map<String, CodegenModel> allModels) {
376+
List<Map<String, String>> entries = parent.oneOf.stream()
377+
.map(allModels::get)
378+
.filter(Objects::nonNull)
379+
.map(child -> Map.of("classname", child.classname, "schemaName", child.name))
380+
.collect(java.util.stream.Collectors.toList());
381+
parent.getVendorExtensions().put("x-discriminator-entries", entries);
382+
}
383+
384+
private void markAsSealedTrait(CodegenModel parent, List<CodegenModel> members,
385+
Set<String> childImports) {
386+
parent.getVendorExtensions().put("x-isSealedTrait", true);
387+
parent.getVendorExtensions().put("x-oneOfMembers", members);
388+
389+
if (parent.getVendorExtensions().containsKey("x-synthesized-oneOf")
390+
&& parent.vars != null && !parent.vars.isEmpty()) {
391+
parent.getVendorExtensions().put("x-hasOwnVars", true);
392+
}
393+
394+
mergeChildImports(parent, childImports);
395+
}
396+
397+
private void markAsRegularTrait(CodegenModel parent, List<CodegenModel> partialMembers) {
398+
parent.getVendorExtensions().put("x-isRegularTrait", true);
399+
for (CodegenModel member : partialMembers) {
400+
member.getVendorExtensions().remove("x-isOneOfMember");
401+
member.getVendorExtensions().remove("x-oneOfParent");
402+
member.getVendorExtensions().remove("x-parentDiscriminatorName");
403+
}
404+
}
405+
406+
private void mergeChildImports(CodegenModel parent, Set<String> childImports) {
407+
if (childImports.isEmpty()) return;
408+
Set<String> existing = parent.imports != null ? new HashSet<>(parent.imports) : new HashSet<>();
409+
childImports.removeAll(existing);
410+
if (!childImports.isEmpty()) {
411+
if (parent.imports == null) {
412+
parent.imports = new HashSet<>();
413+
}
414+
parent.imports.addAll(childImports);
415+
}
416+
}
417+
418+
/**
419+
* Remove models that were inlined into their parent sealed trait -
420+
* they don't need separate files.
421+
*/
422+
private void removeInlinedModels(Map<String, ModelsMap> modelsMap) {
423+
modelsMap.entrySet().removeIf(entry ->
424+
entry.getValue().getModels().stream()
425+
.anyMatch(m -> m.getModel().getVendorExtensions().containsKey("x-isOneOfMember"))
426+
);
246427
}
247428

248429
/**

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

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ package {{package}}
55
import {{import}}
66
{{/imports}}
77
{{#circe}}
8-
import io.circe.{Decoder, Encoder, Json}
8+
import io.circe.{Decoder, DecodingFailure, Encoder, Json}
99
import io.circe.syntax._
1010
import {{invokerPackage}}.JsonSupport._
1111
{{/circe}}
@@ -20,6 +20,148 @@ import {{invokerPackage}}.JsonSupport._
2020
{{{description}}}
2121
{{/javadocRenderer}}
2222
{{/description}}
23+
{{#vendorExtensions.x-isSealedTrait}}
24+
sealed trait {{classname}}{{#vendorExtensions.x-hasOwnVars}} {
25+
{{#vars}}
26+
def {{{name}}}: {{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}]{{/required}}
27+
{{/vars}}
28+
}{{/vendorExtensions.x-hasOwnVars}}
29+
object {{classname}} {
30+
{{#circe}}
31+
{{#vendorExtensions.x-use-discr-mapping}}
32+
{{#discriminator}}
33+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
34+
{{#mappedModels}}
35+
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
36+
{{/mappedModels}}
37+
}
38+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
39+
c.downField("{{propertyName}}").as[String].flatMap {
40+
{{#mappedModels}}
41+
case "{{mappingName}}" => c.as[{{model.classname}}]
42+
{{/mappedModels}}
43+
case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history))
44+
}
45+
}
46+
{{/discriminator}}
47+
{{/vendorExtensions.x-use-discr-mapping}}
48+
{{^vendorExtensions.x-use-discr-mapping}}
49+
{{#vendorExtensions.x-use-discr}}
50+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
51+
{{#vendorExtensions.x-discriminator-entries}}
52+
case obj: {{classname}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{schemaName}}".asJson) +: _)
53+
{{/vendorExtensions.x-discriminator-entries}}
54+
}
55+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
56+
c.downField("{{discriminator.propertyName}}").as[String].flatMap {
57+
{{#vendorExtensions.x-discriminator-entries}}
58+
case "{{schemaName}}" => c.as[{{classname}}]
59+
{{/vendorExtensions.x-discriminator-entries}}
60+
case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history))
61+
}
62+
}
63+
{{/vendorExtensions.x-use-discr}}
64+
{{^vendorExtensions.x-use-discr}}
65+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
66+
{{#oneOf}}
67+
case obj: {{.}} => obj.asJson
68+
{{/oneOf}}
69+
}
70+
implicit val decoder{{classname}}: Decoder[{{classname}}] =
71+
List[Decoder[{{classname}}]]({{#oneOf}}Decoder[{{.}}].map(x => x: {{classname}}){{^-last}}, {{/-last}}{{/oneOf}}).reduceLeft(_ or _)
72+
{{/vendorExtensions.x-use-discr}}
73+
{{/vendorExtensions.x-use-discr-mapping}}
74+
{{/circe}}
75+
}
76+
77+
{{#vendorExtensions.x-oneOfMembers}}
78+
case class {{classname}}(
79+
{{#vars}}
80+
{{#description}}
81+
/* {{{.}}} */
82+
{{/description}}
83+
{{{name}}}: {{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}] = None{{/required}}{{^-last}},{{/-last}}
84+
{{/vars}}
85+
) extends {{vendorExtensions.x-oneOfParent}}
86+
{{#circe}}
87+
object {{classname}} {
88+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
89+
Json.fromFields{
90+
Seq(
91+
{{#vars}}
92+
{{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
93+
{{/vars}}
94+
).flatten
95+
}
96+
}
97+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
98+
for {
99+
{{#vars}}
100+
{{{name}}} <- c.downField("{{baseName}}").as[{{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}]{{/required}}]
101+
{{/vars}}
102+
} yield {{classname}}(
103+
{{#vars}}
104+
{{{name}}} = {{{name}}}{{^-last}},{{/-last}}
105+
{{/vars}}
106+
)
107+
}
108+
}
109+
{{/circe}}
110+
111+
{{/vendorExtensions.x-oneOfMembers}}
112+
{{/vendorExtensions.x-isSealedTrait}}
113+
{{#vendorExtensions.x-isRegularTrait}}
114+
trait {{classname}}
115+
object {{classname}} {
116+
{{#circe}}
117+
{{#vendorExtensions.x-use-discr-mapping}}
118+
{{#discriminator}}
119+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
120+
{{#mappedModels}}
121+
case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _)
122+
{{/mappedModels}}
123+
}
124+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
125+
c.downField("{{propertyName}}").as[String].flatMap {
126+
{{#mappedModels}}
127+
case "{{mappingName}}" => c.as[{{model.classname}}]
128+
{{/mappedModels}}
129+
case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history))
130+
}
131+
}
132+
{{/discriminator}}
133+
{{/vendorExtensions.x-use-discr-mapping}}
134+
{{^vendorExtensions.x-use-discr-mapping}}
135+
{{#vendorExtensions.x-use-discr}}
136+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
137+
{{#vendorExtensions.x-discriminator-entries}}
138+
case obj: {{classname}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{schemaName}}".asJson) +: _)
139+
{{/vendorExtensions.x-discriminator-entries}}
140+
}
141+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
142+
c.downField("{{discriminator.propertyName}}").as[String].flatMap {
143+
{{#vendorExtensions.x-discriminator-entries}}
144+
case "{{schemaName}}" => c.as[{{classname}}]
145+
{{/vendorExtensions.x-discriminator-entries}}
146+
case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history))
147+
}
148+
}
149+
{{/vendorExtensions.x-use-discr}}
150+
{{^vendorExtensions.x-use-discr}}
151+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance {
152+
{{#oneOf}}
153+
case obj: {{.}} => obj.asJson
154+
{{/oneOf}}
155+
}
156+
implicit val decoder{{classname}}: Decoder[{{classname}}] =
157+
List[Decoder[{{classname}}]]({{#oneOf}}Decoder[{{.}}].map(x => x: {{classname}}){{^-last}}, {{/-last}}{{/oneOf}}).reduceLeft(_ or _)
158+
{{/vendorExtensions.x-use-discr}}
159+
{{/vendorExtensions.x-use-discr-mapping}}
160+
{{/circe}}
161+
}
162+
{{/vendorExtensions.x-isRegularTrait}}
163+
{{^vendorExtensions.x-isSealedTrait}}
164+
{{^vendorExtensions.x-isRegularTrait}}
23165
{{^isEnum}}
24166
case class {{classname}}(
25167
{{#vars}}
@@ -28,7 +170,7 @@ case class {{classname}}(
28170
{{/description}}
29171
{{{name}}}: {{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}] = None{{/required}}{{^-last}},{{/-last}}
30172
{{/vars}}
31-
)
173+
){{#vendorExtensions.x-oneOfParent}} extends {{vendorExtensions.x-oneOfParent}}{{/vendorExtensions.x-oneOfParent}}
32174
{{#circe}}
33175
object {{classname}} {
34176
{{#hasVars}}
@@ -64,6 +206,8 @@ object {{classname}} {
64206
}
65207
{{/circe}}
66208
{{/isEnum}}
209+
{{/vendorExtensions.x-isRegularTrait}}
210+
{{/vendorExtensions.x-isSealedTrait}}
67211

68212
{{#isEnum}}
69213
object {{classname}} extends Enumeration {

0 commit comments

Comments
 (0)