From 7c9ada88c54d82eebaf7c430e35fb8555ffc3b29 Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 15:31:44 -0700 Subject: [PATCH 1/6] [feat] Adding oneOf and allOf support to scala-sttp-circe generator --- bin/configs/scala-sttp-circe.yaml | 2 +- .../languages/ScalaSttpClientCodegen.java | 187 +++- .../main/resources/scala-sttp/model.mustache | 148 ++- .../scala/ScalaSttpCirceCodegenTest.java | 159 ++++ .../codegen/scala/SttpCodegenTest.java | 63 -- .../3_0/scala-sttp-circe/petstore.yaml | 888 ++++++++++++++++++ .../3_0/scala/mixed-case-fields.yaml | 29 + .../scala-sttp-circe/.openapi-generator/FILES | 3 + .../petstore/scala-sttp-circe/README.md | 3 + .../openapitools/client/model/Animal.scala | 92 ++ .../client/model/ApiResponse.scala | 2 +- .../openapitools/client/model/Category.scala | 2 +- .../openapitools/client/model/Collar.scala | 73 ++ .../openapitools/client/model/EnumTest.scala | 2 +- .../org/openapitools/client/model/Order.scala | 2 +- .../org/openapitools/client/model/Pet.scala | 2 +- .../client/model/PropertyNameMapping.scala | 2 +- .../org/openapitools/client/model/Tag.scala | 2 +- .../org/openapitools/client/model/Treat.scala | 81 ++ .../org/openapitools/client/model/User.scala | 2 +- 20 files changed, 1667 insertions(+), 77 deletions(-) create mode 100644 modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java create mode 100644 modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Collar.scala create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Treat.scala diff --git a/bin/configs/scala-sttp-circe.yaml b/bin/configs/scala-sttp-circe.yaml index 37771342b0fd..f48d2e47d295 100644 --- a/bin/configs/scala-sttp-circe.yaml +++ b/bin/configs/scala-sttp-circe.yaml @@ -1,6 +1,6 @@ generatorName: scala-sttp outputDir: samples/client/petstore/scala-sttp-circe -inputSpec: modules/openapi-generator/src/test/resources/3_0/scala/petstore.yaml +inputSpec: modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml templateDir: modules/openapi-generator/src/main/resources/scala-sttp nameMappings: _type: "`underscoreType`" diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java index c3266f72bb86..6b8c6ed5d130 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java @@ -94,6 +94,10 @@ public ScalaSttpClientCodegen() { .excludeSchemaSupportFeatures( SchemaSupportFeature.Polymorphism ) + .includeSchemaSupportFeatures( + SchemaSupportFeature.oneOf, + SchemaSupportFeature.allOf + ) .excludeParameterFeatures( ParameterFeature.Cookie ) @@ -240,9 +244,186 @@ public ModelsMap postProcessModels(ModelsMap objs) { */ @Override public Map postProcessAllModels(Map objs) { - final Map processed = super.postProcessAllModels(objs); - postProcessUpdateImports(processed); - return processed; + Map modelsMap = super.postProcessAllModels(objs); + + Map allModels = collectAllModels(modelsMap); + synthesizeOneOfFromDiscriminator(allModels); + Map refCounts = countOneOfReferences(allModels); + markOneOfTraits(modelsMap, allModels, refCounts); + removeInlinedModels(modelsMap); + + postProcessUpdateImports(modelsMap); + return modelsMap; + } + + /** + * Collect all CodegenModels by classname for lookup. + */ + private Map collectAllModels(Map modelsMap) { + return modelsMap.values().stream() + .flatMap(mm -> mm.getModels().stream()) + .map(ModelMap::getModel) + .collect(java.util.stream.Collectors.toMap(m -> m.classname, m -> m, (a, b) -> a)); + } + + /** + * For specs that use allOf+discriminator (children reference parent via allOf, parent has + * discriminator.mapping but no oneOf), synthesize the oneOf set from the discriminator mapping. + * This allows the standard oneOf processing logic to handle both patterns uniformly. + */ + private void synthesizeOneOfFromDiscriminator(Map allModels) { + for (CodegenModel model : allModels.values()) { + if (!model.oneOf.isEmpty() || model.discriminator == null) { + continue; + } + + if (model.discriminator.getMappedModels() != null + && !model.discriminator.getMappedModels().isEmpty()) { + for (CodegenDiscriminator.MappedModel mapped : model.discriminator.getMappedModels()) { + model.oneOf.add(mapped.getModelName()); + } + } else if (model.discriminator.getMapping() != null) { + for (String ref : model.discriminator.getMapping().values()) { + String modelName = ref.contains("/") ? ref.substring(ref.lastIndexOf('/') + 1) : ref; + if (allModels.containsKey(modelName)) { + model.oneOf.add(modelName); + } + } + } + + if (!model.oneOf.isEmpty()) { + model.getVendorExtensions().put("x-synthesized-oneOf", true); + } + } + } + + /** + * Count how many oneOf parents reference each child, used to determine + * whether a child can be inlined (only if referenced by exactly one parent). + */ + private Map countOneOfReferences(Map allModels) { + return allModels.values().stream() + .flatMap(m -> m.oneOf.stream()) + .collect(java.util.stream.Collectors.toMap(name -> name, name -> 1, Integer::sum)); + } + + /** + * Mark oneOf parents as sealed/regular traits with discriminator vendor extensions, + * and configure child models for inlining. + */ + private void markOneOfTraits(Map modelsMap, + Map allModels, + Map refCounts) { + for (ModelsMap mm : modelsMap.values()) { + for (ModelMap modelMap : mm.getModels()) { + CodegenModel model = modelMap.getModel(); + + if (!model.oneOf.isEmpty()) { + configureOneOfModel(model, allModels, refCounts); + } + + if (model.discriminator != null) { + model.getVendorExtensions().put("x-use-discr", true); + if (model.discriminator.getMapping() != null) { + model.getVendorExtensions().put("x-use-discr-mapping", true); + } + } + } + } + } + + private void configureOneOfModel(CodegenModel parent, + Map allModels, + Map refCounts) { + List inlineableMembers = new ArrayList<>(); + Set childImports = new HashSet<>(); + + for (String childName : parent.oneOf) { + CodegenModel child = allModels.get(childName); + if (child != null && isInlineable(child, refCounts)) { + markChildForInlining(child, parent); + inlineableMembers.add(child); + if (child.imports != null) { + childImports.addAll(child.imports); + } + } + } + + buildDiscriminatorEntries(parent, allModels); + + if (!inlineableMembers.isEmpty() && inlineableMembers.size() == parent.oneOf.size()) { + markAsSealedTrait(parent, inlineableMembers, childImports); + } else { + markAsRegularTrait(parent, inlineableMembers); + } + } + + private boolean isInlineable(CodegenModel child, Map refCounts) { + return (child.oneOf == null || child.oneOf.isEmpty()) + && refCounts.getOrDefault(child.classname, 0) == 1; + } + + private void markChildForInlining(CodegenModel child, CodegenModel parent) { + child.getVendorExtensions().put("x-isOneOfMember", true); + child.getVendorExtensions().put("x-oneOfParent", parent.classname); + if (parent.discriminator != null) { + child.getVendorExtensions().put("x-parentDiscriminatorName", + parent.discriminator.getPropertyName()); + } + } + + private void buildDiscriminatorEntries(CodegenModel parent, Map allModels) { + List> entries = parent.oneOf.stream() + .map(allModels::get) + .filter(Objects::nonNull) + .map(child -> Map.of("classname", child.classname, "schemaName", child.name)) + .collect(java.util.stream.Collectors.toList()); + parent.getVendorExtensions().put("x-discriminator-entries", entries); + } + + private void markAsSealedTrait(CodegenModel parent, List members, + Set childImports) { + parent.getVendorExtensions().put("x-isSealedTrait", true); + parent.getVendorExtensions().put("x-oneOfMembers", members); + + if (parent.getVendorExtensions().containsKey("x-synthesized-oneOf") + && parent.vars != null && !parent.vars.isEmpty()) { + parent.getVendorExtensions().put("x-hasOwnVars", true); + } + + mergeChildImports(parent, childImports); + } + + private void markAsRegularTrait(CodegenModel parent, List partialMembers) { + parent.getVendorExtensions().put("x-isRegularTrait", true); + for (CodegenModel member : partialMembers) { + member.getVendorExtensions().remove("x-isOneOfMember"); + member.getVendorExtensions().remove("x-oneOfParent"); + member.getVendorExtensions().remove("x-parentDiscriminatorName"); + } + } + + private void mergeChildImports(CodegenModel parent, Set childImports) { + if (childImports.isEmpty()) return; + Set existing = parent.imports != null ? new HashSet<>(parent.imports) : new HashSet<>(); + childImports.removeAll(existing); + if (!childImports.isEmpty()) { + if (parent.imports == null) { + parent.imports = new HashSet<>(); + } + parent.imports.addAll(childImports); + } + } + + /** + * Remove models that were inlined into their parent sealed trait - + * they don't need separate files. + */ + private void removeInlinedModels(Map modelsMap) { + modelsMap.entrySet().removeIf(entry -> + entry.getValue().getModels().stream() + .anyMatch(m -> m.getModel().getVendorExtensions().containsKey("x-isOneOfMember")) + ); } /** diff --git a/modules/openapi-generator/src/main/resources/scala-sttp/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp/model.mustache index e584c94da3b1..3168fe30de98 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp/model.mustache @@ -5,7 +5,7 @@ package {{package}} import {{import}} {{/imports}} {{#circe}} -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import {{invokerPackage}}.JsonSupport._ {{/circe}} @@ -20,6 +20,148 @@ import {{invokerPackage}}.JsonSupport._ {{{description}}} {{/javadocRenderer}} {{/description}} +{{#vendorExtensions.x-isSealedTrait}} +sealed trait {{classname}}{{#vendorExtensions.x-hasOwnVars}} { +{{#vars}} + def {{{name}}}: {{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}]{{/required}} +{{/vars}} +}{{/vendorExtensions.x-hasOwnVars}} +object {{classname}} { +{{#circe}} +{{#vendorExtensions.x-use-discr-mapping}} +{{#discriminator}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#mappedModels}} + case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _) +{{/mappedModels}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c => + c.downField("{{propertyName}}").as[String].flatMap { +{{#mappedModels}} + case "{{mappingName}}" => c.as[{{model.classname}}] +{{/mappedModels}} + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +{{/discriminator}} +{{/vendorExtensions.x-use-discr-mapping}} +{{^vendorExtensions.x-use-discr-mapping}} +{{#vendorExtensions.x-use-discr}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#vendorExtensions.x-discriminator-entries}} + case obj: {{classname}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{schemaName}}".asJson) +: _) +{{/vendorExtensions.x-discriminator-entries}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c => + c.downField("{{discriminator.propertyName}}").as[String].flatMap { +{{#vendorExtensions.x-discriminator-entries}} + case "{{schemaName}}" => c.as[{{classname}}] +{{/vendorExtensions.x-discriminator-entries}} + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +{{/vendorExtensions.x-use-discr}} +{{^vendorExtensions.x-use-discr}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#oneOf}} + case obj: {{.}} => obj.asJson +{{/oneOf}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = + List[Decoder[{{classname}}]]({{#oneOf}}Decoder[{{.}}].map(x => x: {{classname}}){{^-last}}, {{/-last}}{{/oneOf}}).reduceLeft(_ or _) +{{/vendorExtensions.x-use-discr}} +{{/vendorExtensions.x-use-discr-mapping}} +{{/circe}} +} + +{{#vendorExtensions.x-oneOfMembers}} +case class {{classname}}( + {{#vars}} + {{#description}} + /* {{{.}}} */ + {{/description}} + {{{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}} + {{/vars}} +) extends {{vendorExtensions.x-oneOfParent}} +{{#circe}} +object {{classname}} { + implicit val 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 + } + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c => + for { + {{#vars}} + {{{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}}] + {{/vars}} + } yield {{classname}}( + {{#vars}} + {{{name}}} = {{{name}}}{{^-last}},{{/-last}} + {{/vars}} + ) + } +} +{{/circe}} + +{{/vendorExtensions.x-oneOfMembers}} +{{/vendorExtensions.x-isSealedTrait}} +{{#vendorExtensions.x-isRegularTrait}} +trait {{classname}} +object {{classname}} { +{{#circe}} +{{#vendorExtensions.x-use-discr-mapping}} +{{#discriminator}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#mappedModels}} + case obj: {{model.classname}} => obj.asJson.mapObject(("{{propertyName}}" -> "{{mappingName}}".asJson) +: _) +{{/mappedModels}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c => + c.downField("{{propertyName}}").as[String].flatMap { +{{#mappedModels}} + case "{{mappingName}}" => c.as[{{model.classname}}] +{{/mappedModels}} + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +{{/discriminator}} +{{/vendorExtensions.x-use-discr-mapping}} +{{^vendorExtensions.x-use-discr-mapping}} +{{#vendorExtensions.x-use-discr}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#vendorExtensions.x-discriminator-entries}} + case obj: {{classname}} => obj.asJson.mapObject(("{{discriminator.propertyName}}" -> "{{schemaName}}".asJson) +: _) +{{/vendorExtensions.x-discriminator-entries}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c => + c.downField("{{discriminator.propertyName}}").as[String].flatMap { +{{#vendorExtensions.x-discriminator-entries}} + case "{{schemaName}}" => c.as[{{classname}}] +{{/vendorExtensions.x-discriminator-entries}} + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +{{/vendorExtensions.x-use-discr}} +{{^vendorExtensions.x-use-discr}} + implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { +{{#oneOf}} + case obj: {{.}} => obj.asJson +{{/oneOf}} + } + implicit val decoder{{classname}}: Decoder[{{classname}}] = + List[Decoder[{{classname}}]]({{#oneOf}}Decoder[{{.}}].map(x => x: {{classname}}){{^-last}}, {{/-last}}{{/oneOf}}).reduceLeft(_ or _) +{{/vendorExtensions.x-use-discr}} +{{/vendorExtensions.x-use-discr-mapping}} +{{/circe}} +} +{{/vendorExtensions.x-isRegularTrait}} +{{^vendorExtensions.x-isSealedTrait}} +{{^vendorExtensions.x-isRegularTrait}} {{^isEnum}} case class {{classname}}( {{#vars}} @@ -28,7 +170,7 @@ case class {{classname}}( {{/description}} {{{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}} {{/vars}} -) +){{#vendorExtensions.x-oneOfParent}} extends {{vendorExtensions.x-oneOfParent}}{{/vendorExtensions.x-oneOfParent}} {{#circe}} object {{classname}} { {{#hasVars}} @@ -64,6 +206,8 @@ object {{classname}} { } {{/circe}} {{/isEnum}} +{{/vendorExtensions.x-isRegularTrait}} +{{/vendorExtensions.x-isSealedTrait}} {{#isEnum}} object {{classname}} extends Enumeration { diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java new file mode 100644 index 000000000000..0092fd968f6f --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java @@ -0,0 +1,159 @@ +package org.openapitools.codegen.scala; + +import io.swagger.parser.OpenAPIParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.openapitools.codegen.ClientOptInput; +import org.openapitools.codegen.CodegenConstants; +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.languages.ScalaSttpClientCodegen; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.openapitools.codegen.TestUtils.assertFileContains; +import static org.openapitools.codegen.TestUtils.assertFileNotContains; + +/** + * Tests for scala-sttp generator with circe JSON library. + * Covers baseName field mapping, discriminator/polymorphism, and special type handling. + */ +public class ScalaSttpCirceCodegenTest { + + private DefaultGenerator generateFromSpec(String specPath, File output) { + OpenAPI openAPI = new OpenAPIParser() + .readLocation(specPath, null, new ParseOptions()).getOpenAPI(); + + ScalaSttpClientCodegen codegen = new ScalaSttpClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put("jsonLibrary", "circe"); + + ClientOptInput input = new ClientOptInput(); + input.openAPI(openAPI); + input.config(codegen); + + DefaultGenerator generator = new DefaultGenerator(); + generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "true"); + generator.opts(input).generate(); + return generator; + } + + @Test(description = "circe encoder/decoder uses baseName for JSON field names") + public void verifyBaseNameFieldMapping() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + generateFromSpec("src/test/resources/3_0/scala/mixed-case-fields.yaml", output); + + // MixedCaseModel: verify baseName is used in encoder/decoder + Path modelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala"); + assertFileContains(modelPath, "\"first-name\""); + assertFileContains(modelPath, "\"phone_number\""); + assertFileContains(modelPath, "\"lastName\""); + assertFileContains(modelPath, "\"ZipCode\""); + assertFileContains(modelPath, "c.downField(\"first-name\")"); + assertFileContains(modelPath, "c.downField(\"phone_number\")"); + assertFileContains(modelPath, "c.downField(\"ZipCode\")"); + assertFileContains(modelPath, "implicit val encoderMixedCaseModel"); + assertFileContains(modelPath, "implicit val decoderMixedCaseModel"); + + // BinaryPayload: File and untyped object fields + Path binaryPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/BinaryPayload.scala"); + assertFileContains(binaryPath, "data: Option[File]"); + assertFileContains(binaryPath, "implicit val encoderBinaryPayload"); + assertFileContains(binaryPath, "implicit val decoderBinaryPayload"); + + // AdditionalTypeSerializers: File and Any codecs + Path serializersPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala"); + assertFileContains(serializersPath, "FileDecoder"); + assertFileContains(serializersPath, "FileEncoder"); + assertFileContains(serializersPath, "AnyDecoder"); + assertFileContains(serializersPath, "AnyEncoder"); + + // JsonSupport should NOT use AutoDerivation + Path jsonSupportPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/JsonSupport.scala"); + assertFileNotContains(jsonSupportPath, "AutoDerivation"); + } + + @Test(description = "allOf + discriminator generates sealed trait with discriminator-based circe codecs") + public void verifyAllOfDiscriminator() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + generateFromSpec("src/test/resources/3_0/scala/mixed-case-fields.yaml", output); + + // Animal should be a sealed trait with base fields as abstract defs + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "sealed trait Animal"); + assertFileContains(animalPath, "def className: String"); + assertFileContains(animalPath, "def color: Option[String]"); + + // Discriminator-based encoder/decoder + assertFileContains(animalPath, "implicit val encoderAnimal"); + assertFileContains(animalPath, "implicit val decoderAnimal"); + assertFileContains(animalPath, "\"DOG\""); + assertFileContains(animalPath, "\"CAT\""); + assertFileContains(animalPath, "c.downField(\"className\")"); + assertFileContains(animalPath, "DecodingFailure"); + + // Cat and Dog inlined in Animal.scala, extending Animal + assertFileContains(animalPath, "case class Cat"); + assertFileContains(animalPath, "case class Dog"); + assertFileContains(animalPath, "extends Animal"); + assertFileContains(animalPath, "declawed"); + assertFileContains(animalPath, "breed"); + + // Cat/Dog should have their own encoder/decoder (for the discriminator to delegate to) + assertFileContains(animalPath, "implicit val encoderCat"); + assertFileContains(animalPath, "implicit val decoderCat"); + assertFileContains(animalPath, "implicit val encoderDog"); + assertFileContains(animalPath, "implicit val decoderDog"); + + // Cat and Dog should NOT have separate files + Assert.assertFalse( + Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Cat.scala").toFile().exists(), + "Cat.scala should not exist (inlined in Animal.scala)"); + Assert.assertFalse( + Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala").toFile().exists(), + "Dog.scala should not exist (inlined in Animal.scala)"); + } + + @Test(description = "oneOf + discriminator generates sealed trait (standard pattern)") + public void verifyOneOfDiscriminator() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + generateFromSpec("src/test/resources/3_0/oneOfDiscriminator.yaml", output); + + // FruitReqDisc: sealed trait with inlined members (oneOf + discriminator, no mapping) + Path fruitPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/FruitReqDisc.scala"); + assertFileContains(fruitPath, "sealed trait FruitReqDisc"); + assertFileContains(fruitPath, "case class AppleReqDisc"); + assertFileContains(fruitPath, "case class BananaReqDisc"); + assertFileContains(fruitPath, "extends FruitReqDisc"); + assertFileContains(fruitPath, "\"fruitType\""); + + // FruitOneOfEnumMappingDisc: sealed trait with explicit discriminator mapping + Path fruitMappingPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/FruitOneOfEnumMappingDisc.scala"); + assertFileContains(fruitMappingPath, "sealed trait FruitOneOfEnumMappingDisc"); + assertFileContains(fruitMappingPath, "\"APPLE\""); + assertFileContains(fruitMappingPath, "\"BANANA\""); + + // Inlined members should not have separate files + Assert.assertFalse( + Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/AppleReqDisc.scala").toFile().exists(), + "AppleReqDisc.scala should not exist (inlined)"); + } +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/SttpCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/SttpCodegenTest.java index e5f24e8d722f..c6f2150321bd 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/SttpCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/SttpCodegenTest.java @@ -110,69 +110,6 @@ public void verifyApiKeyLocations() throws IOException { assertFileContains(path, ".cookie(\"apikey\", apiKeyCookie)"); } - @Test - public void verifyCirceSerdeWithMixedCaseFields() throws IOException { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - output.deleteOnExit(); - String outputPath = output.getAbsolutePath().replace('\\', '/'); - - OpenAPI openAPI = new OpenAPIParser() - .readLocation("src/test/resources/3_0/scala/mixed-case-fields.yaml", null, new ParseOptions()).getOpenAPI(); - - ScalaSttpClientCodegen codegen = new ScalaSttpClientCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put("jsonLibrary", "circe"); - - ClientOptInput input = new ClientOptInput(); - input.openAPI(openAPI); - input.config(codegen); - - DefaultGenerator generator = new DefaultGenerator(); - - generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true"); - generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false"); - generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "true"); - generator.opts(input).generate(); - - Path mixedCaseModelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala"); - - assertFileContains(mixedCaseModelPath, "firstName"); - assertFileContains(mixedCaseModelPath, "phoneNumber"); - assertFileContains(mixedCaseModelPath, "lastName"); - assertFileContains(mixedCaseModelPath, "zipCode"); - assertFileContains(mixedCaseModelPath, "address"); - - assertFileContains(mixedCaseModelPath, "\"first-name\""); - assertFileContains(mixedCaseModelPath, "\"phone_number\""); - assertFileContains(mixedCaseModelPath, "\"lastName\""); - assertFileContains(mixedCaseModelPath, "\"ZipCode\""); - assertFileContains(mixedCaseModelPath, "\"address\""); - - assertFileContains(mixedCaseModelPath, "c.downField(\"first-name\")"); - assertFileContains(mixedCaseModelPath, "c.downField(\"phone_number\")"); - assertFileContains(mixedCaseModelPath, "c.downField(\"ZipCode\")"); - - assertFileContains(mixedCaseModelPath, "object MixedCaseModel"); - assertFileContains(mixedCaseModelPath, "implicit val encoderMixedCaseModel"); - assertFileContains(mixedCaseModelPath, "implicit val decoderMixedCaseModel"); - - Path binaryModelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/BinaryPayload.scala"); - assertFileContains(binaryModelPath, "data: Option[File]"); - assertFileContains(binaryModelPath, "metadata: Option[io.circe.Json]"); - assertFileContains(binaryModelPath, "c.downField(\"data\")"); - assertFileContains(binaryModelPath, "c.downField(\"metadata\")"); - assertFileContains(binaryModelPath, "implicit val encoderBinaryPayload"); - assertFileContains(binaryModelPath, "implicit val decoderBinaryPayload"); - - Path additionalSerializersPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala"); - assertFileContains(additionalSerializersPath, "FileDecoder"); - assertFileContains(additionalSerializersPath, "FileEncoder"); - assertFileContains(additionalSerializersPath, "AnyDecoder"); - assertFileContains(additionalSerializersPath, "AnyEncoder"); - } - @Test public void headerSerialization() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); diff --git a/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml b/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml new file mode 100644 index 000000000000..81c709944e5c --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml @@ -0,0 +1,888 @@ +openapi: 3.0.0 +servers: + - url: 'http://petstore.swagger.io/v2' +info: + description: >- + This is a sample server Petstore server. For this sample, you can use the api key + `special-key` to test the authorization filters. + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /pet: + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{orderId}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generate exceptions + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + security: + - api_key: [] + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + Set-Cookie: + description: >- + Cookie authentication key for use with the `api_key` + apiKey authentication. + schema: + type: string + example: AUTH_KEY=abcde12345; Path=/; HttpOnly + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + security: + - api_key: [] + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + security: + - api_key: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - api_key: [] + /fake/parameter-name-mapping: + get: + tags: + - fake + summary: parameter name mapping test + operationId: getParameterNameMapping + parameters: + - name: _type + in: header + description: _type + required: true + schema: + type: integer + format: int64 + - name: type + in: query + description: type + required: true + schema: + type: string + - name: type_ + in: header + description: type_ + required: true + schema: + type: string + - name: http_debug_option + in: query + description: http debug option (to test parameter naming option) + required: true + schema: + type: string + responses: + 200: + description: OK +externalDocs: + description: Find out more about Swagger + url: 'http://swagger.io' +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + schemas: + Order: + title: Pet Order + description: An order for a pets from the pet store + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + title: Pet category + description: A category for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + pattern: '^[a-zA-Z0-9]+[a-zA-Z0-9\.\-_]*[a-zA-Z0-9]+$' + xml: + name: Category + User: + title: a User + description: A User who is purchasing from the pet store + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + title: Pet Tag + description: A tag for a pet + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + title: a Pet + description: A pet for sale in the pet store + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + deprecated: true + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + title: An uploaded response + description: Describes the result of uploading an image resource + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + EnumTest: + properties: + emails: + items: + type: string + type: array + search: + enum: + - first_name + - last_name + - email + - full_name + type: string + sort_by: + items: + enum: + - first_name + - last_name + - email + type: string + type: array + type: object + PropertyNameMapping: + properties: + http_debug_operation: + type: string + _type: + type: string + type: + type: string + type_: + type: string + Animal: + type: object + required: + - className + properties: + className: + type: string + color: + type: string + default: red + discriminator: + propertyName: className + mapping: + DOG: '#/components/schemas/Dog' + CAT: '#/components/schemas/Cat' + Cat: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + declawed: + type: boolean + Dog: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + breed: + type: string + Treat: + oneOf: + - $ref: '#/components/schemas/DryFood' + - $ref: '#/components/schemas/WetFood' + discriminator: + propertyName: treatType + DryFood: + type: object + required: + - treatType + - kibbleSize + properties: + treatType: + type: string + kibbleSize: + type: number + format: double + WetFood: + type: object + required: + - treatType + - canSize + properties: + treatType: + type: string + canSize: + type: string + Collar: + type: object + required: + - collarType + properties: + collarType: + type: string + color: + type: string + oneOf: + - $ref: '#/components/schemas/LeatherCollar' + - $ref: '#/components/schemas/NylonCollar' + discriminator: + propertyName: collarType + mapping: + LEATHER: '#/components/schemas/LeatherCollar' + NYLON: '#/components/schemas/NylonCollar' + LeatherCollar: + type: object + properties: + grainType: + type: string + NylonCollar: + type: object + properties: + reflective: + type: boolean diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/mixed-case-fields.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/mixed-case-fields.yaml index 6b78aa8fc08a..e5cdbae40845 100644 --- a/modules/openapi-generator/src/test/resources/3_0/scala/mixed-case-fields.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/scala/mixed-case-fields.yaml @@ -36,3 +36,32 @@ components: format: binary metadata: type: object + Animal: + type: object + required: + - className + properties: + className: + type: string + color: + type: string + default: red + discriminator: + propertyName: className + mapping: + DOG: '#/components/schemas/Dog' + CAT: '#/components/schemas/Cat' + Cat: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + declawed: + type: boolean + Dog: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + breed: + type: string diff --git a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES index 7e661202c5c2..ecbd6fc800f9 100644 --- a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES +++ b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES @@ -10,11 +10,14 @@ src/main/scala/org/openapitools/client/api/UserApi.scala src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala src/main/scala/org/openapitools/client/core/DateSerializers.scala src/main/scala/org/openapitools/client/core/JsonSupport.scala +src/main/scala/org/openapitools/client/model/Animal.scala src/main/scala/org/openapitools/client/model/ApiResponse.scala src/main/scala/org/openapitools/client/model/Category.scala +src/main/scala/org/openapitools/client/model/Collar.scala src/main/scala/org/openapitools/client/model/EnumTest.scala src/main/scala/org/openapitools/client/model/Order.scala src/main/scala/org/openapitools/client/model/Pet.scala src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala src/main/scala/org/openapitools/client/model/Tag.scala +src/main/scala/org/openapitools/client/model/Treat.scala src/main/scala/org/openapitools/client/model/User.scala diff --git a/samples/client/petstore/scala-sttp-circe/README.md b/samples/client/petstore/scala-sttp-circe/README.md index d23798dac4ba..419676fb33ec 100644 --- a/samples/client/petstore/scala-sttp-circe/README.md +++ b/samples/client/petstore/scala-sttp-circe/README.md @@ -91,13 +91,16 @@ Class | Method | HTTP request | Description ## Documentation for Models + - [Animal](Animal.md) - [ApiResponse](ApiResponse.md) - [Category](Category.md) + - [Collar](Collar.md) - [EnumTest](EnumTest.md) - [Order](Order.md) - [Pet](Pet.md) - [PropertyNameMapping](PropertyNameMapping.md) - [Tag](Tag.md) + - [Treat](Treat.md) - [User](User.md) diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala new file mode 100644 index 000000000000..3e62ce51c0ec --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala @@ -0,0 +1,92 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +sealed trait Animal { + def className: String + def color: Option[String] +} +object Animal { + implicit val encoderAnimal: Encoder[Animal] = Encoder.instance { + case obj: Cat => obj.asJson.mapObject(("className" -> "CAT".asJson) +: _) + case obj: Dog => obj.asJson.mapObject(("className" -> "DOG".asJson) +: _) + } + implicit val decoderAnimal: Decoder[Animal] = Decoder.instance { c => + c.downField("className").as[String].flatMap { + case "CAT" => c.as[Cat] + case "DOG" => c.as[Dog] + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +} + +case class Cat( + className: String, + color: Option[String] = None, + declawed: Option[Boolean] = None +) extends Animal +object Cat { + implicit val encoderCat: Encoder[Cat] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("className" -> t.className.asJson), + t.color.map(v => "color" -> v.asJson), + t.declawed.map(v => "declawed" -> v.asJson) + ).flatten + } + } + implicit val decoderCat: Decoder[Cat] = Decoder.instance { c => + for { + className <- c.downField("className").as[String] + color <- c.downField("color").as[Option[String]] + declawed <- c.downField("declawed").as[Option[Boolean]] + } yield Cat( + className = className, + color = color, + declawed = declawed + ) + } +} + +case class Dog( + className: String, + color: Option[String] = None, + breed: Option[String] = None +) extends Animal +object Dog { + implicit val encoderDog: Encoder[Dog] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("className" -> t.className.asJson), + t.color.map(v => "color" -> v.asJson), + t.breed.map(v => "breed" -> v.asJson) + ).flatten + } + } + implicit val decoderDog: Decoder[Dog] = Decoder.instance { c => + for { + className <- c.downField("className").as[String] + color <- c.downField("color").as[Option[String]] + breed <- c.downField("breed").as[Option[String]] + } yield Dog( + className = className, + color = color, + breed = breed + ) + } +} + + diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala index 514f416b07a1..66394f9951d0 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Category.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Category.scala index c03cc7fc6a1f..a7de397eb9b9 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Category.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Category.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Collar.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Collar.scala new file mode 100644 index 000000000000..9c52ef2abae0 --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Collar.scala @@ -0,0 +1,73 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +sealed trait Collar +object Collar { + implicit val encoderCollar: Encoder[Collar] = Encoder.instance { + case obj: LeatherCollar => obj.asJson.mapObject(("collarType" -> "LEATHER".asJson) +: _) + case obj: NylonCollar => obj.asJson.mapObject(("collarType" -> "NYLON".asJson) +: _) + } + implicit val decoderCollar: Decoder[Collar] = Decoder.instance { c => + c.downField("collarType").as[String].flatMap { + case "LEATHER" => c.as[LeatherCollar] + case "NYLON" => c.as[NylonCollar] + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +} + +case class LeatherCollar( + grainType: Option[String] = None +) extends Collar +object LeatherCollar { + implicit val encoderLeatherCollar: Encoder[LeatherCollar] = Encoder.instance { t => + Json.fromFields{ + Seq( + t.grainType.map(v => "grainType" -> v.asJson) + ).flatten + } + } + implicit val decoderLeatherCollar: Decoder[LeatherCollar] = Decoder.instance { c => + for { + grainType <- c.downField("grainType").as[Option[String]] + } yield LeatherCollar( + grainType = grainType + ) + } +} + +case class NylonCollar( + reflective: Option[Boolean] = None +) extends Collar +object NylonCollar { + implicit val encoderNylonCollar: Encoder[NylonCollar] = Encoder.instance { t => + Json.fromFields{ + Seq( + t.reflective.map(v => "reflective" -> v.asJson) + ).flatten + } + } + implicit val decoderNylonCollar: Decoder[NylonCollar] = Decoder.instance { c => + for { + reflective <- c.downField("reflective").as[Option[Boolean]] + } yield NylonCollar( + reflective = reflective + ) + } +} + + diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/EnumTest.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/EnumTest.scala index 27de86d3402d..1b7428b8edb5 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/EnumTest.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/EnumTest.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Order.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Order.scala index a3bf7ea39dcf..d5aa9a596d09 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Order.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Order.scala @@ -12,7 +12,7 @@ package org.openapitools.client.model import java.time.OffsetDateTime -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Pet.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Pet.scala index e9f8b4b2eb61..1767a660ac7c 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Pet.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Pet.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala index cd870e77f4e9..7148a0485f67 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Tag.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Tag.scala index d1b9289b90cf..c569965d956b 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Tag.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Tag.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Treat.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Treat.scala new file mode 100644 index 000000000000..10ee5bfcf544 --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Treat.scala @@ -0,0 +1,81 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +sealed trait Treat +object Treat { + implicit val encoderTreat: Encoder[Treat] = Encoder.instance { + case obj: DryFood => obj.asJson.mapObject(("treatType" -> "DryFood".asJson) +: _) + case obj: WetFood => obj.asJson.mapObject(("treatType" -> "WetFood".asJson) +: _) + } + implicit val decoderTreat: Decoder[Treat] = Decoder.instance { c => + c.downField("treatType").as[String].flatMap { + case "DryFood" => c.as[DryFood] + case "WetFood" => c.as[WetFood] + case other => Left(DecodingFailure(s"Unknown discriminator value: $other", c.history)) + } + } +} + +case class DryFood( + treatType: String, + kibbleSize: Double +) extends Treat +object DryFood { + implicit val encoderDryFood: Encoder[DryFood] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("treatType" -> t.treatType.asJson), + Some("kibbleSize" -> t.kibbleSize.asJson) + ).flatten + } + } + implicit val decoderDryFood: Decoder[DryFood] = Decoder.instance { c => + for { + treatType <- c.downField("treatType").as[String] + kibbleSize <- c.downField("kibbleSize").as[Double] + } yield DryFood( + treatType = treatType, + kibbleSize = kibbleSize + ) + } +} + +case class WetFood( + treatType: String, + canSize: String +) extends Treat +object WetFood { + implicit val encoderWetFood: Encoder[WetFood] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("treatType" -> t.treatType.asJson), + Some("canSize" -> t.canSize.asJson) + ).flatten + } + } + implicit val decoderWetFood: Decoder[WetFood] = Decoder.instance { c => + for { + treatType <- c.downField("treatType").as[String] + canSize <- c.downField("canSize").as[String] + } yield WetFood( + treatType = treatType, + canSize = canSize + ) + } +} + + diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/User.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/User.scala index 339936997b69..273cf9191239 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/User.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/User.scala @@ -11,7 +11,7 @@ */ package org.openapitools.client.model -import io.circe.{Decoder, Encoder, Json} +import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ From 435db41dce892058d57c4c414b134eb8e9edf6dd Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 17:08:07 -0700 Subject: [PATCH 2/6] Update docs --- docs/generators/scala-sttp.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/generators/scala-sttp.md b/docs/generators/scala-sttp.md index 798740d25668..0160442c0abb 100644 --- a/docs/generators/scala-sttp.md +++ b/docs/generators/scala-sttp.md @@ -226,9 +226,9 @@ These options may be applied as additional-properties (cli) or configOptions (pl |Composite|✓|OAS2,OAS3 |Polymorphism|✗|OAS2,OAS3 |Union|✗|OAS3 -|allOf|✗|OAS2,OAS3 +|allOf|✓|OAS2,OAS3 |anyOf|✗|OAS3 -|oneOf|✗|OAS3 +|oneOf|✓|OAS3 |not|✗|OAS3 ### Security Feature From a09bdc40a99efda14e2220780d4336b82c44cdf6 Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 17:32:49 -0700 Subject: [PATCH 3/6] Fix bug --- .../languages/ScalaSttpClientCodegen.java | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java index 6b8c6ed5d130..4b3d83e8a0cf 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java @@ -248,7 +248,7 @@ public Map postProcessAllModels(Map objs) Map allModels = collectAllModels(modelsMap); synthesizeOneOfFromDiscriminator(allModels); - Map refCounts = countOneOfReferences(allModels); + Map refCounts = countModelReferences(allModels); markOneOfTraits(modelsMap, allModels, refCounts); removeInlinedModels(modelsMap); @@ -298,13 +298,26 @@ private void synthesizeOneOfFromDiscriminator(Map allModel } /** - * Count how many oneOf parents reference each child, used to determine - * whether a child can be inlined (only if referenced by exactly one parent). + * Count how many times each model is referenced - both as a oneOf member and as a + * property type. A child can only be inlined if it's referenced exactly once (by its + * oneOf parent) and not used as a property type elsewhere. */ - private Map countOneOfReferences(Map allModels) { - return allModels.values().stream() + private Map countModelReferences(Map allModels) { + Map counts = new HashMap<>(); + + // Count oneOf parent references + allModels.values().stream() .flatMap(m -> m.oneOf.stream()) - .collect(java.util.stream.Collectors.toMap(name -> name, name -> 1, Integer::sum)); + .forEach(name -> counts.merge(name, 1, Integer::sum)); + + // Count property-type references (prevents inlining models used as field types) + allModels.values().stream() + .flatMap(m -> m.vars.stream()) + .map(prop -> prop.dataType) + .filter(allModels::containsKey) + .forEach(name -> counts.merge(name, 1, Integer::sum)); + + return counts; } /** From 8a1759071709f7e498f05ebfe62a022541468b9e Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 18:00:49 -0700 Subject: [PATCH 4/6] Fix bug --- .../languages/ScalaSttpClientCodegen.java | 58 +++++++++++-------- .../scala/ScalaSttpCirceCodegenTest.java | 35 +++++++++++ .../3_0/scala-sttp-circe/petstore.yaml | 9 +++ .../scala-sttp-circe/.openapi-generator/FILES | 1 + .../petstore/scala-sttp-circe/README.md | 1 + .../openapitools/client/model/Kennel.scala | 41 +++++++++++++ 6 files changed, 120 insertions(+), 25 deletions(-) create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Kennel.scala diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java index 4b3d83e8a0cf..fb25eda08f71 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttpClientCodegen.java @@ -310,12 +310,18 @@ private Map countModelReferences(Map allM .flatMap(m -> m.oneOf.stream()) .forEach(name -> counts.merge(name, 1, Integer::sum)); - // Count property-type references (prevents inlining models used as field types) + // Count property-type references (prevents inlining models used as field types). + // Check both dataType and complexType allModels.values().stream() .flatMap(m -> m.vars.stream()) - .map(prop -> prop.dataType) - .filter(allModels::containsKey) - .forEach(name -> counts.merge(name, 1, Integer::sum)); + .forEach(prop -> { + if (prop.dataType != null && allModels.containsKey(prop.dataType)) { + counts.merge(prop.dataType, 1, Integer::sum); + } + if (prop.complexType != null && allModels.containsKey(prop.complexType)) { + counts.merge(prop.complexType, 1, Integer::sum); + } + }); return counts; } @@ -324,9 +330,10 @@ private Map countModelReferences(Map allM * Mark oneOf parents as sealed/regular traits with discriminator vendor extensions, * and configure child models for inlining. */ - private void markOneOfTraits(Map modelsMap, - Map allModels, - Map refCounts) { + private void markOneOfTraits( + Map modelsMap, + Map allModels, + Map refCounts) { for (ModelsMap mm : modelsMap.values()) { for (ModelMap modelMap : mm.getModels()) { CodegenModel model = modelMap.getModel(); @@ -345,16 +352,26 @@ private void markOneOfTraits(Map modelsMap, } } - private void configureOneOfModel(CodegenModel parent, - Map allModels, - Map refCounts) { + private void configureOneOfModel( + CodegenModel parent, + Map allModels, + Map refCounts) { List inlineableMembers = new ArrayList<>(); Set childImports = new HashSet<>(); for (String childName : parent.oneOf) { CodegenModel child = allModels.get(childName); - if (child != null && isInlineable(child, refCounts)) { - markChildForInlining(child, parent); + if (child == null) continue; + + // All children extend the parent trait + child.getVendorExtensions().put("x-oneOfParent", parent.classname); + if (parent.discriminator != null) { + child.getVendorExtensions().put("x-parentDiscriminatorName", + parent.discriminator.getPropertyName()); + } + + if (isInlineable(child, refCounts)) { + child.getVendorExtensions().put("x-isOneOfMember", true); inlineableMembers.add(child); if (child.imports != null) { childImports.addAll(child.imports); @@ -376,15 +393,6 @@ private boolean isInlineable(CodegenModel child, Map refCounts) && refCounts.getOrDefault(child.classname, 0) == 1; } - private void markChildForInlining(CodegenModel child, CodegenModel parent) { - child.getVendorExtensions().put("x-isOneOfMember", true); - child.getVendorExtensions().put("x-oneOfParent", parent.classname); - if (parent.discriminator != null) { - child.getVendorExtensions().put("x-parentDiscriminatorName", - parent.discriminator.getPropertyName()); - } - } - private void buildDiscriminatorEntries(CodegenModel parent, Map allModels) { List> entries = parent.oneOf.stream() .map(allModels::get) @@ -394,8 +402,10 @@ private void buildDiscriminatorEntries(CodegenModel parent, Map members, - Set childImports) { + private void markAsSealedTrait( + CodegenModel parent, + List members, + Set childImports) { parent.getVendorExtensions().put("x-isSealedTrait", true); parent.getVendorExtensions().put("x-oneOfMembers", members); @@ -411,8 +421,6 @@ private void markAsRegularTrait(CodegenModel parent, List partialM parent.getVendorExtensions().put("x-isRegularTrait", true); for (CodegenModel member : partialMembers) { member.getVendorExtensions().remove("x-isOneOfMember"); - member.getVendorExtensions().remove("x-oneOfParent"); - member.getVendorExtensions().remove("x-parentDiscriminatorName"); } } diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java index 0092fd968f6f..2c73ed21950f 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java @@ -129,6 +129,41 @@ public void verifyAllOfDiscriminator() throws IOException { "Dog.scala should not exist (inlined in Animal.scala)"); } + @Test(description = "container-wrapped model ref (Seq[Dog]) prevents inlining of oneOf child") + public void verifyContainerWrappedRefPreventsInlining() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + generateFromSpec("src/test/resources/3_0/scala-sttp-circe/petstore.yaml", output); + + // Dog is referenced both as a oneOf child of Animal AND as Seq[Dog] in Kennel.dogs. + // Since not all children can be inlined, Animal becomes a regular trait (not sealed). + // Both Dog and Cat get their own files. + Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala"); + Assert.assertTrue(dogPath.toFile().exists(), + "Dog.scala must exist as a separate file (used as array element in Kennel)"); + assertFileContains(dogPath, "case class Dog"); + assertFileContains(dogPath, "extends Animal"); + + // Cat also gets its own file (regular trait = no inlining) + Path catPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Cat.scala"); + Assert.assertTrue(catPath.toFile().exists(), + "Cat.scala must exist (Animal is a regular trait, no children inlined)"); + assertFileContains(catPath, "case class Cat"); + assertFileContains(catPath, "extends Animal"); + + // Animal is a regular trait (not sealed) because not all children can be inlined + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "trait Animal"); + assertFileNotContains(animalPath, "case class Cat"); + assertFileNotContains(animalPath, "case class Dog"); + + // Kennel should reference Dog via Seq + Path kennelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Kennel.scala"); + assertFileContains(kennelPath, "dogs: Option[Seq[Dog]]"); + } + @Test(description = "oneOf + discriminator generates sealed trait (standard pattern)") public void verifyOneOfDiscriminator() throws IOException { File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); diff --git a/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml b/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml index 81c709944e5c..65c92603c2bf 100644 --- a/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/scala-sttp-circe/petstore.yaml @@ -886,3 +886,12 @@ components: properties: reflective: type: boolean + Kennel: + type: object + properties: + name: + type: string + dogs: + type: array + items: + $ref: '#/components/schemas/Dog' diff --git a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES index ecbd6fc800f9..c609a6ba913c 100644 --- a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES +++ b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES @@ -15,6 +15,7 @@ src/main/scala/org/openapitools/client/model/ApiResponse.scala src/main/scala/org/openapitools/client/model/Category.scala src/main/scala/org/openapitools/client/model/Collar.scala src/main/scala/org/openapitools/client/model/EnumTest.scala +src/main/scala/org/openapitools/client/model/Kennel.scala src/main/scala/org/openapitools/client/model/Order.scala src/main/scala/org/openapitools/client/model/Pet.scala src/main/scala/org/openapitools/client/model/PropertyNameMapping.scala diff --git a/samples/client/petstore/scala-sttp-circe/README.md b/samples/client/petstore/scala-sttp-circe/README.md index 419676fb33ec..e90cea0cb65a 100644 --- a/samples/client/petstore/scala-sttp-circe/README.md +++ b/samples/client/petstore/scala-sttp-circe/README.md @@ -96,6 +96,7 @@ Class | Method | HTTP request | Description - [Category](Category.md) - [Collar](Collar.md) - [EnumTest](EnumTest.md) + - [Kennel](Kennel.md) - [Order](Order.md) - [Pet](Pet.md) - [PropertyNameMapping](PropertyNameMapping.md) diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Kennel.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Kennel.scala new file mode 100644 index 000000000000..16a0b8cd5297 --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Kennel.scala @@ -0,0 +1,41 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +case class Kennel( + name: Option[String] = None, + dogs: Option[Seq[Dog]] = None +) +object Kennel { + implicit val encoderKennel: Encoder[Kennel] = Encoder.instance { t => + Json.fromFields{ + Seq( + t.name.map(v => "name" -> v.asJson), + t.dogs.map(v => "dogs" -> v.asJson) + ).flatten + } + } + implicit val decoderKennel: Decoder[Kennel] = Decoder.instance { c => + for { + name <- c.downField("name").as[Option[String]] + dogs <- c.downField("dogs").as[Option[Seq[Dog]]] + } yield Kennel( + name = name, + dogs = dogs + ) + } +} + From 05e50294cec835eff894d93ff12e3dde0c47300f Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 18:04:33 -0700 Subject: [PATCH 5/6] Regen samples --- .../scala-sttp-circe/.openapi-generator/FILES | 2 + .../petstore/scala-sttp-circe/README.md | 2 + .../openapitools/client/model/Animal.scala | 62 +------------------ .../org/openapitools/client/model/Cat.scala | 45 ++++++++++++++ .../org/openapitools/client/model/Dog.scala | 45 ++++++++++++++ 5 files changed, 95 insertions(+), 61 deletions(-) create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Cat.scala create mode 100644 samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Dog.scala diff --git a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES index c609a6ba913c..36d094a9e07c 100644 --- a/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES +++ b/samples/client/petstore/scala-sttp-circe/.openapi-generator/FILES @@ -12,8 +12,10 @@ src/main/scala/org/openapitools/client/core/DateSerializers.scala src/main/scala/org/openapitools/client/core/JsonSupport.scala src/main/scala/org/openapitools/client/model/Animal.scala src/main/scala/org/openapitools/client/model/ApiResponse.scala +src/main/scala/org/openapitools/client/model/Cat.scala src/main/scala/org/openapitools/client/model/Category.scala src/main/scala/org/openapitools/client/model/Collar.scala +src/main/scala/org/openapitools/client/model/Dog.scala src/main/scala/org/openapitools/client/model/EnumTest.scala src/main/scala/org/openapitools/client/model/Kennel.scala src/main/scala/org/openapitools/client/model/Order.scala diff --git a/samples/client/petstore/scala-sttp-circe/README.md b/samples/client/petstore/scala-sttp-circe/README.md index e90cea0cb65a..1fa29d4e86b3 100644 --- a/samples/client/petstore/scala-sttp-circe/README.md +++ b/samples/client/petstore/scala-sttp-circe/README.md @@ -93,8 +93,10 @@ Class | Method | HTTP request | Description - [Animal](Animal.md) - [ApiResponse](ApiResponse.md) + - [Cat](Cat.md) - [Category](Category.md) - [Collar](Collar.md) + - [Dog](Dog.md) - [EnumTest](EnumTest.md) - [Kennel](Kennel.md) - [Order](Order.md) diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala index 3e62ce51c0ec..6a77fa7542e7 100644 --- a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Animal.scala @@ -15,10 +15,7 @@ import io.circe.{Decoder, DecodingFailure, Encoder, Json} import io.circe.syntax._ import org.openapitools.client.core.JsonSupport._ -sealed trait Animal { - def className: String - def color: Option[String] -} +trait Animal object Animal { implicit val encoderAnimal: Encoder[Animal] = Encoder.instance { case obj: Cat => obj.asJson.mapObject(("className" -> "CAT".asJson) +: _) @@ -33,60 +30,3 @@ object Animal { } } -case class Cat( - className: String, - color: Option[String] = None, - declawed: Option[Boolean] = None -) extends Animal -object Cat { - implicit val encoderCat: Encoder[Cat] = Encoder.instance { t => - Json.fromFields{ - Seq( - Some("className" -> t.className.asJson), - t.color.map(v => "color" -> v.asJson), - t.declawed.map(v => "declawed" -> v.asJson) - ).flatten - } - } - implicit val decoderCat: Decoder[Cat] = Decoder.instance { c => - for { - className <- c.downField("className").as[String] - color <- c.downField("color").as[Option[String]] - declawed <- c.downField("declawed").as[Option[Boolean]] - } yield Cat( - className = className, - color = color, - declawed = declawed - ) - } -} - -case class Dog( - className: String, - color: Option[String] = None, - breed: Option[String] = None -) extends Animal -object Dog { - implicit val encoderDog: Encoder[Dog] = Encoder.instance { t => - Json.fromFields{ - Seq( - Some("className" -> t.className.asJson), - t.color.map(v => "color" -> v.asJson), - t.breed.map(v => "breed" -> v.asJson) - ).flatten - } - } - implicit val decoderDog: Decoder[Dog] = Decoder.instance { c => - for { - className <- c.downField("className").as[String] - color <- c.downField("color").as[Option[String]] - breed <- c.downField("breed").as[Option[String]] - } yield Dog( - className = className, - color = color, - breed = breed - ) - } -} - - diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Cat.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Cat.scala new file mode 100644 index 000000000000..38fde3f6419c --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Cat.scala @@ -0,0 +1,45 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +case class Cat( + className: String, + color: Option[String] = None, + declawed: Option[Boolean] = None +) extends Animal +object Cat { + implicit val encoderCat: Encoder[Cat] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("className" -> t.className.asJson), + t.color.map(v => "color" -> v.asJson), + t.declawed.map(v => "declawed" -> v.asJson) + ).flatten + } + } + implicit val decoderCat: Decoder[Cat] = Decoder.instance { c => + for { + className <- c.downField("className").as[String] + color <- c.downField("color").as[Option[String]] + declawed <- c.downField("declawed").as[Option[Boolean]] + } yield Cat( + className = className, + color = color, + declawed = declawed + ) + } +} + diff --git a/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Dog.scala b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Dog.scala new file mode 100644 index 000000000000..56d25e4e722f --- /dev/null +++ b/samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Dog.scala @@ -0,0 +1,45 @@ +/** + * OpenAPI Petstore + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ +package org.openapitools.client.model + +import io.circe.{Decoder, DecodingFailure, Encoder, Json} +import io.circe.syntax._ +import org.openapitools.client.core.JsonSupport._ + +case class Dog( + className: String, + color: Option[String] = None, + breed: Option[String] = None +) extends Animal +object Dog { + implicit val encoderDog: Encoder[Dog] = Encoder.instance { t => + Json.fromFields{ + Seq( + Some("className" -> t.className.asJson), + t.color.map(v => "color" -> v.asJson), + t.breed.map(v => "breed" -> v.asJson) + ).flatten + } + } + implicit val decoderDog: Decoder[Dog] = Decoder.instance { c => + for { + className <- c.downField("className").as[String] + color <- c.downField("color").as[Option[String]] + breed <- c.downField("breed").as[Option[String]] + } yield Dog( + className = className, + color = color, + breed = breed + ) + } +} + From 821db67a8e939a316f43982073758fc812f172c5 Mon Sep 17 00:00:00 2001 From: Nikhil Sulegaon Date: Thu, 9 Apr 2026 18:13:40 -0700 Subject: [PATCH 6/6] Update test --- .../openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java index 2c73ed21950f..950987bfe911 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/ScalaSttpCirceCodegenTest.java @@ -156,6 +156,7 @@ public void verifyContainerWrappedRefPreventsInlining() throws IOException { // Animal is a regular trait (not sealed) because not all children can be inlined Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); assertFileContains(animalPath, "trait Animal"); + assertFileNotContains(animalPath, "sealed trait Animal"); assertFileNotContains(animalPath, "case class Cat"); assertFileNotContains(animalPath, "case class Dog");