zio / zio-json

Fast, secure JSON library with tight ZIO integration.
https://zio.dev/zio-json
Apache License 2.0
407 stars 146 forks source link

[Feature Request] support jsonDiscriminator in case class #1056

Open alphaho opened 9 months ago

alphaho commented 9 months ago

With a sealed trait like below, the anmailCodec would emita type property in the encoded json string as expected like: {"type": "dog", "name": "Snoopy"}.

import zio.json._

@jsonDiscriminator("type")
sealed trait Animal
object Animal {
  @jsonHint("dog")
  case class Dog(name: String) extends Animal
  object Dog {
    implicit val dogCodec: JsonCodec[Dog] = DeriveJsonCodec.gen
  }

  @jsonHint("cat")
  case class Cat(name: String, weight: Double) extends Animal

  implicit val animalCodec: JsonCodec[Animal] = DeriveJsonCodec.gen
}

But sometimes in the client code, I want to use the more specific Dog type instead of Animal. That requires me to define the dogCodec as above in Dog's companion object. However, the dogCodec would not emit a type property in the encoded json, and get us something like {"name": "Snoopy"} instead. And it would not honor the type property during decoding, accepting invalid input with unexpected type. For example, below test would fail as it parses the json successfully:

import zio.json._
import zio.test._

object AnimalCodecSpec extends ZIOSpecDefault {
  override def spec: Spec[TestEnvironment, Any] =
    test("this would fail, as it ignores the discriminator") {
      val json    = """{"type":"cat","name":"Kitty", "weight": 8}"""
      val decoded = json.fromJson[Dog]
      assertTrue(decoded.is(_.left.anything)) // decoded == Right(Dog(Kitty))
    }
}

One workaround I have is to define extra sealed traits for the case classes like the Dog type. For example, to emit type property for Dog, we can:

@jsonDiscriminator("type")
sealed trait Animal2

object Animal2 {
  @jsonDiscriminator("type")
  sealed trait Dog extends Animal2 { // extra sealed trait needed as a workaround
    val name: String
  }

  @jsonHint("dog")
  case class DogImpl(name: String) extends Dog
  object Dog {
    implicit val dogCodec: JsonCodec[Dog] = DeriveJsonCodec.gen
  }

  @jsonHint("cat")
  case class Cat(name: String, weight: Double) extends Animal2

  implicit val animalCodec: JsonCodec[Animal2] = DeriveJsonCodec.gen
}

object Animal2CodecSpec extends ZIOSpecDefault {
  override def spec: Spec[TestEnvironment, Any] =
    test("this would success, as it honors the discriminator") {
      val json    = """{"type":"cat","name":"Kitty", "weight": 8}"""
      val decoded = json.fromJson[Animal2.Dog]
      assertTrue(decoded.is(_.left.anything)) // decoded == Left((invalid disambiguator))
    }
}

However, this workaround is quite verbose, especially if we need multiple case class codecs to emit the discriminator. It would be better if: 1) we can support @jsonDiscriminator in case class (with the risk that the case class's jsonDiscriminator value different from the parent's jsonDiscriminator value), or 2) we can support reading the parent sealed trait's @jsonDiscriminator when we deriving case class's codec

jdegoes commented 5 months ago

/bounty $100

algora-pbc[bot] commented 5 months ago

💎 $100 bounty • ZIO

Steps to solve:

  1. Start working: Comment /attempt #1056 with your implementation plan
  2. Submit work: Create a pull request including /claim #1056 in the PR body to claim the bounty
  3. Receive payment: 100% of the bounty is received 2-5 days post-reward. Make sure you are eligible for payouts

Thank you for contributing to zio/zio-json!

Add a bounty • Share on socials

Attempt Started (GMT+0) Solution
🟢 @987Nabil #1112
🟢 @pablf #1113
algora-pbc[bot] commented 4 months ago

💡 @987Nabil submitted a pull request that claims the bounty. You can visit your bounty board to reward.

algora-pbc[bot] commented 4 months ago

💡 @pablf submitted a pull request that claims the bounty. You can visit your bounty board to reward.