Skip to content

Commit

Permalink
Type hint transformation for sealed hierarchies (#1093)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladimirkl committed Apr 25, 2024
1 parent 3e155fa commit bc86948
Show file tree
Hide file tree
Showing 8 changed files with 247 additions and 7 deletions.
26 changes: 26 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,32 @@ banana.toJson
apple.toJson
```

Another way of changing type hint is using `@jsonHintNames` annotation on sealed class. It allows to apply transformation
to all type hint values in hierarchy. Same transformations are provided as for `@jsonMemberNames` annotation.

Here's an example:

```scala mdoc
import zio.json._

@jsonHintNames(SnakeCase)
sealed trait FruitKind

case class GoodFruit(good: Boolean) extends FruitKind

case class BadFruit(bad: Boolean) extends FruitKind

object FruitKind {
implicit val encoder: JsonEncoder[FruitKind] =
DeriveJsonEncoder.gen[FruitKind]
}

val goodFruit: FruitKind = GoodFruit(true)
val badFruit: FruitKind = BadFruit(true)

goodFruit.toJson
badFruit.toJson
```
## jsonDiscriminator


Expand Down
37 changes: 37 additions & 0 deletions zio-json-macros/shared/src/test/scala/zio/json/DeriveSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,28 @@ object DeriveSpec extends ZIOSpecDefault {
assert("""{"Child2":{}}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"type":"Child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)")))
},
test("sum encoding with hint names") {
import examplesumhintnames._

assert("""{"child1":{}}""".fromJson[Parent])(isRight(equalTo(Child1()))) &&
assert("""{"child2":{}}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"type":"child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)")))
},
test("sum alternative encoding") {
import examplealtsum._

assert("""{"hint":"Cain"}""".fromJson[Parent])(isRight(equalTo(Child1()))) &&
assert("""{"hint":"Abel"}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"hint":"Samson"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) &&
assert("""{"Cain":{}}""".fromJson[Parent])(isLeft(equalTo("(missing hint 'hint')")))
},
test("sum alternative encoding with hint names") {
import examplealtsumhintnames._

assert("""{"hint":"child1"}""".fromJson[Parent])(isRight(equalTo(Child1()))) &&
assert("""{"hint":"Abel"}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"hint":"Child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) &&
assert("""{"child1":{}}""".fromJson[Parent])(isLeft(equalTo("(missing hint 'hint')")))
}
)
)
Expand All @@ -59,6 +74,15 @@ object DeriveSpec extends ZIOSpecDefault {
case class Child2() extends Parent
}

object examplesumhintnames {
@jsonDerive
@jsonHintNames(SnakeCase)
sealed abstract class Parent

case class Child1() extends Parent
case class Child2() extends Parent
}

object exampleempty {
@jsonDerive
case class Empty(a: Option[String])
Expand All @@ -78,6 +102,19 @@ object DeriveSpec extends ZIOSpecDefault {
case class Child2() extends Parent
}

object examplealtsumhintnames {

@jsonDerive
@jsonDiscriminator("hint")
@jsonHintNames(SnakeCase)
sealed abstract class Parent

case class Child1() extends Parent

@jsonHint("Abel")
case class Child2() extends Parent
}

object logEvent {
@jsonDerive(JsonDeriveConfig.Decoder)
case class Event(at: Long, message: String, a: Seq[String] = Nil)
Expand Down
18 changes: 15 additions & 3 deletions zio-json/shared/src/main/scala-2.x/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ private[json] object jsonMemberNames {
*/
final case class jsonHint(name: String) extends Annotation

/**
* If used on a sealed class will determine the strategy of type hint value transformation for disambiguating
* classes during serialization and deserialization. Same strategies are provided as for [[jsonMemberNames]].
*/
final case class jsonHintNames(format: JsonMemberFormat) extends Annotation

/**
* If used on a case class, will exit early if any fields are in the JSON that
* do not correspond to field names in the case class.
Expand All @@ -200,11 +206,13 @@ final class jsonExclude extends Annotation
* @param sumTypeHandling see [[jsonDiscriminator]]
* @param fieldNameMapping see [[jsonMemberNames]]
* @param allowExtraFields see [[jsonNoExtraFields]]
* @param sumTypeMapping see [[jsonHintNames]]
*/
final case class JsonCodecConfiguration(
sumTypeHandling: SumTypeHandling = WrapperWithClassNameField,
fieldNameMapping: JsonMemberFormat = IdentityFormat,
allowExtraFields: Boolean = true
allowExtraFields: Boolean = true,
sumTypeMapping: JsonMemberFormat = IdentityFormat
)

object JsonCodecConfiguration {
Expand Down Expand Up @@ -416,10 +424,12 @@ object DeriveJsonDecoder {
}

def split[A](ctx: SealedTrait[JsonDecoder, A])(implicit config: JsonCodecConfiguration): JsonDecoder[A] = {
val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(config.sumTypeMapping)
val names: Array[String] = ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
}.getOrElse(p.typeName.short)
}.getOrElse(jsonHintFormat(p.typeName.short))
}.toArray
val matrix: StringMatrix = new StringMatrix(names)
lazy val tcs: Array[JsonDecoder[Any]] =
Expand Down Expand Up @@ -594,10 +604,12 @@ object DeriveJsonEncoder {
}

def split[A](ctx: SealedTrait[JsonEncoder, A])(implicit config: JsonCodecConfiguration): JsonEncoder[A] = {
val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(config.sumTypeMapping)
val names: Array[String] = ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
}.getOrElse(p.typeName.short)
}.getOrElse(jsonHintFormat(p.typeName.short))
}.toArray
def discrim =
ctx.annotations.collectFirst { case jsonDiscriminator(n) => n }.orElse(config.sumTypeHandling.discriminatorField)
Expand Down
21 changes: 17 additions & 4 deletions zio-json/shared/src/main/scala-3/zio/json/macros.scala
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ case object PascalCase extends JsonMemberFormat {
case object KebabCase extends JsonMemberFormat {
override def apply(memberName: String): String = jsonMemberNames.enforceSnakeOrKebabCase(memberName, '-')
}
case object IdentityFormat extends JsonMemberFormat {
override def apply(memberName: String): String = memberName
}

/** zio-json version 0.3.0 formats. abc123Def -> abc_123_def */
object ziojson_03 {
Expand Down Expand Up @@ -175,6 +178,12 @@ private[json] object jsonMemberNames {
*/
final case class jsonHint(name: String) extends Annotation

/**
* If used on a sealed class will determine the strategy of type hint value transformation for disambiguating
* classes during serialization and deserialization. Same strategies are provided as for [[jsonMemberNames]].
*/
final case class jsonHintNames(format: JsonMemberFormat) extends Annotation

/**
* If used on a case class, will exit early if any fields are in the JSON that
* do not correspond to field names in the case class.
Expand Down Expand Up @@ -370,10 +379,12 @@ object DeriveJsonDecoder extends Derivation[JsonDecoder] { self =>
}

def split[A](ctx: SealedTrait[JsonDecoder, A]): JsonDecoder[A] = {
val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(IdentityFormat)
val names: Array[String] = IArray.genericWrapArray(ctx.subtypes.map { p =>
p.annotations.collectFirst { case jsonHint(name) =>
name
}.getOrElse(p.typeInfo.short)
}.getOrElse(jsonHintFormat(p.typeInfo.short))
}).toArray

val matrix: StringMatrix = new StringMatrix(names)
Expand Down Expand Up @@ -594,6 +605,8 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
}

def split[A](ctx: SealedTrait[JsonEncoder, A]): JsonEncoder[A] = {
val jsonHintFormat: JsonMemberFormat =
ctx.annotations.collectFirst { case jsonHintNames(format) => format }.getOrElse(IdentityFormat)
val discrim = ctx
.annotations
.collectFirst {
Expand All @@ -608,7 +621,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
.annotations
.collectFirst {
case jsonHint(name) => name
}.getOrElse(sub.typeInfo.short)
}.getOrElse(jsonHintFormat(sub.typeInfo.short))

out.write("{")
val indent_ = JsonEncoder.bump(indent)
Expand All @@ -635,7 +648,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
.annotations
.collectFirst {
case jsonHint(name) => name
}.getOrElse(sub.typeInfo.short)
}.getOrElse(jsonHintFormat(sub.typeInfo.short))

Json.Obj(
Chunk(
Expand All @@ -652,7 +665,7 @@ object DeriveJsonEncoder extends Derivation[JsonEncoder] { self =>
def getName(annotations: Iterable[_], default: => String): String =
annotations
.collectFirst { case jsonHint(name) => name }
.getOrElse(default)
.getOrElse(jsonHintFormat(default))

new JsonEncoder[A] {
def unsafeEncode(a: A, indent: Option[Int], out: Write): Unit = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,19 @@ object ConfigurableDeriveCodecSpec extends ZIOSpecDefault {
expectedObj.toJson == expectedStr
)
},
test("should override sum type mapping") {
val expectedStr = """{"$type":"case_class","i":1}"""
val expectedObj: ST = ST.CaseClass(i = 1)

implicit val config: JsonCodecConfiguration =
JsonCodecConfiguration(sumTypeHandling = DiscriminatorField("$type"), sumTypeMapping = SnakeCase)
implicit val codec: JsonCodec[ST] = DeriveJsonCodec.gen

assertTrue(
expectedStr.fromJson[ST].toOption.get == expectedObj,
expectedObj.toJson == expectedStr
)
},
test("should prevent extra fields") {
val jsonStr = """{"someField":1,"someOtherField":"a","extra":123}"""

Expand Down
43 changes: 43 additions & 0 deletions zio-json/shared/src/test/scala/zio/json/CodecSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ object CodecSpec extends ZIOSpecDefault {
assert("""{"Child2":{}}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"type":"Child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)")))
},
test("sum encoding with hint names") {
import examplesumhintnames._

assert("""{"child1":{}}""".fromJson[Parent])(isRight(equalTo(Child1()))) &&
assert("""{"child2":{}}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"type":"child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)")))
},
test("sum alternative encoding") {
import examplealtsum._

Expand All @@ -86,6 +93,14 @@ object CodecSpec extends ZIOSpecDefault {
assert("""{"hint":"Samson"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) &&
assert("""{"Cain":{}}""".fromJson[Parent])(isLeft(equalTo("(missing hint 'hint')")))
},
test("sum alternative encoding with hint names") {
import examplealtsumhintnames._

assert("""{"hint":"child1"}""".fromJson[Parent])(isRight(equalTo(Child1()))) &&
assert("""{"hint":"Abel"}""".fromJson[Parent])(isRight(equalTo(Child2()))) &&
assert("""{"hint":"Child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) &&
assert("""{"child1":{}}""".fromJson[Parent])(isLeft(equalTo("(missing hint 'hint')")))
},
test("key transformation") {
import exampletransformkeys._
val kebabed = """{"shish123-kebab":""}"""
Expand Down Expand Up @@ -231,6 +246,17 @@ object CodecSpec extends ZIOSpecDefault {
case class Child2() extends Parent
}

object examplesumhintnames {
@jsonHintNames(SnakeCase)
sealed abstract class Parent

object Parent {
implicit val codec: JsonCodec[Parent] = DeriveJsonCodec.gen[Parent]
}
case class Child1() extends Parent
case class Child2() extends Parent
}

object exampleempty {
case class Empty(a: Option[String])

Expand All @@ -242,6 +268,7 @@ object CodecSpec extends ZIOSpecDefault {
object examplealtsum {

@jsonDiscriminator("hint")
@jsonHintNames(SnakeCase)
sealed abstract class Parent

object Parent {
Expand All @@ -255,6 +282,22 @@ object CodecSpec extends ZIOSpecDefault {
case class Child2() extends Parent
}

object examplealtsumhintnames {

@jsonDiscriminator("hint")
@jsonHintNames(SnakeCase)
sealed abstract class Parent

object Parent {
implicit val codec: JsonCodec[Parent] = DeriveJsonCodec.gen[Parent]
}

case class Child1() extends Parent

@jsonHint("Abel")
case class Child2() extends Parent
}

object exampletransformkeys {
@jsonMemberNames(KebabCase)
case class Kebabed(shish123Kebab: String)
Expand Down
Loading

0 comments on commit bc86948

Please sign in to comment.