diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5320795 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.idea/ +/project +/target +/.bsp/ diff --git a/README.md b/README.md index c8896d9..0389b70 100644 --- a/README.md +++ b/README.md @@ -47,16 +47,23 @@ val config = ConfigFactory.parseString(""" | elements = 2 | burst-duration = 100 millis | check-interval = 2 weeks + | values = [ first, second ] |} """.stripMargin) -case class Rate(elements: Int, burstDuration: FiniteDuration, checkInterval: Period) +case class Rate( + elements: Int, + burstDuration: FiniteDuration, + checkInterval: Period, + values: List[String] +) val hocon = hoconAt(config)("rate") ( hocon("elements").as[Int], hocon("burst-duration").as[FiniteDuration], - hocon("check-interval").as[Period] + hocon("check-interval").as[Period], + hocon.list("values").as[List[String]] ).parMapN(Rate.apply).load[IO].map { rate => assertEquals(rate.burstDuration, 100.millis) assertEquals(rate.checkInterval, Period.ofWeeks(2)) diff --git a/src/main/scala/Hocon.scala b/src/main/scala/Hocon.scala index 4240c01..34dbac1 100644 --- a/src/main/scala/Hocon.scala +++ b/src/main/scala/Hocon.scala @@ -16,8 +16,10 @@ package lt.dvim.ciris +import scala.concurrent.duration.FiniteDuration import scala.util.Try +import cats.Show import ciris._ import com.typesafe.config.{Config, ConfigException, ConfigFactory, ConfigValue => HoconConfigValue} @@ -26,13 +28,21 @@ object Hocon extends HoconConfigDecoders { final class HoconAt(config: Config, path: String) { def apply(name: String): ConfigValue[Effect, HoconConfigValue] = Try(config.getValue(fullPath(name))).fold( - { - case _: ConfigException.Missing => ConfigValue.missing(key(name)) - case ex => ConfigValue.failed(ConfigError(ex.getMessage)) - }, + errHandler(name), ConfigValue.loaded(key(name), _) ) + def list(name: String): ConfigValue[Effect, HoconConfigValue] = + Try(config.getList(fullPath(name))).fold( + errHandler(name), + ConfigValue.loaded(key(name), _) + ) + + private def errHandler(name: String): Throwable => ConfigValue[Effect, HoconConfigValue] = { + case _: ConfigException.Missing => ConfigValue.missing(key(name)) + case ex => ConfigValue.failed(ConfigError(ex.getMessage)) + } + private def key(name: String) = ConfigKey(fullPath(name)) private def fullPath(name: String) = s"$path.$name" } @@ -48,6 +58,49 @@ trait HoconConfigDecoders { implicit val stringHoconDecoder: ConfigDecoder[HoconConfigValue, String] = ConfigDecoder[HoconConfigValue].map(_.atKey("t").getString("t")) + private implicit val show: Show[HoconConfigValue] = new Show[HoconConfigValue]() { + def show(t: HoconConfigValue): String = t.toString + } + + implicit val listStringHoconDecoder: ConfigDecoder[HoconConfigValue, List[String]] = + ConfigDecoder[HoconConfigValue].mapOption("List[String]") { c => + Try(asScalaList(c.atKey("t").getStringList("t"))).toOption + } + + implicit val listIntHoconDecoder: ConfigDecoder[HoconConfigValue, List[Int]] = + ConfigDecoder[HoconConfigValue].mapOption("List[Int]") { c => + Try(asScalaList(c.atKey("t").getIntList("t")).map(_.intValue())).toOption + } + + implicit val listLongHoconDecoder: ConfigDecoder[HoconConfigValue, List[Long]] = + ConfigDecoder[HoconConfigValue].mapOption("List[Long]") { c => + Try(asScalaList(c.atKey("t").getLongList("t")).map(_.longValue())).toOption + } + + implicit val listBooleanHoconDecoder: ConfigDecoder[HoconConfigValue, List[Boolean]] = + ConfigDecoder[HoconConfigValue].mapOption("List[Boolean]") { c => + Try(asScalaList(c.atKey("t").getBooleanList("t")).map(_.booleanValue())).toOption + } + + implicit val listDoubleHoconDecoder: ConfigDecoder[HoconConfigValue, List[Double]] = + ConfigDecoder[HoconConfigValue].mapOption("List[Double]") { c => + Try(asScalaList(c.atKey("t").getDoubleList("t")).map(_.doubleValue())).toOption + } + + implicit val listJavaDurationHoconDecoder: ConfigDecoder[HoconConfigValue, List[java.time.Duration]] = + ConfigDecoder[HoconConfigValue].mapOption("List[java.time.Duration]") { c => + Try(asScalaList(c.atKey("t").getDurationList("t"))).toOption + } + + implicit val listDurationHoconDecoder: ConfigDecoder[HoconConfigValue, List[FiniteDuration]] = + ConfigDecoder[HoconConfigValue].mapOption("List[FiniteDuration]") { c => + Try { + asScalaList(c.atKey("t").getDurationList("t")) + .map(_.toNanos) + .map(scala.concurrent.duration.Duration.fromNanos) + }.toOption + } + implicit val javaTimeDurationHoconDecoder: ConfigDecoder[HoconConfigValue, java.time.Duration] = ConfigDecoder[HoconConfigValue].map(_.atKey("t").getDuration("t")) @@ -56,4 +109,11 @@ trait HoconConfigDecoders { implicit def throughStringHoconDecoder[T](implicit d: ConfigDecoder[String, T]): ConfigDecoder[HoconConfigValue, T] = stringHoconDecoder.as[T] + + private def asScalaList[T](collection: java.util.Collection[T]): List[T] = { + val builder = List.newBuilder[T] + val it = collection.iterator() + while (it.hasNext) builder += it.next() + builder.result() + } } diff --git a/src/test/scala/HoconSpec.scala b/src/test/scala/HoconSpec.scala index 52ae1ef..a1c6db5 100644 --- a/src/test/scala/HoconSpec.scala +++ b/src/test/scala/HoconSpec.scala @@ -31,6 +31,12 @@ class HoconSpec extends CatsEffectSuite { | dur = 10 ms | bool = true | per = 2 weeks + | listInt = [ 1, 2, 3, 4 ] + | listString = [ a, b, c, d ] + | listBool = [ true, false, true ] + | listDouble = [ 1.12, 2.34, 2.33 ] + | listDur = [ 10 ms, 15 ms, 1 s ] + | invalidList = [ 1, a, true ] | } |} |subst { @@ -59,6 +65,44 @@ class HoconSpec extends CatsEffectSuite { test("parse Period") { nested("per").as[java.time.Period].load[IO] assertEquals java.time.Period.ofWeeks(2) } + test("parse List[Int]") { + nested.list("listInt").as[List[Int]].load[IO] assertEquals List(1, 2, 3, 4) + } + test("parse List[Long]") { + nested.list("listInt").as[List[Long]].load[IO] assertEquals List(1L, 2, 3, 4) + } + test("parse List[String]") { + nested.list("listString").as[List[String]].load[IO] assertEquals List("a", "b", "c", "d") + } + test("parse List[Bool]") { + nested.list("listBool").as[List[Boolean]].load[IO] assertEquals List(true, false, true) + } + test("parse List[Double]") { + nested.list("listDouble").as[List[Double]].load[IO] assertEquals List(1.12, 2.34, 2.33) + } + test("parse List[java Duration]") { + nested.list("listDur").as[List[java.time.Duration]].load[IO] assertEquals List( + java.time.Duration.ofMillis(10), + java.time.Duration.ofMillis(15), + java.time.Duration.ofSeconds(1) + ) + } + test("parse List[scala Duration]") { + nested.list("listDur").as[List[FiniteDuration]].load[IO] assertEquals List(10.millis, 15.millis, 1.second) + } + test("handle decode error for invalid list") { + nested + .list("invalidList") + .as[List[Int]] + .attempt[IO] + .map { + case Left(error) => error.messages.toList.head + case Right(_) => "config loaded" + } + .assertEquals( + "Nested.config.invalidList with value SimpleConfigList([1,\"a\",true]) cannot be converted to List[Int]" + ) + } test("handle missing") { nested("missing") .as[Int]