More rework and expansion, have a complete audited implementation. Need more docs.
This commit is contained in:
parent
5a977acacb
commit
d51890f5bf
12 changed files with 274 additions and 81 deletions
15
README.md
15
README.md
|
@ -8,11 +8,18 @@ Configuration library for Scala 3.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
This library is not yet published.
|
This artifact is available in the Garrity Software Maven repository.
|
||||||
|
|
||||||
```scala
|
```scala
|
||||||
object GS {
|
externalResolvers +=
|
||||||
val Config: ModuleID =
|
"Garrity Software Releases" at "https://maven.garrity.co/releases"
|
||||||
|
|
||||||
|
val GsConfig: ModuleID =
|
||||||
"gs" %% "gs-config-v0" % "0.1.0"
|
"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.
|
||||||
|
|
|
@ -101,8 +101,8 @@ lazy val publishSettings = Seq(
|
||||||
publishTo := {
|
publishTo := {
|
||||||
val repo = "https://maven.garrity.co/"
|
val repo = "https://maven.garrity.co/"
|
||||||
if (SelectedVersion.endsWith("SNAPSHOT"))
|
if (SelectedVersion.endsWith("SNAPSHOT"))
|
||||||
Some("snapshots" at repo + "snapshots")
|
Some("Garrity Software Maven" at repo + "snapshots")
|
||||||
else Some("releases" at repo + "releases")
|
else Some("Garrity Software Maven" at repo + "releases")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
88
src/main/scala/gs/config/AuditedConfiguration.scala
Normal file
88
src/main/scala/gs/config/AuditedConfiguration.scala
Normal file
|
@ -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
|
32
src/main/scala/gs/config/BaseConfiguration.scala
Normal file
32
src/main/scala/gs/config/BaseConfiguration.scala
Normal file
|
@ -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)
|
|
@ -1,7 +1,5 @@
|
||||||
package gs.config
|
package gs.config
|
||||||
|
|
||||||
import gs.config.audit.ConfigSource
|
|
||||||
|
|
||||||
/** Error hierarchy for the `gs-config` library. Indicates that something went
|
/** Error hierarchy for the `gs-config` library. Indicates that something went
|
||||||
* wrong while attempting to load configuration values.
|
* 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
|
/** Attempted to retreive the value for some [[ConfigKey]], but no value could
|
||||||
* be found.
|
* be found.
|
||||||
*
|
*
|
||||||
* @param name
|
* @param configName
|
||||||
* The name of the configuration value which was not found.
|
* 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
|
/** Found a value for some [[ConfigKey]], but that value could not be parsed
|
||||||
* as the appropriate type.
|
* as the appropriate type.
|
||||||
*
|
*
|
||||||
* @param name
|
* @param configName
|
||||||
* The name of the configuration value which could not be parsed.
|
* The name of the configuration value which could not be parsed.
|
||||||
* @param candidate
|
* @param candidateValue
|
||||||
* The raw value that could not be parsed.
|
* The raw value that could not be parsed.
|
||||||
* @param source
|
* @param source
|
||||||
* The [[ConfigSource]] which provided the candidate value.
|
* The [[ConfigSource]] which provided the candidate value.
|
||||||
*/
|
*/
|
||||||
case class CannotParseValue(
|
case class CannotParseValue(
|
||||||
name: ConfigName,
|
configName: ConfigName,
|
||||||
candidate: String,
|
candidateValue: String,
|
||||||
source: ConfigSource
|
sourceName: String
|
||||||
) extends ConfigError
|
) extends ConfigError
|
||||||
|
|
||||||
end ConfigError
|
end ConfigError
|
||||||
|
|
|
@ -1,24 +1,4 @@
|
||||||
package gs.config
|
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[_]]:
|
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]]
|
def getValue[A: Configurable](key: ConfigKey[A]): F[Either[ConfigError, A]]
|
||||||
|
|
||||||
/** @return
|
|
||||||
* The backing source for this configuration.
|
|
||||||
*/
|
|
||||||
def source: ConfigSource
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ package gs.config.audit
|
||||||
|
|
||||||
import cats.effect.Ref
|
import cats.effect.Ref
|
||||||
import cats.effect.Sync
|
import cats.effect.Sync
|
||||||
|
import cats.syntax.all.*
|
||||||
import gs.config.ConfigName
|
import gs.config.ConfigName
|
||||||
|
|
||||||
trait ConfigManifest[F[_]]:
|
trait ConfigManifest[F[_]]:
|
||||||
|
@ -12,44 +13,66 @@ trait ConfigManifest[F[_]]:
|
||||||
* @return
|
* @return
|
||||||
* The current state of this manifest.
|
* 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.
|
/** Record a query for some [[ConfigName]] in this manifest.
|
||||||
*
|
*
|
||||||
* @param name
|
* @param name
|
||||||
* The [[ConfigName]] that was queried.
|
* The [[ConfigName]] that was queried.
|
||||||
* @param query
|
* @param queryResult
|
||||||
* The [[ConfigQuery]] describing the result.
|
* The [[ConfigQueryResult]] describing the result.
|
||||||
* @return
|
* @return
|
||||||
* Side-effect indicating that the query was recorded.
|
* Side-effect indicating that the query was recorded.
|
||||||
*/
|
*/
|
||||||
def record(
|
def record(
|
||||||
name: ConfigName,
|
name: ConfigName,
|
||||||
query: ConfigQuery
|
queryResult: ConfigQueryResult
|
||||||
): F[Unit]
|
): F[Unit]
|
||||||
|
|
||||||
object ConfigManifest:
|
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 (
|
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]:
|
) extends ConfigManifest[F]:
|
||||||
|
|
||||||
override def snapshot(): F[Map[ConfigName, List[ConfigQuery]]] =
|
override def snapshot(): F[Map[ConfigName, List[ConfigQueryResult]]] =
|
||||||
manifest.get
|
manifest.get
|
||||||
|
|
||||||
override def record(
|
override def record(
|
||||||
name: ConfigName,
|
name: ConfigName,
|
||||||
query: ConfigQuery
|
query: ConfigQueryResult
|
||||||
): F[Unit] =
|
): F[Unit] =
|
||||||
manifest.update(addQueryToName(name, query, _))
|
manifest.update(addQueryToName(name, query, _))
|
||||||
|
|
||||||
private def addQueryToName(
|
private def addQueryToName(
|
||||||
name: ConfigName,
|
name: ConfigName,
|
||||||
query: ConfigQuery,
|
query: ConfigQueryResult,
|
||||||
state: Map[ConfigName, List[ConfigQuery]]
|
state: Map[ConfigName, List[ConfigQueryResult]]
|
||||||
): Map[ConfigName, List[ConfigQuery]] =
|
): Map[ConfigName, List[ConfigQueryResult]] =
|
||||||
state.get(name) match
|
state.get(name) match
|
||||||
case Some(queries) => state.updated(name, queries ++ List(query))
|
case Some(queries) => state.updated(name, queries ++ List(query))
|
||||||
case None => state + (name -> 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
|
end ConfigManifest
|
||||||
|
|
|
@ -5,9 +5,9 @@ import gs.config.ConfigError
|
||||||
/** Describes queries used to find configuration. Used for auditing purposes and
|
/** Describes queries used to find configuration. Used for auditing purposes and
|
||||||
* is captured by [[ConfigManifest]].
|
* is captured by [[ConfigManifest]].
|
||||||
*/
|
*/
|
||||||
sealed trait ConfigQuery
|
sealed trait ConfigQueryResult
|
||||||
|
|
||||||
object ConfigQuery:
|
object ConfigQueryResult:
|
||||||
|
|
||||||
/** Represents a query for some configuration that completed successfully.
|
/** Represents a query for some configuration that completed successfully.
|
||||||
*
|
*
|
||||||
|
@ -16,10 +16,10 @@ object ConfigQuery:
|
||||||
* @param rawValue
|
* @param rawValue
|
||||||
* The raw value that the source returned.
|
* The raw value that the source returned.
|
||||||
*/
|
*/
|
||||||
case class SuccessfulQuery(
|
case class Success(
|
||||||
source: ConfigSource,
|
source: String,
|
||||||
rawValue: String
|
rawValue: String
|
||||||
) extends ConfigQuery
|
) extends ConfigQueryResult
|
||||||
|
|
||||||
/** Represents a query for some configuration that failed.
|
/** Represents a query for some configuration that failed.
|
||||||
*
|
*
|
||||||
|
@ -29,9 +29,9 @@ object ConfigQuery:
|
||||||
* @param error
|
* @param error
|
||||||
* The reason why this query failed.
|
* The reason why this query failed.
|
||||||
*/
|
*/
|
||||||
case class FailedQuery(
|
case class Failure(
|
||||||
sources: List[ConfigSource],
|
sources: List[String],
|
||||||
error: ConfigError
|
error: ConfigError
|
||||||
)
|
) extends ConfigQueryResult
|
||||||
|
|
||||||
end ConfigQuery
|
end ConfigQueryResult
|
|
@ -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
|
|
48
src/main/scala/gs/config/source/ConfigSource.scala
Normal file
48
src/main/scala/gs/config/source/ConfigSource.scala
Normal file
|
@ -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
|
|
@ -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"
|
27
src/main/scala/gs/config/source/MemoryConfigSource.scala
Normal file
27
src/main/scala/gs/config/source/MemoryConfigSource.scala
Normal file
|
@ -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"
|
Loading…
Add table
Reference in a new issue