More rework and expansion, have a complete audited implementation. Need more docs.

This commit is contained in:
Pat Garrity 2023-12-26 19:01:12 -06:00
parent 5a977acacb
commit d51890f5bf
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
12 changed files with 274 additions and 81 deletions

View file

@ -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"
"gs" %% "gs-config-v0" % "0.1.0"
} 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.

View file

@ -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")
} }
) )

View 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

View 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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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"

View 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"