diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5417a84..d1ebf63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Verify code format run: sbt scalafmtCheckAll + - name: Verify mdoc + run: sbt "mdoc --check" + - name: Compile run: sbt compile examples/compile integrationTests/compile integrationTests/Test/compile Test/compile diff --git a/README.md b/README.md index 8a2b01e..4aefd6b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Jurate -[![Maven Central](https://maven-badges.sml.io/maven-central/io.github.katlasik/jurate_3/badge.svg)](https://maven-badges.sml.io/maven-central/io.github.katlasik/jurate_3) ## Intro -Jurate is a simple library for instantiating case class instances from environment variables and system properties. 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. +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. + ```scala import jurate.{*, given} @@ -18,13 +18,24 @@ case class Config( @env("HOST") host: String, @env("PORT") port: Int = 8080, @env("ADMIN_EMAIL") adminEmail: Option[String], - @prop("app.debug") debug: Boolean, + @prop("app.debug") debug: Boolean = false, dbConfig: DbConfig ) -load[Config] // Right(Config(localhost, 8080, None, true, DbConfig(*****, user))) +println(load[Config]) +// Right(Config(localhost,8080,None,false,DbConfig(Secret(d74ff0ee8d),db_reader))) +``` + +## Installation +**Requirements:** Scala 3.3+ + +Add to your `build.sbt`: +```scala +libraryDependencies += "io.github.katlasik" %% "jurate" % "0.3.0" ``` +## Getting Started + You have to import givens using: ```scala @@ -40,7 +51,7 @@ You can provide multiple annotations to a field. The library will try to load th 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. ```scala -case class Config( +case class EmailConfig( @prop("debug.email") @env("EMAIL") @env("ADMIN_EMAIL") email: String = "foo@bar.com" ) ``` @@ -51,7 +62,7 @@ In this example library will first check if system property `debug.email` exists You can make field optional by using `Option` type. If the value is not found, the field will be set to `None`. ```scala -case class Config( +case class AdminEmailConfig( @env("ADMIN_EMAIL") adminEmail: Option[String], ) ``` @@ -60,15 +71,15 @@ case class Config( You can use nested case classes to organize your config. ```scala -case class DbConfig( +case class DatabaseConfig( @env("DB_PASSWORD") password: Secret[String], @env("DB_USERNAME") username: String ) -case class Config( +case class AppConfig( @env("HOST") host: String, @env("PORT") port: Int = 8080, - dbConfig: DbConfig + dbConfig: DatabaseConfig ) ``` @@ -79,7 +90,7 @@ You can load values of singleton enums (with no fields) using `@env` or `@prop` enum Environment: case DEV, PROD, STAGING -case class Config( +case class EnvConfig( @env("ENV") env: Environment ) ``` @@ -88,11 +99,11 @@ If you want to customize loading of enum you can provide your own instance of `C ```scala given ConfigDecoder[Environment] = new ConfigDecoder[Environment]: - def decode(raw: String): Either[ConfigError, Environment] = + def decode(raw: String, ctx: DecodingContext): Either[ConfigError, Environment] = val rawLowercased = raw.trim().toLowerCase() Environment.values .find(_.toString().toLowerCase() == rawLowercased) - .toRight(ConfigError.invalid(s"Couldn't find right value for Environment", raw)) + .toRight(ConfigError.invalid(ctx.fieldPath, s"Couldn't find right value for Environment", raw, ctx.evaluatedAnnotation)) ``` ## Subclasses @@ -116,64 +127,94 @@ 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 will hide the value when printed. +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. ```scala -case class DbConfig( +case class DbCredsConfig( @env("DB_PASSWORD") password: Secret[String], @env("DB_USERNAME") username: String ) -val config = load[DbConfig] - -println(config) // Right(DbConfig(*****, user)) +println(load[DbCredsConfig]) +// Right(DbCredsConfig(Secret(d74ff0ee8d),db_reader)) ``` -## Collection -You can load comma-separated values into collections. Currently it's not possible to change separator. +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. + ```scala case class Numbers(@env("NUMBERS") numbers: List[Int]) -val result = load[Numbers] +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)))`. -# Adding custom decoders -You can add custom decoders for your types by implementing `ConfigDecoder` typeclass. +# 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`, `URL` +- `Path` (java.nio.file.Path) +- `File` (java.io.File) +- `FiniteDuration` (scala.concurrent.duration.FiniteDuration) +- `Duration` (java.time.Duration) +- `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant` (java.time.*) + +**Collections:** +- `List[T]`, `Seq[T]`, `Vector[T]`, `Set[T]` +- Any collection with a `T` that has a `ConfigDecoder[T]` + +**Special Types:** +- `Option[T]` - returns `None` if value not found +- `Secret[T]` - wraps sensitive values with hash display +- Case classes (automatically derived) +- Sealed traits and enums (automatically derived) +# Adding custom decoders +You can add custom decoders for your types by implementing `ConfigDecoder` typeclass: ```scala class MyClass(val value: String) given ConfigDecoder[MyClass] with { - def decode(value: String): Either[ConfigError, MyClass] = { - if (value.isEmpty) Left(ConfigError.invalid("Value is empty", value)) - else Right(new MyClass(value)) + def decode(raw: String, ctx: DecodingContext): Either[ConfigError, MyClass] = { + if (raw.isEmpty) Left(ConfigError.invalid(ctx.fieldPath, "Value is empty", raw, ctx.evaluatedAnnotation)) + else Right(new MyClass(raw)) } } ``` -Library provides default decoders for common types like `UUID`, `Path`, `URI` etc. - # Testing - You can override behavior of `load` function by providing instance of `ConfigReader`. -For test you can use mocked ConfigReader: +For test, you can use mocked ConfigReader: ```scala -case class DbConfig(@env("DATABASE_HOST") host: String, @prop("dbpass") password: String) +case class DbConf(@env("DATABASE_HOST") host: String, @prop("dbpass") password: String) given ConfigReader = ConfigReader .mocked .onEnv("DATABASE_HOST", "localhost") .onProp("dbpass", "mypass") -load[DbConfig] // Right(DbConfig("localhost", "mypass")) - +println(load[DbConf]) +// Right(DbConf(localhost,mypass)) ``` # Error Handling @@ -185,12 +226,12 @@ Configuration loading returns an `Either[ConfigError, Config]`. When errors occu By default, errors use `getMessage` which provides a text-based error list: ```scala -case class Config( +case class AppConfig( @env("PORT") port: Int, @env("HOST") host: String ) -load[Config] match { +load[AppConfig] match { case Left(error) => println(error.getMessage) // Configuration loading failed with following issues: @@ -225,8 +266,9 @@ load[Config] match { You can create custom error formatters by implementing the `ErrorPrinter` trait: + ```scala -import jurate.printers.ErrorPrinter +import jurate.ErrorPrinter object CompactPrinter extends ErrorPrinter { def format(error: ConfigError): String = @@ -237,9 +279,8 @@ object CompactPrinter extends ErrorPrinter { }.mkString(" | ") } -// Usage error.print(using CompactPrinter) -// Output: Missing: port | Missing: host +// Missing: port | Missing: host ``` # Examples diff --git a/README.template.md b/README.template.md new file mode 100644 index 0000000..f1f29f7 --- /dev/null +++ b/README.template.md @@ -0,0 +1,301 @@ + +# 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. + +```scala mdoc:invisible +given jurate.ConfigReader = jurate.ConfigReader.mocked + .onEnv("DB_PASSWORD", "pass") + .onEnv("DB_USERNAME", "db_reader") + .onEnv("HOST", "localhost") +``` + +```scala mdoc +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]) +``` + +## Installation +**Requirements:** Scala 3.3+ + +Add to your `build.sbt`: +```scala +libraryDependencies += "io.github.katlasik" %% "jurate" % "0.3.0" +``` + +## Getting Started + +You have to import givens using: + +```scala +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. + +```scala mdoc:compile-only +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`. + +```scala mdoc:compile-only +case class AdminEmailConfig( + @env("ADMIN_EMAIL") adminEmail: Option[String], +) +``` + +## Nested case classes +You can use nested case classes to organize your config. + +```scala mdoc:compile-only +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. + +```scala mdoc +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`: + +```scala mdoc:compile-only +given ConfigDecoder[Environment] = new ConfigDecoder[Environment]: + def decode(raw: String, ctx: DecodingContext): Either[ConfigError, Environment] = + val rawLowercased = raw.trim().toLowerCase() + Environment.values + .find(_.toString().toLowerCase() == rawLowercased) + .toRight(ConfigError.invalid(ctx.fieldPath, s"Couldn't find right value for Environment", raw, ctx.evaluatedAnnotation)) +``` + +## Subclasses +The result of loading sealed trait will be first subclass to load successfully. + +```scala mdoc:compile-only +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 +```scala mdoc:compile-only +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. + +```scala mdoc +case class DbCredsConfig( + @env("DB_PASSWORD") password: Secret[String], + @env("DB_USERNAME") username: String +) + +println(load[DbCredsConfig]) +``` + +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. + +```scala mdoc:invisible:nest +given jurate.ConfigReader = jurate.ConfigReader.mocked + .onEnv("NUMBERS", "1,2,3") +``` + +```scala mdoc +case class Numbers(@env("NUMBERS") numbers: List[Int]) + +println(load[Numbers]) +``` + +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`, `URL` +- `Path` (java.nio.file.Path) +- `File` (java.io.File) +- `FiniteDuration` (scala.concurrent.duration.FiniteDuration) +- `Duration` (java.time.Duration) +- `LocalDate`, `LocalTime`, `LocalDateTime`, `Instant` (java.time.*) + +**Collections:** +- `List[T]`, `Seq[T]`, `Vector[T]`, `Set[T]` +- Any collection with a `T` that has a `ConfigDecoder[T]` + +**Special Types:** +- `Option[T]` - returns `None` if value not found +- `Secret[T]` - wraps sensitive values with hash display +- Case classes (automatically derived) +- Sealed traits and enums (automatically derived) + +# Adding custom decoders +You can add custom decoders for your types by implementing `ConfigDecoder` typeclass: + +```scala mdoc:compile-only +class MyClass(val value: String) + +given ConfigDecoder[MyClass] with { + def decode(raw: String, ctx: DecodingContext): Either[ConfigError, MyClass] = { + if (raw.isEmpty) Left(ConfigError.invalid(ctx.fieldPath, "Value is empty", raw, ctx.evaluatedAnnotation)) + 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: + + +```scala mdoc:nest +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]) +``` + +# 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: + +```scala mdoc:compile-only +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: + +```scala mdoc:compile-only +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: + +```scala mdoc:invisible +val error = ConfigError(Nil) +``` + +```scala mdoc:compile-only +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](./src/examples/scala). +You can run them using `sbt "examples/runMain "` command (set necessary environment variables first). For instance: + +```bash +sbt "examples/runMain jurate.simpleApp" +``` \ No newline at end of file diff --git a/build.sbt b/build.sbt index 88a6ff2..aea3ac6 100644 --- a/build.sbt +++ b/build.sbt @@ -14,6 +14,8 @@ ThisBuild / scalacOptions ++= Seq( ThisBuild / versionScheme := Some("early-semver") ThisBuild / sonatypeCredentialHost := sonatypeCentralHost + + inThisBuild( List( organization := "io.github.katlasik", @@ -55,9 +57,19 @@ lazy val integrationTests = (project in file("integration-tests")) publish / skip := true ) + +lazy val docs = (project in file("docs")) + .enablePlugins(MdocPlugin) + .dependsOn(core, examples, integrationTests) + .settings( + mdocIn := file("README.template.md"), + mdocOut := file("README.md"), + ) + + lazy val root = (project in file(".")) - .aggregate(core, examples, integrationTests) + .aggregate(core, examples, integrationTests, docs) .settings( name := "root", publish / skip := true - ) + ) \ No newline at end of file diff --git a/core/src/main/scala/jurate/ConfigAnnotation.scala b/core/src/main/scala/jurate/ConfigAnnotation.scala index 40b35e5..1810579 100644 --- a/core/src/main/scala/jurate/ConfigAnnotation.scala +++ b/core/src/main/scala/jurate/ConfigAnnotation.scala @@ -4,16 +4,16 @@ import scala.annotation.StaticAnnotation private[jurate] sealed abstract class ConfigAnnotation extends StaticAnnotation -/** - * Annotation to load configuration from an environment variable. - * - * @param name the name of the environment variable - */ +/** Annotation to load configuration from an environment variable. + * + * @param name + * the name of the environment variable + */ case class env(name: String) extends ConfigAnnotation -/** - * Annotation to load configuration from a system property. - * - * @param path the path of the system property - */ +/** Annotation to load configuration from a system property. + * + * @param path + * the path of the system property + */ case class prop(path: String) extends ConfigAnnotation diff --git a/core/src/main/scala/jurate/ConfigDecoder.scala b/core/src/main/scala/jurate/ConfigDecoder.scala index a12b187..d118f4f 100644 --- a/core/src/main/scala/jurate/ConfigDecoder.scala +++ b/core/src/main/scala/jurate/ConfigDecoder.scala @@ -2,58 +2,69 @@ package jurate import jurate.utils.FieldPath -/** - * Type class for decoding raw string values into typed configuration values. - * - * @tparam C the type to decode into - */ +/** Type class for decoding raw string values into typed configuration values. + * + * @tparam C + * the type to decode into + */ trait ConfigDecoder[C] extends ConfigLoader[C]: - /** - * Decodes a raw string value into a typed value. - * - * @param raw the raw string value to decode - * @param ctx the decoding context containing annotations and field path - * @return either a configuration error or the decoded value - */ + /** Decodes a raw string value into a typed value. + * + * @param raw + * the raw string value to decode + * @param ctx + * the decoding context containing annotations and field path + * @return + * either a configuration error or the decoded value + */ def decode(raw: String, ctx: DecodingContext): Either[ConfigError, C] - /** - * Maps the decoded value through a function. - * - * @param f the function to apply to the decoded value - * @tparam B the target type - * @return a new decoder for type B - */ + /** Maps the decoded value through a function. + * + * @param f + * the function to apply to the decoded value + * @tparam B + * the target type + * @return + * a new decoder for type B + */ def contramap[B](f: C => B): ConfigDecoder[B] = decode(_, _).map(f) - /** - * Maps the decoded value through an error-handling function. - * - * @param f the function to apply that may fail with a ConfigError - * @tparam B the target type - * @return a new decoder for type B - */ + /** Maps the decoded value through an error-handling function. + * + * @param f + * the function to apply that may fail with a ConfigError + * @tparam B + * the target type + * @return + * a new decoder for type B + */ def emap[B]( f: (C, String, DecodingContext) => Either[ConfigError, B] ): ConfigDecoder[B] = (raw, ctx) => decode(raw, ctx).flatMap(f(_, raw, ctx)) object ConfigDecoder: - /** - * Summons an implicit ConfigDecoder instance for type C. - * - * @tparam C the type to decode - * @return the decoder instance - */ + /** Summons an implicit ConfigDecoder instance for type C. + * + * @tparam C + * the type to decode + * @return + * the decoder instance + */ def apply[C](using value: ConfigDecoder[C]): ConfigDecoder[C] = value -/** - * Context information available during decoding. - * - * @param annotations the configuration source annotations - * @param fieldPath the path to the field being decoded - */ +/** Context information available during decoding. + * + * @param annotations + * the configuration source annotations + * @param evaluatedAnnotation + * the annotation that provided the value + * @param fieldPath + * the path to the field being decoded + */ case class DecodingContext( annotations: Seq[ConfigAnnotation], + evaluatedAnnotation: ConfigAnnotation, fieldPath: FieldPath ) diff --git a/core/src/main/scala/jurate/ConfigError.scala b/core/src/main/scala/jurate/ConfigError.scala index 991d6e5..c4c950f 100644 --- a/core/src/main/scala/jurate/ConfigError.scala +++ b/core/src/main/scala/jurate/ConfigError.scala @@ -2,51 +2,57 @@ package jurate import jurate.utils.FieldPath -/** - * Base trait for different types of configuration error reasons. - */ +/** Base trait for different types of configuration error reasons. + */ sealed trait ConfigErrorReason { - /** - * Returns true if this error is due to a missing configuration value. - */ + + /** Returns true if this error is due to a missing configuration value. + */ def missing: Boolean } -/** - * Represents an error where a required configuration value was not found. - * - * @param fieldPath the path to the field that was missing - * @param annotations the annotations that were tried to find the value - */ +/** Represents an error where a required configuration value was not found. + * + * @param fieldPath + * the path to the field that was missing + * @param annotations + * the annotations that were tried to find the value + */ case class Missing(fieldPath: FieldPath, annotations: Seq[ConfigAnnotation]) extends ConfigErrorReason { override def missing: Boolean = true } -/** - * Represents an error where a configuration value was found but could not be decoded. - * - * @param receivedValue the raw string value that was received - * @param detail additional details about why decoding failed - * @param fieldPath the path to the field that failed to decode - * @param annotation the annotation that provided the value - */ +/** Represents an error where a configuration value was found but could not be + * decoded. + * + * @param receivedValue + * the raw string value that was received + * @param detail + * additional details about why decoding failed + * @param fieldPath + * the path to the field that failed to decode + * @param annotation + * the annotation that provided the value + */ case class Invalid( receivedValue: String, detail: String, fieldPath: FieldPath, - annotation: Option[ConfigAnnotation] + annotation: ConfigAnnotation ) extends ConfigErrorReason { override def missing: Boolean = false } -/** - * Represents other configuration errors. - * - * @param fieldPath the path to the field where the error occurred - * @param detail details about the error - * @param annotation optional annotation related to the error - */ +/** Represents other configuration errors. + * + * @param fieldPath + * the path to the field where the error occurred + * @param detail + * details about the error + * @param annotation + * optionally the annotation related to the error + */ case class Other( fieldPath: FieldPath, detail: String, @@ -55,20 +61,21 @@ case class Other( override def missing: Boolean = false } -/** - * Configuration loading error containing one or more error reasons. - * - * @param reasons the list of error reasons that caused this configuration error - */ +/** Configuration loading error containing one or more error reasons. + * + * @param reasons + * the list of error reasons that caused this configuration error + */ case class ConfigError(reasons: List[ConfigErrorReason]) extends Exception(ConfigError.createErrorMessage(reasons)) { - /** - * Combines this error with another error, merging their reasons. - * - * @param other the other error to combine with - * @return a new ConfigError containing all reasons from both errors - */ + /** Combines this error with another error, merging their reasons. + * + * @param other + * the other error to combine with + * @return + * a new ConfigError containing all reasons from both errors + */ infix def ++(other: ConfigError): ConfigError = { ConfigError(reasons ++ other.reasons) } @@ -78,12 +85,13 @@ case class ConfigError(reasons: List[ConfigErrorReason]) reasons.forall(_.missing) } - /** - * Formats this error using the provided error printer. - * - * @param printer the error printer to use for formatting - * @return the formatted error message - */ + /** Formats this error using the provided error printer. + * + * @param printer + * the error printer to use for formatting + * @return + * the formatted error message + */ def print(using printer: ErrorPrinter): String = printer.format(this) } @@ -108,32 +116,34 @@ object ConfigError { createAnnotationMessage(annotations) case Invalid(receivedValue, detail, fieldName, annotation) => annotation match { - case Some(env(name)) => + case env(name) => s"Invalid value received while reading environment variable $name: $detail, received value: '$receivedValue'" - case Some(prop(path)) => + case prop(path) => s"Invalid value received while reading system property $path: $detail, received value: '$receivedValue'" - case _ => - s"Invalid value received: $detail, received value: '$receivedValue'" } case Other(fieldName, detail, annotations) => detail } .mkString("\n", "\n", "") } - /** - * Creates a configuration error for an invalid value. - * - * @param fieldPath the path to the field - * @param detail details about why the value is invalid - * @param receivedValue the raw value that was received - * @param annotation the annotation that provided the value - * @return a ConfigError with an Invalid reason - */ + /** Creates a configuration error for an invalid value. + * + * @param fieldPath + * the path to the field + * @param detail + * details about why the value is invalid + * @param receivedValue + * the raw value that was received + * @param annotation + * the annotation that provided the value + * @return + * a ConfigError with an Invalid reason + */ def invalid( fieldPath: FieldPath, detail: String, receivedValue: String, - annotation: Option[ConfigAnnotation] + annotation: ConfigAnnotation ): ConfigError = ConfigError( Invalid( @@ -144,13 +154,15 @@ object ConfigError { ) :: Nil ) - /** - * Creates a configuration error for a missing value. - * - * @param fieldPath the path to the field - * @param annotations the annotations that were tried - * @return a ConfigError with a Missing reason - */ + /** Creates a configuration error for a missing value. + * + * @param fieldPath + * the path to the field + * @param annotations + * the annotations that were tried + * @return + * a ConfigError with a Missing reason + */ def missing( fieldPath: FieldPath, annotations: Seq[ConfigAnnotation] @@ -159,13 +171,15 @@ object ConfigError { Missing(fieldPath, annotations = annotations) :: Nil ) - /** - * Creates a configuration error for other types of errors. - * - * @param fieldPath the path to the field - * @param detail details about the error - * @return a ConfigError with an Other reason - */ + /** Creates a configuration error for other types of errors. + * + * @param fieldPath + * the path to the field + * @param detail + * details about the error + * @return + * a ConfigError with an Other reason + */ def other(fieldPath: FieldPath, detail: String): ConfigError = ConfigError( Other(fieldPath, detail) :: Nil ) diff --git a/core/src/main/scala/jurate/ConfigLoader.scala b/core/src/main/scala/jurate/ConfigLoader.scala index b5e089d..31de394 100644 --- a/core/src/main/scala/jurate/ConfigLoader.scala +++ b/core/src/main/scala/jurate/ConfigLoader.scala @@ -8,18 +8,23 @@ import scala.compiletime.* /** Base trait for loading configuration values. * - * ConfigLoader is the root of the type class hierarchy for configuration loading. - * It can be either a [[ConfigDecoder]] (for primitive types) or a [[ConfigHandler]] - * (for complex types like case classes). + * ConfigLoader is the root of the type class hierarchy for configuration + * loading. It can be either a [[ConfigDecoder]] (for primitive types) or a + * [[ConfigHandler]] (for complex types like case classes). * - * @tparam C the type to load configuration into + * @tparam C + * the type to load configuration into */ trait ConfigLoader[C] { + /** Loads configuration using the provided reader and field path. * - * @param reader the ConfigReader to use for reading configuration sources - * @param parentFieldPath the path prefix for this configuration field - * @return Either a ConfigError or the loaded configuration value + * @param reader + * the ConfigReader to use for reading configuration sources + * @param parentFieldPath + * the path prefix for this configuration field + * @return + * Either a ConfigError or the loaded configuration value */ def load( reader: ConfigReader, @@ -35,8 +40,10 @@ trait ConfigLoader[C] { /** Converts this loader to a ConfigDecoder if possible. * - * @return the ConfigDecoder instance - * @throws UnsupportedOperationException if this is not a ConfigDecoder + * @return + * the ConfigDecoder instance + * @throws UnsupportedOperationException + * if this is not a ConfigDecoder */ def asDecoder: ConfigDecoder[C] = this match { @@ -49,33 +56,39 @@ trait ConfigLoader[C] { } -/** A ConfigLoader implementation for complex types like case classes and sealed traits. +/** A ConfigLoader implementation for complex types like case classes and sealed + * traits. * - * ConfigHandler wraps a function that knows how to load and compose configuration - * from multiple fields recursively. + * ConfigHandler wraps a function that knows how to load and compose + * configuration from multiple fields recursively. * - * @param handle function that loads configuration given a reader and field path - * @tparam C the type to load configuration into + * @param handle + * function that loads configuration given a reader and field path + * @tparam C + * the type to load configuration into */ final case class ConfigHandler[C]( handle: (ConfigReader, FieldPath) => Either[ConfigError, C] ) extends ConfigLoader[C] -/** Companion object providing automatic derivation of ConfigLoader instances. */ +/** Companion object providing automatic derivation of ConfigLoader instances. + */ object ConfigLoader: /** Summons a ConfigLoader instance for type C from implicit scope. * - * @tparam C the configuration type - * @param value the implicit ConfigLoader instance - * @return the ConfigLoader instance + * @tparam C + * the configuration type + * @param value + * the implicit ConfigLoader instance + * @return + * the ConfigLoader instance */ def apply[C](using value: ConfigLoader[C]): ConfigLoader[C] = value - /** Automatically derives a ConfigLoader for types with a Mirror. - * This method uses Scala 3 compile-time reflection to automatically derive - * ConfigLoader instances for case classes (products) and sealed traits (sums). - * Example: + /** Automatically derives a ConfigLoader for types with a Mirror. This method + * uses Scala 3 compile-time reflection to automatically derive ConfigLoader + * instances for case classes (products) and sealed traits (sums). Example: * {{{ * case class DbConfig( * @env("DB_HOST") host: String, @@ -83,10 +96,12 @@ object ConfigLoader: * ) derives ConfigLoader * }}} * - * @tparam A the type to derive a ConfigLoader for - * @param mirror the Mirror for type A - * @return a derived ConfigLoader instance - * + * @tparam A + * the type to derive a ConfigLoader for + * @param mirror + * the Mirror for type A + * @return + * a derived ConfigLoader instance */ inline given derived[A](using mirror: Mirror.Of[A]): ConfigLoader[A] = inline mirror match @@ -140,10 +155,14 @@ object ConfigLoader: ) else reader.read(fieldPath, fieldMetadata.annotations) match { - case Right(raw) => + case Right((annotation, raw)) => decoder.decode( raw, - DecodingContext(fieldMetadata.annotations, fieldPath) + DecodingContext( + fieldMetadata.annotations, + annotation, + fieldPath + ) ) case Left(e) if e.onlyContainsMissing => fieldMetadata.default match { diff --git a/core/src/main/scala/jurate/ConfigReader.scala b/core/src/main/scala/jurate/ConfigReader.scala index a3ac186..4ad7859 100644 --- a/core/src/main/scala/jurate/ConfigReader.scala +++ b/core/src/main/scala/jurate/ConfigReader.scala @@ -4,46 +4,57 @@ import jurate.utils.FieldPath import scala.annotation.tailrec -/** Abstraction for reading configuration from environment variables and system properties. +/** Abstraction for reading configuration from environment variables and system + * properties. * * ConfigReader provides methods to read from different configuration sources. * Use [[LiveConfigReader]] for production and [[MapConfigReader]] for testing. */ trait ConfigReader { + /** Reads an environment variable by name. * - * @param name the environment variable name - * @return Some(value) if found, None otherwise + * @param name + * the environment variable name + * @return + * Some(value) if found, None otherwise */ def readEnv(name: String): Option[String] /** Reads a system property by name. * - * @param name the system property name - * @return Some(value) if found, None otherwise + * @param name + * the system property name + * @return + * Some(value) if found, None otherwise */ def readProp(name: String): Option[String] /** Attempts to read configuration from annotations in left-to-right order, - * returning the first successful read. This enables fallback patterns like: - * @env("PRIMARY") @env("FALLBACK") @prop("default.value") - */ + * returning the first successful read. This enables fallback patterns like: + * @env("PRIMARY") @env("FALLBACK") @prop("default.value"). It returns pair of + * annotation that provided the value and the raw value. + */ private[jurate] def read( fieldPath: FieldPath, annotations: Seq[ConfigAnnotation] - ): Either[ConfigError, String] = { + ): Either[ConfigError, (ConfigAnnotation, String)] = { @tailrec - def iterate(values: Seq[ConfigAnnotation]): Either[ConfigError, String] = + def iterate( + values: Seq[ConfigAnnotation] + ): Either[ConfigError, (ConfigAnnotation, String)] = if values.isEmpty then Left(ConfigError.missing(fieldPath, annotations)) else - val maybeValue = values.head match + val annotation = values.head + + val maybeValue = annotation match case env(name) => readEnv(name) case prop(name) => readProp(name) maybeValue match - case Some(value) => Right(value) + case Some(value) => Right((annotation, value)) case None => iterate(values.tail) iterate(annotations) @@ -56,8 +67,10 @@ trait ConfigReader { * * Provides a fluent builder API for setting up test configuration values. * - * @param envs map of environment variable names to values - * @param props map of system property names to values + * @param envs + * map of environment variable names to values + * @param props + * map of system property names to values */ case class MapConfigReader( private val envs: Map[String, String], @@ -65,32 +78,42 @@ case class MapConfigReader( ) extends ConfigReader: /** Reads an environment variable from the internal map. * - * @param name the environment variable name - * @return Some(value) if found, None otherwise + * @param name + * the environment variable name + * @return + * Some(value) if found, None otherwise */ def readEnv(name: String): Option[String] = envs.get(name) /** Reads a system property from the internal map. * - * @param name the system property name - * @return Some(value) if found, None otherwise + * @param name + * the system property name + * @return + * Some(value) if found, None otherwise */ def readProp(name: String): Option[String] = props.get(name) /** Adds or updates an environment variable in the configuration. * - * @param name the environment variable name - * @param value the value to set - * @return a new MapConfigReader with the updated environment variable + * @param name + * the environment variable name + * @param value + * the value to set + * @return + * a new MapConfigReader with the updated environment variable */ def onEnv(name: String, value: String): MapConfigReader = MapConfigReader(envs.updated(name, value), props) /** Adds or updates a system property in the configuration. * - * @param name the system property name - * @param value the value to set - * @return a new MapConfigReader with the updated system property + * @param name + * the system property name + * @param value + * the value to set + * @return + * a new MapConfigReader with the updated system property */ def onProp(name: String, value: String): MapConfigReader = MapConfigReader(envs, props.updated(name, value)) @@ -101,9 +124,12 @@ private object LiveConfigReader extends ConfigReader { } object ConfigReader { + /** Creates an empty MapConfigReader for testing. * - * @return a new MapConfigReader with no environment variables or system properties set + * @return + * a new MapConfigReader with no environment variables or system properties + * set */ def mocked: MapConfigReader = MapConfigReader(Map.empty, Map.empty) } diff --git a/core/src/main/scala/jurate/ErrorPrinter.scala b/core/src/main/scala/jurate/ErrorPrinter.scala index 65a8db5..b50f694 100644 --- a/core/src/main/scala/jurate/ErrorPrinter.scala +++ b/core/src/main/scala/jurate/ErrorPrinter.scala @@ -2,14 +2,18 @@ package jurate /** Trait for formatting ConfigError instances into human-readable strings. * - * Implementations can provide different formatting strategies such as plain text, - * tables, or JSON. See [[jurate.printers.DefaultPrinter]] and [[jurate.printers.TablePrinter]]. + * Implementations can provide different formatting strategies such as plain + * text, tables, or JSON. See [[jurate.printers.DefaultPrinter]] and + * [[jurate.printers.TablePrinter]]. */ trait ErrorPrinter { + /** Formats a configuration error into a string. * - * @param error the configuration error to format - * @return a formatted string representation of the error + * @param error + * the configuration error to format + * @return + * a formatted string representation of the error */ def format(error: ConfigError): String } diff --git a/core/src/main/scala/jurate/Secret.scala b/core/src/main/scala/jurate/Secret.scala index 80875a7..1944c5c 100644 --- a/core/src/main/scala/jurate/Secret.scala +++ b/core/src/main/scala/jurate/Secret.scala @@ -4,52 +4,63 @@ import jurate.utils.Hasher /** A wrapper for sensitive values that masks them in toString. * - * Secret is useful for wrapping passwords, API keys, and other sensitive configuration - * values. The actual value is never exposed in toString output, only a short hash. + * Secret is useful for wrapping passwords, API keys, and other sensitive + * configuration values. The actual value is never exposed in toString output, + * only a short hash. * - * @param value the sensitive value to wrap - * @tparam V the type of the secret value + * @param value + * the sensitive value to wrap + * @tparam V + * the type of the secret value */ case class Secret[V](value: V) extends AnyVal { /** Returns the SHA-256 hash of the secret value. * - * @return a hex-encoded SHA-256 hash of the value + * @return + * a hex-encoded SHA-256 hash of the value */ def hash: String = Hasher.hash(value) /** Returns the first 10 characters of the hash. * - * @return a truncated hash for display purposes + * @return + * a truncated hash for display purposes */ def shortHash: String = hash.take(10) /** Returns a string representation that masks the actual value. * - * @return a string showing only the short hash, not the actual value + * @return + * a string showing only the short hash, not the actual value */ override def toString: String = s"Secret($shortHash)" } /** Companion object for Secret. */ object Secret { + /** Creates a new Secret wrapping the given value. * - * @param value the value to wrap - * @tparam V the type of the value - * @return a new Secret instance + * @param value + * the value to wrap + * @tparam V + * the type of the value + * @return + * a new Secret instance */ def apply[V](value: V): Secret[V] = new Secret(value) /** Provides a ConfigDecoder for Secret[V] given a ConfigDecoder for V. * - * @tparam V the type of the secret value - * @param decoder the decoder for the underlying value type - * @return a ConfigDecoder that wraps decoded values in Secret + * @tparam V + * the type of the secret value + * @param decoder + * the decoder for the underlying value type + * @return + * a ConfigDecoder that wraps decoded values in Secret */ given [V: ConfigDecoder] => ConfigDecoder[Secret[V]] = ConfigDecoder[V].contramap(Secret.apply) } - - diff --git a/core/src/main/scala/jurate/package.scala b/core/src/main/scala/jurate/package.scala index f833c7f..23a6998 100644 --- a/core/src/main/scala/jurate/package.scala +++ b/core/src/main/scala/jurate/package.scala @@ -59,7 +59,7 @@ given ConfigDecoder[Short] with { ctx.fieldPath, "can't decode short value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -76,7 +76,7 @@ given ConfigDecoder[Int] with { ctx.fieldPath, s"can't decode integer", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -93,7 +93,7 @@ given ConfigDecoder[Long] with { ctx.fieldPath, s"can't decode long", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -110,7 +110,7 @@ given ConfigDecoder[Float] with { ctx.fieldPath, s"can't decode float value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -127,7 +127,7 @@ given ConfigDecoder[Double] with { ctx.fieldPath, s"can't decode double value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -144,7 +144,7 @@ given ConfigDecoder[BigDecimal] with { ctx.fieldPath, s"can't decode BigDecimal value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -161,7 +161,7 @@ given ConfigDecoder[BigInt] with { ctx.fieldPath, s"can't decode BigInt value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -178,7 +178,7 @@ given ConfigDecoder[InetAddress] with { ctx.fieldPath, s"can't decode InetAddress value: ${e.getMessage}", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -195,7 +195,7 @@ given ConfigDecoder[UUID] with { ctx.fieldPath, s"can't decode UUID value: ${e.getMessage}", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -212,7 +212,7 @@ given ConfigDecoder[Path] with { ctx.fieldPath, s"can't decode Path value: ${e.getMessage}", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -240,7 +240,7 @@ given ConfigDecoder[Boolean] with { ctx.fieldPath, s"can't decode boolean value", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -258,7 +258,7 @@ given ConfigDecoder[URI] with { ctx.fieldPath, s"can't decode URI value: ${e.getMessage}", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -276,7 +276,7 @@ given ConfigDecoder[Duration] with { ctx.fieldPath, s"can't decode Duration value: ${e.getMessage}", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } @@ -289,15 +289,16 @@ given ConfigDecoder[FiniteDuration] = ConfigDecoder[Duration].emap { ctx.fieldPath, s"expected finite duration but got infinite", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) } /** Loads configuration into a case class instance. * - * This is the main entry point for loading configuration. It requires a ConfigLoader - * instance (typically auto-derived) and a ConfigReader (typically the live reader). + * This is the main entry point for loading configuration. It requires a + * ConfigLoader instance (typically auto-derived) and a ConfigReader (typically + * the live reader). * * Example: * {{{ @@ -309,10 +310,14 @@ given ConfigDecoder[FiniteDuration] = ConfigDecoder[Duration].emap { * val config: Either[ConfigError, DatabaseConfig] = load[DatabaseConfig] * }}} * - * @tparam C the configuration type to load - * @param loader the ConfigLoader instance, typically auto-derived - * @param reader the ConfigReader to use for reading configuration sources - * @return Either a ConfigError or the loaded configuration instance + * @tparam C + * the configuration type to load + * @param loader + * the ConfigLoader instance, typically auto-derived + * @param reader + * the ConfigReader to use for reading configuration sources + * @return + * Either a ConfigError or the loaded configuration instance */ def load[C](using @implicitNotFound( @@ -321,9 +326,10 @@ def load[C](using reader: ConfigReader ): Either[ConfigError, C] = loader.load(reader, FieldPath.root) -/** Default ConfigReader that reads from system environment variables and properties. - * This instance is automatically available when you import jurate.{*, given}. - * - * For testing, use ConfigReader.mocked to provide custom values. - */ +/** Default ConfigReader that reads from system environment variables and + * properties. This instance is automatically available when you import + * jurate.{*, given}. + * + * For testing, use ConfigReader.mocked to provide custom values. + */ given ConfigReader = LiveConfigReader diff --git a/core/src/main/scala/jurate/printers/DefaultPrinter.scala b/core/src/main/scala/jurate/printers/DefaultPrinter.scala index daabf0b..69730a1 100644 --- a/core/src/main/scala/jurate/printers/DefaultPrinter.scala +++ b/core/src/main/scala/jurate/printers/DefaultPrinter.scala @@ -4,13 +4,17 @@ import jurate.{ConfigError, ErrorPrinter} /** A simple error printer that uses the default error message format. * - * This printer returns the basic string representation of configuration errors. + * This printer returns the basic string representation of configuration + * errors. */ object DefaultPrinter extends ErrorPrinter { + /** Formats a configuration error using its default message format. * - * @param error the configuration error to format - * @return the error's default message string + * @param error + * the configuration error to format + * @return + * the error's default message string */ override def format(error: ConfigError): String = error.getMessage } diff --git a/core/src/main/scala/jurate/printers/TablePrinter.scala b/core/src/main/scala/jurate/printers/TablePrinter.scala index 95c8af0..95073e0 100644 --- a/core/src/main/scala/jurate/printers/TablePrinter.scala +++ b/core/src/main/scala/jurate/printers/TablePrinter.scala @@ -4,23 +4,25 @@ import jurate.* /** An error printer that formats configuration errors as ASCII tables. * - * This printer creates well-formatted tables with columns for field names, sources - * (environment variables or system properties), and error messages. It includes - * word wrapping and column width management for readable output. + * This printer creates well-formatted tables with columns for field names, + * sources (environment variables or system properties), and error messages. It + * includes word wrapping and column width management for readable output. */ object TablePrinter extends ErrorPrinter { private val FieldHeader = "Field" private val SourceHeader = "Source" private val MessageHeader = "Message" - + private case class TableRow(field: String, source: String, message: String) private case class ColumnWidths(field: Int, source: Int, message: Int) /** Formats a configuration error as an ASCII table. * - * @param error the configuration error to format - * @return a formatted table string showing all error reasons + * @param error + * the configuration error to format + * @return + * a formatted table string showing all error reasons */ override def format(error: ConfigError): String = { val rows = error.reasons.map(reasonToRow) @@ -35,11 +37,10 @@ object TablePrinter extends ErrorPrinter { message = "Missing configuration value" ) - case Invalid(receivedValue, detail, fieldPath, annotationOpt) => + case Invalid(receivedValue, detail, fieldPath, annotation) => TableRow( field = fieldPath.dottedPath, - source = - annotationOpt.map(ann => formatAnnotations(Seq(ann))).getOrElse(""), + source = formatAnnotations(Seq(annotation)), message = s"Invalid value: $detail, received: '$receivedValue'" ) @@ -78,7 +79,7 @@ object TablePrinter extends ErrorPrinter { } private def calculateColumnWidths(rows: List[TableRow]): ColumnWidths = { - + val MaxFieldWidth = 60 val MaxSourceWidth = 80 val MaxMessageWidth = 150 diff --git a/core/src/main/scala/jurate/utils/EnumConfigDecoder.scala b/core/src/main/scala/jurate/utils/EnumConfigDecoder.scala index e21e28f..b80afa6 100644 --- a/core/src/main/scala/jurate/utils/EnumConfigDecoder.scala +++ b/core/src/main/scala/jurate/utils/EnumConfigDecoder.scala @@ -14,6 +14,6 @@ private[jurate] final class EnumConfigDecoder[C]( ctx.fieldPath, s"couldn't find case for enum $enumName (available values: ${values.mkString(", ")})", raw, - ctx.annotations.headOption + ctx.evaluatedAnnotation ) ) diff --git a/core/src/main/scala/jurate/utils/FieldPath.scala b/core/src/main/scala/jurate/utils/FieldPath.scala index 5d53b4d..79e536d 100644 --- a/core/src/main/scala/jurate/utils/FieldPath.scala +++ b/core/src/main/scala/jurate/utils/FieldPath.scala @@ -2,16 +2,21 @@ package jurate.utils /** Represents a hierarchical path to a configuration field. * - * FieldPath is used to track the location of configuration values in nested structures, - * which helps provide detailed error messages showing exactly which field failed to load. + * FieldPath is used to track the location of configuration values in nested + * structures, which helps provide detailed error messages showing exactly + * which field failed to load. * - * @param values the list of path segments from root to leaf + * @param values + * the list of path segments from root to leaf */ case class FieldPath(values: List[String]) { + /** Appends a segment to the path. * - * @param value the path segment to append - * @return a new FieldPath with the appended segment + * @param value + * the path segment to append + * @return + * a new FieldPath with the appended segment */ def /(value: String): FieldPath = { assert(value.trim.nonEmpty) @@ -20,35 +25,44 @@ case class FieldPath(values: List[String]) { /** Converts the path to a dot-separated string. * - * @return the path as a string like "parent.child.field" + * @return + * the path as a string like "parent.child.field" */ def dottedPath: String = values.mkString(".") } /** Companion object for FieldPath. */ object FieldPath { + /** Creates an empty FieldPath representing the root. * - * @return an empty FieldPath + * @return + * an empty FieldPath */ def root: FieldPath = FieldPath(Nil) /** Creates a FieldPath from a varargs list of segments. * - * @param values the path segments - * @return a new FieldPath + * @param values + * the path segments + * @return + * a new FieldPath */ def apply(values: String*): FieldPath = new FieldPath(values.toList) /** String interpolator extension for creating FieldPaths. * - * Allows creating paths using string interpolation syntax like: path"parent.child.field" + * Allows creating paths using string interpolation syntax like: + * path"parent.child.field" */ extension (sc: StringContext) { + /** Creates a FieldPath from a dot-separated string. * - * @param args interpolation arguments (unused) - * @return a new FieldPath parsed from the string + * @param args + * interpolation arguments (unused) + * @return + * a new FieldPath parsed from the string */ def path(args: Any*): FieldPath = { val pathString = sc.parts.mkString diff --git a/core/src/main/scala/jurate/utils/Hasher.scala b/core/src/main/scala/jurate/utils/Hasher.scala index be00ee8..f68ae51 100644 --- a/core/src/main/scala/jurate/utils/Hasher.scala +++ b/core/src/main/scala/jurate/utils/Hasher.scala @@ -7,7 +7,7 @@ private[jurate] object Hasher { def hash[T](value: T): String = { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(value.toString.getBytes("UTF-8")) - + hash.map("%02x".format(_)).mkString } diff --git a/core/src/main/scala/jurate/utils/Macros.scala b/core/src/main/scala/jurate/utils/Macros.scala index b77e67a..bfedb8c 100644 --- a/core/src/main/scala/jurate/utils/Macros.scala +++ b/core/src/main/scala/jurate/utils/Macros.scala @@ -26,7 +26,7 @@ private[jurate] object Macros { optExpr match case Some(e) => '{ Some($e) } case None => '{ None } - + private[jurate] inline def isSingletonEnum[A] = ${ isSingletonEnumImpl[A] } @@ -63,7 +63,7 @@ private[jurate] object Macros { Select.unique(companion, valuesMethod.name).asExprOf[Array[C]] } - + private[jurate] inline def decoderError[T] = ${ decoderErrorImpl[T] } @@ -77,7 +77,7 @@ private[jurate] object Macros { report.errorAndAbort(s"Couldn't find decoder for type $typeName") } - + private[jurate] inline def fieldMetadata[A] = ${ fieldMetadataImpl[A] } @@ -141,7 +141,7 @@ private[jurate] object Macros { '{ Map.from($values) } - + private[jurate] inline def typeName[A] = ${ typeNameImpl[A] } diff --git a/core/src/test/scala/jurate/caseclass.scala b/core/src/test/scala/jurate/caseclass.scala index c758bb5..55bc5e6 100644 --- a/core/src/test/scala/jurate/caseclass.scala +++ b/core/src/test/scala/jurate/caseclass.scala @@ -126,7 +126,7 @@ class CaseClassSpec extends AnyFlatSpec with Matchers with EitherValues { path"httpConfig.port", "can't decode integer", "Bad", - Some(env("PORT")) + env("PORT") ) ) } diff --git a/core/src/test/scala/jurate/enums.scala b/core/src/test/scala/jurate/enums.scala index 9c6be22..9a2d01e 100644 --- a/core/src/test/scala/jurate/enums.scala +++ b/core/src/test/scala/jurate/enums.scala @@ -76,10 +76,10 @@ class EnumsSpec extends AnyFlatSpec with Matchers with EitherValues { .find(_.toString().toLowerCase() == rawLowercased) .toRight( ConfigError.invalid( - path"protocol", + ctx.fieldPath, "Couldn't find right value for Protocol", raw, - Some(prop("protocol")) + ctx.evaluatedAnnotation ) ) @@ -108,7 +108,7 @@ class EnumsSpec extends AnyFlatSpec with Matchers with EitherValues { path"bugSeverity", "couldn't find case for enum Severity (available values: Error, Warning)", "Bad", - Some(env("SEV")) + env("SEV") ) ) } diff --git a/core/src/test/scala/jurate/printer.scala b/core/src/test/scala/jurate/printer.scala index 7753a67..0e28bec 100644 --- a/core/src/test/scala/jurate/printer.scala +++ b/core/src/test/scala/jurate/printer.scala @@ -34,7 +34,7 @@ class ErrorPrinterSpec extends AnyFlatSpec with Matchers with EitherValues { path"port", "can't decode", "bad", - Some(env("TIMEOUT")) + env("TIMEOUT") ) val error3 = ConfigError.other(path"custom", "custom message") val combined = error1 ++ error2 ++ error3 diff --git a/core/src/test/scala/jurate/simple.scala b/core/src/test/scala/jurate/simple.scala index 890c39e..83cd19f 100644 --- a/core/src/test/scala/jurate/simple.scala +++ b/core/src/test/scala/jurate/simple.scala @@ -71,7 +71,7 @@ class SimpleSpec extends AnyFlatSpec with Matchers with EitherValues { FieldPath("port"), "can't decode integer", "bad", - Some(env("PORT")) + env("PORT") ) ) } diff --git a/core/src/test/scala/jurate/utils/HasherSpec.scala b/core/src/test/scala/jurate/utils/HasherSpec.scala index 06faf5e..d6a3ce1 100644 --- a/core/src/test/scala/jurate/utils/HasherSpec.scala +++ b/core/src/test/scala/jurate/utils/HasherSpec.scala @@ -4,10 +4,14 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class HasherSpec extends AnyFlatSpec with Matchers { - + "Hasher" should "return full SHA-256 hash as hex string" in { - Hasher.hash("qwerty") should be("65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5") - Hasher.hash("") should be("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + Hasher.hash("qwerty") should be( + "65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5" + ) + Hasher.hash("") should be( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) } it should "return consistent hash for same input" in { diff --git a/project/plugins.sbt b/project/plugins.sbt index df9cdf7..ad9159b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") - +addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.8.0" ) addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2") addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.3.1") addSbtPlugin("com.github.sbt" % "sbt-dynver" % "5.1.1")