Adding substantial functionality and documentation.
This commit is contained in:
parent
f8928d24af
commit
67f5adc2a9
10 changed files with 360 additions and 29 deletions
|
@ -1,11 +1,11 @@
|
||||||
---
|
---
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.4.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
|
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
|
||||||
rev: v0.1.0
|
rev: v0.1.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: scalafmt
|
- id: scalafmt
|
||||||
|
|
56
build.sbt
56
build.sbt
|
@ -13,12 +13,14 @@ externalResolvers := Seq(
|
||||||
val ProjectName: String = "gs-config"
|
val ProjectName: String = "gs-config"
|
||||||
val Description: String = "Garrity Software Configuration Library"
|
val Description: String = "Garrity Software Configuration Library"
|
||||||
|
|
||||||
/**
|
/** Helper to extract the value from `-Dproperty=value`.
|
||||||
* Helper to extract the value from `-Dproperty=value`.
|
|
||||||
*
|
*
|
||||||
* @param name The property name.
|
* @param name
|
||||||
* @param conv The conversion function to the output type.
|
* The property name.
|
||||||
* @return The converted value, or `None` if no value exists.
|
* @param conv
|
||||||
|
* The conversion function to the output type.
|
||||||
|
* @return
|
||||||
|
* The converted value, or `None` if no value exists.
|
||||||
*/
|
*/
|
||||||
def getProperty[A](
|
def getProperty[A](
|
||||||
name: String,
|
name: String,
|
||||||
|
@ -26,53 +28,50 @@ def getProperty[A](
|
||||||
): Option[A] =
|
): Option[A] =
|
||||||
Option(System.getProperty(name)).map(conv)
|
Option(System.getProperty(name)).map(conv)
|
||||||
|
|
||||||
/**
|
/** Use `sbt -Dversion=<version>` to provide the version, minus the SNAPSHOT
|
||||||
* Use `sbt -Dversion=<version>` to provide the version, minus the SNAPSHOT
|
|
||||||
* modifier. This is the typical approach for producing releases.
|
* modifier. This is the typical approach for producing releases.
|
||||||
*/
|
*/
|
||||||
val VersionProperty: String = "version"
|
val VersionProperty: String = "version"
|
||||||
|
|
||||||
/**
|
/** Use `sbt -Drelease=true` to trigger a release build.
|
||||||
* Use `sbt -Drelease=true` to trigger a release build.
|
|
||||||
*/
|
*/
|
||||||
val ReleaseProperty: String = "release"
|
val ReleaseProperty: String = "release"
|
||||||
|
|
||||||
/**
|
/** The value of `-Dversion=<value>`.
|
||||||
* The value of `-Dversion=<value>`.
|
|
||||||
*
|
*
|
||||||
* @return The version passed as input to SBT.
|
* @return
|
||||||
|
* The version passed as input to SBT.
|
||||||
*/
|
*/
|
||||||
lazy val InputVersion: Option[String] =
|
lazy val InputVersion: Option[String] =
|
||||||
getProperty(VersionProperty, identity)
|
getProperty(VersionProperty, identity)
|
||||||
|
|
||||||
/**
|
/** @return
|
||||||
* @return "-SNAPSHOT" if this is NOT a release, empty string otherwise.
|
* "-SNAPSHOT" if this is NOT a release, empty string otherwise.
|
||||||
*/
|
*/
|
||||||
lazy val Modifier: String =
|
lazy val Modifier: String =
|
||||||
if (getProperty(ReleaseProperty, _.toBoolean).getOrElse(false)) ""
|
if (getProperty(ReleaseProperty, _.toBoolean).getOrElse(false)) ""
|
||||||
else "-SNAPSHOT"
|
else "-SNAPSHOT"
|
||||||
|
|
||||||
/**
|
/** Version used if no version is passed as input. This helps with default/local
|
||||||
* Version used if no version is passed as input. This helps with default/local
|
|
||||||
* builds.
|
* builds.
|
||||||
*/
|
*/
|
||||||
val DefaultVersion: String = "0.1.0-SNAPSHOT"
|
val DefaultVersion: String = "0.1.0-SNAPSHOT"
|
||||||
|
|
||||||
/**
|
/** This is the output version of the published artifact. If this build is not a
|
||||||
* This is the output version of the published artifact. If this build is not
|
* release, the suffix "-SNAPSHOT" will be appended.
|
||||||
* a release, the suffix "-SNAPSHOT" will be appended.
|
|
||||||
*
|
*
|
||||||
* @return The project version.
|
* @return
|
||||||
|
* The project version.
|
||||||
*/
|
*/
|
||||||
lazy val SelectedVersion: String =
|
lazy val SelectedVersion: String =
|
||||||
InputVersion
|
InputVersion
|
||||||
.map(v => s"$v$Modifier")
|
.map(v => s"$v$Modifier")
|
||||||
.getOrElse(DefaultVersion)
|
.getOrElse(DefaultVersion)
|
||||||
|
|
||||||
/**
|
/** The major version (first segment) value. Used to label releases.
|
||||||
* The major version (first segment) value. Used to label releases.
|
|
||||||
*
|
*
|
||||||
* @return The major version of the project.
|
* @return
|
||||||
|
* The major version of the project.
|
||||||
*/
|
*/
|
||||||
lazy val MajorVersion: String =
|
lazy val MajorVersion: String =
|
||||||
SelectedVersion.split('.').apply(0)
|
SelectedVersion.split('.').apply(0)
|
||||||
|
@ -96,10 +95,13 @@ lazy val publishSettings = Seq(
|
||||||
licenses := List(
|
licenses := List(
|
||||||
"Apache 2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.html")
|
"Apache 2.0" -> url("https://www.apache.org/licenses/LICENSE-2.0.html")
|
||||||
),
|
),
|
||||||
homepage := Some(url(s"https://git.garrity.co/garrity-software/$ProjectName")),
|
homepage := Some(
|
||||||
|
url(s"https://git.garrity.co/garrity-software/$ProjectName")
|
||||||
|
),
|
||||||
publishTo := {
|
publishTo := {
|
||||||
val repo = "https://maven.garrity.co/"
|
val repo = "https://maven.garrity.co/"
|
||||||
if (SelectedVersion.endsWith("SNAPSHOT")) Some("snapshots" at repo + "snapshots")
|
if (SelectedVersion.endsWith("SNAPSHOT"))
|
||||||
|
Some("snapshots" at repo + "snapshots")
|
||||||
else Some("releases" at repo + "releases")
|
else Some("releases" at repo + "releases")
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -110,13 +112,15 @@ lazy val testSettings = Seq(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
lazy val `gs-config` = (project.in(file(".")))
|
lazy val `gs-config` = project
|
||||||
|
.in(file("."))
|
||||||
.settings(sharedSettings)
|
.settings(sharedSettings)
|
||||||
.settings(publishSettings)
|
.settings(publishSettings)
|
||||||
.settings(testSettings)
|
.settings(testSettings)
|
||||||
.settings(name := s"$ProjectName-v$MajorVersion")
|
.settings(name := s"$ProjectName-v$MajorVersion")
|
||||||
.settings(
|
.settings(
|
||||||
libraryDependencies ++= Seq(
|
libraryDependencies ++= Seq(
|
||||||
|
"org.typelevel" %% "cats-effect" % "3.5.2"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
35
src/main/scala/gs/config/ConfigError.scala
Normal file
35
src/main/scala/gs/config/ConfigError.scala
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
sealed trait ConfigError
|
||||||
|
|
||||||
|
object ConfigError:
|
||||||
|
/** Attempted to retreive the value for some [[ConfigKey]], but no value could
|
||||||
|
* be found.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of the configuration value which was not found.
|
||||||
|
*/
|
||||||
|
case class MissingValue(name: ConfigName) extends ConfigError
|
||||||
|
|
||||||
|
/** Found a value for some [[ConfigKey]], but that value could not be parsed
|
||||||
|
* as the appropriate type.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of the configuration value which could not be parsed.
|
||||||
|
* @param candidate
|
||||||
|
* 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
|
||||||
|
) extends ConfigError
|
||||||
|
|
||||||
|
end ConfigError
|
41
src/main/scala/gs/config/ConfigKey.scala
Normal file
41
src/main/scala/gs/config/ConfigKey.scala
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package gs.config
|
||||||
|
|
||||||
|
/** Defines some piece of configuration.
|
||||||
|
*
|
||||||
|
* See:
|
||||||
|
*
|
||||||
|
* - [[ConfigKey.Required]]
|
||||||
|
* - [[ConfigKey.WithDefaultValue]]
|
||||||
|
*
|
||||||
|
* @tparam A
|
||||||
|
* The type of data referenced by this key. This type must be
|
||||||
|
* [[Configurable]].
|
||||||
|
*/
|
||||||
|
sealed trait ConfigKey[A: Configurable]:
|
||||||
|
def name: ConfigName
|
||||||
|
|
||||||
|
object ConfigKey:
|
||||||
|
|
||||||
|
/** Defines a piece of configuration that is required and does not have any
|
||||||
|
* default value. If the value referenced by the name cannot be found, an
|
||||||
|
* [[ConfigError.MissingValue]] is returned.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of this configuration.
|
||||||
|
*/
|
||||||
|
case class Required[A: Configurable](
|
||||||
|
name: ConfigName
|
||||||
|
) extends ConfigKey[A]
|
||||||
|
|
||||||
|
/** Defines a piece of configuration that has a default value. If the value
|
||||||
|
* referenced by the name cannot be found, the default is used.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The name of this piece of configuration.
|
||||||
|
* @param defaultValue
|
||||||
|
* The default value for this configuration.
|
||||||
|
*/
|
||||||
|
case class WithDefaultValue[A: Configurable](
|
||||||
|
name: ConfigName,
|
||||||
|
defaultValue: A
|
||||||
|
) extends ConfigKey[A]
|
66
src/main/scala/gs/config/ConfigName.scala
Normal file
66
src/main/scala/gs/config/ConfigName.scala
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
package gs.config
|
||||||
|
|
||||||
|
/** Uniquely names some piece of configuration. This structure does _not_
|
||||||
|
* attempt to support every possible use case, but supports some common cases
|
||||||
|
* for users that follow some basic rules. Please review conversion functions
|
||||||
|
* to review the rules for each supported conversion.
|
||||||
|
*
|
||||||
|
* ## Recommended Naming Convention
|
||||||
|
*
|
||||||
|
* Use either property form (`foo.bar.baz-qwop`) or environment variable form
|
||||||
|
* (`FOO_BAR_BAZ_QWOP`). Do not use dashes (`-`) in properties if you need to
|
||||||
|
* convert bidirectionally.
|
||||||
|
*
|
||||||
|
* ## Supported Conversions
|
||||||
|
*
|
||||||
|
* - `toRawString`
|
||||||
|
* - `toEnvironmentVariable`
|
||||||
|
* - `toProperty`
|
||||||
|
*/
|
||||||
|
opaque type ConfigName = String
|
||||||
|
|
||||||
|
object ConfigName:
|
||||||
|
|
||||||
|
/** Instantiate a new `ConfigName`. This function trims all leading and
|
||||||
|
* trailing whitespace.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The raw string value of the name.
|
||||||
|
* @return
|
||||||
|
* New `ConfigName`.
|
||||||
|
*/
|
||||||
|
def apply(name: String): ConfigName = name.trim()
|
||||||
|
|
||||||
|
given CanEqual[ConfigName, ConfigName] = CanEqual.derived
|
||||||
|
|
||||||
|
extension (name: ConfigName)
|
||||||
|
/** Extract the unmodified string that backs this name.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The underlying string representation of the name.
|
||||||
|
*/
|
||||||
|
def toRawString(): String = name
|
||||||
|
|
||||||
|
/** Express this name as an environment variable.
|
||||||
|
*
|
||||||
|
* - Replaces all alphanumeric characters with `_`.
|
||||||
|
* - Converts all characters to upper case.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The environment variable form of this name.
|
||||||
|
*/
|
||||||
|
def toEnvironmentVariable(): String =
|
||||||
|
name.replaceAll("[^0-9a-zA-Z_]", "_").toUpperCase()
|
||||||
|
|
||||||
|
/** Express this name as a property.
|
||||||
|
*
|
||||||
|
* - Replaces all underscore characters with `.`.
|
||||||
|
* - Converts all characters to lower case.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The property form of this name.
|
||||||
|
*/
|
||||||
|
def toProperty(): String =
|
||||||
|
name.replaceAll("_", ".").toLowerCase()
|
||||||
|
|
||||||
|
end ConfigName
|
46
src/main/scala/gs/config/Configurable.scala
Normal file
46
src/main/scala/gs/config/Configurable.scala
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
package gs.config
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Type class for types that can be parsed from raw string configuration.
|
||||||
|
*/
|
||||||
|
trait Configurable[A]:
|
||||||
|
/** Parse some raw string value as the desired type.
|
||||||
|
*
|
||||||
|
* @param raw
|
||||||
|
* The raw value to parse.
|
||||||
|
* @return
|
||||||
|
* The parsed value, or `None` if parsing failed.
|
||||||
|
*/
|
||||||
|
def parse(raw: String): Option[A]
|
||||||
|
|
||||||
|
object Configurable:
|
||||||
|
|
||||||
|
def apply[A](
|
||||||
|
using
|
||||||
|
C: Configurable[A]
|
||||||
|
): Configurable[A] = C
|
||||||
|
|
||||||
|
given Configurable[String] with
|
||||||
|
def parse(raw: String): Option[String] = Some(raw)
|
||||||
|
|
||||||
|
given Configurable[Int] with
|
||||||
|
def parse(raw: String): Option[Int] = Try(raw.toInt).toOption
|
||||||
|
|
||||||
|
given Configurable[Long] with
|
||||||
|
def parse(raw: String): Option[Long] = Try(raw.toLong).toOption
|
||||||
|
|
||||||
|
given Configurable[Boolean] with
|
||||||
|
def parse(raw: String): Option[Boolean] = Try(raw.toBoolean).toOption
|
||||||
|
|
||||||
|
given Configurable[LocalDate] with
|
||||||
|
|
||||||
|
def parse(raw: String): Option[LocalDate] =
|
||||||
|
Try(LocalDate.parse(raw)).toOption
|
||||||
|
|
||||||
|
given Configurable[Instant] with
|
||||||
|
|
||||||
|
def parse(raw: String): Option[Instant] =
|
||||||
|
Try(Instant.parse(raw)).toOption
|
|
@ -1,3 +1,24 @@
|
||||||
package gs.config
|
package gs.config
|
||||||
|
|
||||||
trait Configuration
|
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
|
||||||
|
|
55
src/main/scala/gs/config/audit/ConfigManifest.scala
Normal file
55
src/main/scala/gs/config/audit/ConfigManifest.scala
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package gs.config.audit
|
||||||
|
|
||||||
|
import cats.effect.Ref
|
||||||
|
import cats.effect.Sync
|
||||||
|
import gs.config.ConfigName
|
||||||
|
|
||||||
|
trait ConfigManifest[F[_]]:
|
||||||
|
/** Retrieve a snapshot of the current state of this configuration manifest.
|
||||||
|
* This state tracks all configuration names that the caller attempted to
|
||||||
|
* access, along with a record of whether each attempt succeeded or failed.
|
||||||
|
*
|
||||||
|
* @return
|
||||||
|
* The current state of this manifest.
|
||||||
|
*/
|
||||||
|
def snapshot(): F[Map[ConfigName, List[ConfigQuery]]]
|
||||||
|
|
||||||
|
/** Record a query for some [[ConfigName]] in this manifest.
|
||||||
|
*
|
||||||
|
* @param name
|
||||||
|
* The [[ConfigName]] that was queried.
|
||||||
|
* @param query
|
||||||
|
* The [[ConfigQuery]] describing the result.
|
||||||
|
* @return
|
||||||
|
* Side-effect indicating that the query was recorded.
|
||||||
|
*/
|
||||||
|
def record(
|
||||||
|
name: ConfigName,
|
||||||
|
query: ConfigQuery
|
||||||
|
): F[Unit]
|
||||||
|
|
||||||
|
object ConfigManifest:
|
||||||
|
|
||||||
|
final class Standard[F[_]: Sync] private (
|
||||||
|
private val manifest: Ref[F, Map[ConfigName, List[ConfigQuery]]]
|
||||||
|
) extends ConfigManifest[F]:
|
||||||
|
|
||||||
|
override def snapshot(): F[Map[ConfigName, List[ConfigQuery]]] =
|
||||||
|
manifest.get
|
||||||
|
|
||||||
|
override def record(
|
||||||
|
name: ConfigName,
|
||||||
|
query: ConfigQuery
|
||||||
|
): F[Unit] =
|
||||||
|
manifest.update(addQueryToName(name, query, _))
|
||||||
|
|
||||||
|
private def addQueryToName(
|
||||||
|
name: ConfigName,
|
||||||
|
query: ConfigQuery,
|
||||||
|
state: Map[ConfigName, List[ConfigQuery]]
|
||||||
|
): Map[ConfigName, List[ConfigQuery]] =
|
||||||
|
state.get(name) match
|
||||||
|
case Some(queries) => state.updated(name, queries ++ List(query))
|
||||||
|
case None => state + (name -> List(query))
|
||||||
|
|
||||||
|
end ConfigManifest
|
37
src/main/scala/gs/config/audit/ConfigQuery.scala
Normal file
37
src/main/scala/gs/config/audit/ConfigQuery.scala
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package gs.config.audit
|
||||||
|
|
||||||
|
import gs.config.ConfigError
|
||||||
|
|
||||||
|
/** Describes queries used to find configuration. Used for auditing purposes and
|
||||||
|
* is captured by [[ConfigManifest]].
|
||||||
|
*/
|
||||||
|
sealed trait ConfigQuery
|
||||||
|
|
||||||
|
object ConfigQuery:
|
||||||
|
|
||||||
|
/** Represents a query for some configuration that completed successfully.
|
||||||
|
*
|
||||||
|
* @param source
|
||||||
|
* The source which provided the value.
|
||||||
|
* @param rawValue
|
||||||
|
* The raw value that the source returned.
|
||||||
|
*/
|
||||||
|
case class SuccessfulQuery(
|
||||||
|
source: ConfigSource,
|
||||||
|
rawValue: String
|
||||||
|
) extends ConfigQuery
|
||||||
|
|
||||||
|
/** Represents a query for some configuration that failed.
|
||||||
|
*
|
||||||
|
* @param sources
|
||||||
|
* List of all sources, in order, that were consulted to attempt to get
|
||||||
|
* this value.
|
||||||
|
* @param error
|
||||||
|
* The reason why this query failed.
|
||||||
|
*/
|
||||||
|
case class FailedQuery(
|
||||||
|
sources: List[ConfigSource],
|
||||||
|
error: ConfigError
|
||||||
|
)
|
||||||
|
|
||||||
|
end ConfigQuery
|
26
src/main/scala/gs/config/audit/ConfigSource.scala
Normal file
26
src/main/scala/gs/config/audit/ConfigSource.scala
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
Loading…
Add table
Reference in a new issue