4

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.

Allen Han
  • 1,163
  • 7
  • 16

3 Answers3

4

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)
Andrey Patseev
  • 514
  • 4
  • 12
  • Thanks, your answer was very helpful. The only thing I had to change was the fact that FiniteDuration is not a case class, so it needs a "new" in front of it. – Allen Han Apr 26 '19 at 23:28
  • strangely enough my snippet works for me, running scala 2.12.8 and the latest stable version of circe – Andrey Patseev Apr 27 '19 at 12:10
  • I just checked it, and the code compiles and runs using sbt without "new." My Intellij has been acting up in other places and here it underlined FiniteDuration in red. It looks like it was just an issue with Intellij. Thanks again – Allen Han Apr 27 '19 at 20:04
2

I guess you want your parser to be HOCON compliant? Then you can just reuse or copy the parser which is used in com.typesafe.config library. The method you need is

public static long parseDuration(String input, ConfigOrigin originForException, String pathForException)

simpadjo
  • 3,947
  • 1
  • 13
  • 38
  • I decided to go with a pure Scala implementation, but your answer was very helpful. The idea for this requirement was introduced by a coworker, who may have seen this convention somewhere else, which is why he suggested it. The code in question will not be used for HOCON though. – Allen Han Apr 26 '19 at 23:30
0

I think better way to decode FiniteDuration is using existing class scala.concurrent.Duration and it's parsing from std lib:

import io.circe.parser.decode
import io.circe.{ CursorOp, Decoder, DecodingFailure, HCursor }
import cats.syntax.validated._
import cats.data.Validated

import scala.concurrent.duration.{ Duration, FiniteDuration }
import scala.language.postfixOps
import scala.util.Try
import scala.concurrent.duration._

def parseDuration(ops: => List[CursorOp])
  (d: String): Either[DecodingFailure, FiniteDuration] =
  Validated
    .fromTry(Try(Duration(d)))
    .andThen {
      case _: Duration.Infinite     => new Exception("Field can not be infinite")
        .invalid[FiniteDuration]
      case duration: FiniteDuration => duration.valid[Throwable]
    }
    .leftMap(DecodingFailure.fromThrowable(_, ops))
    .toEither

implicit val fDurationDecoder: Decoder[FiniteDuration] = (c: HCursor) => 
  c.as[String].flatMap(parseDuration(c.history))

I added Validated from cats just for more comfortable error handling and add validation on infinite inputs

// tests
decode[FiniteDuration](""""30 seconds"""") == Right(30 seconds)
decode[FiniteDuration](""""{30 seconds"""") match {
  case Left(value) =>
    value match {
      case DecodingFailure(message, Nil) =>
        message.take(56) == """java.lang.NumberFormatException: For input string: "{30""""
    }
}
Boris Azanov
  • 4,408
  • 1
  • 15
  • 28