Skip to content

Instantly share code, notes, and snippets.

@fsarradin
Created December 4, 2020 17:12
Show Gist options
  • Save fsarradin/1db833861a7a80b0bbddf6605d2cf936 to your computer and use it in GitHub Desktop.
Save fsarradin/1db833861a7a80b0bbddf6605d2cf936 to your computer and use it in GitHub Desktop.
A configuration loader to that is able to get data from different sources with different key case schemas (zio-config 1.0.0-RC28)
import java.lang.{Boolean => JBoolean}
import scala.util.{Failure, Success, Try}
import com.typesafe.config._
import zio.config.PropertyTree.{Leaf, Record, Sequence}
import zio.config.{ConfigSource, PropertyTree, ReadError}
import zio.{IO, config}
/**
* Load a configuration from different sources based on the given
* configuration schema.
*
* Configuration is obtained in this order:
* 1. from command line arguments (the most visible one)
* 2. from environment variable
* 3. from service `application.conf` (with all the default values)
*
* Examples:
* - args:`--myConfig=111` & env:`MYCONFIG=222` => myConfig=111
* - env:`MYCONFIG=222` & hocon:`myConfig:222` => myConfig=111
* - args:`--myConfig=111` & hocon:`myConfig:222` => myConfig=111
*
* It is possible for environment variables to come with a predefined
* prefix. This feature allows to get a specific namespace for your
* environment variables by using this prefix. See
* [[loadConfiguration]] for more details.
*
* Note: to ensure ensure source/data reconciliation (ie. matching)
* whatever case schema is used, every keys are converted
* lowercase. Eg. `--myConfig` is the same as `--myconfig` or
* `--MYCONFIG`.
*
* @param schema configuration schema
* @tparam A type of the configuration
*/
case class ServiceConfigurationLoader[A <: ServiceParameters](
schema: zio.config.ConfigDescriptor[A]
) {
import scala.jdk.CollectionConverters._
import ServiceConfigurationLoader._
private def noError[E]: ReadError[E] = ReadError.ListErrors(List.empty)
/* As parameters may appear in any kind of cases (upper case for sys
* env and camel case for the rest), we convert parameter names in
* lower case.
*/
private val effectiveSchema = schema.mapKey(_.toLowerCase)
/**
* Retrieve configuration values.
*
* For environment variables, you may provide you own prefix. Eg. if
* your config key is named `myConfig` and you provide `MY_SERVICE_`
* as prefix, then the descriptor will match with the environment
* variable `MY_SERVICE_MYCONFIG`.
*
* @param prefix prefix used to namespaced you environment variables
* @param args command line arguments
* @return configuration values or an error message
*/
def loadConfiguration(
prefix: String,
args: List[String]
): IO[ReadError[String], A] =
for {
configSource <- configurationSource(prefix, args)
serviceConfig <- configurationValuesFrom(configSource, effectiveSchema)
} yield serviceConfig
private def configurationValuesFrom(
configSource: ConfigSource,
configSchema: zio.config.ConfigDescriptor[A]
): IO[ReadError[String], A] =
IO.fromEither(config.read[A](configSchema.from(configSource)))
private def configurationSource(
prefix: String,
args: List[String]
): IO[ReadError[String], ConfigSource] =
for {
cmdConf <- configFromArgs(args)
sysConf <- configFromSystemEnv(prefix)
ressConf <- configFromResources
} yield cmdConf orElse sysConf orElse ressConf
private def configFromResources: IO[ReadError[String], ConfigSource] =
IO.fromEither(fromTypeSafeConfig(ConfigFactory.defaultApplication()))
private def fromTypeSafeConfig(
input: => com.typesafe.config.Config
): Either[ReadError[String], ConfigSource] =
Try {
input
} match {
case Failure(exception) =>
Left(ReadError.SourceError(message = exception.getMessage))
case Success(value) =>
getPropertyTree(value) match {
case Left(value) => Left(ReadError.SourceError(message = value))
case Right(value) =>
Right(
ConfigSource.fromPropertyTree(
value,
"hocon",
zio.config.LeafForSequence.Invalid
)
)
}
}
private def getPropertyTree(
input: com.typesafe.config.Config
): Either[String, PropertyTree[String, String]] = {
def loopBoolean(value: Boolean) = Leaf(value.toString)
def loopNumber(value: Number) = Leaf(value.toString)
val loopNull = PropertyTree.empty
def loopString(value: String) = Leaf(value)
def loopList(values: List[ConfigValue]) = Sequence(values.map(loopAny))
def loopConfig(config: ConfigObject) =
Record(config.asScala.toVector.map { case (key, value) =>
// *** lower case conversion of the key
key.toLowerCase() -> loopAny(value)
}.toMap)
def loopAny(value: ConfigValue): PropertyTree[String, String] =
value.valueType() match {
case ConfigValueType.OBJECT => loopConfig(value.asInstanceOf[ConfigObject])
case ConfigValueType.LIST => loopList(value.asInstanceOf[ConfigList].asScala.toList)
case ConfigValueType.BOOLEAN => loopBoolean(value.unwrapped().asInstanceOf[JBoolean])
case ConfigValueType.NUMBER => loopNumber(value.unwrapped().asInstanceOf[Number])
case ConfigValueType.NULL => loopNull
case ConfigValueType.STRING => loopString(value.unwrapped().asInstanceOf[String])
}
Try(loopConfig(input.root())) match {
case Failure(t) =>
Left(
"Unable to form the zio.config.PropertyTree from Hocon string." +
" This may be due to the presence of explicit usage of nulls in hocon string. " +
t.getMessage
)
case Success(value) => Right(value)
}
}
private def configFromSystemEnv(
prefix: String
): IO[ReadError[String], ConfigSource] =
IO.effectTotal(sys.env).map { env =>
val configMap =
env.view
.filterKeys(_.startsWith(prefix))
// *** lower case conversion of the key
.map { case (k, v) => k.substring(prefix.length).toLowerCase() -> v }
.toMap
ConfigSource.fromMap(
configMap,
source = "system environment",
keyDelimiter = Some(EnvVarKeyDelimiter),
valueDelimiter = Some(EnvVarValueDelimiter)
)
}
private def configFromArgs(
args: List[String]
): IO[ReadError[String], ConfigSource] = {
val convertedArgs =
args.map { arg =>
// lift converts limited Array[String] to infinite Array[Option[String]]
val kv = arg.split("=", 2).lift
// *** lower case conversion of the key
val key = kv(0).map(_.toLowerCase())
val result =
for {
k <- key
value <- kv(1)
} yield s"$k=$value"
result
.orElse(key)
// should always have a value
.get
}
val config = ConfigSource.fromCommandLineArgs(
convertedArgs,
keyDelimiter = Some(CommandLineKeyDelimiter)
)
IO(config).orElseFail(noError)
}
}
object ServiceConfigurationLoader {
val CommandLineKeyDelimiter = '.'
val EnvVarKeyDelimiter = '_'
val EnvVarValueDelimiter = ','
}
/**
* Base parameter trait to extends by every services.
*/
trait ServiceParameters {
val serviceName: String
val kafka: KafkaConfig
}
case class KafkaConfig(
bootstrapServers: String,
schemaRegistryUrl: String,
performCleanup: Boolean = false
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment