Search code examples
jsonscalacirce

Using Scala to represent two JSON fields of which only one can be null


Let's say my API returns a JSON that looks like this:

{
  "field1": "hey",
  "field2": null,
}

I have this rule that only one of these fields can be null at the same time. In this example, only field2 is null so we're ok.

I can represent this in Scala with the following case class:

case class MyFields(
  field1: Option[String],
  field2: Option[String]
)

And by implementing some implicits and let circe do it's magic of converting the objects to JSON.

object MyFields {
  implicit lazy val encoder: Encoder[MyFields] = deriveEncoder[MyFields]
  implicit lazy val decoder: Decoder[MyFields] = deriveDecoder[MyFields]

Now, this strategy works. Kinda.

MyFields(Some("hey"), None)
MyFields(None, Some("hey"))
MyFields(Some("hey"), Some("hey"))

These all lead to JSONs that follow the rule. But it's also possible to do:

MyFields(None, None)

Which will lead to a JSON that breaks the rule.

So this strategy doesn't express the rule adequately. What's a better way to do it?


Solution

  • (This is based on Martijn's answer and comment.)

    Cats Ior datatype could be used, as following:

    import cats.data.Ior
    import io.circe.parser._
    import io.circe.syntax._
    import io.circe._
    
    
    case class Fields(fields: Ior[String, String])
    
    implicit val encodeFields: Encoder[Fields] = (a: Fields) =>
      a.fields match {
        case Ior.Both(v1, v2) => Json.obj(
          ("field1", Json.fromString(v1)),
          ("field2", Json.fromString(v2))
        )
        case Ior.Left(v) => Json.obj(
          ("field1", Json.fromString(v)),
          ("field2", Json.Null)
          )
        case Ior.Right(v) => Json.obj(
          ("field1", Json.Null),
          ("field2", Json.fromString(v))
        )
      }
    
    implicit val decodeFields: Decoder[Fields] = (c: HCursor) => {
      val f1 = c.downField("field1").as[Option[String]]
      val f2 = c.downField("field2").as[Option[String]]
      (f1, f2) match {
        case (Right(Some(v1)), Right(Some(v2))) => Right(Fields(Ior.Both(v1, v2)))
        case (Right(Some(v1)), Right(None)) => Right(Fields(Ior.Left(v1)))
        case (Right(None), Right(Some(v2))) => Right(Fields(Ior.Right(v2)))
        case (Left(failure), _) => Left(failure)
        case (_, Left(failure)) => Left(failure)
        case (Right(None), Right(None)) => Left(DecodingFailure("At least one of field1 or field2 must be non-null", Nil))
      }
    }
    
    
    
    println(Fields(Ior.Right("right")).asJson)
    println(Fields(Ior.Left("left")).asJson)
    println(Fields(Ior.both("right", "left")).asJson)
    
    
    println(parse("""{"field1": null, "field2": "right"}""").flatMap(_.as[Fields]))