Skip to content

Commit 149a373

Browse files
committed
[Fix][scala-sttp][circe] Circe codecs do not preserve original JSON field names for non-camelCase properties
1 parent 7973088 commit 149a373

18 files changed

Lines changed: 439 additions & 213 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ public ScalaSttpClientCodegen() {
141141
typeMapping.put("short", "Short");
142142
typeMapping.put("char", "Char");
143143
typeMapping.put("double", "Double");
144-
typeMapping.put("object", "Any");
144+
typeMapping.put("object", jsonValueClass);
145145
typeMapping.put("file", "File");
146146
typeMapping.put("binary", "File");
147147
typeMapping.put("number", "Double");

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

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ object AdditionalTypeSerializers {
77
import org.json4s.{Serializer, CustomSerializer, JNull, MappingException}
88
import org.json4s.JsonAST.JString
99
case object URISerializer extends CustomSerializer[URI]( _ => ( {
10-
case JString(s) =>
10+
case JString(s) =>
1111
try new URI(s)
1212
catch {
1313
case _: URISyntaxException =>
@@ -25,21 +25,46 @@ object AdditionalTypeSerializers {
2525
}
2626
{{/json4s}}
2727
{{#circe}}
28+
import java.io.File
29+
import java.nio.file.Files
30+
2831
trait AdditionalTypeSerializers {
29-
import io.circe._
30-
31-
implicit final lazy val URIDecoder: Decoder[URI] = Decoder.decodeString.emap(string =>
32-
try Right(new URI(string))
33-
catch {
34-
case _: URISyntaxException =>
35-
Left("String could not be parsed as a URI reference, it violates RFC 2396.")
36-
case _: NullPointerException =>
37-
Left("String is null.")
38-
}
39-
)
40-
41-
implicit final lazy val URIEncoder: Encoder[URI] = new Encoder[URI] {
42-
final def apply(a: URI): Json = Json.fromString(a.toString)
32+
import io.circe._
33+
34+
implicit final lazy val URIDecoder: Decoder[URI] = Decoder.decodeString.emap(string =>
35+
try Right(new URI(string))
36+
catch {
37+
case _: URISyntaxException =>
38+
Left("String could not be parsed as a URI reference, it violates RFC 2396.")
39+
case _: NullPointerException =>
40+
Left("String is null.")
41+
}
42+
)
43+
44+
implicit final lazy val URIEncoder: Encoder[URI] = new Encoder[URI] {
45+
final def apply(a: URI): Json = Json.fromString(a.toString)
46+
}
47+
48+
implicit final lazy val FileDecoder: Decoder[File] = Decoder[Array[Byte]].emap { bytes =>
49+
try {
50+
val tmpFile = File.createTempFile("download", ".tmp")
51+
tmpFile.deleteOnExit()
52+
Files.write(tmpFile.toPath, bytes)
53+
Right(tmpFile)
54+
} catch {
55+
case e: Exception => Left(s"Failed to write binary content to file: ${e.getMessage}")
56+
}
57+
}
58+
59+
implicit final lazy val FileEncoder: Encoder[File] = Encoder[Array[Byte]].contramap(
60+
f => Files.readAllBytes(f.toPath)
61+
)
62+
63+
implicit final lazy val AnyDecoder: Decoder[Any] = Decoder[Json].map(_.asInstanceOf[Any])
64+
65+
implicit final lazy val AnyEncoder: Encoder[Any] = Encoder.instance {
66+
case json: Json => json
67+
case other => Json.fromString(other.toString)
4368
}
4469
}
4570
{{/circe}}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,9 @@ object JsonSupport extends SttpJson4sApi {
4242
{{/json4s}}
4343
{{#circe}}
4444
import io.circe.{Decoder, Encoder}
45-
import io.circe.generic.AutoDerivation
4645
import sttp.client3.circe.SttpCirceApi
4746

48-
object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers {
47+
object JsonSupport extends SttpCirceApi with DateSerializers with AdditionalTypeSerializers {
4948
5049
{{#models}}
5150
{{#model}}

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ package {{package}}
44
{{#imports}}
55
import {{import}}
66
{{/imports}}
7+
{{#circe}}
8+
import io.circe.{Decoder, Encoder, Json}
9+
import io.circe.syntax._
10+
import {{invokerPackage}}.JsonSupport._
11+
{{/circe}}
712

813
{{#models}}
914
{{#model}}
@@ -24,6 +29,30 @@ case class {{classname}}(
2429
{{{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}}
2530
{{/vars}}
2631
)
32+
{{#circe}}
33+
object {{classname}} {
34+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
35+
Json.fromFields{
36+
Seq(
37+
{{#vars}}
38+
{{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
39+
{{/vars}}
40+
).flatten
41+
}
42+
}
43+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
44+
for {
45+
{{#vars}}
46+
{{{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}}]
47+
{{/vars}}
48+
} yield {{classname}}(
49+
{{#vars}}
50+
{{{name}}} = {{{name}}}{{^-last}},{{/-last}}
51+
{{/vars}}
52+
)
53+
}
54+
}
55+
{{/circe}}
2756
{{/isEnum}}
2857

2958
{{#isEnum}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/scala/SttpCodegenTest.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,73 @@ public void verifyApiKeyLocations() throws IOException {
110110
assertFileContains(path, ".cookie(\"apikey\", apiKeyCookie)");
111111
}
112112

113+
@Test
114+
public void circeSerdeWithMixedCaseFields() throws IOException {
115+
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
116+
output.deleteOnExit();
117+
String outputPath = output.getAbsolutePath().replace('\\', '/');
118+
119+
OpenAPI openAPI = new OpenAPIParser()
120+
.readLocation("src/test/resources/3_0/scala/mixed-case-fields.yaml", null, new ParseOptions()).getOpenAPI();
121+
122+
ScalaSttpClientCodegen codegen = new ScalaSttpClientCodegen();
123+
codegen.setOutputDir(output.getAbsolutePath());
124+
codegen.additionalProperties().put("jsonLibrary", "circe");
125+
126+
ClientOptInput input = new ClientOptInput();
127+
input.openAPI(openAPI);
128+
input.config(codegen);
129+
130+
DefaultGenerator generator = new DefaultGenerator();
131+
132+
generator.setGeneratorPropertyDefault(CodegenConstants.MODELS, "true");
133+
generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_TESTS, "false");
134+
generator.setGeneratorPropertyDefault(CodegenConstants.MODEL_DOCS, "false");
135+
generator.setGeneratorPropertyDefault(CodegenConstants.APIS, "false");
136+
generator.setGeneratorPropertyDefault(CodegenConstants.SUPPORTING_FILES, "true");
137+
generator.opts(input).generate();
138+
139+
Path modelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala");
140+
Path jsonSupportPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/JsonSupport.scala");
141+
142+
// Model should have camelCase Scala field names in the case class
143+
assertFileContains(modelPath, "firstName");
144+
assertFileContains(modelPath, "phoneNumber");
145+
assertFileContains(modelPath, "lastName");
146+
assertFileContains(modelPath, "zipCode");
147+
assertFileContains(modelPath, "address");
148+
149+
// Encoder should use original baseName for JSON keys
150+
assertFileContains(modelPath, "\"first-name\""); // kebab-case preserved
151+
assertFileContains(modelPath, "\"phone_number\""); // snake_case preserved
152+
assertFileContains(modelPath, "\"lastName\""); // camelCase preserved
153+
assertFileContains(modelPath, "\"ZipCode\""); // PascalCase preserved
154+
assertFileContains(modelPath, "\"address\""); // lowercase preserved
155+
156+
// Decoder should use original baseName in downField
157+
assertFileContains(modelPath, "c.downField(\"first-name\")");
158+
assertFileContains(modelPath, "c.downField(\"phone_number\")");
159+
assertFileContains(modelPath, "c.downField(\"ZipCode\")");
160+
161+
// Model should have explicit encoder/decoder companion object
162+
assertFileContains(modelPath, "object MixedCaseModel");
163+
assertFileContains(modelPath, "implicit val encoderMixedCaseModel");
164+
assertFileContains(modelPath, "implicit val decoderMixedCaseModel");
165+
166+
// Model should import JsonSupport for enum implicits
167+
assertFileContains(modelPath, "import org.openapitools.client.core.JsonSupport._");
168+
169+
// JsonSupport should NOT use AutoDerivation (we have explicit instances now)
170+
assertFileNotContains(jsonSupportPath, "AutoDerivation");
171+
172+
// AdditionalTypeSerializers should have File and Any codecs for circe
173+
Path additionalSerializersPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala");
174+
assertFileContains(additionalSerializersPath, "FileDecoder");
175+
assertFileContains(additionalSerializersPath, "FileEncoder");
176+
assertFileContains(additionalSerializersPath, "AnyDecoder");
177+
assertFileContains(additionalSerializersPath, "AnyEncoder");
178+
}
179+
113180
@Test
114181
public void headerSerialization() throws IOException {
115182
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
openapi: 3.0.0
2+
info:
3+
title: Mixed Case Test
4+
version: 1.0.0
5+
paths:
6+
/test:
7+
get:
8+
operationId: getTest
9+
responses:
10+
'200':
11+
description: OK
12+
content:
13+
application/json:
14+
schema:
15+
$ref: '#/components/schemas/MixedCaseModel'
16+
components:
17+
schemas:
18+
MixedCaseModel:
19+
type: object
20+
properties:
21+
first-name:
22+
type: string
23+
phone_number:
24+
type: string
25+
lastName:
26+
type: string
27+
ZipCode:
28+
type: string
29+
address:
30+
type: string

samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,45 @@ package org.openapitools.client.core
22

33
import java.net.{ URI, URISyntaxException }
44

5+
import java.io.File
6+
import java.nio.file.Files
7+
58
trait AdditionalTypeSerializers {
6-
import io.circe._
7-
8-
implicit final lazy val URIDecoder: Decoder[URI] = Decoder.decodeString.emap(string =>
9-
try Right(new URI(string))
10-
catch {
11-
case _: URISyntaxException =>
12-
Left("String could not be parsed as a URI reference, it violates RFC 2396.")
13-
case _: NullPointerException =>
14-
Left("String is null.")
15-
}
16-
)
17-
18-
implicit final lazy val URIEncoder: Encoder[URI] = new Encoder[URI] {
19-
final def apply(a: URI): Json = Json.fromString(a.toString)
9+
import io.circe._
10+
11+
implicit final lazy val URIDecoder: Decoder[URI] = Decoder.decodeString.emap(string =>
12+
try Right(new URI(string))
13+
catch {
14+
case _: URISyntaxException =>
15+
Left("String could not be parsed as a URI reference, it violates RFC 2396.")
16+
case _: NullPointerException =>
17+
Left("String is null.")
18+
}
19+
)
20+
21+
implicit final lazy val URIEncoder: Encoder[URI] = new Encoder[URI] {
22+
final def apply(a: URI): Json = Json.fromString(a.toString)
23+
}
24+
25+
implicit final lazy val FileDecoder: Decoder[File] = Decoder[Array[Byte]].emap { bytes =>
26+
try {
27+
val tmpFile = File.createTempFile("download", ".tmp")
28+
tmpFile.deleteOnExit()
29+
Files.write(tmpFile.toPath, bytes)
30+
Right(tmpFile)
31+
} catch {
32+
case e: Exception => Left(s"Failed to write binary content to file: ${e.getMessage}")
33+
}
34+
}
35+
36+
implicit final lazy val FileEncoder: Encoder[File] = Encoder[Array[Byte]].contramap(
37+
f => Files.readAllBytes(f.toPath)
38+
)
39+
40+
implicit final lazy val AnyDecoder: Decoder[Any] = Decoder[Json].map(_.asInstanceOf[Any])
41+
42+
implicit final lazy val AnyEncoder: Encoder[Any] = Encoder.instance {
43+
case json: Json => json
44+
case other => Json.fromString(other.toString)
2045
}
2146
}

samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/core/JsonSupport.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ package org.openapitools.client.core
1313

1414
import org.openapitools.client.model._
1515
import io.circe.{Decoder, Encoder}
16-
import io.circe.generic.AutoDerivation
1716
import sttp.client3.circe.SttpCirceApi
1817

19-
object JsonSupport extends SttpCirceApi with AutoDerivation with DateSerializers with AdditionalTypeSerializers {
18+
object JsonSupport extends SttpCirceApi with DateSerializers with AdditionalTypeSerializers {
2019

2120
implicit val EnumTestSearchDecoder: Decoder[EnumTestEnums.Search] = Decoder.decodeEnumeration(EnumTestEnums.Search)
2221
implicit val EnumTestSearchEncoder: Encoder[EnumTestEnums.Search] = Encoder.encodeEnumeration(EnumTestEnums.Search)

samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/ApiResponse.scala

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
*/
1212
package org.openapitools.client.model
1313

14+
import io.circe.{Decoder, Encoder, Json}
15+
import io.circe.syntax._
16+
import org.openapitools.client.core.JsonSupport._
1417

1518
/**
1619
* An uploaded response
@@ -21,4 +24,26 @@ case class ApiResponse(
2124
`type`: Option[String] = None,
2225
message: Option[String] = None
2326
)
27+
object ApiResponse {
28+
implicit val encoderApiResponse: Encoder[ApiResponse] = Encoder.instance { t =>
29+
Json.fromFields{
30+
Seq(
31+
t.code.map(v => "code" -> v.asJson),
32+
t.`type`.map(v => "type" -> v.asJson),
33+
t.message.map(v => "message" -> v.asJson)
34+
).flatten
35+
}
36+
}
37+
implicit val decoderApiResponse: Decoder[ApiResponse] = Decoder.instance { c =>
38+
for {
39+
code <- c.downField("code").as[Option[Int]]
40+
`type` <- c.downField("type").as[Option[String]]
41+
message <- c.downField("message").as[Option[String]]
42+
} yield ApiResponse(
43+
code = code,
44+
`type` = `type`,
45+
message = message
46+
)
47+
}
48+
}
2449

samples/client/petstore/scala-sttp-circe/src/main/scala/org/openapitools/client/model/Category.scala

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
*/
1212
package org.openapitools.client.model
1313

14+
import io.circe.{Decoder, Encoder, Json}
15+
import io.circe.syntax._
16+
import org.openapitools.client.core.JsonSupport._
1417

1518
/**
1619
* Pet category
@@ -20,4 +23,23 @@ case class Category(
2023
id: Option[Long] = None,
2124
name: Option[String] = None
2225
)
26+
object Category {
27+
implicit val encoderCategory: Encoder[Category] = Encoder.instance { t =>
28+
Json.fromFields{
29+
Seq(
30+
t.id.map(v => "id" -> v.asJson),
31+
t.name.map(v => "name" -> v.asJson)
32+
).flatten
33+
}
34+
}
35+
implicit val decoderCategory: Decoder[Category] = Decoder.instance { c =>
36+
for {
37+
id <- c.downField("id").as[Option[Long]]
38+
name <- c.downField("name").as[Option[String]]
39+
} yield Category(
40+
id = id,
41+
name = name
42+
)
43+
}
44+
}
2345

0 commit comments

Comments
 (0)