Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Type hint transformation for sealed hierarchies #1093

Merged
merged 1 commit into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -176,6 +176,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 @@ -201,11 +207,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 @@ -417,10 +425,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 @@ -595,10 +605,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 @@ -232,6 +247,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 @@ -243,6 +269,7 @@ object CodecSpec extends ZIOSpecDefault {
object examplealtsum {

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

object Parent {
Expand All @@ -256,6 +283,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
Loading