Search code examples
jsonscalacirce

Enforce a "at least one of two fields should be present" rule in Circe


I'm writing a Circe parser where the schema requires that at least one of two fields is set. This is quite specific and it doesn't seem to be a way to do it easily with Circe API.

Let's call our fields text and html.

I tried already to create a class, let's call it Content, add it to the general model as a single parameter and raise an exception in the constructor if both its fields (text and html) are None. The problem is how to define the decoder, because if I do something like this

implicit val decodeContent: Decoder[ItemContent] =
    Decoder.forProduct2("text", "html")(Content.apply)

it requires both fields to be present anyway.

What I would like would be to have a decoder that, if the field is missing, pass a None to the Content.apply but I don't think this is the expected behaviour.

Otherwise there should be a totally different solution but I cannot think of one.

Thank you


Solution

  • You can use Decoder#emap:

    import io.circe._, parser._
    
    case class ItemContent(text: Option[String], html: Option[String])
    
    object ItemContent {
      implicit val decoder =
        Decoder.forProduct2("text", "html")(ItemContent.apply).emap {
          case ItemContent(None, None) => Left("Neither text nor html is present")
          case x                       => Right(x)
        }
    }
    
    assert {
      decode[ItemContent]("{}").isLeft &&
      decode[ItemContent]("""{"html": "foo"}""") == Right(
        ItemContent(None, Some("foo"))) &&
      decode[ItemContent]("""{"text": "bar"}""") == Right(
        ItemContent(Some("bar"), None)) &&
      decode[ItemContent]("""{"html": "foo", "text": "bar"}""") == Right(
        ItemContent(Some("bar"), Some("foo")))
    }
    

    Runnable version


    To avoid specifying other fields it is possible to use semi-automatic derivation as a base:

    import io.circe._, parser._, io.circe.generic.semiauto._
    
    case class ItemContent(text: Option[String],
                           html: Option[String],
                           other: Int,
                           fields: String)
    
    object ItemContent {
      implicit val decoder =
        deriveDecoder[ItemContent].emap { ic =>
          if (ic.text.isEmpty && ic.html.isEmpty)
            Left("Both `text` and `html` are missing")
          else Right(ic)
        }
    }
    

    Runnable version