From a0443cce82cd47c85f9638ef8aed384fecb71729 Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Fri, 6 Feb 2026 22:05:30 -0500 Subject: [PATCH 1/8] feat: add support for `oneOf` in sttp4 client --- .../languages/ScalaSttp4ClientCodegen.java | 108 ++++++++- .../main/resources/scala-sttp4/api.mustache | 4 +- .../scala-sttp4/jsonSupport.mustache | 12 +- .../main/resources/scala-sttp4/model.mustache | 219 ++++++++++++++++-- .../scala-sttp4/paramCreation.mustache | 2 +- .../codegen/scala/Sttp4CodegenTest.java | 149 ++++++++++++ .../3_0/scala/sttp4-oneOf-empty-members.yaml | 61 +++++ .../test/resources/3_0/scala/sttp4-oneOf.yaml | 107 +++++++++ 8 files changed, 622 insertions(+), 40 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf-empty-members.yaml create mode 100644 modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf.yaml diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java index 9c54671b8634..380a65320341 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java @@ -86,15 +86,17 @@ public ScalaSttp4ClientCodegen() { ) ); + // Enable oneOf interface generation + useOneOfInterfaces = true; + supportsMultipleInheritance = true; + supportsInheritance = true; + addOneOfInterfaceImports = true; + outputFolder = "generated-code/scala-sttp4"; modelTemplateFiles.put("model.mustache", ".scala"); apiTemplateFiles.put("api.mustache", ".scala"); embeddedTemplateDir = templateDir = "scala-sttp4"; - String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties); - - String jsonValueClass = "circe".equals(jsonLibrary) ? "io.circe.Json" : "org.json4s.JValue"; - additionalProperties.put(CodegenConstants.GROUP_ID, groupId); additionalProperties.put(CodegenConstants.ARTIFACT_ID, artifactId); additionalProperties.put(CodegenConstants.ARTIFACT_VERSION, artifactVersion); @@ -124,13 +126,12 @@ public ScalaSttp4ClientCodegen() { typeMapping.put("short", "Short"); typeMapping.put("char", "Char"); typeMapping.put("double", "Double"); - typeMapping.put("object", "Any"); typeMapping.put("file", "File"); typeMapping.put("binary", "File"); typeMapping.put("number", "Double"); typeMapping.put("decimal", "BigDecimal"); typeMapping.put("ByteArray", "Array[Byte]"); - typeMapping.put("AnyType", jsonValueClass); + // AnyType and object mapping will be set in processOpts() based on jsonLibrary instantiationTypes.put("array", "ListBuffer"); instantiationTypes.put("map", "Map"); @@ -149,6 +150,20 @@ public void processOpts() { apiPackage = PACKAGE_PROPERTY.getApiPackage(additionalProperties); modelPackage = PACKAGE_PROPERTY.getModelPackage(additionalProperties); + // Set AnyType and object mapping based on jsonLibrary + String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties); + if ("circe".equals(jsonLibrary)) { + typeMapping.put("AnyType", "io.circe.Json"); + typeMapping.put("object", "io.circe.JsonObject"); + importMapping.put("io.circe.Json", "io.circe.Json"); + importMapping.put("io.circe.JsonObject", "io.circe.JsonObject"); + } else { + typeMapping.put("AnyType", "org.json4s.JValue"); + typeMapping.put("object", "org.json4s.JObject"); + importMapping.put("org.json4s.JValue", "org.json4s.JValue"); + importMapping.put("org.json4s.JObject", "org.json4s.JObject"); + } + supportingFiles.add(new SupportingFile("README.mustache", "", "README.md")); supportingFiles.add(new SupportingFile("build.sbt.mustache", "", "build.sbt")); final String invokerFolder = (sourceFolder + File.separator + invokerPackage).replace(".", File.separator); @@ -221,6 +236,87 @@ public ModelsMap postProcessModels(ModelsMap objs) { @Override public Map postProcessAllModels(Map objs) { final Map processed = super.postProcessAllModels(objs); + + // First pass: count how many oneOf parents each model has + Map oneOfMemberCount = new HashMap<>(); + for (ModelsMap mm : processed.values()) { + for (ModelMap model : mm.getModels()) { + CodegenModel cModel = model.getModel(); + if (!cModel.oneOf.isEmpty()) { + for (String childName : cModel.oneOf) { + oneOfMemberCount.put(childName, oneOfMemberCount.getOrDefault(childName, 0) + 1); + } + } + } + } + + // Second pass: process models + for (ModelsMap mm : processed.values()) { + for (ModelMap model : mm.getModels()) { + CodegenModel cModel = model.getModel(); + + if (!cModel.oneOf.isEmpty()) { + cModel.getVendorExtensions().put("x-isSealedTrait", true); + + // Collect child models for inline generation + // Only inline if they are used exclusively by this oneOf parent + List childModels = new ArrayList<>(); + + for (String childName : cModel.oneOf) { + CodegenModel childModel = ModelUtils.getModelByName(childName, processed); + if (childModel != null && oneOfMemberCount.getOrDefault(childName, 0) == 1) { + // This child is only used by this parent - can be inlined + childModel.getVendorExtensions().put("x-isOneOfMember", true); + childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname); + + // Remove discriminator field from child if parent has discriminator + // (circe-generic-extras adds it automatically) + if (cModel.discriminator != null) { + String discriminatorName = cModel.discriminator.getPropertyName(); + childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + childModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + } + + childModels.add(childModel); + } + } + cModel.getVendorExtensions().put("x-oneOfMembers", childModels); + } else if (cModel.isEnum) { + cModel.getVendorExtensions().put("x-isEnum", true); + } else { + cModel.getVendorExtensions().put("x-isRegularModel", true); + } + + if (cModel.discriminator != null) { + cModel.getVendorExtensions().put("x-use-discr", true); + + if (cModel.discriminator.getMapping() != null) { + cModel.getVendorExtensions().put("x-use-discr-mapping", true); + } + } + + // Remove discriminator property from models that extend a oneOf parent + // (circe-generic-extras adds it automatically) + if (cModel.parent != null && cModel.parentModel != null && cModel.parentModel.discriminator != null) { + String discriminatorName = cModel.parentModel.discriminator.getPropertyName(); + cModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + cModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + cModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + cModel.optionalVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); + } + } + } + + // Third pass: remove oneOf members from the map to skip file generation + // (they are already inlined in their parent sealed trait) + processed.entrySet().removeIf(entry -> { + ModelsMap mm = entry.getValue(); + return mm.getModels().stream() + .anyMatch(model -> model.getModel().getVendorExtensions().containsKey("x-isOneOfMember")); + }); + postProcessUpdateImports(processed); return processed; } diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache index db73b440585a..c47cdc43bf6d 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/api.mustache @@ -20,7 +20,7 @@ class {{classname}}(baseUrl: String) { {{>javadoc}} {{/javadocRenderer}} - def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String, Exception], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] = + def {{operationId}}({{>methodParameters}}): Request[{{#separateErrorChannel}}Either[ResponseException[String], {{>operationReturnType}}]{{/separateErrorChannel}}{{^separateErrorChannel}}{{>operationReturnType}}{{/separateErrorChannel}}] = basicRequest .method(Method.{{httpMethod.toUpperCase}}, uri"$baseUrl{{{path}}}{{#queryParams.0}}?{{/queryParams.0}}{{#queryParams}}{{baseName}}=${ {{paramName}} }{{^-last}}&{{/-last}}{{/queryParams}}{{#authMethods}}{{#isApiKey}}{{#isKeyInQuery}}{{#queryParams.0}}&{{/queryParams.0}}{{^queryParams.0}}?{{/queryParams.0}}{{keyParamName}}=${apiKeyQuery}{{/isKeyInQuery}}{{/isApiKey}}{{/authMethods}}") .contentType({{#consumes.0}}"{{{mediaType}}}"{{/consumes.0}}{{^consumes}}"application/json"{{/consumes}}){{#headerParams}} @@ -35,7 +35,7 @@ class {{classname}}(baseUrl: String) { .multipartBody(Seq({{#formParams}} {{>paramMultipartCreation}}{{^-last}}, {{/-last}}{{/formParams}} ).flatten){{/isMultipart}}{{/formParams.0}}{{#bodyParam}} - .body({{paramName}}){{/bodyParam}} + .body(asJson({{paramName}})){{/bodyParam}} .response({{#separateErrorChannel}}{{^returnType}}asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(()))){{/returnType}}{{#returnType}}asJson[{{>operationReturnType}}]{{/returnType}}{{/separateErrorChannel}}{{^separateErrorChannel}}{{^returnType}}asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(()))).getRight{{/returnType}}{{#returnType}}asJson[{{>operationReturnType}}].getRight{{/returnType}}{{/separateErrorChannel}}) {{/operation}} diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/jsonSupport.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/jsonSupport.mustache index d8f929c43225..c0ba583b2771 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/jsonSupport.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/jsonSupport.mustache @@ -43,17 +43,9 @@ object JsonSupport extends SttpJson4sApi { {{#circe}} import io.circe.{Decoder, Encoder} import io.circe.generic.AutoDerivation -import sttp.client3.circe.SttpCirceApi +import sttp.client4.circe.SttpCirceApi object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers { - -{{#models}} -{{#model}} -{{#isEnum}} - implicit val {{classname}}Decoder: Decoder[{{classname}}.{{classname}}] = Decoder.decodeEnumeration({{classname}}) - implicit val {{classname}}Encoder: Encoder[{{classname}}.{{classname}}] = Encoder.encodeEnumeration({{classname}}) -{{/isEnum}} -{{/model}} -{{/models}} + // Enum encoders/decoders are defined in their respective companion objects } {{/circe}} diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache index ecece27a59a5..e2443d32f89d 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -7,6 +7,11 @@ import {{import}} {{#models}} {{#model}} +{{#vendorExtensions.x-isOneOfMember}} +// This case class is defined inline in {{vendorExtensions.x-oneOfParent}}.scala +// This file is intentionally minimal to avoid duplication. +{{/vendorExtensions.x-isOneOfMember}} +{{^vendorExtensions.x-isOneOfMember}} {{#description}} {{#javadocRenderer}} {{#title}} @@ -15,46 +20,218 @@ import {{import}} {{{description}}} {{/javadocRenderer}} {{/description}} -{{^isEnum}} +{{#vendorExtensions.x-isSealedTrait}} +sealed trait {{classname}} + +{{! Generate inline case classes for oneOf members }} +{{#vendorExtensions.x-oneOfMembers}} case class {{classname}}( - {{#vars}} + {{#allVars}} {{#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}} -) -{{/isEnum}} + {{{name}}}: {{^required}}Option[{{/required}}{{dataType}}{{^required}}] = None{{/required}}{{^-last}},{{/-last}} + {{/allVars}} + {{^allVars}} + {{! Empty case class for models with no properties }} + {{/allVars}} +) extends {{vendorExtensions.x-oneOfParent}} + +{{/vendorExtensions.x-oneOfMembers}} +object {{classname}} { +{{#json4s}} + import org.json4s._ + +{{^vendorExtensions.x-use-discr}} + // oneOf without discriminator - json4s custom serializer + implicit object {{classname}}Serializer extends Serializer[{{classname}}] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = { + case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) => + // Try each oneOf type in order + {{#oneOf}} + Extraction.extract[{{.}}](json) match { + case x: {{.}} => return x + case _ => // continue + } + {{/oneOf}} + throw new MappingException(s"Can't convert $json to {{classname}}") + } -{{#isEnum}} -object {{classname}} extends Enumeration { - type {{classname}} = {{classname}}.Value + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { + {{#oneOf}} + case x: {{.}} => Extraction.decompose(x) + {{/oneOf}} + } + } +{{/vendorExtensions.x-use-discr}} +{{#vendorExtensions.x-use-discr}} + // oneOf with discriminator + implicit object {{classname}}Serializer extends Serializer[{{classname}}] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = { + case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) => + (json \ "{{discriminator.propertyName}}") match { + {{#oneOf}} + case JString("{{.}}") => Extraction.extract[{{.}}](json) + {{/oneOf}} + case _ => throw new MappingException(s"Unknown discriminator value in $json") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { + {{#oneOf}} + case x: {{.}} => Extraction.decompose(x).merge(JObject("{{discriminator.propertyName}}" -> JString("{{.}}"))) + {{/oneOf}} + } + } +{{/vendorExtensions.x-use-discr}} +{{/json4s}} +{{#circe}} +{{^vendorExtensions.x-use-discr}} + // oneOf without discriminator - using semiauto derivation + import io.circe.{Encoder, Decoder} + import io.circe.generic.semiauto._ + import io.circe.generic.auto._ + + implicit val encoder: Encoder[{{classname}}] = deriveEncoder + implicit val decoder: Decoder[{{classname}}] = deriveDecoder +{{/vendorExtensions.x-use-discr}} +{{#vendorExtensions.x-use-discr}} + // oneOf with discriminator - using semiauto derivation with Configuration + import io.circe.{Encoder, Decoder} + import io.circe.generic.extras._ + import io.circe.generic.extras.semiauto._ + import io.circe.generic.auto._ + + private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyName}}") + + implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder + implicit val decoder: Decoder[{{classname}}] = deriveConfiguredDecoder +{{/vendorExtensions.x-use-discr}} +{{/circe}} +} +{{/vendorExtensions.x-isSealedTrait}} +{{#vendorExtensions.x-isEnum}} +sealed trait {{classname}} + +object {{classname}} { {{#allowableValues}} - {{#values}} - val {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} = Value("{{.}}") - {{/values}} + {{#values}} + case object {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} extends {{classname}} + {{/values}} {{/allowableValues}} + +{{#circe}} + import io.circe.{Encoder, Decoder} + + implicit val encoder: Encoder[{{classname}}] = Encoder.encodeString.contramap[{{classname}}] { +{{#allowableValues}} + {{#values}} + case {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} => "{{.}}" + {{/values}} +{{/allowableValues}} + } + + implicit val decoder: Decoder[{{classname}}] = Decoder.decodeString.emap { +{{#allowableValues}} + {{#values}} + case "{{.}}" => Right({{#fnEnumEntry}}{{.}}{{/fnEnumEntry}}) + {{/values}} +{{/allowableValues}} + case other => Left(s"Invalid {{classname}}: $other") + } +{{/circe}} +{{#json4s}} + import org.json4s._ + + implicit object {{classname}}Serializer extends Serializer[{{classname}}] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = { + case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) => + json match { +{{#allowableValues}} + {{#values}} + case JString("{{.}}") => {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} + {{/values}} +{{/allowableValues}} + case other => throw new MappingException(s"Invalid {{classname}}: $other") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { +{{#allowableValues}} + {{#values}} + case {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} => JString("{{.}}") + {{/values}} +{{/allowableValues}} + } + } +{{/json4s}} } +{{/vendorExtensions.x-isEnum}} +{{#vendorExtensions.x-isRegularModel}} +{{^isEnum}} +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}} +){{#parent}} extends {{parent}}{{/parent}} {{/isEnum}} {{#hasEnums}} object {{classname}}Enums { - - {{#vars}} - {{#isEnum}} - type {{datatypeWithEnum}} = {{datatypeWithEnum}}.Value - {{/isEnum}} - {{/vars}} {{#vars}} {{#isEnum}} - object {{datatypeWithEnum}} extends Enumeration { + + sealed trait {{datatypeWithEnum}} + object {{datatypeWithEnum}} { {{#_enum}} - val {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} = Value("{{.}}") + case object {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} extends {{datatypeWithEnum}} {{/_enum}} - } +{{#circe}} + import io.circe.{Encoder, Decoder} + + implicit val encoder: Encoder[{{datatypeWithEnum}}] = Encoder.encodeString.contramap[{{datatypeWithEnum}}] { +{{#_enum}} + case {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} => "{{.}}" +{{/_enum}} + } + + implicit val decoder: Decoder[{{datatypeWithEnum}}] = Decoder.decodeString.emap { +{{#_enum}} + case "{{.}}" => Right({{#fnEnumEntry}}{{.}}{{/fnEnumEntry}}) +{{/_enum}} + case other => Left(s"Invalid {{datatypeWithEnum}}: $other") + } +{{/circe}} +{{#json4s}} + import org.json4s._ + + implicit object {{datatypeWithEnum}}Serializer extends Serializer[{{datatypeWithEnum}}] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{datatypeWithEnum}}] = { + case (TypeInfo(clazz, _), json) if classOf[{{datatypeWithEnum}}].isAssignableFrom(clazz) => + json match { +{{#_enum}} + case JString("{{.}}") => {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} +{{/_enum}} + case other => throw new MappingException(s"Invalid {{datatypeWithEnum}}: $other") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { +{{#_enum}} + case {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} => JString("{{.}}") +{{/_enum}} + } + } +{{/json4s}} + } {{/isEnum}} {{/vars}} } {{/hasEnums}} +{{/vendorExtensions.x-isRegularModel}} +{{/vendorExtensions.x-isOneOfMember}} {{/model}} {{/models}} diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/paramCreation.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/paramCreation.mustache index 25ec73e8d5e1..c1dddfcd892e 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/paramCreation.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/paramCreation.mustache @@ -1 +1 @@ -"{{baseName}}", {{#isContainer}}ArrayValues({{{paramName}}}{{#collectionFormat}}, {{collectionFormat.toUpperCase}}{{/collectionFormat}}){{/isContainer}}{{^isContainer}}{{{paramName}}}{{/isContainer}}.toString \ No newline at end of file +"{{baseName}}", {{#isContainer}}{{{paramName}}}.mkString(","){{/isContainer}}{{^isContainer}}{{{paramName}}}{{/isContainer}}.toString \ No newline at end of file diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java index 74f7f43ae1e1..f0e7daf68584 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java @@ -53,4 +53,153 @@ public void verifyApiKeyLocations() throws IOException { assertFileContains(path, ".header(\"X-Api-Key\", apiKeyHeader)"); assertFileContains(path, ".cookie(\"apikey\", apiKeyCookie)"); } + + @Test + public void verifyOneOfSupportWithCirce() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + 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, "false"); + generator.opts(input).generate(); + + // Test oneOf without discriminator generates sealed trait with semiauto + Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); + assertFileContains(petPath, "sealed trait Pet"); + assertFileContains(petPath, "object Pet {"); + assertFileContains(petPath, "import io.circe.generic.semiauto._"); + assertFileContains(petPath, "// oneOf without discriminator - using semiauto derivation"); + assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder"); + assertFileContains(petPath, "implicit val decoder: Decoder[Pet] = deriveDecoder"); + + // Test oneOf with discriminator uses semiauto with Configuration + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "sealed trait Animal"); + assertFileContains(animalPath, "object Animal {"); + assertFileContains(animalPath, "import io.circe.generic.extras.semiauto._"); + assertFileContains(animalPath, "// oneOf with discriminator - using semiauto derivation with Configuration"); + assertFileContains(animalPath, "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"petType\")"); + assertFileContains(animalPath, "implicit val encoder: Encoder[Animal] = deriveConfiguredEncoder"); + assertFileContains(animalPath, "implicit val decoder: Decoder[Animal] = deriveConfiguredDecoder"); + + // Test oneOf with discriminator mapping + Path vehiclePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Vehicle.scala"); + assertFileContains(vehiclePath, "sealed trait Vehicle"); + assertFileContains(vehiclePath, "object Vehicle {"); + assertFileContains(vehiclePath, "// oneOf with discriminator - using semiauto derivation with Configuration"); + assertFileContains(vehiclePath, "Configuration.default.withDiscriminator(\"vehicleType\")"); + + // Verify regular models are still case classes + Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala"); + assertFileContains(dogPath, "case class Dog("); + assertFileContains(dogPath, "name: String"); + assertFileContains(dogPath, "breed: String"); + } + + @Test + public void verifyOneOfSupportWithJson4s() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put("jsonLibrary", "json4s"); + + 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, "false"); + generator.opts(input).generate(); + + // Test oneOf without discriminator generates sealed trait with json4s + Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); + assertFileContains(petPath, "sealed trait Pet"); + assertFileContains(petPath, "object Pet {"); + assertFileContains(petPath, "import org.json4s._"); + assertFileContains(petPath, "// oneOf without discriminator - json4s custom serializer"); + assertFileContains(petPath, "implicit object PetSerializer extends Serializer[Pet]"); + assertFileContains(petPath, "Extraction.extract[Dog](json)"); + assertFileContains(petPath, "Extraction.extract[Cat](json)"); + + // Test oneOf with discriminator + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "sealed trait Animal"); + assertFileContains(animalPath, "// oneOf with discriminator"); + assertFileContains(animalPath, "petType"); + } + + @Test + public void verifyOneOfWithEmptyMembers() 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/sttp4-oneOf-empty-members.yaml", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + 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, "false"); + generator.opts(input).generate(); + + // Test sealed trait is generated correctly + Path eventPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Event.scala"); + assertFileContains(eventPath, "sealed trait Event"); + + // Test empty case classes (no properties except discriminator which was removed) + assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); + assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); + + // Test case class with properties (PurchaseEvent has amount) + assertFileContains(eventPath, "case class PurchaseEvent("); + assertFileContains(eventPath, "amount: Double"); + + // Verify discriminator is configured + assertFileContains(eventPath, "Configuration.default.withDiscriminator(\"eventType\")"); + + // Verify the discriminator property was removed from inline members + // ClickEvent and ViewEvent should have NO properties at all + assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); + assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); + } } diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf-empty-members.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf-empty-members.yaml new file mode 100644 index 000000000000..3d2be6e556db --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf-empty-members.yaml @@ -0,0 +1,61 @@ +openapi: 3.0.0 +info: + title: OneOf Empty Members Test + version: 1.0.0 +paths: + /event: + post: + operationId: sendEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Event' + responses: + '200': + description: Success +components: + schemas: + Event: + oneOf: + - $ref: '#/components/schemas/ClickEvent' + - $ref: '#/components/schemas/ViewEvent' + - $ref: '#/components/schemas/PurchaseEvent' + discriminator: + propertyName: eventType + mapping: + click: '#/components/schemas/ClickEvent' + view: '#/components/schemas/ViewEvent' + purchase: '#/components/schemas/PurchaseEvent' + + ClickEvent: + type: object + required: + - eventType + properties: + eventType: + type: string + const: click + + ViewEvent: + type: object + required: + - eventType + properties: + eventType: + type: string + const: view + + PurchaseEvent: + type: object + required: + - eventType + - amount + properties: + eventType: + type: string + const: purchase + amount: + type: number + description: Purchase amount diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf.yaml new file mode 100644 index 000000000000..480996ce2d06 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-oneOf.yaml @@ -0,0 +1,107 @@ +openapi: 3.0.0 +info: + title: OneOf Test API + version: 1.0.0 +paths: + /pets: + get: + operationId: getPet + responses: + '200': + description: A pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' +components: + schemas: + Dog: + type: object + required: + - petType + - name + - breed + properties: + petType: + type: string + name: + type: string + breed: + type: string + barkVolume: + type: integer + + Cat: + type: object + required: + - petType + - name + - age + properties: + petType: + type: string + name: + type: string + age: + type: integer + indoor: + type: boolean + + # OneOf without discriminator + Pet: + oneOf: + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Cat' + + # OneOf with discriminator + Animal: + oneOf: + - $ref: '#/components/schemas/Dog' + - $ref: '#/components/schemas/Cat' + discriminator: + propertyName: petType + + # OneOf with discriminator and mapping + Vehicle: + oneOf: + - $ref: '#/components/schemas/Car' + - $ref: '#/components/schemas/Truck' + discriminator: + propertyName: vehicleType + mapping: + car: '#/components/schemas/Car' + truck: '#/components/schemas/Truck' + + Car: + type: object + required: + - vehicleType + - make + - model + properties: + vehicleType: + type: string + const: car + make: + type: string + model: + type: string + doors: + type: integer + + Truck: + type: object + required: + - vehicleType + - make + - model + properties: + vehicleType: + type: string + const: truck + make: + type: string + model: + type: string + bedLength: + type: number From 4437a9effdf051e6e5a2c56673b33b10656c80e4 Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Fri, 6 Feb 2026 23:43:21 -0500 Subject: [PATCH 2/8] fix: pass `Option[String]` directly to .header --- .../languages/ScalaSttp4ClientCodegen.java | 24 ++++++++++++++++--- .../scala-sttp4/methodParameters.mustache | 2 +- .../scala-sttp4/paramCreation.mustache | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java index 380a65320341..beaa93bddd89 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java @@ -226,6 +226,16 @@ public ModelsMap postProcessModels(ModelsMap objs) { return objs; } + private void setParameterDefaults(CodegenParameter param) { + // Set default values for optional parameters + // Template will handle Option[] wrapping, so all defaults should be None + if (!param.required) { + param.defaultValue = "None"; + } + } + + + /** * Invoked by {@link DefaultGenerator} after all models have been post-processed, * allowing for a last pass of codegen-specific model cleanup. @@ -445,6 +455,14 @@ public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List Date: Mon, 9 Feb 2026 12:32:49 -0500 Subject: [PATCH 3/8] fix: use oneOf mapping to map discriminator values to types --- .../languages/ScalaSttp4ClientCodegen.java | 21 +- .../main/resources/scala-sttp4/model.mustache | 8 +- .../codegen/scala/Sttp4CodegenTest.java | 366 +++++++++--------- 3 files changed, 212 insertions(+), 183 deletions(-) diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java index beaa93bddd89..bb5a883cfd3c 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java @@ -279,10 +279,27 @@ public Map postProcessAllModels(Map objs) childModel.getVendorExtensions().put("x-isOneOfMember", true); childModel.getVendorExtensions().put("x-oneOfParent", cModel.classname); - // Remove discriminator field from child if parent has discriminator - // (circe-generic-extras adds it automatically) + // Add discriminator mapping value if present if (cModel.discriminator != null) { String discriminatorName = cModel.discriminator.getPropertyName(); + + // Find the mapping value for this child model + String discriminatorValue = null; + if (cModel.discriminator.getMappedModels() != null) { + for (CodegenDiscriminator.MappedModel mappedModel : cModel.discriminator.getMappedModels()) { + if (mappedModel.getModelName().equals(childName)) { + discriminatorValue = mappedModel.getMappingName(); + break; + } + } + } + + if (discriminatorValue != null) { + childModel.getVendorExtensions().put("x-discriminator-value", discriminatorValue); + } + + // Remove discriminator field from child + // (circe-generic-extras adds it automatically) childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName)); childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache index e2443d32f89d..a94bc44b8acc 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -103,7 +103,13 @@ object {{classname}} { import io.circe.generic.auto._ private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyName}}") - + .copy( + transformConstructorNames = { +{{#vendorExtensions.x-oneOfMembers}} + case "{{classname}}" => "{{vendorExtensions.x-discriminator-value}}" +{{/vendorExtensions.x-oneOfMembers}} + } + ) implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder implicit val decoder: Decoder[{{classname}}] = deriveConfiguredDecoder {{/vendorExtensions.x-use-discr}} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java index f0e7daf68584..4f6f56248aa1 100644 --- a/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/Sttp4CodegenTest.java @@ -22,184 +22,190 @@ public class Sttp4CodegenTest { - @Test - public void verifyApiKeyLocations() throws IOException { - File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); - output.deleteOnExit(); - String outputPath = output.getAbsolutePath().replace('\\', '/'); - - OpenAPI openAPI = new OpenAPIParser() - .readLocation("src/test/resources/bugs/issue_13474.json", null, new ParseOptions()).getOpenAPI(); - - ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put(CXFServerFeatures.LOAD_TEST_DATA_FROM_FILE, "true"); - - 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, "true"); - generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); - generator.opts(input).generate(); - - Path path = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/api/DefaultApi.scala"); - assertFileContains(path, ".method(Method.GET, uri\"$baseUrl/entities/?api_key=${apiKeyQuery}\")\n"); - assertFileContains(path, ".header(\"X-Api-Key\", apiKeyHeader)"); - assertFileContains(path, ".cookie(\"apikey\", apiKeyCookie)"); - } - - @Test - public void verifyOneOfSupportWithCirce() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); - - ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); - 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, "false"); - generator.opts(input).generate(); - - // Test oneOf without discriminator generates sealed trait with semiauto - Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); - assertFileContains(petPath, "sealed trait Pet"); - assertFileContains(petPath, "object Pet {"); - assertFileContains(petPath, "import io.circe.generic.semiauto._"); - assertFileContains(petPath, "// oneOf without discriminator - using semiauto derivation"); - assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder"); - assertFileContains(petPath, "implicit val decoder: Decoder[Pet] = deriveDecoder"); - - // Test oneOf with discriminator uses semiauto with Configuration - Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); - assertFileContains(animalPath, "sealed trait Animal"); - assertFileContains(animalPath, "object Animal {"); - assertFileContains(animalPath, "import io.circe.generic.extras.semiauto._"); - assertFileContains(animalPath, "// oneOf with discriminator - using semiauto derivation with Configuration"); - assertFileContains(animalPath, "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"petType\")"); - assertFileContains(animalPath, "implicit val encoder: Encoder[Animal] = deriveConfiguredEncoder"); - assertFileContains(animalPath, "implicit val decoder: Decoder[Animal] = deriveConfiguredDecoder"); - - // Test oneOf with discriminator mapping - Path vehiclePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Vehicle.scala"); - assertFileContains(vehiclePath, "sealed trait Vehicle"); - assertFileContains(vehiclePath, "object Vehicle {"); - assertFileContains(vehiclePath, "// oneOf with discriminator - using semiauto derivation with Configuration"); - assertFileContains(vehiclePath, "Configuration.default.withDiscriminator(\"vehicleType\")"); - - // Verify regular models are still case classes - Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala"); - assertFileContains(dogPath, "case class Dog("); - assertFileContains(dogPath, "name: String"); - assertFileContains(dogPath, "breed: String"); - } - - @Test - public void verifyOneOfSupportWithJson4s() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); - - ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); - codegen.setOutputDir(output.getAbsolutePath()); - codegen.additionalProperties().put("jsonLibrary", "json4s"); - - 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, "false"); - generator.opts(input).generate(); - - // Test oneOf without discriminator generates sealed trait with json4s - Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); - assertFileContains(petPath, "sealed trait Pet"); - assertFileContains(petPath, "object Pet {"); - assertFileContains(petPath, "import org.json4s._"); - assertFileContains(petPath, "// oneOf without discriminator - json4s custom serializer"); - assertFileContains(petPath, "implicit object PetSerializer extends Serializer[Pet]"); - assertFileContains(petPath, "Extraction.extract[Dog](json)"); - assertFileContains(petPath, "Extraction.extract[Cat](json)"); - - // Test oneOf with discriminator - Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); - assertFileContains(animalPath, "sealed trait Animal"); - assertFileContains(animalPath, "// oneOf with discriminator"); - assertFileContains(animalPath, "petType"); - } - - @Test - public void verifyOneOfWithEmptyMembers() 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/sttp4-oneOf-empty-members.yaml", null, new ParseOptions()).getOpenAPI(); - - ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); - 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, "false"); - generator.opts(input).generate(); - - // Test sealed trait is generated correctly - Path eventPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Event.scala"); - assertFileContains(eventPath, "sealed trait Event"); - - // Test empty case classes (no properties except discriminator which was removed) - assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); - assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); - - // Test case class with properties (PurchaseEvent has amount) - assertFileContains(eventPath, "case class PurchaseEvent("); - assertFileContains(eventPath, "amount: Double"); - - // Verify discriminator is configured - assertFileContains(eventPath, "Configuration.default.withDiscriminator(\"eventType\")"); - - // Verify the discriminator property was removed from inline members - // ClickEvent and ViewEvent should have NO properties at all - assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); - assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); - } + @Test + public void verifyApiKeyLocations() throws IOException { + File output = Files.createTempDirectory("test").toFile().getCanonicalFile(); + output.deleteOnExit(); + String outputPath = output.getAbsolutePath().replace('\\', '/'); + + OpenAPI openAPI = new OpenAPIParser() + .readLocation("src/test/resources/bugs/issue_13474.json", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put(CXFServerFeatures.LOAD_TEST_DATA_FROM_FILE, "true"); + + 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, "true"); + generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "false"); + generator.opts(input).generate(); + + Path path = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/api/DefaultApi.scala"); + assertFileContains(path, ".method(Method.GET, uri\"$baseUrl/entities/?api_key=${apiKeyQuery}\")\n"); + assertFileContains(path, ".header(\"X-Api-Key\", apiKeyHeader)"); + assertFileContains(path, ".cookie(\"apikey\", apiKeyCookie)"); + } + + @Test + public void verifyOneOfSupportWithCirce() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + 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, "false"); + generator.opts(input).generate(); + + // Test oneOf without discriminator generates sealed trait with semiauto + Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); + assertFileContains(petPath, "sealed trait Pet"); + assertFileContains(petPath, "object Pet {"); + assertFileContains(petPath, "import io.circe.generic.semiauto._"); + assertFileContains(petPath, "// oneOf without discriminator - using semiauto derivation"); + assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder"); + assertFileContains(petPath, "implicit val decoder: Decoder[Pet] = deriveDecoder"); + + // Test oneOf with discriminator uses semiauto with Configuration + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "sealed trait Animal"); + assertFileContains(animalPath, "object Animal {"); + assertFileContains(animalPath, "import io.circe.generic.extras.semiauto._"); + assertFileContains(animalPath, "// oneOf with discriminator - using semiauto derivation with Configuration"); + assertFileContains(animalPath, + "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"petType\")"); + assertFileContains(animalPath, "implicit val encoder: Encoder[Animal] = deriveConfiguredEncoder"); + assertFileContains(animalPath, "implicit val decoder: Decoder[Animal] = deriveConfiguredDecoder"); + + // Test oneOf with discriminator mapping + Path vehiclePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Vehicle.scala"); + assertFileContains(vehiclePath, "sealed trait Vehicle"); + assertFileContains(vehiclePath, "object Vehicle {"); + assertFileContains(vehiclePath, "// oneOf with discriminator - using semiauto derivation with Configuration"); + assertFileContains(vehiclePath, + "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"vehicleType\")"); + assertFileContains(vehiclePath, "\"Car\" => \"car\""); + assertFileContains(vehiclePath, "\"Truck\" => \"truck\""); + + // Verify regular models are still case classes + Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala"); + assertFileContains(dogPath, "case class Dog("); + assertFileContains(dogPath, "name: String"); + assertFileContains(dogPath, "breed: String"); + } + + @Test + public void verifyOneOfSupportWithJson4s() 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/sttp4-oneOf.yaml", null, new ParseOptions()).getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + codegen.setOutputDir(output.getAbsolutePath()); + codegen.additionalProperties().put("jsonLibrary", "json4s"); + + 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, "false"); + generator.opts(input).generate(); + + // Test oneOf without discriminator generates sealed trait with json4s + Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); + assertFileContains(petPath, "sealed trait Pet"); + assertFileContains(petPath, "object Pet {"); + assertFileContains(petPath, "import org.json4s._"); + assertFileContains(petPath, "// oneOf without discriminator - json4s custom serializer"); + assertFileContains(petPath, "implicit object PetSerializer extends Serializer[Pet]"); + assertFileContains(petPath, "Extraction.extract[Dog](json)"); + assertFileContains(petPath, "Extraction.extract[Cat](json)"); + + // Test oneOf with discriminator + Path animalPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Animal.scala"); + assertFileContains(animalPath, "sealed trait Animal"); + assertFileContains(animalPath, "// oneOf with discriminator"); + assertFileContains(animalPath, "petType"); + } + + @Test + public void verifyOneOfWithEmptyMembers() 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/sttp4-oneOf-empty-members.yaml", null, new ParseOptions()) + .getOpenAPI(); + + ScalaSttp4ClientCodegen codegen = new ScalaSttp4ClientCodegen(); + 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, "false"); + generator.opts(input).generate(); + + // Test sealed trait is generated correctly + Path eventPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Event.scala"); + assertFileContains(eventPath, "sealed trait Event"); + + // Test empty case classes (no properties except discriminator which was + // removed) + assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); + assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); + + // Test case class with properties (PurchaseEvent has amount) + assertFileContains(eventPath, "case class PurchaseEvent("); + assertFileContains(eventPath, "amount: Double"); + + // Verify discriminator is configured + assertFileContains(eventPath, "Configuration.default.withDiscriminator(\"eventType\")"); + + // Verify the discriminator property was removed from inline members + // ClickEvent and ViewEvent should have NO properties at all + assertFileContains(eventPath, "case class ClickEvent(\n) extends Event"); + assertFileContains(eventPath, "case class ViewEvent(\n) extends Event"); + } } From bd2a4bdca834e0959ca26b66799887cebb20eff2 Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Mon, 9 Feb 2026 13:25:42 -0500 Subject: [PATCH 4/8] fix: remove unecessary whitespaces --- .../main/resources/scala-sttp4/model.mustache | 20 ++++++++-------- .../org/openapitools/client/api/PetApi.scala | 22 ++++++++--------- .../openapitools/client/api/StoreApi.scala | 10 ++++---- .../org/openapitools/client/api/UserApi.scala | 24 +++++++++---------- .../client/model/ApiResponse.scala | 1 - .../openapitools/client/model/Category.scala | 1 - 6 files changed, 38 insertions(+), 40 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache index a94bc44b8acc..d245fb11ba53 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -41,7 +41,7 @@ case class {{classname}}( object {{classname}} { {{#json4s}} import org.json4s._ - + {{^vendorExtensions.x-use-discr}} // oneOf without discriminator - json4s custom serializer implicit object {{classname}}Serializer extends Serializer[{{classname}}] { @@ -91,7 +91,7 @@ object {{classname}} { import io.circe.{Encoder, Decoder} import io.circe.generic.semiauto._ import io.circe.generic.auto._ - + implicit val encoder: Encoder[{{classname}}] = deriveEncoder implicit val decoder: Decoder[{{classname}}] = deriveDecoder {{/vendorExtensions.x-use-discr}} @@ -101,7 +101,7 @@ object {{classname}} { import io.circe.generic.extras._ import io.circe.generic.extras.semiauto._ import io.circe.generic.auto._ - + private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyName}}") .copy( transformConstructorNames = { @@ -128,7 +128,7 @@ object {{classname}} { {{#circe}} import io.circe.{Encoder, Decoder} - + implicit val encoder: Encoder[{{classname}}] = Encoder.encodeString.contramap[{{classname}}] { {{#allowableValues}} {{#values}} @@ -136,7 +136,7 @@ object {{classname}} { {{/values}} {{/allowableValues}} } - + implicit val decoder: Decoder[{{classname}}] = Decoder.decodeString.emap { {{#allowableValues}} {{#values}} @@ -148,7 +148,7 @@ object {{classname}} { {{/circe}} {{#json4s}} import org.json4s._ - + implicit object {{classname}}Serializer extends Serializer[{{classname}}] { def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{classname}}] = { case (TypeInfo(clazz, _), json) if classOf[{{classname}}].isAssignableFrom(clazz) => @@ -188,7 +188,7 @@ case class {{classname}}( object {{classname}}Enums { {{#vars}} {{#isEnum}} - + sealed trait {{datatypeWithEnum}} object {{datatypeWithEnum}} { {{#_enum}} @@ -197,13 +197,13 @@ object {{classname}}Enums { {{#circe}} import io.circe.{Encoder, Decoder} - + implicit val encoder: Encoder[{{datatypeWithEnum}}] = Encoder.encodeString.contramap[{{datatypeWithEnum}}] { {{#_enum}} case {{#fnEnumEntry}}{{.}}{{/fnEnumEntry}} => "{{.}}" {{/_enum}} } - + implicit val decoder: Decoder[{{datatypeWithEnum}}] = Decoder.decodeString.emap { {{#_enum}} case "{{.}}" => Right({{#fnEnumEntry}}{{.}}{{/fnEnumEntry}}) @@ -213,7 +213,7 @@ object {{classname}}Enums { {{/circe}} {{#json4s}} import org.json4s._ - + implicit object {{datatypeWithEnum}}Serializer extends Serializer[{{datatypeWithEnum}}] { def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), {{datatypeWithEnum}}] = { case (TypeInfo(clazz, _), json) if classOf[{{datatypeWithEnum}}].isAssignableFrom(clazz) => diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/PetApi.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/PetApi.scala index 89bc472e65f6..3744397ca2d1 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/PetApi.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/PetApi.scala @@ -33,11 +33,11 @@ class PetApi(baseUrl: String) { * * @param pet Pet object that needs to be added to the store */ - def addPet(pet: Pet): Request[Either[ResponseException[String, Exception], Pet]] = + def addPet(pet: Pet): Request[Either[ResponseException[String], Pet]] = basicRequest .method(Method.POST, uri"$baseUrl/pet") .contentType("application/json") - .body(pet) + .body(asJson(pet)) .response(asJson[Pet]) /** @@ -49,11 +49,11 @@ class PetApi(baseUrl: String) { * @param petId Pet id to delete * @param apiKey */ - def deletePet(petId: Long, apiKey: Option[String] = None): Request[Either[ResponseException[String, Exception], Unit]] = + def deletePet(petId: Long, apiKey: Option[String] = None): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.DELETE, uri"$baseUrl/pet/${petId}") .contentType("application/json") - .header("api_key", apiKey.toString) + .header("api_key", apiKey) .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) /** @@ -65,7 +65,7 @@ class PetApi(baseUrl: String) { * * @param status Status values that need to be considered for filter */ - def findPetsByStatus(status: Seq[String]): Request[Either[ResponseException[String, Exception], Seq[Pet]]] = + def findPetsByStatus(status: Seq[String] = Seq.empty): Request[Either[ResponseException[String], Seq[Pet]]] = basicRequest .method(Method.GET, uri"$baseUrl/pet/findByStatus?status=${ status }") .contentType("application/json") @@ -80,7 +80,7 @@ class PetApi(baseUrl: String) { * * @param tags Tags to filter by */ - def findPetsByTags(tags: Seq[String]): Request[Either[ResponseException[String, Exception], Seq[Pet]]] = + def findPetsByTags(tags: Seq[String] = Seq.empty): Request[Either[ResponseException[String], Seq[Pet]]] = basicRequest .method(Method.GET, uri"$baseUrl/pet/findByTags?tags=${ tags }") .contentType("application/json") @@ -99,7 +99,7 @@ class PetApi(baseUrl: String) { * * @param petId ID of pet to return */ - def getPetById(apiKeyHeader: String)(petId: Long): Request[Either[ResponseException[String, Exception], Pet]] = + def getPetById(apiKeyHeader: String)(petId: Long): Request[Either[ResponseException[String], Pet]] = basicRequest .method(Method.GET, uri"$baseUrl/pet/${petId}") .contentType("application/json") @@ -117,11 +117,11 @@ class PetApi(baseUrl: String) { * * @param pet Pet object that needs to be added to the store */ - def updatePet(pet: Pet): Request[Either[ResponseException[String, Exception], Pet]] = + def updatePet(pet: Pet): Request[Either[ResponseException[String], Pet]] = basicRequest .method(Method.PUT, uri"$baseUrl/pet") .contentType("application/json") - .body(pet) + .body(asJson(pet)) .response(asJson[Pet]) /** @@ -134,7 +134,7 @@ class PetApi(baseUrl: String) { * @param name Updated name of the pet * @param status Updated status of the pet */ - def updatePetWithForm(petId: Long, name: Option[String] = None, status: Option[String] = None): Request[Either[ResponseException[String, Exception], Unit]] = + def updatePetWithForm(petId: Long, name: Option[String] = None, status: Option[String] = None): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.POST, uri"$baseUrl/pet/${petId}") .contentType("application/x-www-form-urlencoded") @@ -154,7 +154,7 @@ class PetApi(baseUrl: String) { * @param additionalMetadata Additional data to pass to server * @param file file to upload */ - def uploadFile(petId: Long, additionalMetadata: Option[String] = None, file: Option[File] = None): Request[Either[ResponseException[String, Exception], ApiResponse]] = + def uploadFile(petId: Long, additionalMetadata: Option[String] = None, file: Option[File] = None): Request[Either[ResponseException[String], ApiResponse]] = basicRequest .method(Method.POST, uri"$baseUrl/pet/${petId}/uploadImage") .contentType("multipart/form-data") diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/StoreApi.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/StoreApi.scala index 507611de4d13..27efb05281b1 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/StoreApi.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/StoreApi.scala @@ -31,7 +31,7 @@ class StoreApi(baseUrl: String) { * * @param orderId ID of the order that needs to be deleted */ - def deleteOrder(orderId: String): Request[Either[ResponseException[String, Exception], Unit]] = + def deleteOrder(orderId: String): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.DELETE, uri"$baseUrl/store/order/${orderId}") .contentType("application/json") @@ -46,7 +46,7 @@ class StoreApi(baseUrl: String) { * Available security schemes: * api_key (apiKey) */ - def getInventory(apiKeyHeader: String)(): Request[Either[ResponseException[String, Exception], Map[String, Int]]] = + def getInventory(apiKeyHeader: String)(): Request[Either[ResponseException[String], Map[String, Int]]] = basicRequest .method(Method.GET, uri"$baseUrl/store/inventory") .contentType("application/json") @@ -63,7 +63,7 @@ class StoreApi(baseUrl: String) { * * @param orderId ID of pet that needs to be fetched */ - def getOrderById(orderId: Long): Request[Either[ResponseException[String, Exception], Order]] = + def getOrderById(orderId: Long): Request[Either[ResponseException[String], Order]] = basicRequest .method(Method.GET, uri"$baseUrl/store/order/${orderId}") .contentType("application/json") @@ -78,11 +78,11 @@ class StoreApi(baseUrl: String) { * * @param order order placed for purchasing the pet */ - def placeOrder(order: Order): Request[Either[ResponseException[String, Exception], Order]] = + def placeOrder(order: Order): Request[Either[ResponseException[String], Order]] = basicRequest .method(Method.POST, uri"$baseUrl/store/order") .contentType("application/json") - .body(order) + .body(asJson(order)) .response(asJson[Order]) } diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/UserApi.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/UserApi.scala index 67a17958468e..176c21e2b316 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/UserApi.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/api/UserApi.scala @@ -34,12 +34,12 @@ class UserApi(baseUrl: String) { * * @param user Created user object */ - def createUser(apiKeyHeader: String)(user: User): Request[Either[ResponseException[String, Exception], Unit]] = + def createUser(apiKeyHeader: String)(user: User): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.POST, uri"$baseUrl/user") .contentType("application/json") .header("api_key", apiKeyHeader) - .body(user) + .body(asJson(user)) .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) /** @@ -53,12 +53,12 @@ class UserApi(baseUrl: String) { * * @param user List of user object */ - def createUsersWithArrayInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String, Exception], Unit]] = + def createUsersWithArrayInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.POST, uri"$baseUrl/user/createWithArray") .contentType("application/json") .header("api_key", apiKeyHeader) - .body(user) + .body(asJson(user)) .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) /** @@ -72,12 +72,12 @@ class UserApi(baseUrl: String) { * * @param user List of user object */ - def createUsersWithListInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String, Exception], Unit]] = + def createUsersWithListInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.POST, uri"$baseUrl/user/createWithList") .contentType("application/json") .header("api_key", apiKeyHeader) - .body(user) + .body(asJson(user)) .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) /** @@ -92,7 +92,7 @@ class UserApi(baseUrl: String) { * * @param username The name that needs to be deleted */ - def deleteUser(apiKeyHeader: String)(username: String): Request[Either[ResponseException[String, Exception], Unit]] = + def deleteUser(apiKeyHeader: String)(username: String): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.DELETE, uri"$baseUrl/user/${username}") .contentType("application/json") @@ -109,7 +109,7 @@ class UserApi(baseUrl: String) { * * @param username The name that needs to be fetched. Use user1 for testing. */ - def getUserByName(username: String): Request[Either[ResponseException[String, Exception], User]] = + def getUserByName(username: String): Request[Either[ResponseException[String], User]] = basicRequest .method(Method.GET, uri"$baseUrl/user/${username}") .contentType("application/json") @@ -129,7 +129,7 @@ class UserApi(baseUrl: String) { * @param username The user name for login * @param password The password for login in clear text */ - def loginUser(username: String, password: String): Request[Either[ResponseException[String, Exception], String]] = + def loginUser(username: String, password: String): Request[Either[ResponseException[String], String]] = basicRequest .method(Method.GET, uri"$baseUrl/user/login?username=${ username }&password=${ password }") .contentType("application/json") @@ -144,7 +144,7 @@ class UserApi(baseUrl: String) { * Available security schemes: * api_key (apiKey) */ - def logoutUser(apiKeyHeader: String)(): Request[Either[ResponseException[String, Exception], Unit]] = + def logoutUser(apiKeyHeader: String)(): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.GET, uri"$baseUrl/user/logout") .contentType("application/json") @@ -164,12 +164,12 @@ class UserApi(baseUrl: String) { * @param username name that need to be deleted * @param user Updated user object */ - def updateUser(apiKeyHeader: String)(username: String, user: User): Request[Either[ResponseException[String, Exception], Unit]] = + def updateUser(apiKeyHeader: String)(username: String, user: User): Request[Either[ResponseException[String], Unit]] = basicRequest .method(Method.PUT, uri"$baseUrl/user/${username}") .contentType("application/json") .header("api_key", apiKeyHeader) - .body(user) + .body(asJson(user)) .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) } diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/ApiResponse.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/ApiResponse.scala index b0abb512265e..9d09300eae38 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/ApiResponse.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/ApiResponse.scala @@ -21,4 +21,3 @@ case class ApiResponse( `type`: Option[String] = None, message: Option[String] = None ) - diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Category.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Category.scala index 169acc49cefd..c0c51c5e53eb 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Category.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Category.scala @@ -20,4 +20,3 @@ case class Category( id: Option[Long] = None, name: Option[String] = None ) - From e59402398ece4b7baff3e08637360f4c65129acf Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Mon, 9 Feb 2026 13:48:52 -0500 Subject: [PATCH 5/8] fix: generate samples, make a new one for sttp4-circe --- bin/configs/scala-sttp4-circe.yaml | 7 + .../languages/ScalaSttp4ClientCodegen.java | 8 +- .../resources/scala-sttp4/build.sbt.mustache | 2 + .../.openapi-generator-ignore | 23 +++ .../.openapi-generator/FILES | 15 ++ .../.openapi-generator/VERSION | 1 + .../petstore/scala-sttp4-circe/README.md | 117 ++++++++++++ .../petstore/scala-sttp4-circe/build.sbt | 19 ++ .../project/build.properties | 1 + .../org/openapitools/client/api/PetApi.scala | 169 +++++++++++++++++ .../openapitools/client/api/StoreApi.scala | 88 +++++++++ .../org/openapitools/client/api/UserApi.scala | 175 ++++++++++++++++++ .../core/AdditionalTypeSerializers.scala | 21 +++ .../client/core/DateSerializers.scala | 13 ++ .../client/core/JsonSupport.scala | 21 +++ .../client/model/ApiResponse.scala | 23 +++ .../openapitools/client/model/Category.scala | 22 +++ .../org/openapitools/client/model/Order.scala | 52 ++++++ .../org/openapitools/client/model/Pet.scala | 51 +++++ .../org/openapitools/client/model/Tag.scala | 22 +++ .../org/openapitools/client/model/User.scala | 29 +++ samples/client/petstore/scala-sttp4/build.sbt | 4 +- .../org/openapitools/client/model/Order.scala | 32 +++- .../org/openapitools/client/model/Pet.scala | 32 +++- .../org/openapitools/client/model/Tag.scala | 1 - .../org/openapitools/client/model/User.scala | 1 - 26 files changed, 929 insertions(+), 20 deletions(-) create mode 100644 bin/configs/scala-sttp4-circe.yaml create mode 100644 samples/client/petstore/scala-sttp4-circe/.openapi-generator-ignore create mode 100644 samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES create mode 100644 samples/client/petstore/scala-sttp4-circe/.openapi-generator/VERSION create mode 100644 samples/client/petstore/scala-sttp4-circe/README.md create mode 100644 samples/client/petstore/scala-sttp4-circe/build.sbt create mode 100644 samples/client/petstore/scala-sttp4-circe/project/build.properties create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/PetApi.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/StoreApi.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/UserApi.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/DateSerializers.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/JsonSupport.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala create mode 100644 samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala diff --git a/bin/configs/scala-sttp4-circe.yaml b/bin/configs/scala-sttp4-circe.yaml new file mode 100644 index 000000000000..330a9ae1803d --- /dev/null +++ b/bin/configs/scala-sttp4-circe.yaml @@ -0,0 +1,7 @@ +generatorName: scala-sttp4 +outputDir: samples/client/petstore/scala-sttp4-circe +inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +templateDir: modules/openapi-generator/src/main/resources/scala-sttp4 +additionalProperties: + hideGenerationTimestamp: "true" + jsonLibrary: "circe" diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java index bb5a883cfd3c..7385f7115518 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaSttp4ClientCodegen.java @@ -24,7 +24,7 @@ public class ScalaSttp4ClientCodegen extends AbstractScalaCodegen implements CodegenConfig { private static final StringProperty STTP_CLIENT_VERSION = new StringProperty("sttpClientVersion", "The version of " + - "sttp client", "4.0.0-M1"); + "sttp client", "4.0.15"); private static final BooleanProperty USE_SEPARATE_ERROR_CHANNEL = new BooleanProperty("separateErrorChannel", "Whether to return response as " + "F[Either[ResponseError[ErrorType], ReturnType]]] or to flatten " + @@ -33,6 +33,10 @@ public class ScalaSttp4ClientCodegen extends AbstractScalaCodegen implements Cod "joda-time library", "2.10.13"); private static final StringProperty JSON4S_VERSION = new StringProperty("json4sVersion", "The version of json4s " + "library", "4.0.6"); + private static final StringProperty CIRCE_VERSION = new StringProperty("circeVersion", "The version of circe " + + "library", "0.14.15"); + private static final StringProperty CIRCE_EXTRAS_VERSION = new StringProperty("circeExtrasVersion", + "The version of circe-generic-extras library", "0.14.4"); private static final JsonLibraryProperty JSON_LIBRARY_PROPERTY = new JsonLibraryProperty(); @@ -41,7 +45,7 @@ public class ScalaSttp4ClientCodegen extends AbstractScalaCodegen implements Cod private static final List> properties = Arrays.asList( STTP_CLIENT_VERSION, USE_SEPARATE_ERROR_CHANNEL, JODA_TIME_VERSION, - JSON4S_VERSION, JSON_LIBRARY_PROPERTY, PACKAGE_PROPERTY); + JSON4S_VERSION, CIRCE_VERSION, CIRCE_EXTRAS_VERSION, JSON_LIBRARY_PROPERTY, PACKAGE_PROPERTY); private final Logger LOGGER = LoggerFactory.getLogger(ScalaSttp4ClientCodegen.class); diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache index d915432ab27a..1c55e7c67cbc 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache @@ -16,6 +16,8 @@ libraryDependencies ++= Seq( {{/json4s}} {{#circe}} "com.softwaremill.sttp.client4" %% "circe" % "{{sttpClientVersion}}" + "io.circe" %% "circe-generic" % "{{circeVersion}}", + "io.circe" %% "circe-generic-extras" % "{{circeExtrasVersion}}", {{/circe}} ) diff --git a/samples/client/petstore/scala-sttp4-circe/.openapi-generator-ignore b/samples/client/petstore/scala-sttp4-circe/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES new file mode 100644 index 000000000000..93bed4b07d22 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES @@ -0,0 +1,15 @@ +README.md +build.sbt +project/build.properties +src/main/scala/org/openapitools/client/api/PetApi.scala +src/main/scala/org/openapitools/client/api/StoreApi.scala +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/ApiResponse.scala +src/main/scala/org/openapitools/client/model/Category.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/Tag.scala +src/main/scala/org/openapitools/client/model/User.scala diff --git a/samples/client/petstore/scala-sttp4-circe/.openapi-generator/VERSION b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/VERSION new file mode 100644 index 000000000000..193a12d6e891 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.20.0-SNAPSHOT diff --git a/samples/client/petstore/scala-sttp4-circe/README.md b/samples/client/petstore/scala-sttp4-circe/README.md new file mode 100644 index 000000000000..e2d5251a5750 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/README.md @@ -0,0 +1,117 @@ +# openapi-client + +OpenAPI Petstore +- API version: 1.0.0 + - Generator version: 7.20.0-SNAPSHOT + +This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + + +*Automatically generated by the [OpenAPI Generator](https://openapi-generator.tech)* + +## Requirements + +Building the API client library requires: +1. Java 1.7+ +2. Maven/Gradle/SBT + +## Installation + +To install the API client library to your local Maven repository, simply execute: + +```shell +mvn clean install +``` + +To deploy it to a remote Maven repository instead, configure the settings of the repository and execute: + +```shell +mvn clean deploy +``` + +Refer to the [OSSRH Guide](http://central.sonatype.org/pages/ossrh-guide.html) for more information. + +### Maven users + +Add this dependency to your project's POM: + +```xml + + org.openapitools + openapi-client + 1.0.0 + compile + +``` + +### Gradle users + +Add this dependency to your project's build file: + +```groovy +compile "org.openapitools:openapi-client:1.0.0" +``` + +### SBT users + +```scala +libraryDependencies += "org.openapitools" % "openapi-client" % "1.0.0" +``` + +## Getting Started + +## Documentation for API Endpoints + +All URIs are relative to *http://petstore.swagger.io/v2* + +Class | Method | HTTP request | Description +------------ | ------------- | ------------- | ------------- +*PetApi* | **addPet** | **POST** /pet | Add a new pet to the store +*PetApi* | **deletePet** | **DELETE** /pet/${petId} | Deletes a pet +*PetApi* | **findPetsByStatus** | **GET** /pet/findByStatus | Finds Pets by status +*PetApi* | **findPetsByTags** | **GET** /pet/findByTags | Finds Pets by tags +*PetApi* | **getPetById** | **GET** /pet/${petId} | Find pet by ID +*PetApi* | **updatePet** | **PUT** /pet | Update an existing pet +*PetApi* | **updatePetWithForm** | **POST** /pet/${petId} | Updates a pet in the store with form data +*PetApi* | **uploadFile** | **POST** /pet/${petId}/uploadImage | uploads an image +*StoreApi* | **deleteOrder** | **DELETE** /store/order/${orderId} | Delete purchase order by ID +*StoreApi* | **getInventory** | **GET** /store/inventory | Returns pet inventories by status +*StoreApi* | **getOrderById** | **GET** /store/order/${orderId} | Find purchase order by ID +*StoreApi* | **placeOrder** | **POST** /store/order | Place an order for a pet +*UserApi* | **createUser** | **POST** /user | Create user +*UserApi* | **createUsersWithArrayInput** | **POST** /user/createWithArray | Creates list of users with given input array +*UserApi* | **createUsersWithListInput** | **POST** /user/createWithList | Creates list of users with given input array +*UserApi* | **deleteUser** | **DELETE** /user/${username} | Delete user +*UserApi* | **getUserByName** | **GET** /user/${username} | Get user by user name +*UserApi* | **loginUser** | **GET** /user/login | Logs user into the system +*UserApi* | **logoutUser** | **GET** /user/logout | Logs out current logged in user session +*UserApi* | **updateUser** | **PUT** /user/${username} | Updated user + + +## Documentation for Models + + - [ApiResponse](ApiResponse.md) + - [Category](Category.md) + - [Order](Order.md) + - [Pet](Pet.md) + - [Tag](Tag.md) + - [User](User.md) + + + +## Documentation for Authorization + + +Authentication schemes defined for the API: + + ### api_key + + - **Type**: API key + - **API key parameter name**: api_key + - **Location**: HTTP header + + +## Author + + + diff --git a/samples/client/petstore/scala-sttp4-circe/build.sbt b/samples/client/petstore/scala-sttp4-circe/build.sbt new file mode 100644 index 000000000000..7268c6e3f1d3 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/build.sbt @@ -0,0 +1,19 @@ +version := "1.0.0" +name := "openapi-client" +organization := "org.openapitools" + +scalaVersion := "2.13.16" +crossScalaVersions := Seq(scalaVersion.value, "2.12.20") + +libraryDependencies ++= Seq( + "com.softwaremill.sttp.client4" %% "core" % "4.0.15", + "com.softwaremill.sttp.client4" %% "circe" % "4.0.15" + "io.circe" %% "circe-generic" % "0.14.15", + "io.circe" %% "circe-generic-extras" % "0.14.4", +) + +scalacOptions := Seq( + "-unchecked", + "-deprecation", + "-feature" +) diff --git a/samples/client/petstore/scala-sttp4-circe/project/build.properties b/samples/client/petstore/scala-sttp4-circe/project/build.properties new file mode 100644 index 000000000000..cc68b53f1a30 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.11 diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/PetApi.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/PetApi.scala new file mode 100644 index 000000000000..3744397ca2d1 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/PetApi.scala @@ -0,0 +1,169 @@ +/** + * 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.api + +import org.openapitools.client.model.ApiResponse +import java.io.File +import org.openapitools.client.model.Pet +import org.openapitools.client.core.JsonSupport._ +import sttp.client4._ +import sttp.model.Method + +object PetApi { + def apply(baseUrl: String = "http://petstore.swagger.io/v2") = new PetApi(baseUrl) +} + +class PetApi(baseUrl: String) { + + /** + * + * + * Expected answers: + * code 200 : Pet (successful operation) + * code 405 : (Invalid input) + * + * @param pet Pet object that needs to be added to the store + */ + def addPet(pet: Pet): Request[Either[ResponseException[String], Pet]] = + basicRequest + .method(Method.POST, uri"$baseUrl/pet") + .contentType("application/json") + .body(asJson(pet)) + .response(asJson[Pet]) + + /** + * + * + * Expected answers: + * code 400 : (Invalid pet value) + * + * @param petId Pet id to delete + * @param apiKey + */ + def deletePet(petId: Long, apiKey: Option[String] = None): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.DELETE, uri"$baseUrl/pet/${petId}") + .contentType("application/json") + .header("api_key", apiKey) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * Multiple status values can be provided with comma separated strings + * + * Expected answers: + * code 200 : Seq[Pet] (successful operation) + * code 400 : (Invalid status value) + * + * @param status Status values that need to be considered for filter + */ + def findPetsByStatus(status: Seq[String] = Seq.empty): Request[Either[ResponseException[String], Seq[Pet]]] = + basicRequest + .method(Method.GET, uri"$baseUrl/pet/findByStatus?status=${ status }") + .contentType("application/json") + .response(asJson[Seq[Pet]]) + + /** + * Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + * + * Expected answers: + * code 200 : Seq[Pet] (successful operation) + * code 400 : (Invalid tag value) + * + * @param tags Tags to filter by + */ + def findPetsByTags(tags: Seq[String] = Seq.empty): Request[Either[ResponseException[String], Seq[Pet]]] = + basicRequest + .method(Method.GET, uri"$baseUrl/pet/findByTags?tags=${ tags }") + .contentType("application/json") + .response(asJson[Seq[Pet]]) + + /** + * Returns a single pet + * + * Expected answers: + * code 200 : Pet (successful operation) + * code 400 : (Invalid ID supplied) + * code 404 : (Pet not found) + * + * Available security schemes: + * api_key (apiKey) + * + * @param petId ID of pet to return + */ + def getPetById(apiKeyHeader: String)(petId: Long): Request[Either[ResponseException[String], Pet]] = + basicRequest + .method(Method.GET, uri"$baseUrl/pet/${petId}") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .response(asJson[Pet]) + + /** + * + * + * Expected answers: + * code 200 : Pet (successful operation) + * code 400 : (Invalid ID supplied) + * code 404 : (Pet not found) + * code 405 : (Validation exception) + * + * @param pet Pet object that needs to be added to the store + */ + def updatePet(pet: Pet): Request[Either[ResponseException[String], Pet]] = + basicRequest + .method(Method.PUT, uri"$baseUrl/pet") + .contentType("application/json") + .body(asJson(pet)) + .response(asJson[Pet]) + + /** + * + * + * Expected answers: + * code 405 : (Invalid input) + * + * @param petId ID of pet that needs to be updated + * @param name Updated name of the pet + * @param status Updated status of the pet + */ + def updatePetWithForm(petId: Long, name: Option[String] = None, status: Option[String] = None): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.POST, uri"$baseUrl/pet/${petId}") + .contentType("application/x-www-form-urlencoded") + .body(Map( + "name" -> name, + "status" -> status + )) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * + * + * Expected answers: + * code 200 : ApiResponse (successful operation) + * + * @param petId ID of pet to update + * @param additionalMetadata Additional data to pass to server + * @param file file to upload + */ + def uploadFile(petId: Long, additionalMetadata: Option[String] = None, file: Option[File] = None): Request[Either[ResponseException[String], ApiResponse]] = + basicRequest + .method(Method.POST, uri"$baseUrl/pet/${petId}/uploadImage") + .contentType("multipart/form-data") + .multipartBody(Seq( + additionalMetadata.map(multipart("additionalMetadata", _)) +, + file.map(multipartFile("file", _)) + + ).flatten) + .response(asJson[ApiResponse]) + +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/StoreApi.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/StoreApi.scala new file mode 100644 index 000000000000..27efb05281b1 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/StoreApi.scala @@ -0,0 +1,88 @@ +/** + * 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.api + +import org.openapitools.client.model.Order +import org.openapitools.client.core.JsonSupport._ +import sttp.client4._ +import sttp.model.Method + +object StoreApi { + def apply(baseUrl: String = "http://petstore.swagger.io/v2") = new StoreApi(baseUrl) +} + +class StoreApi(baseUrl: String) { + + /** + * For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + * + * Expected answers: + * code 400 : (Invalid ID supplied) + * code 404 : (Order not found) + * + * @param orderId ID of the order that needs to be deleted + */ + def deleteOrder(orderId: String): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.DELETE, uri"$baseUrl/store/order/${orderId}") + .contentType("application/json") + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * Returns a map of status codes to quantities + * + * Expected answers: + * code 200 : Map[String, Int] (successful operation) + * + * Available security schemes: + * api_key (apiKey) + */ + def getInventory(apiKeyHeader: String)(): Request[Either[ResponseException[String], Map[String, Int]]] = + basicRequest + .method(Method.GET, uri"$baseUrl/store/inventory") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .response(asJson[Map[String, Int]]) + + /** + * For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions + * + * Expected answers: + * code 200 : Order (successful operation) + * code 400 : (Invalid ID supplied) + * code 404 : (Order not found) + * + * @param orderId ID of pet that needs to be fetched + */ + def getOrderById(orderId: Long): Request[Either[ResponseException[String], Order]] = + basicRequest + .method(Method.GET, uri"$baseUrl/store/order/${orderId}") + .contentType("application/json") + .response(asJson[Order]) + + /** + * + * + * Expected answers: + * code 200 : Order (successful operation) + * code 400 : (Invalid Order) + * + * @param order order placed for purchasing the pet + */ + def placeOrder(order: Order): Request[Either[ResponseException[String], Order]] = + basicRequest + .method(Method.POST, uri"$baseUrl/store/order") + .contentType("application/json") + .body(asJson(order)) + .response(asJson[Order]) + +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/UserApi.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/UserApi.scala new file mode 100644 index 000000000000..176c21e2b316 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/api/UserApi.scala @@ -0,0 +1,175 @@ +/** + * 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.api + +import java.time.OffsetDateTime +import org.openapitools.client.model.User +import org.openapitools.client.core.JsonSupport._ +import sttp.client4._ +import sttp.model.Method + +object UserApi { + def apply(baseUrl: String = "http://petstore.swagger.io/v2") = new UserApi(baseUrl) +} + +class UserApi(baseUrl: String) { + + /** + * This can only be done by the logged in user. + * + * Expected answers: + * code 0 : (successful operation) + * + * Available security schemes: + * api_key (apiKey) + * + * @param user Created user object + */ + def createUser(apiKeyHeader: String)(user: User): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.POST, uri"$baseUrl/user") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .body(asJson(user)) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * + * + * Expected answers: + * code 0 : (successful operation) + * + * Available security schemes: + * api_key (apiKey) + * + * @param user List of user object + */ + def createUsersWithArrayInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.POST, uri"$baseUrl/user/createWithArray") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .body(asJson(user)) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * + * + * Expected answers: + * code 0 : (successful operation) + * + * Available security schemes: + * api_key (apiKey) + * + * @param user List of user object + */ + def createUsersWithListInput(apiKeyHeader: String)(user: Seq[User]): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.POST, uri"$baseUrl/user/createWithList") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .body(asJson(user)) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * This can only be done by the logged in user. + * + * Expected answers: + * code 400 : (Invalid username supplied) + * code 404 : (User not found) + * + * Available security schemes: + * api_key (apiKey) + * + * @param username The name that needs to be deleted + */ + def deleteUser(apiKeyHeader: String)(username: String): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.DELETE, uri"$baseUrl/user/${username}") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * + * + * Expected answers: + * code 200 : User (successful operation) + * code 400 : (Invalid username supplied) + * code 404 : (User not found) + * + * @param username The name that needs to be fetched. Use user1 for testing. + */ + def getUserByName(username: String): Request[Either[ResponseException[String], User]] = + basicRequest + .method(Method.GET, uri"$baseUrl/user/${username}") + .contentType("application/json") + .response(asJson[User]) + + /** + * + * + * Expected answers: + * code 200 : String (successful operation) + * Headers : + * Set-Cookie - Cookie authentication key for use with the `api_key` apiKey authentication. + * X-Rate-Limit - calls per hour allowed by the user + * X-Expires-After - date in UTC when token expires + * code 400 : (Invalid username/password supplied) + * + * @param username The user name for login + * @param password The password for login in clear text + */ + def loginUser(username: String, password: String): Request[Either[ResponseException[String], String]] = + basicRequest + .method(Method.GET, uri"$baseUrl/user/login?username=${ username }&password=${ password }") + .contentType("application/json") + .response(asJson[String]) + + /** + * + * + * Expected answers: + * code 0 : (successful operation) + * + * Available security schemes: + * api_key (apiKey) + */ + def logoutUser(apiKeyHeader: String)(): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.GET, uri"$baseUrl/user/logout") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + + /** + * This can only be done by the logged in user. + * + * Expected answers: + * code 400 : (Invalid user supplied) + * code 404 : (User not found) + * + * Available security schemes: + * api_key (apiKey) + * + * @param username name that need to be deleted + * @param user Updated user object + */ + def updateUser(apiKeyHeader: String)(username: String, user: User): Request[Either[ResponseException[String], Unit]] = + basicRequest + .method(Method.PUT, uri"$baseUrl/user/${username}") + .contentType("application/json") + .header("api_key", apiKeyHeader) + .body(asJson(user)) + .response(asString.mapWithMetadata(ResponseAs.deserializeRightWithError(_ => Right(())))) + +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala new file mode 100644 index 000000000000..137dbc248fad --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala @@ -0,0 +1,21 @@ +package org.openapitools.client.core + +import java.net.{ URI, URISyntaxException } + +trait AdditionalTypeSerializers { + import io.circe._ + + implicit final lazy val URIDecoder: Decoder[URI] = Decoder.decodeString.emap(string => + try Right(new URI(string)) + catch { + case _: URISyntaxException => + Left("String could not be parsed as a URI reference, it violates RFC 2396.") + case _: NullPointerException => + Left("String is null.") + } + ) + + implicit final lazy val URIEncoder: Encoder[URI] = new Encoder[URI] { + final def apply(a: URI): Json = Json.fromString(a.toString) + } +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/DateSerializers.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/DateSerializers.scala new file mode 100644 index 000000000000..1bed2914651f --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/DateSerializers.scala @@ -0,0 +1,13 @@ +package org.openapitools.client.core + +import java.time.{LocalDate, OffsetDateTime} +import java.time.format.DateTimeFormatter + +trait DateSerializers { + import io.circe.{Decoder, Encoder} + implicit val isoOffsetDateTimeDecoder: Decoder[OffsetDateTime] = Decoder.decodeOffsetDateTimeWithFormatter(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + implicit val isoOffsetDateTimeEncoder: Encoder[OffsetDateTime] = Encoder.encodeOffsetDateTimeWithFormatter(DateTimeFormatter.ISO_OFFSET_DATE_TIME) + + implicit val localDateDecoder: Decoder[LocalDate] = Decoder.decodeLocalDateWithFormatter(DateTimeFormatter.ISO_LOCAL_DATE) + implicit val localDateEncoder: Encoder[LocalDate] = Encoder.encodeLocalDateWithFormatter(DateTimeFormatter.ISO_LOCAL_DATE) +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/JsonSupport.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/JsonSupport.scala new file mode 100644 index 000000000000..dd7cd56e15e0 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/core/JsonSupport.scala @@ -0,0 +1,21 @@ +/** + * 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.core + +import org.openapitools.client.model._ +import io.circe.{Decoder, Encoder} +import io.circe.generic.AutoDerivation +import sttp.client4.circe.SttpCirceApi + +object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers { + // Enum encoders/decoders are defined in their respective companion objects +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala new file mode 100644 index 000000000000..9d09300eae38 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala @@ -0,0 +1,23 @@ +/** + * 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 + + + /** + * An uploaded response + * Describes the result of uploading an image resource + */ +case class ApiResponse( + code: Option[Int] = None, + `type`: Option[String] = None, + message: Option[String] = None +) diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala new file mode 100644 index 000000000000..c0c51c5e53eb --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala @@ -0,0 +1,22 @@ +/** + * 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 + + + /** + * Pet category + * A category for a pet + */ +case class Category( + id: Option[Long] = None, + name: Option[String] = None +) diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala new file mode 100644 index 000000000000..33ba24020309 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala @@ -0,0 +1,52 @@ +/** + * 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 java.time.OffsetDateTime + + /** + * Pet Order + * An order for a pets from the pet store + */ +case class Order( + id: Option[Long] = None, + petId: Option[Long] = None, + quantity: Option[Int] = None, + shipDate: Option[OffsetDateTime] = None, + /* Order Status */ + status: Option[OrderEnums.Status] = None, + complete: Option[Boolean] = None +) +object OrderEnums { + + sealed trait Status + object Status { + case object Placed extends Status + case object Approved extends Status + case object Delivered extends Status + + import io.circe.{Encoder, Decoder} + + implicit val encoder: Encoder[Status] = Encoder.encodeString.contramap[Status] { + case Placed => "placed" + case Approved => "approved" + case Delivered => "delivered" + } + + implicit val decoder: Decoder[Status] = Decoder.decodeString.emap { + case "placed" => Right(Placed) + case "approved" => Right(Approved) + case "delivered" => Right(Delivered) + case other => Left(s"Invalid Status: $other") + } + } +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala new file mode 100644 index 000000000000..c6162f3adb26 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala @@ -0,0 +1,51 @@ +/** + * 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 + + + /** + * a Pet + * A pet for sale in the pet store + */ +case class Pet( + id: Option[Long] = None, + category: Option[Category] = None, + name: String, + photoUrls: Seq[String], + tags: Option[Seq[Tag]] = None, + /* pet status in the store */ + status: Option[PetEnums.Status] = None +) +object PetEnums { + + sealed trait Status + object Status { + case object Available extends Status + case object Pending extends Status + case object Sold extends Status + + import io.circe.{Encoder, Decoder} + + implicit val encoder: Encoder[Status] = Encoder.encodeString.contramap[Status] { + case Available => "available" + case Pending => "pending" + case Sold => "sold" + } + + implicit val decoder: Decoder[Status] = Decoder.decodeString.emap { + case "available" => Right(Available) + case "pending" => Right(Pending) + case "sold" => Right(Sold) + case other => Left(s"Invalid Status: $other") + } + } +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala new file mode 100644 index 000000000000..9af834f41b9e --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala @@ -0,0 +1,22 @@ +/** + * 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 + + + /** + * Pet Tag + * A tag for a pet + */ +case class Tag( + id: Option[Long] = None, + name: Option[String] = None +) diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala new file mode 100644 index 000000000000..c48dd41458b4 --- /dev/null +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala @@ -0,0 +1,29 @@ +/** + * 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 + + + /** + * a User + * A User who is purchasing from the pet store + */ +case class User( + id: Option[Long] = None, + username: Option[String] = None, + firstName: Option[String] = None, + lastName: Option[String] = None, + email: Option[String] = None, + password: Option[String] = None, + phone: Option[String] = None, + /* User Status */ + userStatus: Option[Int] = None +) diff --git a/samples/client/petstore/scala-sttp4/build.sbt b/samples/client/petstore/scala-sttp4/build.sbt index 9ad901170074..a002d012cd30 100644 --- a/samples/client/petstore/scala-sttp4/build.sbt +++ b/samples/client/petstore/scala-sttp4/build.sbt @@ -6,8 +6,8 @@ scalaVersion := "2.13.16" crossScalaVersions := Seq(scalaVersion.value, "2.12.20") libraryDependencies ++= Seq( - "com.softwaremill.sttp.client4" %% "core" % "4.0.0-M1", - "com.softwaremill.sttp.client4" %% "json4s" % "4.0.0-M1", + "com.softwaremill.sttp.client4" %% "core" % "4.0.15", + "com.softwaremill.sttp.client4" %% "json4s" % "4.0.15", "org.json4s" %% "json4s-jackson" % "4.0.6" ) diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Order.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Order.scala index 9e805e256b21..893cad11d288 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Order.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Order.scala @@ -26,14 +26,32 @@ case class Order( status: Option[OrderEnums.Status] = None, complete: Option[Boolean] = None ) - object OrderEnums { - type Status = Status.Value - object Status extends Enumeration { - val Placed = Value("placed") - val Approved = Value("approved") - val Delivered = Value("delivered") - } + sealed trait Status + object Status { + case object Placed extends Status + case object Approved extends Status + case object Delivered extends Status + + import org.json4s._ + implicit object StatusSerializer extends Serializer[Status] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), Status] = { + case (TypeInfo(clazz, _), json) if classOf[Status].isAssignableFrom(clazz) => + json match { + case JString("placed") => Placed + case JString("approved") => Approved + case JString("delivered") => Delivered + case other => throw new MappingException(s"Invalid Status: $other") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { + case Placed => JString("placed") + case Approved => JString("approved") + case Delivered => JString("delivered") + } + } + } } diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Pet.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Pet.scala index 3cbab6051284..d5805371fac8 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Pet.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Pet.scala @@ -25,14 +25,32 @@ case class Pet( /* pet status in the store */ status: Option[PetEnums.Status] = None ) - object PetEnums { - type Status = Status.Value - object Status extends Enumeration { - val Available = Value("available") - val Pending = Value("pending") - val Sold = Value("sold") - } + sealed trait Status + object Status { + case object Available extends Status + case object Pending extends Status + case object Sold extends Status + + import org.json4s._ + implicit object StatusSerializer extends Serializer[Status] { + def deserialize(implicit format: Formats): PartialFunction[(TypeInfo, JValue), Status] = { + case (TypeInfo(clazz, _), json) if classOf[Status].isAssignableFrom(clazz) => + json match { + case JString("available") => Available + case JString("pending") => Pending + case JString("sold") => Sold + case other => throw new MappingException(s"Invalid Status: $other") + } + } + + def serialize(implicit format: Formats): PartialFunction[Any, JValue] = { + case Available => JString("available") + case Pending => JString("pending") + case Sold => JString("sold") + } + } + } } diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Tag.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Tag.scala index c2020246658a..9af834f41b9e 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Tag.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/Tag.scala @@ -20,4 +20,3 @@ case class Tag( id: Option[Long] = None, name: Option[String] = None ) - diff --git a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/User.scala b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/User.scala index 6977180bccee..c48dd41458b4 100644 --- a/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/User.scala +++ b/samples/client/petstore/scala-sttp4/src/main/scala/org/openapitools/client/model/User.scala @@ -27,4 +27,3 @@ case class User( /* User Status */ userStatus: Option[Int] = None ) - From 7aba995daff13b935045ccd52bd4f50893d88ca7 Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Mon, 9 Feb 2026 13:52:15 -0500 Subject: [PATCH 6/8] fix: make partial function total --- .../src/main/resources/scala-sttp4/model.mustache | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache index d245fb11ba53..3d5fc2995143 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -108,6 +108,7 @@ object {{classname}} { {{#vendorExtensions.x-oneOfMembers}} case "{{classname}}" => "{{vendorExtensions.x-discriminator-value}}" {{/vendorExtensions.x-oneOfMembers}} + case other => sys.error(s"Invalid {{classname}} discriminant: ${other}") } ) implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder From 921be4b2777ab881e61fb45c9284e2f18750655a Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Mon, 9 Feb 2026 22:32:12 -0500 Subject: [PATCH 7/8] fix: only use semiauto derivation On large APIs this can dramatically speed up compilation. --- .../main/resources/scala-sttp4/model.mustache | 22 +++++++++++++++++-- .../client/model/ApiResponse.scala | 8 +++++++ .../openapitools/client/model/Category.scala | 8 +++++++ .../org/openapitools/client/model/Order.scala | 8 +++++++ .../org/openapitools/client/model/Pet.scala | 8 +++++++ .../org/openapitools/client/model/Tag.scala | 8 +++++++ .../org/openapitools/client/model/User.scala | 8 +++++++ 7 files changed, 68 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache index 3d5fc2995143..b4483ab3b31b 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -36,6 +36,16 @@ case class {{classname}}( {{! Empty case class for models with no properties }} {{/allVars}} ) extends {{vendorExtensions.x-oneOfParent}} +{{#circe}} +object {{classname}} { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[{{classname}}] = deriveEncoder + implicit val decoder: Decoder[{{classname}}] = deriveDecoder +} +{{/circe}} {{/vendorExtensions.x-oneOfMembers}} object {{classname}} { @@ -90,7 +100,6 @@ object {{classname}} { // oneOf without discriminator - using semiauto derivation import io.circe.{Encoder, Decoder} import io.circe.generic.semiauto._ - import io.circe.generic.auto._ implicit val encoder: Encoder[{{classname}}] = deriveEncoder implicit val decoder: Decoder[{{classname}}] = deriveDecoder @@ -100,7 +109,6 @@ object {{classname}} { import io.circe.{Encoder, Decoder} import io.circe.generic.extras._ import io.circe.generic.extras.semiauto._ - import io.circe.generic.auto._ private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyName}}") .copy( @@ -185,6 +193,16 @@ case class {{classname}}( {{/vars}} ){{#parent}} extends {{parent}}{{/parent}} {{/isEnum}} +{{#circe}} +object {{classname}} { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[{{classname}}] = deriveEncoder + implicit val decoder: Decoder[{{classname}}] = deriveDecoder +} +{{/circe}} {{#hasEnums}} object {{classname}}Enums { {{#vars}} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala index 9d09300eae38..b1b1c90495a6 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala @@ -21,3 +21,11 @@ case class ApiResponse( `type`: Option[String] = None, message: Option[String] = None ) +object ApiResponse { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[ApiResponse] = deriveEncoder + implicit val decoder: Decoder[ApiResponse] = deriveDecoder +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala index c0c51c5e53eb..def67cc526f2 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Category.scala @@ -20,3 +20,11 @@ case class Category( id: Option[Long] = None, name: Option[String] = None ) +object Category { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[Category] = deriveEncoder + implicit val decoder: Decoder[Category] = deriveDecoder +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala index 33ba24020309..6cb20a7a07cd 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Order.scala @@ -26,6 +26,14 @@ case class Order( status: Option[OrderEnums.Status] = None, complete: Option[Boolean] = None ) +object Order { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[Order] = deriveEncoder + implicit val decoder: Decoder[Order] = deriveDecoder +} object OrderEnums { sealed trait Status diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala index c6162f3adb26..88f0ce279b25 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Pet.scala @@ -25,6 +25,14 @@ case class Pet( /* pet status in the store */ status: Option[PetEnums.Status] = None ) +object Pet { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[Pet] = deriveEncoder + implicit val decoder: Decoder[Pet] = deriveDecoder +} object PetEnums { sealed trait Status diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala index 9af834f41b9e..4672a3316f36 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/Tag.scala @@ -20,3 +20,11 @@ case class Tag( id: Option[Long] = None, name: Option[String] = None ) +object Tag { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[Tag] = deriveEncoder + implicit val decoder: Decoder[Tag] = deriveDecoder +} diff --git a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala index c48dd41458b4..f488d72e97d1 100644 --- a/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala +++ b/samples/client/petstore/scala-sttp4-circe/src/main/scala/org/openapitools/client/model/User.scala @@ -27,3 +27,11 @@ case class User( /* User Status */ userStatus: Option[Int] = None ) +object User { + import io.circe._ + import io.circe.syntax._ + import io.circe.generic.semiauto._ + + implicit val encoder: Encoder[User] = deriveEncoder + implicit val decoder: Decoder[User] = deriveDecoder +} From 74a8c3ec1ee15708c01760b871c7739d611bafb4 Mon Sep 17 00:00:00 2001 From: Philippe Laflamme Date: Mon, 9 Feb 2026 22:34:27 -0500 Subject: [PATCH 8/8] fix: missing comma --- .../src/main/resources/scala-sttp4/build.sbt.mustache | 2 +- .../client/petstore/scala-sttp4-circe/.openapi-generator/FILES | 1 + samples/client/petstore/scala-sttp4-circe/build.sbt | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache b/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache index 1c55e7c67cbc..0047155c9fde 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/build.sbt.mustache @@ -15,7 +15,7 @@ libraryDependencies ++= Seq( "org.json4s" %% "json4s-jackson" % "{{json4sVersion}}" {{/json4s}} {{#circe}} - "com.softwaremill.sttp.client4" %% "circe" % "{{sttpClientVersion}}" + "com.softwaremill.sttp.client4" %% "circe" % "{{sttpClientVersion}}", "io.circe" %% "circe-generic" % "{{circeVersion}}", "io.circe" %% "circe-generic-extras" % "{{circeExtrasVersion}}", {{/circe}} diff --git a/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES index 93bed4b07d22..693b268692c0 100644 --- a/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES +++ b/samples/client/petstore/scala-sttp4-circe/.openapi-generator/FILES @@ -1,3 +1,4 @@ +.openapi-generator-ignore README.md build.sbt project/build.properties diff --git a/samples/client/petstore/scala-sttp4-circe/build.sbt b/samples/client/petstore/scala-sttp4-circe/build.sbt index 7268c6e3f1d3..7d3f14401015 100644 --- a/samples/client/petstore/scala-sttp4-circe/build.sbt +++ b/samples/client/petstore/scala-sttp4-circe/build.sbt @@ -7,7 +7,7 @@ crossScalaVersions := Seq(scalaVersion.value, "2.12.20") libraryDependencies ++= Seq( "com.softwaremill.sttp.client4" %% "core" % "4.0.15", - "com.softwaremill.sttp.client4" %% "circe" % "4.0.15" + "com.softwaremill.sttp.client4" %% "circe" % "4.0.15", "io.circe" %% "circe-generic" % "0.14.15", "io.circe" %% "circe-generic-extras" % "0.14.4", )