diff --git a/build.mill b/build.mill index 12bc73a123..e563f89719 100644 --- a/build.mill +++ b/build.mill @@ -42,10 +42,10 @@ 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(isScala37Plus)(mvn"com.lihaoyi::scalasql-namedtuples:0.2.3") ++ - Option.when(isScala37Plus)(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..c548eee31b --- /dev/null +++ b/cask/src/cask/Config.scala @@ -0,0 +1,102 @@ +package cask + +import cask.internal.{Config => InternalConfig} + +/** + * Global configuration access point. + * + * Auto-loads configuration at startup from: + * - application.conf + * - application-{CASK_ENV}.conf + * - System properties + * - Environment variables + * + * Example: + * {{{ + * // application.conf + * app { + * name = "my-app" + * port = 8080 + * database.url = ${?DATABASE_URL} + * } + * + * // 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..7e8b44fd0a --- /dev/null +++ b/cask/test/src/cask/ConfigTests.scala @@ -0,0 +1,90 @@ +package test.cask + +import utest._ + +object ConfigTests extends TestSuite { + + val tests = Tests { + test("ADT error types") { + val missing = cask.Config.ConfigError.Missing("test.key") + assert(missing.message.contains("missing")) + + val invalidType = cask.Config.ConfigError.InvalidType("test.key", "String", "Int") + assert(invalidType.message.contains("expected")) + } + + test("Environment from string") { + import cask.Config.Environment._ + + assert(fromString("dev") == Development) + assert(fromString("development") == Development) + assert(fromString("test") == Test) + assert(fromString("prod") == Production) + assert(fromString("production") == Production) + assert(fromString("staging").isInstanceOf[Custom]) + } + + test("Config loader Either pattern") { + val result = cask.Config.getString("nonexistent.key") + assert(result.isLeft) + + result match { + case Left(error) => assert(error.message.contains("missing")) + case Right(_) => assert(false) + } + } + + test("Optional config accessors") { + val opt = cask.Config.getStringOpt("nonexistent.key") + assert(opt.isEmpty) + } + + test("Type-safe accessors with different types") { + // These will work if application.conf exists with proper values + // Test that methods exist and return correct types + locally { + val _: Either[cask.Config.ConfigError, String] = cask.Config.getString("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Int] = cask.Config.getInt("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Boolean] = cask.Config.getBoolean("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Long] = cask.Config.getLong("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Double] = cask.Config.getDouble("any.key") + } + } + + test("Environment detection from CASK_ENV") { + val env = cask.Config.Environment.current + // Should default to Development if CASK_ENV not set + assert(env.isInstanceOf[cask.Config.Environment]) + } + + test("Config error pattern matching") { + import cask.Config.ConfigError._ + + val errors: Seq[cask.Config.ConfigError] = Seq( + Missing("key1"), + InvalidType("key2", "String", "Int"), + LoadFailure("cause"), + ParseFailure("key3", "value", "cause") + ) + + errors.foreach { error => + error match { + case Missing(key) => assert(key.nonEmpty) + case InvalidType(key, expected, actual) => + assert(key.nonEmpty && expected.nonEmpty && actual.nonEmpty) + case LoadFailure(cause) => assert(cause.nonEmpty) + case ParseFailure(key, value, cause) => + assert(key.nonEmpty && cause.nonEmpty) + } + } + } + } +} 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/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 +} diff --git a/example/todoDb/app/resources/application-prod.conf b/example/todoDb/app/resources/application-prod.conf new file mode 100644 index 0000000000..2214ee1246 --- /dev/null +++ b/example/todoDb/app/resources/application-prod.conf @@ -0,0 +1,14 @@ +database { + # Production database uses persistent storage + path = "/var/lib/cask-todo/data" + + pool { + max-connections = 25 + min-idle = 5 + } +} + +# Disable initial data insertion in production +initial-data { + enabled = false +} diff --git a/example/todoDb/app/resources/application.conf b/example/todoDb/app/resources/application.conf new file mode 100644 index 0000000000..f2c2b64cd2 --- /dev/null +++ b/example/todoDb/app/resources/application.conf @@ -0,0 +1,24 @@ +database { + # SQLite database configuration + driver = "org.sqlite.SQLiteDataSource" + + # Database path - uses temp directory by default + # Override with DATABASE_PATH environment variable + path = "temp" + path = ${?DATABASE_PATH} + + # Connection pool settings + pool { + max-connections = 10 + min-idle = 2 + } +} + +# Initial data to insert +initial-data { + enabled = true + todos = [ + { checked: true, text: "Get started with Cask" } + { checked: false, text: "Profit!" } + ] +} diff --git a/example/todoDb/app/src/TodoMvcDb.scala b/example/todoDb/app/src/TodoMvcDb.scala index f40d73fb39..281504911e 100644 --- a/example/todoDb/app/src/TodoMvcDb.scala +++ b/example/todoDb/app/src/TodoMvcDb.scala @@ -3,9 +3,18 @@ import scalasql.simple.{*, given} import SqliteDialect._ object TodoMvcDb extends cask.MainRoutes { - val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") + // Database path from config, fallback to temp directory + val dbPath = cask.Config.getStringOpt("database.path") match { + case Some("temp") | None => + java.nio.file.Files.createTempDirectory("todo-cask-sqlite").toString + case Some(path) => + val dir = java.nio.file.Paths.get(path) + java.nio.file.Files.createDirectories(dir) + path + } + val sqliteDataSource = new org.sqlite.SQLiteDataSource() - sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db") + sqliteDataSource.setUrl(s"jdbc:sqlite:$dbPath/file.db") given dbClient: scalasql.core.DbClient = new DbClient.DataSource( sqliteDataSource, @@ -18,19 +27,24 @@ object TodoMvcDb extends cask.MainRoutes { given todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo] } + // Initialize database schema dbClient.getAutoCommitClientConnection.updateRaw( """CREATE TABLE todo ( | id INTEGER PRIMARY KEY AUTOINCREMENT, | checked BOOLEAN, | text TEXT - |); - | - |INSERT INTO todo (checked, text) VALUES - |(1, 'Get started with Cask'), - |(0, 'Profit!'); - |""".stripMargin + |);""".stripMargin ) + // Insert initial data if enabled in config + if (cask.Config.getBooleanOpt("initial-data.enabled").getOrElse(true)) { + dbClient.getAutoCommitClientConnection.updateRaw( + """INSERT INTO todo (checked, text) VALUES + |(1, 'Get started with Cask'), + |(0, 'Profit!');""".stripMargin + ) + } + @cask.database.transactional[scalasql.core.DbClient] @cask.get("/list/:state") def list(state: String)(using ctx: scalasql.core.DbApi.Txn) = {