// available for Scala 2.12, 2.13
libraryDependencies += "com.github.leigh-perry" %% "conduction-core" % "0.6.2"Configuration is via a configuration library that inductively derives the configuration for known types. It is able to decode nested classes of arbitrary complexity from key-value pairs, typically environment variables or system properties.
For example, if your app has the following configuration:
final case class AppConfig(
appName: String,
endpoint: Endpoint,
role: Option[AppRole],
intermediates: List[TwoEndpoints],
)
final case class Endpoint(host: String, port: Int)
final case class TwoEndpoints(ep1: Endpoint, ep2: Endpoint)
// A String newtype
final case class AppRole(value: String) extends AnyValthen loading it via:
Configured[IO, AppConfig]
.value("MYAPP")
.run(Environment.fromEnvVars)with the environment variables:
export MYAPP_APP_NAME=someAppName
export MYAPP_ENDPOINT_HOST=12.23.34.45
export MYAPP_ENDPOINT_PORT=6789
export MYAPP_ROLE_OPT=somerole
export MYAPP_INTERMEDIATE_COUNT=2
export MYAPP_INTERMEDIATE_0_EP1_HOST=11.11.11.11
export MYAPP_INTERMEDIATE_0_EP1_PORT=6790
export MYAPP_INTERMEDIATE_0_EP2_HOST=22.22.22.22
export MYAPP_INTERMEDIATE_0_EP2_PORT=6791
export MYAPP_INTERMEDIATE_1_EP1_HOST=33.33.33.33
export MYAPP_INTERMEDIATE_1_EP1_PORT=6792
export MYAPP_INTERMEDIATE_1_EP2_HOST=44.44.44.44
export MYAPP_INTERMEDIATE_1_EP2_PORT=6793would yield the following instance of AppConfig:
AppConfig(
"someAppName",
Endpoint("12.23.34.45", 6789),
Some(AppRole("somerole"),
List(
TwoEndpoints(
Endpoint("11.11.11.11", 6790),
Endpoint("22.22.22.22", 6791)
),
TwoEndpoints(
Endpoint("33.33.33.33", 6792),
Endpoint("44.44.44.44", 6793)
)
)
)Note: to support this, you need to also tell the library how to decode each component data item by defining an implicit instance, usually in the companion object of each type as follows:
object AppConfig {
implicit def configured[F[_] : Monad]: Configured[F, AppConfig] = (
Configured[F, String].withSuffix("APP_NAME"),
Configured[F, Endpoint].withSuffix("ENDPOINT"),
Configured[F, Option[AppRole]].withSuffix("ROLE"),
Configured[F, List[TwoEndpoints]].withSuffix("INTERMEDIATE"),
).mapN(AppConfig.apply)
}
object Endpoint {
implicit def configuredf[F[_]](implicit F: Applicative[F]): Configured[F, Endpoint] = (
Configured[F, String].withSuffix("HOST"),
Configured[F, Int].withSuffix("PORT")
).mapN(Endpoint.apply)
}
object TwoEndpoints {
implicit def configuredf[F[_]](implicit F: Applicative[F]): Configured[F, TwoEndpoints] = (
Configured[F, Endpoint].withSuffix("EP1"),
Configured[F, Endpoint].withSuffix("EP2")
).mapN(TwoEndpoints.apply)
}
object AppRole {
implicit def conversion: Conversion[AppRole] =
Conversion[String].map(AppRole.apply)
}Each data item is retrieved from a key-value pair (typically an environment variable, with the key being the environment variable name). Key naming reflects the structure of the configuration case class. In the example above, configuration was loaded via
Configured[IO, AppConfig]
.value("MYAPP")so all keys will begin with MYAPP_.
The Configured typeclass instance for AppConfig, defined in AppConfig's companion object loads the appName field using
Configured[F, String].withSuffix("APP_NAME")so the key MYAPP_APP_NAME is used to load the value someAppName.
The key name is formed by concatenating the overall name MYAPP with the suffix fragment APP_NAME.
When assembling a composite key name, the fragments are separated by _, yielding MYAPP_APP_NAME.
By virtue of the inductive derivation of Configured typeclass instances for each configuration element,
configuration classes can contain primitive types, nested case classes, and other Scala constructs like List, Option, and Either.
List configuration consists of a count value plus a value for every element of the list.
The count field has suffix fragment COUNT, and each field has suffix fragment specifying the index within the list, starting from 0.
In the example above,
export MYAPP_INTERMEDIATE_COUNT=2
export MYAPP_INTERMEDIATE_0_EP1_HOST=11.11.11.11
export MYAPP_INTERMEDIATE_0_EP1_PORT=6790
export MYAPP_INTERMEDIATE_0_EP2_HOST=22.22.22.22
export MYAPP_INTERMEDIATE_0_EP2_PORT=6791
export MYAPP_INTERMEDIATE_1_EP1_HOST=33.33.33.33
export MYAPP_INTERMEDIATE_1_EP1_PORT=6792
export MYAPP_INTERMEDIATE_1_EP2_HOST=44.44.44.44
export MYAPP_INTERMEDIATE_1_EP2_PORT=6793yields
List(
TwoEndpoints(
Endpoint("11.11.11.11", 6790),
Endpoint("22.22.22.22", 6791)
),
TwoEndpoints(
Endpoint("33.33.33.33", 6792),
Endpoint("44.44.44.44", 6793)
)
)Option configuration uses the OPT suffix fragment.
In the example above,
export MYAPP_ROLE_OPT=someroleyields
Some(AppRole("somerole")If no value is present for MYAPP_ROLE_OPT, the value is None.
Similar to Option, Either configuration uses two suffix fragments:
C1 for a Left value, and C2 for a Right value.
For example, if your app has the following configuration:
final case class EitherConfig(
choice: Either[String, Endpoint]
)
object EitherConfig {
implicit def configured[F[_] : Monad]: Configured[F, EitherConfig] =
Configured[F, Either[String, Endpoint]].withSuffix("CHOICE")
.map(EitherConfig.apply)
}then loading it via:
Configured[IO, AppConfig]
.value("MYAPP")
.run(Environment.fromEnvVars)with the environment variables:
export MYAPP_CHOICE_C1=someAppNamewould yield the following instance of EitherConfig:
EitherConfig(
Left("someAppName")
)but with the environment variables:
export MYAPP_CHOICE_C2_HOST=12.23.34.45
export MYAPP_CHOICE_C2_PORT=6789would yield the following instance of EitherConfig:
EitherConfig(
Right(Endpoint("12.23.34.45,6789"))
)A Configured typeclass instance is available for any type that has an instance of the Conversion typeclass.
To support another primitive type, such as a Java enum, create an instance of Conversion.
For example, for AWS's Regions enum:
object ConfigSupportAws {
implicit def conversionRegion: Conversion[Regions] =
(s: String) => Either.catchNonFatal(Regions.fromName(s))
.leftMap(_ => s"invalid region $s")
def configuredRegion[F[_]](defaultRegion: Regions)(implicit F: Applicative[F]): Configured[F, Regions] =
Configured[F, Option[Regions]]
.map(_.getOrElse(defaultRegion))
}Newtypes that wrap an underlying type can easily be created by converting the underlying type and mapping to the newtype.
For example:
final case class Latitude(value: Double) extends AnyVal
object Latitude {
implicit def conversion: Conversion[Latitude] =
Conversion[Double].map(Latitude.apply)
}List, Option, and Either are currently supported. You can add support to your configuration module for other
effects such as NonEmptyList etc.
Although configuration values are typically read from environment variables, they can be read from any
source that provides an instance of Environment:
trait Environment {
def get(key: String): Option[String]
}Environment.fromEnvVars provides normal access to environment variables.
Environment.fromMap(map: Map[String, String]) uses a prepopulated map of values, which is useful for unit testing.
The library is invoked with the Environment instance injected via Reader Monad, and returns a ValidatedNec[ConfiguredError, A].
Composition of Configured instances is done using applicative combination, eg
implicit def configuredf[F[_]](implicit F: Applicative[F]): Configured[F, Endpoint] = (
Configured[F, String].withSuffix("HOST"),
Configured[F, Int].withSuffix("PORT")
).mapN(Endpoint.apply)This means that if configuration errors are present, all errors are reported, rather than bailing at the first error discovered.
VERS=0.6.2
git tag -a v${VERS} -m "v${VERS}"
git push origin v${VERS}