diff --git a/docs/configuration.md b/docs/configuration.md index 3c7da6f6f..87631ca8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/zio-json-macros/shared/src/test/scala/zio/json/DeriveSpec.scala b/zio-json-macros/shared/src/test/scala/zio/json/DeriveSpec.scala index d62fd4d60..c88f87a12 100644 --- a/zio-json-macros/shared/src/test/scala/zio/json/DeriveSpec.scala +++ b/zio-json-macros/shared/src/test/scala/zio/json/DeriveSpec.scala @@ -31,6 +31,13 @@ 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._ @@ -38,6 +45,14 @@ object DeriveSpec extends ZIOSpecDefault { 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')"))) } ) ) @@ -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]) @@ -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) diff --git a/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala b/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala index 3810b8c58..2db9cd39f 100644 --- a/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala +++ b/zio-json/shared/src/main/scala-2.x/zio/json/macros.scala @@ -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. @@ -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 { @@ -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]] = @@ -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) diff --git a/zio-json/shared/src/main/scala-3/zio/json/macros.scala b/zio-json/shared/src/main/scala-3/zio/json/macros.scala index 134711172..d61500909 100644 --- a/zio-json/shared/src/main/scala-3/zio/json/macros.scala +++ b/zio-json/shared/src/main/scala-3/zio/json/macros.scala @@ -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 { @@ -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. @@ -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) @@ -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 { @@ -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) @@ -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( @@ -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 = { diff --git a/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala b/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala index b61ff8485..4f2094c07 100644 --- a/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala +++ b/zio-json/shared/src/test/scala-2.x/zio/json/ConfigurableDeriveCodecSpec.scala @@ -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}""" diff --git a/zio-json/shared/src/test/scala/zio/json/CodecSpec.scala b/zio-json/shared/src/test/scala/zio/json/CodecSpec.scala index c05d08986..fc367ea89 100644 --- a/zio-json/shared/src/test/scala/zio/json/CodecSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/CodecSpec.scala @@ -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._ @@ -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":""}""" @@ -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]) @@ -243,6 +269,7 @@ object CodecSpec extends ZIOSpecDefault { object examplealtsum { @jsonDiscriminator("hint") + @jsonHintNames(SnakeCase) sealed abstract class Parent object Parent { @@ -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) diff --git a/zio-json/shared/src/test/scala/zio/json/DecoderSpec.scala b/zio-json/shared/src/test/scala/zio/json/DecoderSpec.scala index 358352882..78fbe99b3 100644 --- a/zio-json/shared/src/test/scala/zio/json/DecoderSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/DecoderSpec.scala @@ -149,6 +149,14 @@ object DecoderSpec 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("""{"Child1":{}}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) && + assert("""{"type":"child1"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) + }, test("sum alternative encoding") { import examplealtsum._ @@ -157,6 +165,14 @@ object DecoderSpec 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":"Child2"}""".fromJson[Parent])(isLeft(equalTo("(invalid disambiguator)"))) && + assert("""{"child1":{}}""".fromJson[Parent])(isLeft(equalTo("(missing hint 'hint')"))) + }, test("unicode") { assert(""""€🐵🥰"""".fromJson[String])(isRight(equalTo("€🐵🥰"))) }, @@ -544,6 +560,21 @@ object DecoderSpec extends ZIOSpecDefault { } + object examplesumhintnames { + + @jsonHintNames(CamelCase) + sealed abstract class Parent + + object Parent { + implicit val decoder: JsonDecoder[Parent] = DeriveJsonDecoder.gen[Parent] + } + + case class Child1() extends Parent + + case class Child2() extends Parent + + } + object examplealtsum { @jsonDiscriminator("hint") @@ -561,6 +592,23 @@ object DecoderSpec extends ZIOSpecDefault { } + object examplealtsumhintnames { + + @jsonDiscriminator("hint") + @jsonHintNames(CamelCase) + sealed abstract class Parent + + object Parent { + implicit val decoder: JsonDecoder[Parent] = DeriveJsonDecoder.gen[Parent] + } + + case class Child1() extends Parent + + @jsonHint("Abel") + case class Child2() extends Parent + + } + object logEvent { case class Event(at: Long, message: String) diff --git a/zio-json/shared/src/test/scala/zio/json/EncoderSpec.scala b/zio-json/shared/src/test/scala/zio/json/EncoderSpec.scala index 77bf941ab..c8cd85791 100644 --- a/zio-json/shared/src/test/scala/zio/json/EncoderSpec.scala +++ b/zio-json/shared/src/test/scala/zio/json/EncoderSpec.scala @@ -400,6 +400,12 @@ object EncoderSpec extends ZIOSpecDefault { assert((Child1(): Parent).toJsonAST)(isRight(equalTo(Json.Obj(Chunk("Child1" -> Json.Obj()))))) && assert((Child2(): Parent).toJsonAST)(isRight(equalTo(Json.Obj(Chunk("Cain" -> Json.Obj()))))) }, + test("sum encoding with hint names") { + import examplesumhintnames._ + + assert((Child1(): Parent).toJsonAST)(isRight(equalTo(Json.Obj(Chunk("child1" -> Json.Obj()))))) && + assert((Child2(): Parent).toJsonAST)(isRight(equalTo(Json.Obj(Chunk("Cain" -> Json.Obj()))))) + }, test("sum alternative encoding") { import examplealtsum._ @@ -409,6 +415,15 @@ object EncoderSpec extends ZIOSpecDefault { (isRight(equalTo(Json.Obj("s" -> Json.Str("hello"), "hint" -> Json.Str("Abel"))))) ) }, + test("sum alternative encoding with hint names") { + import examplealtsumhintnames._ + + assert((Child1(): Parent).toJsonAST)(isRight(equalTo(Json.Obj("hint" -> Json.Str("child1"))))) && + assert((Child2(None): Parent).toJsonAST)(isRight(equalTo(Json.Obj("hint" -> Json.Str("Abel"))))) && + assert((Child2(Some("hello")): Parent).toJsonAST)( + (isRight(equalTo(Json.Obj("s" -> Json.Str("hello"), "hint" -> Json.Str("Abel"))))) + ) + }, test("alias") { import exampleproducts._ @@ -488,6 +503,22 @@ object EncoderSpec extends ZIOSpecDefault { } + object examplesumhintnames { + + @jsonHintNames(CamelCase) + sealed abstract class Parent + + object Parent { + implicit val encoder: JsonEncoder[Parent] = DeriveJsonEncoder.gen[Parent] + } + + case class Child1() extends Parent + + @jsonHint("Cain") + case class Child2() extends Parent + + } + object examplealtsum { @jsonDiscriminator("hint") @@ -504,4 +535,21 @@ object EncoderSpec extends ZIOSpecDefault { } + object examplealtsumhintnames { + + @jsonDiscriminator("hint") + @jsonHintNames(CamelCase) + sealed abstract class Parent + + object Parent { + implicit val encoder: JsonEncoder[Parent] = DeriveJsonEncoder.gen[Parent] + } + + case class Child1() extends Parent + + @jsonHint("Abel") + case class Child2(s: Option[String]) extends Parent + + } + }