Search code examples
jsonscalajson-deserializationcirce

How to create a custom decoder in Circe that parses time values


I am trying to decode a string of the form "5m" or "5s" or "5ms" into objects of type FiniteDuration that are, respectively, 5.minutes, 5.seconds, 5.milliseconds.

I am trying to create a custom decoder and encoder for a project that involves the FiniteDuration class. The encoder is no problem, since it's just reading the fields of the FiniteDuration class and generating a string. However, I am having difficulty writing the decoder and am wondering if what I am doing is possible at all.

FiniteDuration is a class that has a constructor as follows: FiniteDuration(length: Long, unit: TimeUnit). Scala comes with some convenient syntactic sugar so that the class can be called using the notation 5.minutes, 5.seconds, or 5.milliseconds. In that case Scala takes care of the creation of the FiniteDuration class for you.

The idea is to convert this FiniteDuration class to a string like "5m" or "5s" or "5ms" which is easier on the eyes.

  implicit val d2json: Encoder[FiniteDuration] = new Encoder[FiniteDuration] {
    override def apply(a: FiniteDuration): Json = ???
  }

  implicit val json2d: Decoder[FiniteDuration] = new Decoder[FiniteDuration] {
    override def apply(c: HCursor): Decoder.Result[FiniteDuration] = ???
  }

The encoder I should have no problem writing. The decoder is more tricky. I am not sure what to do since the apply method expect an input of type HCursor.


Solution

  • Here is a basic implementation which works (might need tweaking based on how you encode FiniteDuration.

    Basically, what you need to do is to get value of the cursor as String, split that string into duration and period and try to convert both of the parts to Long and TimeUnit respectively (because FiniteDuration constructor accepts them as parameters).

    Note that these conversions must return Either[DecodingFailure, _] to align with the return type of cursor.as[_] so you can use them in the for-comprehension.

    I've used implicit extension methods for these conversions because I find them handy but you could write basic functions.

    implicit class StringExtended(str: String) {
        def toLongE: Either[DecodingFailure, Long] = {
          Try(str.toLong).toOption match {
            case Some(value) => Right(value)
            case None => Left(DecodingFailure("Couldn't convert String to Long", List.empty))
          }
        }
    
        def toTimeUnitE: Either[DecodingFailure, TimeUnit] = str match {
          case "ms" => Right(TimeUnit.MILLISECONDS)
          case "m" => Right(TimeUnit.MINUTES)
          // add other cases in the same manner
          case _ => Left(DecodingFailure("Couldn't decode time unit", List.empty))
        }
    }
    
    implicit val decoder: Decoder[FiniteDuration] = (c: HCursor) =>
      for {
        durationString <- c.as[String]
        duration <- durationString.takeWhile(_.isDigit).toLongE
        period = durationString.dropWhile(_.isDigit)
        timeUnit <- period.toTimeUnitE
      } yield {
        FiniteDuration(duration, timeUnit)
      }
    
    println(decode[FiniteDuration]("5ms".asJson.toString)) 
    // Right(5 milliseconds)