Skip to content

Commit 226f2af

Browse files
authored
[feat][scala-sttp] Add oneOf and allOf discriminator support with sealed traits for circe generator (#23510)
* [feat] Adding oneOf and allOf support to scala-sttp-circe generator * Update docs * Fix bug * Fix bug * Regen samples * Update test
1 parent b05b5a2 commit 226f2af

24 files changed

Lines changed: 1812 additions & 79 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`"

docs/generators/scala-sttp.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,9 @@ These options may be applied as additional-properties (cli) or configOptions (pl
226226
|Composite|✓|OAS2,OAS3
227227
|Polymorphism|✗|OAS2,OAS3
228228
|Union|✗|OAS3
229-
|allOf||OAS2,OAS3
229+
|allOf||OAS2,OAS3
230230
|anyOf|✗|OAS3
231-
|oneOf||OAS3
231+
|oneOf||OAS3
232232
|not|✗|OAS3
233233

234234
### Security Feature

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

Lines changed: 205 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,207 @@ 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 = countModelReferences(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 times each model is referenced - both as a oneOf member and as a
302+
* property type. A child can only be inlined if it's referenced exactly once (by its
303+
* oneOf parent) and not used as a property type elsewhere.
304+
*/
305+
private Map<String, Integer> countModelReferences(Map<String, CodegenModel> allModels) {
306+
Map<String, Integer> counts = new HashMap<>();
307+
308+
// Count oneOf parent references
309+
allModels.values().stream()
310+
.flatMap(m -> m.oneOf.stream())
311+
.forEach(name -> counts.merge(name, 1, Integer::sum));
312+
313+
// Count property-type references (prevents inlining models used as field types).
314+
// Check both dataType and complexType
315+
allModels.values().stream()
316+
.flatMap(m -> m.vars.stream())
317+
.forEach(prop -> {
318+
if (prop.dataType != null && allModels.containsKey(prop.dataType)) {
319+
counts.merge(prop.dataType, 1, Integer::sum);
320+
}
321+
if (prop.complexType != null && allModels.containsKey(prop.complexType)) {
322+
counts.merge(prop.complexType, 1, Integer::sum);
323+
}
324+
});
325+
326+
return counts;
327+
}
328+
329+
/**
330+
* Mark oneOf parents as sealed/regular traits with discriminator vendor extensions,
331+
* and configure child models for inlining.
332+
*/
333+
private void markOneOfTraits(
334+
Map<String, ModelsMap> modelsMap,
335+
Map<String, CodegenModel> allModels,
336+
Map<String, Integer> refCounts) {
337+
for (ModelsMap mm : modelsMap.values()) {
338+
for (ModelMap modelMap : mm.getModels()) {
339+
CodegenModel model = modelMap.getModel();
340+
341+
if (!model.oneOf.isEmpty()) {
342+
configureOneOfModel(model, allModels, refCounts);
343+
}
344+
345+
if (model.discriminator != null) {
346+
model.getVendorExtensions().put("x-use-discr", true);
347+
if (model.discriminator.getMapping() != null) {
348+
model.getVendorExtensions().put("x-use-discr-mapping", true);
349+
}
350+
}
351+
}
352+
}
353+
}
354+
355+
private void configureOneOfModel(
356+
CodegenModel parent,
357+
Map<String, CodegenModel> allModels,
358+
Map<String, Integer> refCounts) {
359+
List<CodegenModel> inlineableMembers = new ArrayList<>();
360+
Set<String> childImports = new HashSet<>();
361+
362+
for (String childName : parent.oneOf) {
363+
CodegenModel child = allModels.get(childName);
364+
if (child == null) continue;
365+
366+
// All children extend the parent trait
367+
child.getVendorExtensions().put("x-oneOfParent", parent.classname);
368+
if (parent.discriminator != null) {
369+
child.getVendorExtensions().put("x-parentDiscriminatorName",
370+
parent.discriminator.getPropertyName());
371+
}
372+
373+
if (isInlineable(child, refCounts)) {
374+
child.getVendorExtensions().put("x-isOneOfMember", true);
375+
inlineableMembers.add(child);
376+
if (child.imports != null) {
377+
childImports.addAll(child.imports);
378+
}
379+
}
380+
}
381+
382+
buildDiscriminatorEntries(parent, allModels);
383+
384+
if (!inlineableMembers.isEmpty() && inlineableMembers.size() == parent.oneOf.size()) {
385+
markAsSealedTrait(parent, inlineableMembers, childImports);
386+
} else {
387+
markAsRegularTrait(parent, inlineableMembers);
388+
}
389+
}
390+
391+
private boolean isInlineable(CodegenModel child, Map<String, Integer> refCounts) {
392+
return (child.oneOf == null || child.oneOf.isEmpty())
393+
&& refCounts.getOrDefault(child.classname, 0) == 1;
394+
}
395+
396+
private void buildDiscriminatorEntries(CodegenModel parent, Map<String, CodegenModel> allModels) {
397+
List<Map<String, String>> entries = parent.oneOf.stream()
398+
.map(allModels::get)
399+
.filter(Objects::nonNull)
400+
.map(child -> Map.of("classname", child.classname, "schemaName", child.name))
401+
.collect(java.util.stream.Collectors.toList());
402+
parent.getVendorExtensions().put("x-discriminator-entries", entries);
403+
}
404+
405+
private void markAsSealedTrait(
406+
CodegenModel parent,
407+
List<CodegenModel> members,
408+
Set<String> childImports) {
409+
parent.getVendorExtensions().put("x-isSealedTrait", true);
410+
parent.getVendorExtensions().put("x-oneOfMembers", members);
411+
412+
if (parent.getVendorExtensions().containsKey("x-synthesized-oneOf")
413+
&& parent.vars != null && !parent.vars.isEmpty()) {
414+
parent.getVendorExtensions().put("x-hasOwnVars", true);
415+
}
416+
417+
mergeChildImports(parent, childImports);
418+
}
419+
420+
private void markAsRegularTrait(CodegenModel parent, List<CodegenModel> partialMembers) {
421+
parent.getVendorExtensions().put("x-isRegularTrait", true);
422+
for (CodegenModel member : partialMembers) {
423+
member.getVendorExtensions().remove("x-isOneOfMember");
424+
}
425+
}
426+
427+
private void mergeChildImports(CodegenModel parent, Set<String> childImports) {
428+
if (childImports.isEmpty()) return;
429+
Set<String> existing = parent.imports != null ? new HashSet<>(parent.imports) : new HashSet<>();
430+
childImports.removeAll(existing);
431+
if (!childImports.isEmpty()) {
432+
if (parent.imports == null) {
433+
parent.imports = new HashSet<>();
434+
}
435+
parent.imports.addAll(childImports);
436+
}
437+
}
438+
439+
/**
440+
* Remove models that were inlined into their parent sealed trait -
441+
* they don't need separate files.
442+
*/
443+
private void removeInlinedModels(Map<String, ModelsMap> modelsMap) {
444+
modelsMap.entrySet().removeIf(entry ->
445+
entry.getValue().getModels().stream()
446+
.anyMatch(m -> m.getModel().getVendorExtensions().containsKey("x-isOneOfMember"))
447+
);
246448
}
247449

248450
/**

0 commit comments

Comments
 (0)