Thanks to visit codestin.com
Credit goes to Github.com

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 = "[email protected]"
)

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 [email protected] 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