Search code examples
jsonscalacirce

Circe Decoder - Fallback to another decoder if fails


I am using Circe for json operations. I have added custom encoders and decoders to handle some of the types, like Joda Time.

While parsing DateTime, I want to allow multiple formats to be passed. For eg. dd-MM-yyyy'T'HH:mm:ss'Z' and dd-MM-yyyy'T'HH:mm:ss.SSS'Z'

I have defined my decoder like below:

val dateTimeFormat = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
val dateTimeFormatWithMillis = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
implicit val jodaDateTimeFormat: Encoder[DateTime] with Decoder[DateTime] = new Encoder[DateTime] with Decoder[DateTime] {
    override def apply(a: DateTime): Json = Encoder.encodeString(a.toString("yyyy-MM-dd'T'HH:mm:ss'Z'"))

    override def apply(c: HCursor): Result[DateTime] = Decoder.decodeString.map { x =>
      DateTime.parse(x, dateTimeFormat)
    }.apply(c)
  }

Now if i input a datetime string matching the dateTimeFormat, then the decoding will work, but if I pass the datetime in dateTimeFormatWithMillis, it will fail to process.

I know that I can use the DateTimeFormatterBuilder to add multiple parsers and process it, however, I was wondering if there is a way in Circe to chain multiple decoders to try one after another until it succeeds or reached end of chain?


Solution

  • You can use Decoder#or to combine decoders so that the second one is tried in case the first one fails.

    Here is a working example:

    import org.joda.time.DateTime
    import org.joda.time.format.{DateTimeFormat, DateTimeFormatter}
    import io.circe.{Decoder, Encoder}
    import io.circe.parser.decode
    import scala.util.Try
    
    
    val dateTimeFormat = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
    val dateTimeFormatWithMillis = DateTimeFormat.forPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    
    
    /** Creates a decoder that decodes a [[DateTime]] using the provided format. */
    def dateTimeFormatDecoder(format: DateTimeFormatter): Decoder[DateTime] =
      Decoder[String].emapTry(str => Try(DateTime.parse(str, format)))
    
    /** [[Decoder]] for the first format (without milliseconds). */
    val dateTimeWithoutMillisDecoder: Decoder[DateTime] =
      dateTimeFormatDecoder(dateTimeFormat)
    
    /** [[Decoder]] for the second format (with milliseconds). */
    val dateTimeWithMillisDecoder: Decoder[DateTime] =
      dateTimeFormatDecoder(dateTimeFormatWithMillis)
    
    /** Encodes a [[DateTime]] using `Encoder[String].contramap(...)`, which is
      * perhaps a slightly more idiomatic version of 
      * `Encoder.encodeString(a.toString("yyyy-MM-dd'T'HH:mm:ss'Z'"))` */
    implicit val jodaDateTimeEncoder: Encoder[DateTime] =
      Encoder[String].contramap(_.toString("yyyy-MM-dd'T'HH:mm:ss'Z'"))
    
    implicit val jodaDateTimeDecoder: Decoder[DateTime] =
      dateTimeWithoutMillisDecoder or dateTimeWithMillisDecoder
    
    println(decode[DateTime](""" "2001-02-03T04:05:06Z" """))
    println(decode[DateTime](""" "2001-02-03T04:05:06.789Z" """))
    

    Note that the Encoder and Decoder have been separated since Decoder#or returns a Decoder, which wouldn’t work with a combined class (i.e. Encoder[DateTime] with Decoder[DateTime]).

    Also, the DateTime.parse calls have been wrapped with Decoder#emapTry because the or combinator (and in general all Decoder combinators) expects to be dealing with Either values, not exceptions.