diff --git a/README.md b/README.md index c7a3a4c..486ceb3 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,18 @@ Configuration library for Scala 3. ## Usage -This library is not yet published. +This artifact is available in the Garrity Software Maven repository. ```scala -object GS { - val Config: ModuleID = - "gs" %% "gs-config-v0" % "0.1.0" -} +externalResolvers += + "Garrity Software Releases" at "https://maven.garrity.co/releases" + +val GsConfig: ModuleID = + "gs" %% "gs-config-v0" % "0.1.0" ``` + +TODO: + +- Refactor -- move current config implementations to "raw" implementations. +- Create the notion of an audited configuration. +- Create the notion of an audited configuration with a list of sources. diff --git a/build.sbt b/build.sbt index a9e3bc7..10d2ff1 100644 --- a/build.sbt +++ b/build.sbt @@ -101,8 +101,8 @@ lazy val publishSettings = Seq( publishTo := { val repo = "https://maven.garrity.co/" if (SelectedVersion.endsWith("SNAPSHOT")) - Some("snapshots" at repo + "snapshots") - else Some("releases" at repo + "releases") + Some("Garrity Software Maven" at repo + "snapshots") + else Some("Garrity Software Maven" at repo + "releases") } ) diff --git a/src/main/scala/gs/config/AuditedConfiguration.scala b/src/main/scala/gs/config/AuditedConfiguration.scala new file mode 100644 index 0000000..138e639 --- /dev/null +++ b/src/main/scala/gs/config/AuditedConfiguration.scala @@ -0,0 +1,88 @@ +package gs.config + +import cats.effect.Sync +import cats.syntax.all.* +import gs.config.audit.ConfigManifest +import gs.config.audit.ConfigQueryResult +import gs.config.source.ConfigSource + +final class AuditedConfiguration[F[_]: Sync]( + val sources: List[ConfigSource[F]], + val manifest: ConfigManifest[F] +) extends BaseConfiguration[F]: + import AuditedConfiguration.Acc + + override def getValue[A: Configurable]( + key: ConfigKey[A] + ): F[Either[ConfigError, A]] = + findAndParse(key) + .map { acc => + acc -> ( + acc.result match + case None => handleMissingValue(key) + case Some(result) => parse(key, result, acc.lastAttemptedSource) + ) + } + .flatMap { case (acc, result) => audit(key, result, acc).as(result) } + + private def findAndParse[A: Configurable]( + key: ConfigKey[A] + ): F[Acc] = + sources.foldLeft(Acc.empty[F]) { + ( + acc, + src + ) => + acc.flatMap { data => + if data.hasResult then Sync[F].pure(data) + else + src.getValue(key).map { + case None => data.appendSource(src.name) + case Some(v) => data.appendSource(src.name).withResult(v) + } + } + } + + private def audit( + key: ConfigKey[?], + result: Either[ConfigError, ?], + acc: Acc + ): F[Unit] = + result match + case Left(error) => + manifest.record( + name = key.name, + queryResult = ConfigQueryResult.Failure( + sources = acc.sources.toList, + error = error + ) + ) + case Right(_) => + manifest.record( + name = key.name, + queryResult = ConfigQueryResult.Success( + source = acc.lastAttemptedSource, + rawValue = acc.result.getOrElse("") + ) + ) + +object AuditedConfiguration: + + private case class Acc( + sources: Vector[String], + result: Option[String] + ): + def hasResult: Boolean = result.isDefined + + def lastAttemptedSource: String = sources.lastOption.getOrElse("") + + def withResult(r: String): Acc = + if hasResult then this else this.copy(sources, Some(r)) + + def appendSource(s: String): Acc = + if hasResult then this else this.copy(sources.appended(s), result) + + private object Acc: + def empty[F[_]: Sync]: F[Acc] = Sync[F].pure(Acc(Vector.empty, None)) + +end AuditedConfiguration diff --git a/src/main/scala/gs/config/BaseConfiguration.scala b/src/main/scala/gs/config/BaseConfiguration.scala new file mode 100644 index 0000000..c951ee5 --- /dev/null +++ b/src/main/scala/gs/config/BaseConfiguration.scala @@ -0,0 +1,32 @@ +package gs.config + +/** Base class for most [[Configuration]] implementations. Provides standard + * support for properly handling default values and parsing strings to the + * correct type, returning the correct errors. + */ +abstract class BaseConfiguration[F[_]] extends Configuration[F]: + + protected def handleMissingValue[A]( + key: ConfigKey[A] + ): Either[ConfigError, A] = + key match + case ConfigKey.WithDefaultValue(_, defaultValue) => + Right(defaultValue) + case _ => + Left(ConfigError.MissingValue(key.name)) + + protected def parse[A: Configurable]( + key: ConfigKey[A], + raw: String, + sourceName: String + ): Either[ConfigError, A] = + Configurable[A].parse(raw) match + case None => + Left( + ConfigError.CannotParseValue( + configName = key.name, + candidateValue = raw, + sourceName = sourceName + ) + ) + case Some(parsed) => Right(parsed) diff --git a/src/main/scala/gs/config/ConfigError.scala b/src/main/scala/gs/config/ConfigError.scala index 47599fc..daa0d4b 100644 --- a/src/main/scala/gs/config/ConfigError.scala +++ b/src/main/scala/gs/config/ConfigError.scala @@ -1,7 +1,5 @@ package gs.config -import gs.config.audit.ConfigSource - /** Error hierarchy for the `gs-config` library. Indicates that something went * wrong while attempting to load configuration values. */ @@ -11,25 +9,25 @@ object ConfigError: /** Attempted to retreive the value for some [[ConfigKey]], but no value could * be found. * - * @param name + * @param configName * The name of the configuration value which was not found. */ - case class MissingValue(name: ConfigName) extends ConfigError + case class MissingValue(configName: ConfigName) extends ConfigError /** Found a value for some [[ConfigKey]], but that value could not be parsed * as the appropriate type. * - * @param name + * @param configName * The name of the configuration value which could not be parsed. - * @param candidate + * @param candidateValue * The raw value that could not be parsed. * @param source * The [[ConfigSource]] which provided the candidate value. */ case class CannotParseValue( - name: ConfigName, - candidate: String, - source: ConfigSource + configName: ConfigName, + candidateValue: String, + sourceName: String ) extends ConfigError end ConfigError diff --git a/src/main/scala/gs/config/Configuration.scala b/src/main/scala/gs/config/Configuration.scala index da31c38..e9561e9 100644 --- a/src/main/scala/gs/config/Configuration.scala +++ b/src/main/scala/gs/config/Configuration.scala @@ -1,24 +1,4 @@ package gs.config -import gs.config.audit.ConfigSource - -/** Interface for loading configuration values. - * - * This interface is **not** intended to be used with sensitive information. Do - * not use this interface for loading passwords, encryption keys, or any other - * sensitive information. - */ trait Configuration[F[_]]: - /** Retrieve the value for the specified key. - * - * @param key - * The key which defines the piece of configuration. - * @return - * The value, or an error if no value can be retrieved. - */ def getValue[A: Configurable](key: ConfigKey[A]): F[Either[ConfigError, A]] - - /** @return - * The backing source for this configuration. - */ - def source: ConfigSource diff --git a/src/main/scala/gs/config/audit/ConfigManifest.scala b/src/main/scala/gs/config/audit/ConfigManifest.scala index 5aa4359..cba5f65 100644 --- a/src/main/scala/gs/config/audit/ConfigManifest.scala +++ b/src/main/scala/gs/config/audit/ConfigManifest.scala @@ -2,6 +2,7 @@ package gs.config.audit import cats.effect.Ref import cats.effect.Sync +import cats.syntax.all.* import gs.config.ConfigName trait ConfigManifest[F[_]]: @@ -12,44 +13,66 @@ trait ConfigManifest[F[_]]: * @return * The current state of this manifest. */ - def snapshot(): F[Map[ConfigName, List[ConfigQuery]]] + def snapshot(): F[Map[ConfigName, List[ConfigQueryResult]]] /** Record a query for some [[ConfigName]] in this manifest. * * @param name * The [[ConfigName]] that was queried. - * @param query - * The [[ConfigQuery]] describing the result. + * @param queryResult + * The [[ConfigQueryResult]] describing the result. * @return * Side-effect indicating that the query was recorded. */ def record( name: ConfigName, - query: ConfigQuery + queryResult: ConfigQueryResult ): F[Unit] object ConfigManifest: + /** Instantiate a new, empty, standard manifest. + */ + def standard[F[_]: Sync]: F[ConfigManifest[F]] = + Standard.initialize[F] + + /** Standard implementation of [[ConfigManifest]]. Collects an in-memory + * collection to audit configuration access. + * + * @param manifest + * The underlying manifest. + */ final class Standard[F[_]: Sync] private ( - private val manifest: Ref[F, Map[ConfigName, List[ConfigQuery]]] + private val manifest: Ref[F, Map[ConfigName, List[ConfigQueryResult]]] ) extends ConfigManifest[F]: - override def snapshot(): F[Map[ConfigName, List[ConfigQuery]]] = + override def snapshot(): F[Map[ConfigName, List[ConfigQueryResult]]] = manifest.get override def record( name: ConfigName, - query: ConfigQuery + query: ConfigQueryResult ): F[Unit] = manifest.update(addQueryToName(name, query, _)) private def addQueryToName( name: ConfigName, - query: ConfigQuery, - state: Map[ConfigName, List[ConfigQuery]] - ): Map[ConfigName, List[ConfigQuery]] = + query: ConfigQueryResult, + state: Map[ConfigName, List[ConfigQueryResult]] + ): Map[ConfigName, List[ConfigQueryResult]] = state.get(name) match case Some(queries) => state.updated(name, queries ++ List(query)) case None => state + (name -> List(query)) + object Standard: + + /** Instantiate a new, empty, standard manifest. + */ + def initialize[F[_]: Sync]: F[ConfigManifest[F]] = + Ref + .of(Map.empty[ConfigName, List[ConfigQueryResult]]) + .map(m => new Standard[F](m)) + + end Standard + end ConfigManifest diff --git a/src/main/scala/gs/config/audit/ConfigQuery.scala b/src/main/scala/gs/config/audit/ConfigQueryResult.scala similarity index 75% rename from src/main/scala/gs/config/audit/ConfigQuery.scala rename to src/main/scala/gs/config/audit/ConfigQueryResult.scala index b9f0f12..2efb556 100644 --- a/src/main/scala/gs/config/audit/ConfigQuery.scala +++ b/src/main/scala/gs/config/audit/ConfigQueryResult.scala @@ -5,9 +5,9 @@ import gs.config.ConfigError /** Describes queries used to find configuration. Used for auditing purposes and * is captured by [[ConfigManifest]]. */ -sealed trait ConfigQuery +sealed trait ConfigQueryResult -object ConfigQuery: +object ConfigQueryResult: /** Represents a query for some configuration that completed successfully. * @@ -16,10 +16,10 @@ object ConfigQuery: * @param rawValue * The raw value that the source returned. */ - case class SuccessfulQuery( - source: ConfigSource, + case class Success( + source: String, rawValue: String - ) extends ConfigQuery + ) extends ConfigQueryResult /** Represents a query for some configuration that failed. * @@ -29,9 +29,9 @@ object ConfigQuery: * @param error * The reason why this query failed. */ - case class FailedQuery( - sources: List[ConfigSource], + case class Failure( + sources: List[String], error: ConfigError - ) + ) extends ConfigQueryResult -end ConfigQuery +end ConfigQueryResult diff --git a/src/main/scala/gs/config/audit/ConfigSource.scala b/src/main/scala/gs/config/audit/ConfigSource.scala deleted file mode 100644 index d5e94ba..0000000 --- a/src/main/scala/gs/config/audit/ConfigSource.scala +++ /dev/null @@ -1,26 +0,0 @@ -package gs.config.audit - -/** Represents some _source_ of configuration data. This might be the system - * environment, a properties file, application runtime properties, or some - * other source. - * - * This type is used for auditing purposes. Each [[gs.config.Configuration]] - * has a source, and the source which retrieves (or fails to retrieve) some - * value is recorded in the [[ConfigManifest]]. - */ -opaque type ConfigSource = String - -object ConfigSource: - - /** Instantiate a new `ConfigSource`. - * - * @param source - * The name of the source. - * @return - * The new instance. - */ - def apply(source: String): ConfigSource = source - - given CanEqual[ConfigSource, ConfigSource] = CanEqual.derived - -end ConfigSource diff --git a/src/main/scala/gs/config/source/ConfigSource.scala b/src/main/scala/gs/config/source/ConfigSource.scala new file mode 100644 index 0000000..3488096 --- /dev/null +++ b/src/main/scala/gs/config/source/ConfigSource.scala @@ -0,0 +1,48 @@ +package gs.config.source + +import cats.Applicative +import cats.effect.Sync +import gs.config.ConfigKey + +/** Interface for loading raw configuration values. + * + * This interface is **not** intended to be used with sensitive information. Do + * not use this interface for loading passwords, encryption keys, or any other + * sensitive information. + */ +trait ConfigSource[F[_]]: + /** Retrieve the value for the specified key. + * + * @param key + * The key which defines the piece of configuration. + * @return + * The raw value, or an error if no value can be retrieved. + */ + def getValue(key: ConfigKey[?]): F[Option[String]] + + /** @return + * The name of this source. + */ + def name: String + +object ConfigSource: + + def inMemory[F[_]: Applicative]( + configs: Map[String, String] + ): ConfigSource[F] = + new MemoryConfigSource[F](configs) + + def environment[F[_]: Sync]: ConfigSource[F] = + new EnvironmentConfigSource[F] + + def empty[F[_]: Applicative]: ConfigSource[F] = + new Empty[F] + + final class Empty[F[_]: Applicative] extends ConfigSource[F]: + + override def getValue(key: ConfigKey[?]): F[Option[String]] = + Applicative[F].pure(None) + + override val name: String = "empty" + +end ConfigSource diff --git a/src/main/scala/gs/config/source/EnvironmentConfigSource.scala b/src/main/scala/gs/config/source/EnvironmentConfigSource.scala new file mode 100644 index 0000000..03b58e3 --- /dev/null +++ b/src/main/scala/gs/config/source/EnvironmentConfigSource.scala @@ -0,0 +1,16 @@ +package gs.config.source + +import cats.effect.Sync +import gs.config.ConfigKey + +/** Environment variable implementation of [[ConfigSource]]. Pulls all values + * from the system environment that was passed to this process. + */ +final class EnvironmentConfigSource[F[_]: Sync] extends ConfigSource[F]: + + override def getValue( + key: ConfigKey[?] + ): F[Option[String]] = + Sync[F].delay(sys.env.get(key.name.toEnvironmentVariable())) + + override val name: String = "environment" diff --git a/src/main/scala/gs/config/source/MemoryConfigSource.scala b/src/main/scala/gs/config/source/MemoryConfigSource.scala new file mode 100644 index 0000000..4c40182 --- /dev/null +++ b/src/main/scala/gs/config/source/MemoryConfigSource.scala @@ -0,0 +1,27 @@ +package gs.config.source + +import cats.Applicative +import gs.config.ConfigKey +import java.util.UUID + +/** In-memory implementation based on an immutable map. + * + * The raw value of the [[ConfigName]] is used for lookups (`toRawString()`). + * + * @param configs + * The configurations to provide. + */ +final class MemoryConfigSource[F[_]: Applicative]( + private val configs: Map[String, String] +) extends ConfigSource[F]: + val id: UUID = UUID.randomUUID() + + override def getValue( + key: ConfigKey[?] + ): F[Option[String]] = + Applicative[F].pure( + configs + .get(key.name.toRawString()) + ) + + override lazy val name: String = s"in-memory-$id"