Skip to content

Commit a79d60b

Browse files
authored
[Fix][scala-sttp][circe] Circe codecs do not preserve original JSON field names for non-camelCase properties (#23465)
* [Fix][scala-sttp][circe] Circe codecs do not preserve original JSON field names for non-camelCase properties * update tests * Fix bug * Revert accidental delete * [AI review feedback] Fix bugs * Regen Samples * Regen Docs * Fix kotlin-server samples
1 parent 9f81af0 commit a79d60b

File tree

17 files changed

+468
-39
lines changed

17 files changed

+468
-39
lines changed

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

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,6 @@ public ScalaSttpClientCodegen() {
108108
apiTemplateFiles.put("api.mustache", ".scala");
109109
embeddedTemplateDir = templateDir = "scala-sttp";
110110

111-
String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);
112-
113-
String jsonValueClass = "circe".equals(jsonLibrary) ? "io.circe.Json" : "org.json4s.JValue";
114-
115111
additionalProperties.put(CodegenConstants.GROUP_ID, groupId);
116112
additionalProperties.put(CodegenConstants.ARTIFACT_ID, artifactId);
117113
additionalProperties.put(CodegenConstants.ARTIFACT_VERSION, artifactVersion);
@@ -147,7 +143,7 @@ public ScalaSttpClientCodegen() {
147143
typeMapping.put("number", "Double");
148144
typeMapping.put("decimal", "BigDecimal");
149145
typeMapping.put("ByteArray", "Array[Byte]");
150-
typeMapping.put("AnyType", jsonValueClass);
146+
typeMapping.put("AnyType", "Any");
151147

152148
instantiationTypes.put("array", "ListBuffer");
153149
instantiationTypes.put("map", "Map");
@@ -166,6 +162,11 @@ public void processOpts() {
166162
apiPackage = PACKAGE_PROPERTY.getApiPackage(additionalProperties);
167163
modelPackage = PACKAGE_PROPERTY.getModelPackage(additionalProperties);
168164

165+
String jsonLibrary = JSON_LIBRARY_PROPERTY.getValue(additionalProperties);
166+
String jsonValueClass = "circe".equals(jsonLibrary) ? "io.circe.Json" : "org.json4s.JValue";
167+
typeMapping.put("object", jsonValueClass);
168+
typeMapping.put("AnyType", jsonValueClass);
169+
169170
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
170171
supportingFiles.add(new SupportingFile("build.sbt.mustache", "", "build.sbt"));
171172
final String invokerFolder = (sourceFolder + File.separator + invokerPackage).replace(".", File.separator);

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

Lines changed: 45 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,51 @@ 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+
Files.write(tmpFile.toPath, bytes)
52+
Right(tmpFile)
53+
} catch {
54+
case e: Exception => Left(s"Failed to write binary content to file: ${e.getMessage}")
55+
}
56+
}
57+
58+
implicit final lazy val FileEncoder: Encoder[File] = Encoder[Array[Byte]].contramap(
59+
f => Files.readAllBytes(f.toPath)
60+
)
61+
62+
implicit final lazy val AnyDecoder: Decoder[Any] = Decoder[Json].map(_.asInstanceOf[Any])
63+
64+
implicit final lazy val AnyEncoder: Encoder[Any] = Encoder.instance {
65+
case json: Json => json
66+
case b: Boolean => Json.fromBoolean(b)
67+
case n: Int => Json.fromInt(n)
68+
case n: Long => Json.fromLong(n)
69+
case n: Double => Json.fromDoubleOrNull(n)
70+
case n: BigDecimal => Json.fromBigDecimal(n)
71+
case s: String => Json.fromString(s)
72+
case other => Json.fromString(other.toString)
4373
}
4474
}
4575
{{/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: 39 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,40 @@ 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+
{{#hasVars}}
35+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { t =>
36+
Json.fromFields{
37+
Seq(
38+
{{#vars}}
39+
{{#required}}Some("{{baseName}}" -> t.{{{name}}}.asJson){{/required}}{{^required}}t.{{{name}}}.map(v => "{{baseName}}" -> v.asJson){{/required}}{{^-last}},{{/-last}}
40+
{{/vars}}
41+
).flatten
42+
}
43+
}
44+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { c =>
45+
for {
46+
{{#vars}}
47+
{{{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}}]
48+
{{/vars}}
49+
} yield {{classname}}(
50+
{{#vars}}
51+
{{{name}}} = {{{name}}}{{^-last}},{{/-last}}
52+
{{/vars}}
53+
)
54+
}
55+
{{/hasVars}}
56+
{{^hasVars}}
57+
implicit val encoder{{classname}}: Encoder[{{classname}}] = Encoder.instance { _ =>
58+
Json.fromFields(Seq.empty)
59+
}
60+
implicit val decoder{{classname}}: Decoder[{{classname}}] = Decoder.instance { _ =>
61+
Right({{classname}}())
62+
}
63+
{{/hasVars}}
64+
}
65+
{{/circe}}
2766
{{/isEnum}}
2867

2968
{{#isEnum}}

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

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

113+
@Test
114+
public void verifyCirceSerdeWithMixedCaseFields() 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 mixedCaseModelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/MixedCaseModel.scala");
140+
141+
assertFileContains(mixedCaseModelPath, "firstName");
142+
assertFileContains(mixedCaseModelPath, "phoneNumber");
143+
assertFileContains(mixedCaseModelPath, "lastName");
144+
assertFileContains(mixedCaseModelPath, "zipCode");
145+
assertFileContains(mixedCaseModelPath, "address");
146+
147+
assertFileContains(mixedCaseModelPath, "\"first-name\"");
148+
assertFileContains(mixedCaseModelPath, "\"phone_number\"");
149+
assertFileContains(mixedCaseModelPath, "\"lastName\"");
150+
assertFileContains(mixedCaseModelPath, "\"ZipCode\"");
151+
assertFileContains(mixedCaseModelPath, "\"address\"");
152+
153+
assertFileContains(mixedCaseModelPath, "c.downField(\"first-name\")");
154+
assertFileContains(mixedCaseModelPath, "c.downField(\"phone_number\")");
155+
assertFileContains(mixedCaseModelPath, "c.downField(\"ZipCode\")");
156+
157+
assertFileContains(mixedCaseModelPath, "object MixedCaseModel");
158+
assertFileContains(mixedCaseModelPath, "implicit val encoderMixedCaseModel");
159+
assertFileContains(mixedCaseModelPath, "implicit val decoderMixedCaseModel");
160+
161+
Path binaryModelPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/model/BinaryPayload.scala");
162+
assertFileContains(binaryModelPath, "data: Option[File]");
163+
assertFileContains(binaryModelPath, "metadata: Option[io.circe.Json]");
164+
assertFileContains(binaryModelPath, "c.downField(\"data\")");
165+
assertFileContains(binaryModelPath, "c.downField(\"metadata\")");
166+
assertFileContains(binaryModelPath, "implicit val encoderBinaryPayload");
167+
assertFileContains(binaryModelPath, "implicit val decoderBinaryPayload");
168+
169+
Path additionalSerializersPath = Paths.get(outputPath + "/src/main/scala/org/openapitools/client/core/AdditionalTypeSerializers.scala");
170+
assertFileContains(additionalSerializersPath, "FileDecoder");
171+
assertFileContains(additionalSerializersPath, "FileEncoder");
172+
assertFileContains(additionalSerializersPath, "AnyDecoder");
173+
assertFileContains(additionalSerializersPath, "AnyEncoder");
174+
}
175+
113176
@Test
114177
public void headerSerialization() throws IOException {
115178
File output = Files.createTempDirectory("test").toFile().getCanonicalFile();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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
31+
BinaryPayload:
32+
type: object
33+
properties:
34+
data:
35+
type: string
36+
format: binary
37+
metadata:
38+
type: object

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

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,50 @@ 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+
Files.write(tmpFile.toPath, bytes)
29+
Right(tmpFile)
30+
} catch {
31+
case e: Exception => Left(s"Failed to write binary content to file: ${e.getMessage}")
32+
}
33+
}
34+
35+
implicit final lazy val FileEncoder: Encoder[File] = Encoder[Array[Byte]].contramap(
36+
f => Files.readAllBytes(f.toPath)
37+
)
38+
39+
implicit final lazy val AnyDecoder: Decoder[Any] = Decoder[Json].map(_.asInstanceOf[Any])
40+
41+
implicit final lazy val AnyEncoder: Encoder[Any] = Encoder.instance {
42+
case json: Json => json
43+
case b: Boolean => Json.fromBoolean(b)
44+
case n: Int => Json.fromInt(n)
45+
case n: Long => Json.fromLong(n)
46+
case n: Double => Json.fromDoubleOrNull(n)
47+
case n: BigDecimal => Json.fromBigDecimal(n)
48+
case s: String => Json.fromString(s)
49+
case other => Json.fromString(other.toString)
2050
}
2151
}

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

0 commit comments

Comments
 (0)