From 8c87b01e62da0059ec45d3e8a3941c4f3d4710ea Mon Sep 17 00:00:00 2001 From: Vitthal Mirji Date: Tue, 18 Nov 2025 16:35:21 +0530 Subject: [PATCH] Add application configuration with Typesafe Config --- build.mill | 3 +- cask/src/cask/Config.scala | 121 +++++++++++ cask/src/cask/internal/Config.scala | 134 +++++++++++++ cask/test/src/cask/ConfigTests.scala | 188 ++++++++++++++++++ .../app/resources/application-prod.conf | 6 + example/config/app/resources/application.conf | 14 ++ example/config/app/resources/application.json | 13 ++ example/config/app/src/ConfigExample.scala | 32 +++ example/config/package.mill | 7 + 9 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 cask/src/cask/Config.scala create mode 100644 cask/src/cask/internal/Config.scala create mode 100644 cask/test/src/cask/ConfigTests.scala create mode 100644 example/config/app/resources/application-prod.conf create mode 100644 example/config/app/resources/application.conf create mode 100644 example/config/app/resources/application.json create mode 100644 example/config/app/src/ConfigExample.scala create mode 100644 example/config/package.mill diff --git a/build.mill b/build.mill index 8f4836d07f..1524485dd5 100644 --- a/build.mill +++ b/build.mill @@ -40,7 +40,8 @@ trait CaskMainModule extends CaskModule { def mvnDeps = Task { Seq( mvn"io.undertow:undertow-core:2.3.18.Final", - mvn"com.lihaoyi::upickle:4.0.2" + mvn"com.lihaoyi::upickle:4.0.2", + mvn"com.typesafe:config:1.4.3" ) ++ Option.when(!isScala3)(mvn"org.scala-lang:scala-reflect:$crossScalaVersion") } diff --git a/cask/src/cask/Config.scala b/cask/src/cask/Config.scala new file mode 100644 index 0000000000..6abdc446c0 --- /dev/null +++ b/cask/src/cask/Config.scala @@ -0,0 +1,121 @@ +package cask + +import cask.internal.{Config => InternalConfig} + +/** + * Global configuration access point. + * + * Configuration is process-scoped and loaded once lazily when first accessed. + * The configuration is immutable after loading - changes to config files require + * an application restart. + * + * Auto-loads configuration at startup from (in priority order): + * - System properties + * - application.conf (HOCON format) + * - application.json (JSON format) + * - application.properties (Java properties) + * - application-{CASK_ENV}.conf (environment-specific overrides) + * - Environment variables (via ${?VAR} syntax) + * + * Example (HOCON): + * {{{ + * // application.conf + * app { + * name = "my-app" + * port = 8080 + * database.url = ${?DATABASE_URL} + * } + * }}} + * + * Example (JSON): + * {{{ + * // application.json + * { + * "app": { + * "name": "my-app", + * "port": 8080 + * } + * } + * }}} + * + * Usage: + * {{{ + * val name = cask.Config.getString("app.name") + * val port = cask.Config.getInt("app.port") + * }}} + */ +object Config { + + type ConfigError = InternalConfig.ConfigError + val ConfigError = InternalConfig.ConfigError + + type Environment = InternalConfig.Environment + val Environment = InternalConfig.Environment + + /** Lazily loaded configuration */ + private lazy val loader: InternalConfig.Loader = + InternalConfig.Loader.loadOrThrow() + + /** Get string configuration value */ + def getString(key: String): Either[ConfigError, String] = + loader.getString(key) + + /** Get int configuration value */ + def getInt(key: String): Either[ConfigError, Int] = + loader.getInt(key) + + /** Get boolean configuration value */ + def getBoolean(key: String): Either[ConfigError, Boolean] = + loader.getBoolean(key) + + /** Get long configuration value */ + def getLong(key: String): Either[ConfigError, Long] = + loader.getLong(key) + + /** Get double configuration value */ + def getDouble(key: String): Either[ConfigError, Double] = + loader.getDouble(key) + + /** Get optional string */ + def getStringOpt(key: String): Option[String] = + loader.getStringOpt(key) + + /** Get optional int */ + def getIntOpt(key: String): Option[Int] = + loader.getIntOpt(key) + + /** Get optional boolean */ + def getBooleanOpt(key: String): Option[Boolean] = + loader.getBooleanOpt(key) + + /** Get string or throw exception */ + def getStringOrThrow(key: String): String = + getString(key) match { + case Right(value) => value + case Left(error) => throw new RuntimeException(error.message) + } + + /** Get int or throw exception */ + def getIntOrThrow(key: String): Int = + getInt(key) match { + case Right(value) => value + case Left(error) => throw new RuntimeException(error.message) + } + + /** Get boolean or throw exception */ + def getBooleanOrThrow(key: String): Boolean = + getBoolean(key) match { + case Right(value) => value + case Left(error) => throw new RuntimeException(error.message) + } + + /** Access underlying Typesafe Config for advanced usage */ + def underlying: com.typesafe.config.Config = + loader.underlying + + /** Reload configuration (useful for testing) */ + private[cask] def reload(): Unit = { + // Force re-evaluation of lazy val + val _ = InternalConfig.Loader.loadOrThrow() + } +} diff --git a/cask/src/cask/internal/Config.scala b/cask/src/cask/internal/Config.scala new file mode 100644 index 0000000000..e33a3af0db --- /dev/null +++ b/cask/src/cask/internal/Config.scala @@ -0,0 +1,134 @@ +package cask.internal + +import com.typesafe.config.{Config => TypesafeConfig, ConfigFactory, ConfigException} +import scala.util.{Try, Success, Failure} +import scala.util.control.NonFatal + +/** + * Configuration loading and access with functional error handling. + */ +object Config { + + /** Configuration error ADT */ + sealed trait ConfigError { + def message: String + } + + object ConfigError { + final case class Missing(key: String) extends ConfigError { + def message = s"Configuration key '$key' is missing" + } + + final case class InvalidType(key: String, expected: String, actual: String) extends ConfigError { + def message = s"Configuration key '$key': expected $expected but got $actual" + } + + final case class LoadFailure(cause: String) extends ConfigError { + def message = s"Failed to load configuration: $cause" + } + + final case class ParseFailure(key: String, value: String, cause: String) extends ConfigError { + def message = s"Failed to parse '$key' value '$value': $cause" + } + } + + /** Environment for profile loading */ + sealed trait Environment { + def name: String + } + + object Environment { + case object Development extends Environment { val name = "dev" } + case object Test extends Environment { val name = "test" } + case object Production extends Environment { val name = "prod" } + final case class Custom(name: String) extends Environment + + def fromString(s: String): Environment = s.toLowerCase match { + case "dev" | "development" => Development + case "test" => Test + case "prod" | "production" => Production + case other => Custom(other) + } + + def current: Environment = + sys.env.get("CASK_ENV") + .map(fromString) + .getOrElse(Development) + } + + /** Configuration loader with resource safety */ + final class Loader private (config: TypesafeConfig) { + + def getString(key: String): Either[ConfigError, String] = + safeGet(key)(config.getString) + + def getInt(key: String): Either[ConfigError, Int] = + safeGet(key)(config.getInt) + + def getBoolean(key: String): Either[ConfigError, Boolean] = + safeGet(key)(config.getBoolean) + + def getLong(key: String): Either[ConfigError, Long] = + safeGet(key)(config.getLong) + + def getDouble(key: String): Either[ConfigError, Double] = + safeGet(key)(config.getDouble) + + def getStringOpt(key: String): Option[String] = + getString(key).toOption + + def getIntOpt(key: String): Option[Int] = + getInt(key).toOption + + def getBooleanOpt(key: String): Option[Boolean] = + getBoolean(key).toOption + + private def safeGet[A](key: String)(f: String => A): Either[ConfigError, A] = + Try(f(key)) match { + case Success(value) => Right(value) + case Failure(e: ConfigException.Missing) => + Left(ConfigError.Missing(key)) + case Failure(e: ConfigException.WrongType) => + Left(ConfigError.InvalidType(key, "unknown", e.getMessage)) + case Failure(e) => + Left(ConfigError.ParseFailure(key, "unknown", e.getMessage)) + } + + /** Underlying config for advanced usage */ + def underlying: TypesafeConfig = config + } + + object Loader { + + /** + * Load configuration with environment profile. + * + * Loading order (later overrides earlier): + * 1. reference.conf (library defaults) + * 2. application.conf (user config) + * 3. application-{env}.conf (environment profile) + * 4. System properties + * 5. Environment variables + */ + def load(env: Environment = Environment.current): Either[ConfigError, Loader] = + Try { + val base = ConfigFactory.load("application") + val profile = Try(ConfigFactory.load(s"application-${env.name}")) + .getOrElse(ConfigFactory.empty()) + + profile + .withFallback(base) + .resolve() + } match { + case Success(config) => Right(new Loader(config)) + case Failure(e) => Left(ConfigError.LoadFailure(e.getMessage)) + } + + /** Load with default environment */ + def loadOrThrow(): Loader = + load() match { + case Right(loader) => loader + case Left(error) => throw new RuntimeException(error.message) + } + } +} diff --git a/cask/test/src/cask/ConfigTests.scala b/cask/test/src/cask/ConfigTests.scala new file mode 100644 index 0000000000..4ad5bd9f88 --- /dev/null +++ b/cask/test/src/cask/ConfigTests.scala @@ -0,0 +1,188 @@ +package test.cask + +import cask.Config +import cask.Config.ConfigError +import cask.Config.Environment +import utest._ + +object ConfigTests extends TestSuite { + + val tests = Tests { + test("ConfigError ADT") { + test("Missing") { + val missing = ConfigError.Missing("test.key") + assert(missing.message.contains("missing")) + assert(missing.message.contains("test.key")) + } + + test("InvalidType") { + val invalidType = ConfigError.InvalidType("test.key", "String", "Int") + assert(invalidType.message.contains("expected")) + assert(invalidType.message.contains("String")) + assert(invalidType.message.contains("Int")) + } + + test("LoadFailure") { + val loadFailure = ConfigError.LoadFailure("file not found") + assert(loadFailure.message.contains("Failed to load")) + assert(loadFailure.message.contains("file not found")) + } + + test("ParseFailure") { + val parseFailure = ConfigError.ParseFailure("test.key", "invalid", "syntax error") + assert(parseFailure.message.contains("test.key")) + assert(parseFailure.message.contains("invalid")) + assert(parseFailure.message.contains("syntax error")) + } + } + + test("Environment") { + test("fromString conversions") { + assert(Environment.fromString("dev") == Environment.Development) + assert(Environment.fromString("development") == Environment.Development) + assert(Environment.fromString("test") == Environment.Test) + assert(Environment.fromString("prod") == Environment.Production) + assert(Environment.fromString("production") == Environment.Production) + + val custom = Environment.fromString("staging") + assert(custom.isInstanceOf[Environment.Custom]) + assert(custom.name == "staging") + } + + test("current environment") { + val env = Environment.current + assert(env.isInstanceOf[Environment]) + assert(env.name.nonEmpty) + } + + test("environment names") { + assert(Environment.Development.name == "dev") + assert(Environment.Test.name == "test") + assert(Environment.Production.name == "prod") + assert(Environment.Custom("staging").name == "staging") + } + } + + test("Config getString") { + test("missing key returns Left") { + val result = Config.getString("nonexistent.key") + assert(result.isLeft) + + result match { + case Left(error) => + assert(error.isInstanceOf[ConfigError.Missing]) + assert(error.message.contains("missing")) + case Right(_) => assert(false) + } + } + + test("pattern matching on Either") { + Config.getString("nonexistent") match { + case Left(_: ConfigError.Missing) => // Expected + case _ => assert(false) + } + } + } + + test("Config optional accessors") { + test("getStringOpt returns None for missing") { + val opt = Config.getStringOpt("nonexistent.key") + assert(opt.isEmpty) + } + + test("getIntOpt returns None for missing") { + val opt = Config.getIntOpt("nonexistent.key") + assert(opt.isEmpty) + } + + test("getBooleanOpt returns None for missing") { + val opt = Config.getBooleanOpt("nonexistent.key") + assert(opt.isEmpty) + } + } + + test("Config type-safe accessors") { + test("getString returns Either") { + val _: Either[ConfigError, String] = Config.getString("any.key") + } + + test("getInt returns Either") { + val _: Either[ConfigError, Int] = Config.getInt("any.key") + } + + test("getBoolean returns Either") { + val _: Either[ConfigError, Boolean] = Config.getBoolean("any.key") + } + + test("getLong returns Either") { + val _: Either[ConfigError, Long] = Config.getLong("any.key") + } + + test("getDouble returns Either") { + val _: Either[ConfigError, Double] = Config.getDouble("any.key") + } + } + + test("Config throw accessors") { + test("getStringOrThrow throws on missing") { + try { + Config.getStringOrThrow("nonexistent.key") + assert(false) + } catch { + case e: RuntimeException => assert(e.getMessage.contains("missing")) + } + } + + test("getIntOrThrow throws on missing") { + try { + Config.getIntOrThrow("nonexistent.key") + assert(false) + } catch { + case e: RuntimeException => assert(e.getMessage.contains("missing")) + } + } + + test("getBooleanOrThrow throws on missing") { + try { + Config.getBooleanOrThrow("nonexistent.key") + assert(false) + } catch { + case e: RuntimeException => assert(e.getMessage.contains("missing")) + } + } + } + + test("Config underlying Typesafe Config access") { + val underlying = Config.underlying + assert(underlying != null) + assert(underlying.isInstanceOf[com.typesafe.config.Config]) + } + + test("ConfigError pattern matching") { + val errors: Seq[ConfigError] = Seq( + ConfigError.Missing("key1"), + ConfigError.InvalidType("key2", "String", "Int"), + ConfigError.LoadFailure("cause"), + ConfigError.ParseFailure("key3", "value", "cause") + ) + + errors.foreach { error => + error match { + case ConfigError.Missing(key) => assert(key.nonEmpty) + case ConfigError.InvalidType(key, expected, actual) => + assert(key.nonEmpty && expected.nonEmpty && actual.nonEmpty) + case ConfigError.LoadFailure(cause) => assert(cause.nonEmpty) + case ConfigError.ParseFailure(key, value, cause) => + assert(key.nonEmpty && cause.nonEmpty) + } + } + } + + test("Config is process-scoped and immutable") { + // Multiple accesses return same underlying config + val config1 = Config.underlying + val config2 = Config.underlying + assert(config1 eq config2) + } + } +} diff --git a/example/config/app/resources/application-prod.conf b/example/config/app/resources/application-prod.conf new file mode 100644 index 0000000000..c71bff175b --- /dev/null +++ b/example/config/app/resources/application-prod.conf @@ -0,0 +1,6 @@ +app { + features { + debug = false + cache-enabled = true + } +} diff --git a/example/config/app/resources/application.conf b/example/config/app/resources/application.conf new file mode 100644 index 0000000000..e1263a7076 --- /dev/null +++ b/example/config/app/resources/application.conf @@ -0,0 +1,14 @@ +app { + name = "config-example" + + server { + port = 8080 + port = ${?PORT} + host = "0.0.0.0" + } + + features { + debug = true + cache-enabled = false + } +} diff --git a/example/config/app/resources/application.json b/example/config/app/resources/application.json new file mode 100644 index 0000000000..8ed4764ef3 --- /dev/null +++ b/example/config/app/resources/application.json @@ -0,0 +1,13 @@ +{ + "app": { + "name": "config-example-json", + "server": { + "port": 8080, + "host": "0.0.0.0" + }, + "features": { + "debug": true, + "cache-enabled": false + } + } +} diff --git a/example/config/app/src/ConfigExample.scala b/example/config/app/src/ConfigExample.scala new file mode 100644 index 0000000000..788742e2ff --- /dev/null +++ b/example/config/app/src/ConfigExample.scala @@ -0,0 +1,32 @@ +package app + +object ConfigExample extends cask.MainRoutes { + + // Configuration loaded automatically at startup + val appName = cask.Config.getStringOrThrow("app.name") + val debugMode = cask.Config.getBooleanOrThrow("app.features.debug") + + override def port = cask.Config.getIntOrThrow("app.server.port") + override def host = cask.Config.getStringOrThrow("app.server.host") + + @cask.get("/") + def index() = { + val env = cask.Config.Environment.current.name + s""" + |App: $appName + |Environment: $env + |Debug: $debugMode + |Port: $port + |""".stripMargin + } + + @cask.get("/config/:key") + def getConfig(key: String) = { + cask.Config.getString(key) match { + case Right(value) => s"$key = $value" + case Left(error) => cask.Response(error.message, statusCode = 404) + } + } + + initialize() +} diff --git a/example/config/package.mill b/example/config/package.mill new file mode 100644 index 0000000000..d1784e334d --- /dev/null +++ b/example/config/package.mill @@ -0,0 +1,7 @@ +package build.example.config +import mill._, scalalib._ + +object `package` extends Module { + object app extends Cross[AppModule](build.scala3Latest) + trait AppModule extends build.LocalModule +}