Skip to content

Commit f9ecc1a

Browse files
authored
Implicit circe Encoder and Decoder for GeneratedMessage and GeneratedEnum (#90)
* First pass compiles * Revert a scalafmt * codecs for enums * First couple tests * Tests implemented in JVM subproject * Bump version and skim readme one more time * Scalafmted and moved test to shared module * Scalafmt
1 parent 7fb94f2 commit f9ecc1a

File tree

7 files changed

+286
-6
lines changed

7 files changed

+286
-6
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
target/
22
project/sbt-launch-*.jar
33
.idea/
4+
.bsp/

README.md

+56
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ libraryDependencies += "io.github.scalapb-json" %% "scalapb-circe-macros" % "0.8
2727

2828
## Usage
2929

30+
### JsonFormat
31+
3032
There are four functions you can use directly to serialize/deserialize your messages:
3133

3234
```scala
@@ -37,6 +39,60 @@ JsonFormat.fromJsonString(str) // return MessageType
3739
JsonFormat.fromJson(json) // return MessageType
3840
```
3941

42+
### Implicit Circe Codecs
43+
44+
You can also import codecs to support Circe's implicit syntax for objects of type `GeneratedMessage` and `GeneratedEnum`.
45+
46+
Assume a proto message:
47+
48+
```scala
49+
message Guitar {
50+
int32 number_of_strings = 1;
51+
}
52+
```
53+
54+
```scala
55+
import io.circe.syntax._
56+
import io.circe.parser._
57+
import scalapb_circe.codec._
58+
59+
Guitar(42).asJson.noSpaces // returns {"numberOfStrings":42}
60+
61+
decode[Guitar]("""{"numberOfStrings": 42}""") // returns Right(Guitar(42))
62+
Json.obj("numberOfStrings" -> Json.fromInt(42)).as[Guitar] // returns Right(Guitar(42))
63+
```
64+
65+
You can define an implicit `scalapb_circe.Printer` and/or `scalapb_circe.Parser` to control printing and parsing settings.
66+
For example, to include default values in Json:
67+
68+
```scala
69+
import io.circe.syntax._
70+
import io.circe.parser._
71+
import scalapb_circe.codec._
72+
import scalapb_circe.Printer
73+
74+
implicit val p: Printer = new Printer(includingDefaultValueFields = true)
75+
76+
Guitar(0).asJson.noSpaces // returns {"numberOfStrings": 0}
77+
```
78+
79+
Finally, you can include scalapb `GeneratedMessage` and `GeneratedEnum`s in regular case classes with semi-auto derivation:
80+
81+
```scala
82+
import io.circe.generic.semiauto._
83+
import io.circe.syntax._
84+
import io.circe._
85+
import scalapb_circe.codec._ // IntelliJ might say this is unused.
86+
87+
case class Band(guitars: Seq[Guitar])
88+
object Band {
89+
implicit val dec: Decoder[Band] = deriveDecoder[Band]
90+
implicit val enc: Encoder[Band] = deriveEncoder[Band]
91+
}
92+
Band(Seq(Guitar(42))).asJson.noSpaces // returns {"guitars":[{"numberOfStrings":42}]}
93+
```
94+
95+
4096
### Credits
4197

4298
- https://github.com/whisklabs/scalapb-playjson

build.sbt

+1
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ lazy val commonSettings = Def.settings(
133133
circeVersion := "0.13.0",
134134
libraryDependencies ++= Seq(
135135
"com.github.scalaprops" %%% "scalaprops" % "0.8.2" % "test",
136+
"io.circe" %%% "circe-generic" % circeVersion.value % "test",
136137
"io.github.scalapb-json" %%% "scalapb-json-common" % scalapbJsonCommonVersion.value,
137138
"com.thesamet.scalapb" %%% "scalapb-runtime" % scalapbVersion % "protobuf,test",
138139
"org.scalatest" %%% "scalatest" % "3.2.3" % "test"

core/shared/src/main/scala/scalapb_circe/JsonFormat.scala

+10-5
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,18 @@ class Printer(
177177
if (formattingLongAsNumber) Json.fromBigDecimal(v) else Json.fromString(v.toString())
178178
}
179179

180+
private[scalapb_circe] def serializeEnum(e: EnumValueDescriptor): Json = {
181+
formatRegistry.getEnumWriter(e.containingEnum) match {
182+
case Some(writer) => writer(this, e)
183+
case None =>
184+
if (formattingEnumsAsNumber) Json.fromLong(e.number)
185+
else Json.fromString(e.name)
186+
}
187+
}
188+
180189
def serializeSingleValue(fd: FieldDescriptor, value: PValue, formattingLongAsNumber: Boolean): Json =
181190
value match {
182-
case PEnum(e) =>
183-
formatRegistry.getEnumWriter(e.containingEnum) match {
184-
case Some(writer) => writer(this, e)
185-
case None => if (formattingEnumsAsNumber) Json.fromLong(e.number) else Json.fromString(e.name)
186-
}
191+
case PEnum(e) => serializeEnum(e)
187192
case PInt(v) if fd.protoType.isTypeUint32 => Json.fromLong(ScalapbJsonCommon.unsignedInt(v))
188193
case PInt(v) if fd.protoType.isTypeFixed32 => Json.fromLong(ScalapbJsonCommon.unsignedInt(v))
189194
case PInt(v) => Json.fromLong(v)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package scalapb_circe
2+
3+
import io.circe.{Decoder, DecodingFailure, Encoder, HCursor}
4+
import scalapb.{GeneratedEnum, GeneratedEnumCompanion, GeneratedMessage, GeneratedMessageCompanion}
5+
6+
import scala.util.{Failure, Success, Try}
7+
8+
/**
9+
* Implicit circe [[Encoder]] and [[Decoder]]s for scalapb's [[GeneratedMessage]] and [[GeneratedEnum]] classes.
10+
*/
11+
package object codec {
12+
13+
implicit def printer: Printer = JsonFormat.printer
14+
implicit def parser: Parser = JsonFormat.parser
15+
16+
/**
17+
* Encoder for [[GeneratedMessage]] using a specific implicit [[Printer]].
18+
* The [[Printer]] class lets you control some details about the encoding,
19+
* such as whether to include fields with default values in the JSON.
20+
*/
21+
implicit def generatedMessageEncoderWithPrinter[M <: GeneratedMessage](implicit p: Printer): Encoder[M] =
22+
(a: M) => p.toJson(a)
23+
24+
/**
25+
* Decoder for [[GeneratedMessage]] using a specific implicit [[Parser]].
26+
* The [[Parser]] class lets you control some details about the decoding,
27+
* such as whether to preserve the raw field names.
28+
*/
29+
implicit def generatedMessageDecoderWithParser[M <: GeneratedMessage: GeneratedMessageCompanion](implicit
30+
p: Parser
31+
): Decoder[M] =
32+
(c: HCursor) =>
33+
Try(p.fromJson[M](c.value)) match {
34+
case Failure(t) => Left(DecodingFailure(t.getMessage, List.empty))
35+
case Success(m) => Right(m)
36+
}
37+
38+
/**
39+
* Encoder for [[GeneratedEnum]] using a specific implicit [[Printer]].
40+
* The [[Printer]] class lets you control some details about the encoding,
41+
* such as whether to use strings or integers to represent an Enum.
42+
*/
43+
implicit def generatedEnumEncoderWithPrinter[E <: GeneratedEnum](implicit p: Printer): Encoder[E] =
44+
(a: E) => p.serializeEnum(a.scalaValueDescriptor)
45+
46+
/**
47+
* Decoder for [[GeneratedEnum]] using a specific implicit [[Parser]].
48+
* The [[Parser]] class lets you control some details about the decoding.
49+
*/
50+
implicit def generatedEnumDecoderWithParser[E <: GeneratedEnum: GeneratedEnumCompanion](implicit
51+
p: Parser
52+
): Decoder[E] =
53+
(c: HCursor) => {
54+
val companion = implicitly[GeneratedEnumCompanion[E]]
55+
Try(p.defaultEnumParser(companion.scalaDescriptor, c.value)) match {
56+
case Success(e) => Right(companion.fromValue(e.index))
57+
case Failure(t) => Left(DecodingFailure(t.getMessage, List.empty))
58+
}
59+
}
60+
61+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package scalapb_circe
2+
3+
import io.circe.syntax._
4+
import io.circe.{Decoder, Encoder, Json}
5+
import jsontest.anytests.AnyTest
6+
import jsontest.custom_collection.{Guitar, Studio}
7+
import jsontest.issue315.{Bar, Foo, Msg}
8+
import jsontest.test.MyEnum
9+
import jsontest.test3.MyTest3.MyEnum3
10+
import org.scalatest.Assertion
11+
import org.scalatest.freespec.AnyFreeSpec
12+
import org.scalatest.matchers.must.Matchers
13+
import scalapb.{GeneratedEnum, GeneratedEnumCompanion, GeneratedMessage, GeneratedMessageCompanion}
14+
import scalapb_circe.codec._
15+
16+
class CodecSpec extends AnyFreeSpec with Matchers {
17+
18+
"GeneratedMessage" - {
19+
"encode to same value via codec and JsonFormat" in {
20+
def check[M <: GeneratedMessage](m: M): Assertion = {
21+
val encodedWithJsonFormat = JsonFormat.toJson(m)
22+
val encodedImplicitly = m.asJson
23+
encodedImplicitly mustBe encodedWithJsonFormat
24+
}
25+
26+
check(AnyTest("foo"))
27+
check(Guitar(99))
28+
check(Studio(Set(Guitar(1), Guitar(2), Guitar(3))))
29+
check(
30+
Msg(
31+
baz = "bazzz",
32+
someUnion = Msg.SomeUnion.Foo(Foo("fooooo"))
33+
)
34+
)
35+
check(
36+
Msg(
37+
baz = "bazzz",
38+
someUnion = Msg.SomeUnion.Bar(Bar("fooooo"))
39+
)
40+
)
41+
}
42+
43+
"decode to same value via codec and JsonFormat" in {
44+
def check[M <: GeneratedMessage: GeneratedMessageCompanion](m: M): Assertion = {
45+
val decodedWithJsonFormat = JsonFormat.fromJson(JsonFormat.printer.toJson(m))
46+
val decodedImplicitly = m.asJson.as[M]
47+
decodedImplicitly mustBe Right(decodedWithJsonFormat)
48+
}
49+
50+
check(AnyTest("foo"))
51+
check(Guitar(99))
52+
check(Studio(Set(Guitar(1), Guitar(2), Guitar(3))))
53+
check(
54+
Msg(
55+
baz = "bazzz",
56+
someUnion = Msg.SomeUnion.Foo(Foo("fooooo"))
57+
)
58+
)
59+
check(
60+
Msg(
61+
baz = "bazzz",
62+
someUnion = Msg.SomeUnion.Bar(Bar("fooooo"))
63+
)
64+
)
65+
}
66+
67+
"encode using an implicit printer w/ non-standard settings" in {
68+
implicit val printer: Printer = new Printer(includingDefaultValueFields = true)
69+
70+
// Create a proto with a default value (0).
71+
val g = Guitar(0)
72+
73+
// Using regular JsonFormat yields an empty object because 0 is the default value.
74+
JsonFormat.toJson(g) mustBe Json.obj()
75+
76+
// Using asJson with an implicit printer includes the default value.
77+
g.asJson mustBe Json.obj("numberOfStrings" -> Json.fromInt(0))
78+
}
79+
80+
"decode using an implicit parser w/ non-standard settings" in {
81+
implicit val parser: Parser = new Parser(preservingProtoFieldNames = true)
82+
83+
// Use the snake-case naming to define a Guitar Json object.
84+
val j = Json.obj("number_of_strings" -> Json.fromInt(42))
85+
86+
// Using the regular JsonFormat parser decodes to the defaultInstance.
87+
JsonFormat.fromJson[Guitar](j) mustBe Guitar.defaultInstance
88+
89+
// Using as[T] with an implicit parser decodes back to the original value (42).
90+
j.as[Guitar] mustBe Right(Guitar(42))
91+
}
92+
}
93+
94+
"GeneratedEnum" - {
95+
"encode to same value via codec and JsonFormat" in {
96+
def check[E <: GeneratedEnum: GeneratedEnumCompanion](e: E): Assertion = {
97+
val encodedWithJsonFormat = JsonFormat.printer.serializeEnum(e.scalaValueDescriptor)
98+
val encodeImplicitly = e.asJson
99+
encodeImplicitly mustBe encodedWithJsonFormat
100+
}
101+
MyEnum.values.foreach(check(_: MyEnum))
102+
MyEnum3.values.foreach(check(_: MyEnum3))
103+
}
104+
105+
"decode to same value via codec and JsonFormat" in {
106+
def check[E <: GeneratedEnum: GeneratedEnumCompanion](e: E): Assertion = {
107+
val cmp = implicitly[GeneratedEnumCompanion[E]]
108+
val decodedWithJsonFormat = cmp.fromValue(
109+
JsonFormat.parser
110+
.defaultEnumParser(cmp.scalaDescriptor, JsonFormat.printer.serializeEnum(e.scalaValueDescriptor))
111+
.index
112+
)
113+
val decodedImplicitly = e.asJson.as[E]
114+
decodedImplicitly mustBe Right(decodedWithJsonFormat)
115+
}
116+
MyEnum.values.foreach(check(_: MyEnum))
117+
MyEnum3.values.foreach(check(_: MyEnum3))
118+
}
119+
120+
"encode using an implicit printer w/ non-standard settings" in {
121+
implicit val printer = new Printer(formattingEnumsAsNumber = true)
122+
def check[E <: GeneratedEnum: GeneratedEnumCompanion](e: E): Assertion = {
123+
e.asJson mustBe Json.fromInt(e.value)
124+
}
125+
MyEnum.values.foreach(check(_: MyEnum))
126+
MyEnum3.values.foreach(check(_: MyEnum3))
127+
}
128+
129+
}
130+
"Case class with GeneratedMessage and GeneratedEnum" - {
131+
"derive and use a semi-auto codec" in {
132+
133+
import io.circe.generic.semiauto._
134+
import io.circe.syntax._
135+
136+
case class Band(version: MyEnum, guitars: Seq[Guitar])
137+
138+
object Band {
139+
implicit val dec: Decoder[Band] = deriveDecoder[Band]
140+
implicit val enc: Encoder[Band] = deriveEncoder[Band]
141+
}
142+
143+
val band = Band(MyEnum.V1, Seq(Guitar(4), Guitar(5)))
144+
val json = Json.obj(
145+
"version" -> Json.fromString("V1"),
146+
"guitars" -> Json.arr(
147+
Json.obj("numberOfStrings" -> Json.fromInt(4)),
148+
Json.obj("numberOfStrings" -> Json.fromInt(5))
149+
)
150+
)
151+
band.asJson mustBe json
152+
json.as[Band] mustBe Right(band)
153+
}
154+
}
155+
156+
}

version.sbt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version in ThisBuild := "0.8.1-SNAPSHOT"
1+
version in ThisBuild := "0.9.0-SNAPSHOT"

0 commit comments

Comments
 (0)