Skip to main content

Config

Installation

"tf.tofu" %% "tofu" % tofu-version
or as a standalone dependency:
"tf.tofu" %% "tofu-config" % tofu-version

Features

tofu-config is a boilerplate-free way to load configuration files as Scala classes, which provides:

  • parallel error accumulation, which won't give up parsing upon the first error;
  • out-of-the box support for case classes and sealed hierarchies through Magnolia derivation, sugared with Derevo macro-annotations;
  • Typesafe Config interoperability;
  • ease of integration with custom configuration sources and types.

Example

A simple groceries config:

{
"dairy": {
"storeName": "Milkman's dream",
"toBuy": ["eggs", "milk"]
},
"beverages": {
"storeName": "Teaworld",
"toBuy": [{
"teaSort": "Pu'erh",
}, {
"coffeeSort": "Liberica"
}]
}
}

might be parsed into the following structure:

import cats.syntax.show._
import com.typesafe.config.ConfigFactory
import derevo.derive
import tofu.config.typesafe._

sealed trait Item

sealed trait Dairy extends Item
case object eggs extends Dairy
case object milk extends Dairy

sealed trait Beverage extends Item
case class Tea(teaSort: String) extends Beverage
case class Coffee(coffeeSort: String) extends Beverage

case class ItemStore[I <: Item](
storeName: String,
toBuy: List[I]
)

@derive(Configurable)
case class Groceries(
dairy: ItemStore[Dairy],
beverages: ItemStore[Beverage]
)

val cfg = ConfigFactory.parseResources("groceries.conf")
syncParseConfig[Groceries](cfg) match {
case Left(errors) => errors.foreach(err => println(err.show))
case Right(groceries) => println(groceries)
}

If we make a couple of errors in the config file, misspelling "eggs" and introducing beer type into beverages:

{
"dairy": {
"storeName": "Milkman's dream",
"toBuy": ["egs", "milk"]
},
"beverages": {
"storeName": "Teaworld",
"toBuy": [{
"teaSort": "Pu'erh",
}, {
"coffeeSort": "Liberica"
}, {
"beerSort": "Sour"
}]
}
}

we will encounter verbose parsing errors:

dairy.toBuy.[0] : bad string 'egs' : expected one of: eggs,milk
beverages.toBuy.[2] : no variant found

Abstractions

tofu-config comes with a couple of neat abstractions over config parsing.

Configurable

The typeclass, which defines the property of a specific type to be parsed from a config is called Configurable (the definition is simplified):

trait Configurable[A] { 
def apply[F[_]](cfg: ConfigItem[F]): F[A]
}

where ConfigItem is a set of possible types of config elements (analog of com.typesafe.ConfigValue).

ParallelReader

The context bound which is essential for config parsing is ParallelReader. It is defined as a wrapper for cats.Parallel and inferred from it:

final case class ParallelReader[F[_]](paralleled: Parallel[ConfigT[F, *]])

For a config to be parsed into a value of type A in the context of F, the following instances should be provided:

def parseCfg[F[_]: Refs: MonadThrow: ParallelReader, 
A: Configurable](cfg: Config): F[A]

MonadThrow is for raising parsing errors.
ParallelReader is for parallel parsing.
Refs is for storing errors list inside a Ref.

Custom types

Sometimes one needs to define custom ways to parse values from a config. For example, we might want to provide a convenient syntax for request limits:

{
"endpointA": "10 per 1 second",
"endpointB": "1000 per 1 hour"
}

It can be done via a custom Configurable instance:

import com.typesafe.config.ConfigFactory
import derevo.derive
import tofu.config.ConfigError.BadString
import tofu.config.typesafe._
import tofu.syntax.raise._

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

case class Limit(count: Int, duration: FiniteDuration)

val limitRegexp = "(\\d+) per ([0-9a-z ]+)".r

def fromString(string: String): Option[Limit] = string match {
case limitRegexp(count, duration) =>
Try(Limit(count.toInt,
FiniteDuration(Duration(duration).toSeconds, "seconds"))).toOption
case _ => None
}

implicit val rateConfigurable: Configurable[Limit] =
Configurable.stringConfigurable.flatMake { F => str =>
fromString(str).orRaise(BadString(str, "Invalid limit rate"))(F.config, F.monad)
}

@derive(Configurable)
case class Limits(endpointA: Limit, endpointB: Limit)

println(syncParseConfig[Limits](ConfigFactory.parseResources("limits.conf")))

Custom config sources

In order to provide a way to parse config from a custom source, one needs to provide a mapping from that source's types into tofu.config.ConfigItem. For an example, please refer to the typesafe integration.