Skip to content

Simple Scala 3 config library: load environment variables into case classes using annotations.

License

Notifications You must be signed in to change notification settings

katlasik/jurate

Repository files navigation

Jurate

Intro

Jurate is a simple Scala 3 library for instantiating case class instances from environment variables and system properties using compile-time derivation. You just need to create a case class with the desired fields and annotate them with @env or @prop. Then you can load your config using load method.

import jurate.{*, given}

case class DbConfig(
  @env("DB_PASSWORD") password: Secret[String],
  @env("DB_USERNAME") username: String
)

case class Config(
  @env("HOST") host: String,
  @env("PORT") port: Int = 8080,
  @env("ADMIN_EMAIL") adminEmail: Option[String],
  @prop("app.debug") debug: Boolean = false,
  dbConfig: DbConfig
)

println(load[Config])
// Right(Config(localhost,8080,None,false,DbConfig(Secret(d74ff0ee8d),db_reader)))

Installation

Requirements: Scala 3.3+

Add to your build.sbt:

libraryDependencies += "io.github.katlasik" %% "jurate" % "0.3.0"

Getting Started

You have to import givens using:

import jurate.{*, given}

This provides instance of ConfigReader which is required to load values from environment or system properties.

Usage

To load a value into a field from an environment variable, use the @env annotation. To load a value from a system property, use the @prop annotation. You can provide multiple annotations to a field. The library will try to load the value from the first annotation on the left, and if it fails, it will try the next one. You can also provide a default value for a field, which will be used if the value is not found for any of the annotations.

case class EmailConfig(
  @prop("debug.email") @env("EMAIL") @env("ADMIN_EMAIL") email: String = "foo@bar.com"
)

In this example library will first check if system property debug.email exists, then it will look for environment variables EMAIL and ADMIN_EMAIL. If none are found default value foo@bar.com will be used.

Optional values

You can make field optional by using Option type. If the value is not found, the field will be set to None.

case class AdminEmailConfig(
  @env("ADMIN_EMAIL") adminEmail: Option[String],
)

Nested case classes

You can use nested case classes to organize your config.

case class DatabaseConfig(
  @env("DB_PASSWORD") password: Secret[String],
  @env("DB_USERNAME") username: String
) 

case class AppConfig(
  @env("HOST") host: String,
  @env("PORT") port: Int = 8080,
  dbConfig: DatabaseConfig
)

Enums

You can load values of singleton enums (with no fields) using @env or @prop annotations. The library will automatically convert the loaded value to the enum case. Searching for the right enum case is case-sensitive.

enum Environment:
  case DEV, PROD, STAGING

case class EnvConfig(
  @env("ENV") env: Environment
)

If you want to customize loading of enum you can provide your own instance of ConfigDecoder:

given ConfigDecoder[Environment] = new ConfigDecoder[Environment]:
  def decode(raw: String): Either[String, Environment] =
    val rawLowercased = raw.trim().toLowerCase()
    Environment
      .values
      .find(_.toString().toLowerCase() == rawLowercased)
      .toRight(s"Couldn't find right value for Environment: $raw")

Subclasses

The result of loading sealed trait will be first subclass to load successfully.

sealed trait MessagingConfig
case class LiveConfig(@env("BROKER_ADDRESS") brokerAddress: String) extends MessagingConfig
case class TestConfig(@prop("BROKER_NAME" ) brokerName: String) extends MessagingConfig

case class Config(messaging: MessagingConfig)

The same works for enums with fields

enum MessagingConfig: 
  case LiveConfig(@env("BROKER_ADDRESS") brokerAddress: String)
  case TestConfig(@prop("BROKER_NAME" ) brokerName: String)

case class Config(messaging: MessagingConfig)

Secret values

If don't want to expose your secret values in logs or error messages, you can use Secret type. It displays a short SHA-256 hash instead of the actual value when printed.

case class DbCredsConfig(
  @env("DB_PASSWORD") password: Secret[String],
  @env("DB_USERNAME") username: String
)

println(load[DbCredsConfig])
// Right(DbCredsConfig(Secret(d74ff0ee8d),db_reader))

The Secret type shows only the first 10 characters of the SHA-256 hash, which helps with debugging while keeping sensitive data protected.

Collections

You can load comma-separated values into collections. Currently, it's not possible to change separator.

case class Numbers(@env("NUMBERS") numbers: List[Int])

println(load[Numbers])
// Right(Numbers(List(1, 2, 3)))

With environment variable containing "1,2,3" the result will contain Right(Numbers(List(1,2,3))).

Supported Types

Library provides built-in decoders for many common types:

Primitive Types:

  • String
  • Int, Long, Short, Byte
  • Double, Float
  • Boolean
  • Char

Standard Library Types:

  • BigInt, BigDecimal
  • UUID
  • URI
  • Path (java.nio.file.Path)
  • File (java.io.File)
  • FiniteDuration (scala.concurrent.duration.FiniteDuration), Duration (scala.concurrent.duration.Duration)
  • List[T], Seq[T], Vector[T]
  • Option[T] - returns None if value not found

Adding custom decoders

You can add custom decoders for your types by implementing ConfigDecoder typeclass:

class MyClass(val value: String)

given ConfigDecoder[MyClass] with {
  def decode(raw: String): Either[String, MyClass] = {
    if (raw.isEmpty)
      Left("Value is empty")
    else
      Right(new MyClass(raw))
  }
}

Testing

You can override behavior of load function by providing instance of ConfigReader.

For test, you can use mocked ConfigReader:

case class DbConf(@env("DATABASE_HOST") host: String, @prop("dbpass") password: String)

given ConfigReader = ConfigReader
  .mocked
  .onEnv("DATABASE_HOST", "localhost")
  .onProp("dbpass", "mypass")

println(load[DbConf])
// Right(DbConf(localhost,mypass))

Error Handling

Configuration loading returns an Either[ConfigError, Config]. When errors occur, you can format them for display using different printers.

Default Error Format

By default, errors use getMessage which provides a text-based error list:

case class AppConfig(
  @env("PORT") port: Int,
  @env("HOST") host: String
)

load[AppConfig] match {
  case Left(error) =>
    println(error.getMessage)
    // Configuration loading failed with following issues:
    // Missing environment variable PORT
    // Missing environment variable HOST
  case Right(config) => // ...
}

Table Format

For better readability, use TablePrinter to display errors in a formatted table:

import jurate.printers.TablePrinter

load[Config] match {
  case Left(error) =>
    System.err.println(error.print(using TablePrinter))
    // ┌───────┬────────────┬─────────────────────────────┐
    // │ Field │ Source     │ Message                     │
    // ├───────┼────────────┼─────────────────────────────┤
    // │ port  │ PORT (env) │ Missing configuration value │
    // ├───────┼────────────┼─────────────────────────────┤
    // │ host  │ HOST (env) │ Missing configuration value │
    // └───────┴────────────┴─────────────────────────────┘
  case Right(config) => // ...
}

Custom Error Printers

You can create custom error formatters by implementing the ErrorPrinter trait:

import jurate.ErrorPrinter

object CompactPrinter extends ErrorPrinter {
  def format(error: ConfigError): String =
    error.reasons.map {
      case Missing(field, _) => s"Missing: $field"
      case Invalid(_, detail, _, _) => s"Invalid: $detail"
      case Other(field, detail, _) => s"Error: $detail"
    }.mkString(" | ")
}

error.print(using CompactPrinter)
// Missing: port | Missing: host

Examples

You can find more examples under src/examples. You can run them using sbt "examples/runMain <example-class>" command (set necessary environment variables first). For instance:

sbt "examples/runMain jurate.simpleApp"

About

Simple Scala 3 config library: load environment variables into case classes using annotations.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages