When building reactive applications, it is important to fail early and to avoid throwing exceptions. Here, we apply these principles to the Typesafe config library without introducing unnecessary boilerplate code.
To use this library, add the following dependency to your build.sbt
file:
libraryDependencies += "net.cakesolutions" %% "validated-config" % "1.1.3"
To access the validated Typesafe configuration library code in your
project code, simply import net.cakesolutions.config._
.
Using Typesafe config we read in and parse configuration files. Paths into these files are then retrieved and type checked using Ficus.
Using a lightweight DSL, we are able to then check and validate these type checked values. For example, given that the Typesafe configuration:
top-level-name = "test"
test {
nestedVal = 50.68
nestedDuration = 4 h
nestedList = []
context {
valueInt = 30
valueStr = "test string"
valueDuration = 12 ms
valueStrList = [ "addr1:10", "addr2:20", "addr3:30" ]
valueDoubleList = [ 10.2, 20, 0.123 ]
}
}
has been parsed and read into an implicit of type Config
, then we are
able to validate that the value at the path test.nestedVal
has type
Double
and that it satisfies specified size bounds as follows:
case object ShouldBeAPercentageValue extends Exception
validate[Double]("test.nestedVal", ShouldBeAPercentageValue)(n => 0 <= n && n <= 100)
If the configuration value at path test.nestedVal
fails to pass the
percentage bounds check, then Validated.Invalid(NonEmptyList.of(ShouldBeAPercentageValue))
is
returned. Here, we are using the cats Validated
data type.
Likewise, we can enforce that all values in the array at the path
test.context.valueStrList
match the regular expression pattern
[a-z0-9]+:[0-9]+
as follows:
case object ShouldBeASocketValue extends Exception
validate[List[String]]("test.context.valueStrList", ShouldBeASocketValue)(_.matches("[a-z0-9]+:[0-9]+"))
In some instances, we may not care about checking the value at a
configuration path. In these cases we can use unchecked
:
unchecked[FiniteDuration]("test.nestedDuration")
When we require a path to have a value set, then we have two possible options:
- check if the configuration path exists and is defined
- or, use a sentinal value and validate that the path has a differing value.
When configuration paths are specific to your application, then the first of these approaches is suitable to use. However, if you are overriding the values in 3rd party libraries and require a value to be set, then it is necessary to use sentinal values.
In the first case, we use required
as follows:
unchecked[FiniteDuration](required("test.nestedDuration"))
When using required
with sentinal values, it is necessary to specify what
the expected sentinal value is as follows:
unchecked[FiniteDuration](required("test.nestedDuration", "UNDEFINED"))
Building validated configuration case class instances is performed using
the methods via
and mapN
(where N
is the number to paths being validated). via
allows the currently in-scope
implicit Config
instance to be restricted to a specified path. mapN
can be used to construct the case class instance from a product of Validated.Valid
values. To do this,
mapN
takes a validated tuple of arguments that should be the results of either
building inner validated case class instances or from using the
validate
and unchecked
methods to validate values at a given path.
As both unchecked
and validate
use Ficus ValueReader's to parse
and type check configuration values, we only need to define a custom extractor.
Given the following Scala case classes (with refined refinement types):
case object NameShouldBeNonEmptyAndLowerCase extends Exception
case object ShouldBePositive extends Exception
final case class HttpConfig(host: String, port: Int Refined Positive)
final case class Settings(name: String Refined MatchesRegex[W.`"[a-z0-9_-]+"`.T], timeout: FiniteDuration, http: HttpConfig)
and the following Typesafe configuration objects (e.g. stored in a file named application.conf
):
name = "test-data"
http {
host = "localhost"
host = ${?HTTP_ADDR}
port = 80
port = ${?HTTP_PORT}
timeout = 30 s
}
then we can generate a validated Settings
case class instance as
follows:
validateConfig[Settings]("application.conf") { implicit config =>
Applicative[ValidationFailure].map3(
unchecked[String Refined MatchesRegex[W.`"[a-z0-9_-]+"`.T]]("name"),
validate[FiniteDuration]("http.timeout", ShouldBePositive)(_ >= 0.seconds),
via[HttpConfig]("http") { implicit config =>
Applicative[ValidationFailure].map2(
unchecked[String]("host"),
unchecked[Int Refined Int]("port")
)(HttpConfig)
}
)(Settings)
}
Internally, we use NonEmptyList[ValueError]
for error signal management - however, validateConfig
aggregates and materialises
such errors as a ConfigError
instance.
Using abstract sealed case classes, we can validate configuration data and then ensure that the validated case class instances can not be faked (e.g. via copy constructors or uses of the apply method).
If we want to do this, then the previous example's case classes could be rewritten as say:
package net.cakesolutions.example
import scala.concurrent.duration._
import scala.util.Try
import cats.data.Validated
import cats.implicits._
import cats.syntax._
import eu.timepit.refined._
import eu.timepit.refined.api.Refined
import eu.timepit.refined.auto._
import eu.timepit.refined.numeric._
import eu.timepit.refined.string._
import net.cakesolutions.config._
object LoadValidatedConfig {
sealed abstract case class HttpConfig(host: String, port: Int Refined Positive)
sealed abstract case class Settings(name: String Refined MatchesRegex[W.`"[a-z0-9_-]+"`.T], timeout: FiniteDuration, http: HttpConfig)
def apply(): Validated[ConfigError, Settings] =
validateConfig[Settings]("application.conf") { implicit config =>
Applicative[ValidationFailure].map3(
unchecked[String Refined MatchesRegex[W.`"[a-z0-9_-]+"`.T]]("name"),
validate[FiniteDuration]("http.timeout", ShouldBePositive)(_ >= 0.seconds),
via[HttpConfig]("http") { implicit config =>
Applicative[ValidationFailure].map2(
unchecked[String]("host"),
unchecked[Int Refined Positive]("port")
)(new HttpConfig(_, _) {})
}
)(new Settings(_, _, _) {})
}
}
Here, package net.cakesolutions.example
is responsible for loading and parsing our configuration files.
As first reported by @tpolecat and discussed further in
Enforcing invariants in Scala datatypes, the use of an sealed abstract case class
ensures that constructors, copy constructors and companion apply methods are not created
by the compiler. Hence, the only way that instances of HttpConfig
and Settings
can be
created is via the package protected code in the respective implicits - and so
we ensure that all such validated configurations are compile time checked as being invariant!
In this use case, we recommend the use of shims to convert from cats data structures to Scalaz data structures.