From fbf90f3b67719cc676817845b8b48eadf3eb5d36 Mon Sep 17 00:00:00 2001 From: Vivek Mahajan Date: Wed, 12 Aug 2026 08:26:40 +0200 Subject: [PATCH] [scala-sttp4] Circe codecs do not preserve original JSON property names The circe branch of scala-sttp4 derives codecs with io.circe.generic.semiauto, which keys JSON members off the Scala field names. Any property whose name is not already camelCase - snake_case, kebab-case, PascalCase - is silently renamed on the wire. Replace the derived codecs with explicit Encoder/Decoder instances keyed on baseName, the original property name from the spec, following the pattern already used by scala-http4s and adopted for scala-sttp in #23465. This also fixes the discriminated oneOf encoder. deriveConfiguredEncoder only applies withDiscriminator when each member's implicit encoder is an Encoder.AsObject; .mapJson(_.dropNullValues) downgraded them to plain Encoder, so the encoder silently emitted a wrapper object while the decoder expected the flat form, making discriminated oneOf impossible to round-trip. Two related codegen fixes: the discriminator property was matched by getPropertyName() (the escaped Scala identifier) against a property's baseName, so it was only stripped from members when the two happened to coincide; and the property name is now exposed to the template so the sealed trait's encoder emits it. Optional fields set to None remain omitted rather than serialized as null, preserving #24362. --- .../languages/ScalaSttp4ClientCodegen.java | 9 +- .../main/resources/scala-sttp4/model.mustache | 119 ++++++++++++++---- .../codegen/scala/Sttp4CodegenTest.java | 86 +++++++++++-- .../3_0/scala/sttp4-mixed-case-fields.yaml | 94 ++++++++++++++ .../client/model/ApiResponse.scala | 23 +++- .../openapitools/client/model/Category.scala | 20 ++- .../org/openapitools/client/model/Order.scala | 32 ++++- .../org/openapitools/client/model/Pet.scala | 32 ++++- .../org/openapitools/client/model/Tag.scala | 20 ++- .../org/openapitools/client/model/User.scala | 38 +++++- 10 files changed, 418 insertions(+), 55 deletions(-) create mode 100644 modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.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 1e0285c66d0b..818b724d3535 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 @@ -350,7 +350,10 @@ public Map postProcessAllModels(Map objs) // Add discriminator mapping value if present if (cModel.discriminator != null) { - String discriminatorName = cModel.discriminator.getPropertyName(); + // baseName, not propertyName: the latter is the escaped Scala + // identifier and never matches a property's baseName + String discriminatorName = cModel.discriminator.getPropertyBaseName(); + childModel.getVendorExtensions().put("x-discriminator-property", discriminatorName); // Find the mapping value for this child model String discriminatorValue = null; @@ -368,7 +371,7 @@ public Map postProcessAllModels(Map objs) } // Remove discriminator field from child - // (circe-generic-extras adds it automatically) + // (the sealed trait's encoder writes it) childModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName)); childModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); childModel.requiredVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); @@ -423,7 +426,7 @@ public Map postProcessAllModels(Map objs) // 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(); + String discriminatorName = cModel.parentModel.discriminator.getPropertyBaseName(); cModel.vars.removeIf(prop -> prop.baseName.equals(discriminatorName)); cModel.allVars.removeIf(prop -> prop.baseName.equals(discriminatorName)); cModel.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 7c710728f583..67d131fe9d35 100644 --- a/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache +++ b/modules/openapi-generator/src/main/resources/scala-sttp4/model.mustache @@ -49,10 +49,41 @@ case class {{classname}}( object {{classname}} { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues) - implicit val decoder: Decoder[{{classname}}] = deriveDecoder +{{^allVars}} + implicit val encoder: Encoder[{{classname}}] = Encoder.instance(_ => Json.obj()) + implicit val decoder: Decoder[{{classname}}] = Decoder.const({{classname}}()) +{{/allVars}} +{{#allVars}} +{{#-first}} + implicit val encoder: Encoder[{{classname}}] = Encoder.instance { t => + Json.fromFields( + Seq( +{{/-first}} + {{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}} +{{#-last}} + ).flatten + ) + } +{{/-last}} +{{/allVars}} +{{#allVars}} +{{#-first}} + implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c => + for { +{{/-first}} + {{{name}}} <- c.downField("{{baseName}}").as[{{^required}}Option[{{/required}}{{dataType}}{{^required}}]{{/required}}] +{{#-last}} + } yield {{classname}}( +{{/-last}} +{{/allVars}} +{{#allVars}} + {{{name}}} = {{{name}}}{{^-last}},{{/-last}} +{{#-last}} + ) + } +{{/-last}} +{{/allVars}} } {{/circe}} @@ -176,30 +207,39 @@ object {{classname}} { {{#circe}} {{^vendorExtensions.x-hasWrappedOneOfMembers}} {{^vendorExtensions.x-use-discr}} - // oneOf without discriminator - using semiauto derivation + // oneOf without discriminator - try each member in turn import io.circe.{Encoder, Decoder} - import io.circe.generic.semiauto._ + import io.circe.syntax._ - implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues) - implicit val decoder: Decoder[{{classname}}] = deriveDecoder + implicit val encoder: Encoder[{{classname}}] = Encoder.instance { +{{#vendorExtensions.x-oneOfMembers}} + case obj: {{classname}} => obj.asJson +{{/vendorExtensions.x-oneOfMembers}} + } + implicit val decoder: Decoder[{{classname}}] = List[Decoder[{{classname}}]]( +{{#vendorExtensions.x-oneOfMembers}} + Decoder[{{classname}}].map(x => x: {{vendorExtensions.x-oneOfParent}}), +{{/vendorExtensions.x-oneOfMembers}} + ).reduceLeft(_ or _) {{/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._ + // oneOf with discriminator + import io.circe.{Encoder, Decoder, DecodingFailure} + import io.circe.syntax._ - private implicit val config: Configuration = Configuration.default.withDiscriminator("{{discriminator.propertyBaseName}}") - .copy( - transformConstructorNames = { + implicit val encoder: Encoder[{{classname}}] = Encoder.instance { {{#vendorExtensions.x-oneOfMembers}} - case "{{classname}}" => "{{vendorExtensions.x-discriminator-value}}" + case obj: {{classname}} => obj.asJson.mapObject(("{{vendorExtensions.x-discriminator-property}}" -> "{{vendorExtensions.x-discriminator-value}}".asJson) +: _) {{/vendorExtensions.x-oneOfMembers}} - case other => sys.error(s"Invalid {{classname}} discriminant: ${other}") - } - ) - implicit val encoder: Encoder[{{classname}}] = deriveConfiguredEncoder[{{classname}}].mapJson(_.dropNullValues) - implicit val decoder: Decoder[{{classname}}] = deriveConfiguredDecoder + } + implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c => + c.downField("{{discriminator.propertyBaseName}}").as[String].flatMap { +{{#vendorExtensions.x-oneOfMembers}} + case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}].map(x => x: {{vendorExtensions.x-oneOfParent}}) +{{/vendorExtensions.x-oneOfMembers}} + case other => Left(DecodingFailure(s"Unknown {{discriminator.propertyBaseName}}: $other", c.history)) + } + } {{/vendorExtensions.x-use-discr}} {{/vendorExtensions.x-hasWrappedOneOfMembers}} {{#vendorExtensions.x-hasWrappedOneOfMembers}} @@ -239,7 +279,7 @@ object {{classname}} { implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c => c.get[String]("{{discriminator.propertyBaseName}}").flatMap { {{#vendorExtensions.x-oneOfMembers}} - case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}]({{classname}}.decoder).map(x => x: {{parentClassname}}) + case "{{vendorExtensions.x-discriminator-value}}" => c.as[{{classname}}]({{classname}}.decoder).map(x => x: {{vendorExtensions.x-oneOfParent}}) {{/vendorExtensions.x-oneOfMembers}} {{#vendorExtensions.x-wrappedOneOfMembers}} case "{{discriminatorValue}}" => c.as[{{classname}}]({{classname}}.decoder).map({{wrapperClassname}}.apply) @@ -324,10 +364,41 @@ case class {{classname}}( object {{classname}} { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[{{classname}}] = deriveEncoder[{{classname}}].mapJson(_.dropNullValues) - implicit val decoder: Decoder[{{classname}}] = deriveDecoder +{{^vars}} + implicit val encoder: Encoder[{{classname}}] = Encoder.instance(_ => Json.obj()) + implicit val decoder: Decoder[{{classname}}] = Decoder.const({{classname}}()) +{{/vars}} +{{#vars}} +{{#-first}} + implicit val encoder: Encoder[{{classname}}] = Encoder.instance { t => + Json.fromFields( + Seq( +{{/-first}} + {{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}} +{{#-last}} + ).flatten + ) + } +{{/-last}} +{{/vars}} +{{#vars}} +{{#-first}} + implicit val decoder: Decoder[{{classname}}] = Decoder.instance { c => + for { +{{/-first}} + {{{name}}} <- c.downField("{{baseName}}").as[{{^required}}Option[{{/required}}{{^isEnum}}{{dataType}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}Enums.{{datatypeWithEnum}}{{/isArray}}{{#isArray}}Seq[{{classname}}Enums.{{datatypeWithEnum}}]{{/isArray}}{{/isEnum}}{{^required}}]{{/required}}] +{{#-last}} + } yield {{classname}}( +{{/-last}} +{{/vars}} +{{#vars}} + {{{name}}} = {{{name}}}{{^-last}},{{/-last}} +{{#-last}} + ) + } +{{/-last}} +{{/vars}} } {{/circe}} {{#hasEnums}} 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 393965263778..a5d432264402 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 @@ -151,20 +151,22 @@ public void verifyOneOfSupportWithCirce() throws IOException { 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, "// oneOf with discriminator"); assertFileContains(vehiclePath, - "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"vehicleType\")"); - assertFileContains(vehiclePath, "\"Car\" => \"car\""); - assertFileContains(vehiclePath, "\"Truck\" => \"truck\""); + "case obj: Car => obj.asJson.mapObject((\"vehicleType\" -> \"car\".asJson) +: _)"); + assertFileContains(vehiclePath, + "case obj: Truck => obj.asJson.mapObject((\"vehicleType\" -> \"truck\".asJson) +: _)"); + assertFileContains(vehiclePath, "c.downField(\"vehicleType\").as[String].flatMap {"); + assertFileContains(vehiclePath, "case \"car\" => c.as[Car].map(x => x: Vehicle)"); + assertFileContains(vehiclePath, "case \"truck\" => c.as[Truck].map(x => x: Vehicle)"); // Test oneOf with discriminator that is a Scala keyword ("type") // The discriminator should use the original wire name, not the backtick-escaped Scala name Path shapePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Shape.scala"); assertFileContains(shapePath, "sealed trait Shape"); - assertFileContains(shapePath, - "private implicit val config: Configuration = Configuration.default.withDiscriminator(\"type\")"); + assertFileContains(shapePath, "c.downField(\"type\").as[String].flatMap {"); // Discriminator in serialization must not be backtick-escaped - assertFileNotContains(shapePath, "withDiscriminator(\"`type`\")"); + assertFileNotContains(shapePath, "`type`"); // Verify regular models are still case classes Path dogPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Dog.scala"); @@ -263,8 +265,8 @@ public void verifyOneOfWithEmptyMembers() throws IOException { assertFileContains(eventPath, "case class PurchaseEvent("); assertFileContains(eventPath, "amount: Double"); - // Verify discriminator is configured - assertFileContains(eventPath, "Configuration.default.withDiscriminator(\"eventType\")"); + // Verify the discriminator is written by the sealed trait's encoder + assertFileContains(eventPath, "c.downField(\"eventType\").as[String].flatMap {"); // Verify the discriminator property was removed from inline members // ClickEvent and ViewEvent should have NO properties at all @@ -408,6 +410,70 @@ public void verifyOptionalFieldsOmittedWhenNone() throws IOException { // not serialized as null: strict servers reject explicit null for // non-nullable optional properties. Path petPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Pet.scala"); - assertFileContains(petPath, "implicit val encoder: Encoder[Pet] = deriveEncoder[Pet].mapJson(_.dropNullValues)"); + assertFileContains(petPath, "t.tag.map(v => \"tag\" -> v.asJson)"); + assertFileNotContains(petPath, "deriveEncoder"); + } + + @Test + public void verifyCirceCodecsUseOriginalJsonPropertyNames() 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-mixed-case-fields.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(); + + // Scala identifiers stay camelCase; JSON keys are the original spec property names + Path modelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala"); + assertFileContains(modelPath, "assignmentKey: String"); + assertFileContains(modelPath, "addressLine2: Option[String] = None"); + assertFileContains(modelPath, "Some(\"assignment_key\" -> t.assignmentKey.asJson)"); + assertFileContains(modelPath, "t.firstName.map(v => \"first-name\" -> v.asJson)"); + assertFileContains(modelPath, "t.zipCode.map(v => \"ZipCode\" -> v.asJson)"); + // an already-camelCase property must keep its name, so this stays byte-identical + assertFileContains(modelPath, "t.lastName.map(v => \"lastName\" -> v.asJson)"); + assertFileContains(modelPath, "lastName <- c.downField(\"lastName\").as[Option[String]]"); + assertFileContains(modelPath, "t.addressLine2.map(v => \"address_line_2\" -> v.asJson)"); + assertFileContains(modelPath, "assignmentKey <- c.downField(\"assignment_key\").as[String]"); + assertFileContains(modelPath, "addressLine2 <- c.downField(\"address_line_2\").as[Option[String]]"); + assertFileContains(modelPath, + "bookingStatus <- c.downField(\"booking_status\").as[Option[MixedCaseModelEnums.BookingStatus]]"); + assertFileNotContains(modelPath, "deriveEncoder"); + assertFileNotContains(modelPath, "deriveDecoder"); + + // Inline oneOf members: original names too, and the discriminator property is + // written by the sealed trait rather than carried as a member field + Path paymentPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/PaymentMethod.scala"); + assertFileContains(paymentPath, "Some(\"card_holder_name\" -> t.cardHolderName.asJson)"); + assertFileContains(paymentPath, "t.lastFourDigits.map(v => \"last-four-digits\" -> v.asJson)"); + assertFileContains(paymentPath, + "case obj: CreditCardPayment => obj.asJson.mapObject((\"payment_type\" -> \"credit_card\".asJson) +: _)"); + assertFileContains(paymentPath, "c.downField(\"payment_type\").as[String].flatMap {"); + assertFileNotContains(paymentPath, "paymentType"); + + // oneOf without a discriminator encodes the bare member. Deriving it produced a + // constructor-name wrapper ({"Circle":{..}}), which is not what oneOf means. + Path shapePath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/Shape.scala"); + assertFileContains(shapePath, "case obj: Circle => obj.asJson"); + assertFileContains(shapePath, "Decoder[Circle].map(x => x: Shape)"); + assertFileNotContains(shapePath, "deriveEncoder"); } } diff --git a/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml new file mode 100644 index 000000000000..8bb63223a5ab --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/scala/sttp4-mixed-case-fields.yaml @@ -0,0 +1,94 @@ +openapi: 3.0.0 +info: + title: sttp4 mixed case fields + version: 1.0.0 +paths: + /bookings: + post: + operationId: createBooking + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MixedCaseModel' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PaymentMethod' +components: + schemas: + MixedCaseModel: + type: object + required: + - assignment_key + properties: + assignment_key: + type: string + first-name: + type: string + phone_number: + type: string + lastName: + type: string + ZipCode: + type: string + address_line_2: + type: string + trip_summary: + $ref: '#/components/schemas/TripSummary' + booking_status: + type: string + enum: + - pending + - confirmed + TripSummary: + type: object + properties: + departure_airport_code: + type: string + PaymentMethod: + oneOf: + - $ref: '#/components/schemas/CreditCardPayment' + - $ref: '#/components/schemas/BankTransferPayment' + discriminator: + propertyName: payment_type + mapping: + credit_card: '#/components/schemas/CreditCardPayment' + bank_transfer: '#/components/schemas/BankTransferPayment' + CreditCardPayment: + type: object + required: + - card_holder_name + properties: + payment_type: + type: string + card_holder_name: + type: string + last-four-digits: + type: string + BankTransferPayment: + type: object + properties: + payment_type: + type: string + account_holder_name: + type: string + Shape: + oneOf: + - $ref: '#/components/schemas/Circle' + - $ref: '#/components/schemas/Square' + Circle: + type: object + required: + - radius_cm + properties: + radius_cm: + type: number + Square: + type: object + properties: + side_length: + type: number 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 d1a0a16f99f4..c71137577e18 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 @@ -24,8 +24,25 @@ case class ApiResponse( object ApiResponse { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[ApiResponse] = deriveEncoder[ApiResponse].mapJson(_.dropNullValues) - implicit val decoder: Decoder[ApiResponse] = deriveDecoder + implicit val encoder: Encoder[ApiResponse] = Encoder.instance { t => + Json.fromFields( + Seq( + t.code.map(v => "code" -> v.asJson), + t.`type`.map(v => "type" -> v.asJson), + t.message.map(v => "message" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[ApiResponse] = Decoder.instance { c => + for { + code <- c.downField("code").as[Option[Int]] + `type` <- c.downField("type").as[Option[String]] + message <- c.downField("message").as[Option[String]] + } yield ApiResponse( + code = code, + `type` = `type`, + message = message + ) + } } 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 546dacdbb753..8faea9354819 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 @@ -23,8 +23,22 @@ case class Category( object Category { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[Category] = deriveEncoder[Category].mapJson(_.dropNullValues) - implicit val decoder: Decoder[Category] = deriveDecoder + implicit val encoder: Encoder[Category] = Encoder.instance { t => + Json.fromFields( + Seq( + t.id.map(v => "id" -> v.asJson), + t.name.map(v => "name" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[Category] = Decoder.instance { c => + for { + id <- c.downField("id").as[Option[Long]] + name <- c.downField("name").as[Option[String]] + } yield Category( + id = id, + name = name + ) + } } 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 b942df6e0ca3..20302bb72268 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 @@ -29,10 +29,36 @@ case class Order( object Order { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[Order] = deriveEncoder[Order].mapJson(_.dropNullValues) - implicit val decoder: Decoder[Order] = deriveDecoder + implicit val encoder: Encoder[Order] = Encoder.instance { t => + Json.fromFields( + Seq( + t.id.map(v => "id" -> v.asJson), + t.petId.map(v => "petId" -> v.asJson), + t.quantity.map(v => "quantity" -> v.asJson), + t.shipDate.map(v => "shipDate" -> v.asJson), + t.status.map(v => "status" -> v.asJson), + t.complete.map(v => "complete" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[Order] = Decoder.instance { c => + for { + id <- c.downField("id").as[Option[Long]] + petId <- c.downField("petId").as[Option[Long]] + quantity <- c.downField("quantity").as[Option[Int]] + shipDate <- c.downField("shipDate").as[Option[OffsetDateTime]] + status <- c.downField("status").as[Option[OrderEnums.Status]] + complete <- c.downField("complete").as[Option[Boolean]] + } yield Order( + id = id, + petId = petId, + quantity = quantity, + shipDate = shipDate, + status = status, + complete = complete + ) + } } object OrderEnums { 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 d52f5cf86b6c..e76299ae97a0 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 @@ -28,10 +28,36 @@ case class Pet( object Pet { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[Pet] = deriveEncoder[Pet].mapJson(_.dropNullValues) - implicit val decoder: Decoder[Pet] = deriveDecoder + implicit val encoder: Encoder[Pet] = Encoder.instance { t => + Json.fromFields( + Seq( + t.id.map(v => "id" -> v.asJson), + t.category.map(v => "category" -> v.asJson), + Some("name" -> t.name.asJson), + Some("photoUrls" -> t.photoUrls.asJson), + t.tags.map(v => "tags" -> v.asJson), + t.status.map(v => "status" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[Pet] = Decoder.instance { c => + for { + id <- c.downField("id").as[Option[Long]] + category <- c.downField("category").as[Option[Category]] + name <- c.downField("name").as[String] + photoUrls <- c.downField("photoUrls").as[Seq[String]] + tags <- c.downField("tags").as[Option[Seq[Tag]]] + status <- c.downField("status").as[Option[PetEnums.Status]] + } yield Pet( + id = id, + category = category, + name = name, + photoUrls = photoUrls, + tags = tags, + status = status + ) + } } object PetEnums { 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 6840fc050e20..25145aebee85 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 @@ -23,8 +23,22 @@ case class Tag( object Tag { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[Tag] = deriveEncoder[Tag].mapJson(_.dropNullValues) - implicit val decoder: Decoder[Tag] = deriveDecoder + implicit val encoder: Encoder[Tag] = Encoder.instance { t => + Json.fromFields( + Seq( + t.id.map(v => "id" -> v.asJson), + t.name.map(v => "name" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[Tag] = Decoder.instance { c => + for { + id <- c.downField("id").as[Option[Long]] + name <- c.downField("name").as[Option[String]] + } yield Tag( + id = id, + name = name + ) + } } 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 1f42e16a497a..d4eed01035bc 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 @@ -30,8 +30,40 @@ case class User( object User { import io.circe._ import io.circe.syntax._ - import io.circe.generic.semiauto._ - implicit val encoder: Encoder[User] = deriveEncoder[User].mapJson(_.dropNullValues) - implicit val decoder: Decoder[User] = deriveDecoder + implicit val encoder: Encoder[User] = Encoder.instance { t => + Json.fromFields( + Seq( + t.id.map(v => "id" -> v.asJson), + t.username.map(v => "username" -> v.asJson), + t.firstName.map(v => "firstName" -> v.asJson), + t.lastName.map(v => "lastName" -> v.asJson), + t.email.map(v => "email" -> v.asJson), + t.password.map(v => "password" -> v.asJson), + t.phone.map(v => "phone" -> v.asJson), + t.userStatus.map(v => "userStatus" -> v.asJson) + ).flatten + ) + } + implicit val decoder: Decoder[User] = Decoder.instance { c => + for { + id <- c.downField("id").as[Option[Long]] + username <- c.downField("username").as[Option[String]] + firstName <- c.downField("firstName").as[Option[String]] + lastName <- c.downField("lastName").as[Option[String]] + email <- c.downField("email").as[Option[String]] + password <- c.downField("password").as[Option[String]] + phone <- c.downField("phone").as[Option[String]] + userStatus <- c.downField("userStatus").as[Option[Int]] + } yield User( + id = id, + username = username, + firstName = firstName, + lastName = lastName, + email = email, + password = password, + phone = phone, + userStatus = userStatus + ) + } }