'select and use appropriate decoder as decoder for new item while preserving error accumulation

If I have some point where my logic needs to diverge and thus have need to select 1 of some set of possible decoders to use, this issue can arise.

Consider this code snippet:

import cats.implicits.{catsSyntaxTuple3Semigroupal, catsSyntaxTuple2Semigroupal}
import io.circe.{Decoder, HCursor}

sealed trait Example

object Example {
  case class OptionOne(
    name: String,
    time: String,
    platform: String
  ) extends Example

  object OptionOne {
    implicit val decoder: Decoder[OptionOne] = {
      (
        Decoder[String].prepare(_.downField("event")),
        Decoder[String].prepare(_.downField("time")),
        Decoder[String].prepare(_.downField("platform"))
        )
        .mapN(OptionOne(_, _, _))
    }
  }

  case class OptionTwo(
    name: String,
    time: String
  ) extends Example

  object OptionTwo {
    implicit val decoder: Decoder[OptionTwo] = {
      (
        Decoder[String].prepare(_.downField("event")),
        Decoder[String].prepare(_.downField("time"))
        )
        .mapN(OptionTwo(_, _))
    }
  }

  implicit val decoder: Decoder[Example] = (a: HCursor) => {
    a.get[String]("event").flatMap {
      case "some_precise_name" => OptionTwo.decoder(a)
      case _ => OptionOne.decoder(a)
    }
  }
}

If conditionally based on the value of the event value I want to have either OptionOne or OptionTwo selected, the decoder for Example does successfully do this. Additionally, it does return the decoding failure if there is a problem. However, if you want the errors to accumulate, this is where the problem emerges.

If you consider this JSON object for testing:

Json.obj(
  ("event", "randomEvent".asJson),
  ("time", 8.asJson),
  ("platform", 5.asJson)
  )

and pass this in as such: Example.decoder.decodeAccumulating(inJsonInvalid.hcursor), only the first error is returned rather than both errors expected.

So the question is this: how can this be done in a way that preserves the accumulation of errors?

[EDIT] to help with clarification, here is a test suite I would expect to pass for the solution to work:

class ExampleSuite
  extends AnyFunSuite
    with Matchers
    with Inside {
  test("Decoder -- OptionOne") {
    val inJsonValidApp: Json =
      Json.obj(
        ("event", "something".asJson),
        ("time", "2021-05-05 20:09:57.448".asJson),
        ("platform", "app".asJson),
        ("context", Json.obj(
          ("app_version", "5.7.9".asJson)
          ))
        )
    noException should be thrownBy OptionOne.decoder.decodeJson(inJsonValidApp)
    inside(OptionOne.decoder.decodeJson(inJsonValidApp)) {
      case Right(OptionOne(name, time, platform)) =>
        name should be("something")
        time should be("2021-05-05 20:09:57.448")
        platform should be("app")
    }

    val inJsonInvalid =
      Json.obj(
        ("event", "randomEvent".asJson),
        ("time", 8.asJson),
        ("platform", 5.asJson)
        )
    noException should be thrownBy OptionOne.decoder.decodeJson(inJsonInvalid)
    inside(OptionOne.decoder.decodeJson(inJsonInvalid)) {
      case Left(DecodingFailure(msg, ops)) =>
        msg should be("String")
        ops should be(List(DownField("time")))
    }

    noException should be thrownBy OptionOne.decoder.decodeAccumulating(inJsonInvalid.hcursor)
    inside(OptionOne.decoder.decodeAccumulating(inJsonInvalid.hcursor)) {
      case Invalid(NonEmptyList(head, tail)) =>
        inside(head) {
          case DecodingFailure(msg, ops) =>
            msg should be("String")
            ops should be(List(DownField("time")))
        }
        inside(tail) {
          case List(DecodingFailure(msg, ops)) =>
            msg should be("String")
            ops should be(List(DownField("platform")))
        }
    }
  }
  test("Decoder -- OptionTwo") {
    val inJsonValidApp: Json =
      Json.obj(
        ("event", "something".asJson),
        ("time", "2021-05-05 20:09:57.448".asJson),
        ("platform", "app".asJson)
        )
    noException should be thrownBy OptionTwo.decoder.decodeJson(inJsonValidApp)
    inside(OptionTwo.decoder.decodeJson(inJsonValidApp)) {
      case Right(OptionTwo(name, time)) =>
        name should be("something")
        time should be("2021-05-05 20:09:57.448")
    }

    val inJsonInvalid =
      Json.obj(
        ("event", "randomEvent".asJson),
        ("platform", "bee".asJson)
        )
    noException should be thrownBy OptionTwo.decoder.decodeJson(inJsonInvalid)
    inside(OptionTwo.decoder.decodeJson(inJsonInvalid)) {
      case Left(DecodingFailure(msg, ops)) =>
        msg should be("Attempt to decode value on failed cursor")
        ops should be(List(DownField("time")))
    }
  }

  test("Decoder -- Example (valid)") {
    val inJsonValidApp: Json =
      Json.obj(
        ("event", "something".asJson),
        ("time", "2021-05-05 20:09:57.448".asJson),
        ("platform", "app".asJson)
        )
    noException should be thrownBy Example.decoder.decodeJson(inJsonValidApp)
    inside(Example.decoder.decodeJson(inJsonValidApp)) {
      case Right(OptionOne(name, time, platform)) =>
        name should be("something")
        time should be("2021-05-05 20:09:57.448")
        platform should be("app")
    }

    val inJsonBE: Json =
      Json.obj(
        ("event", "some_precise_name".asJson),
        ("time", "2021-05-05 20:09:57.448".asJson),
        ("platform", "app".asJson)
        )
    noException should be thrownBy Example.decoder.decodeJson(inJsonBE)
    inside(Example.decoder.decodeJson(inJsonBE)) {
      case Right(OptionTwo(name, time)) =>
        name should be("some_precise_name")
        time should be("2021-05-05 20:09:57.448")
    }
  }
  test("Decoder -- Example (invalid, single)") {
    val inJsonInvalid =
      Json.obj(
        ("event", "randomEvent".asJson),
        ("time", 5.asJson),
        ("platform", "bee".asJson)
        )
    noException should be thrownBy Example.decoder.decodeJson(inJsonInvalid)
    inside(Example.decoder.decodeJson(inJsonInvalid)) {
      case Left(DecodingFailure(msg, ops)) =>
        msg should be("String")
        ops should be(List(DownField("time")))
    }
  }

  test("Decoder -- Example (invalid, multiple)") {
    val inJsonInvalid =
      Json.obj(
        ("event", "randomEvent".asJson),
        ("time", 8.asJson),
        ("platform", 5.asJson)
        )
    noException should be thrownBy Example.decoder.decodeAccumulating(inJsonInvalid.hcursor)
    inside(Example.decoder.decodeAccumulating(inJsonInvalid.hcursor)) {
      case Invalid(NonEmptyList(head, tail)) =>
        inside(head) {
          case DecodingFailure(msg, ops) =>
            msg should be("String")
            ops should be(List(DownField("time")))
        }
        inside(tail) {
          case List(DecodingFailure(msg, ops)) =>
            msg should be("String")
            ops should be(List(DownField("platform")))
        }
    }
  }
}


Solution 1:[1]

since they are Decoders, you can combine them in a such way:

implicit val decoder: Decoder[Example] = List[Decoder[Example]](
  Decoder[OptionOne].widen,
  Decoder[OptionTwo].widen,
  ...
).reduceLeft(_ or _)

it will just try each one

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Alexey Rykhalskiy