Adding substantial functionality and documentation.

This commit is contained in:
Pat Garrity 2023-12-23 20:41:45 -06:00
parent f8928d24af
commit 67f5adc2a9
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
10 changed files with 360 additions and 29 deletions

View file

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

View file

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

View 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

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

View 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

View 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

View file

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

View 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

View 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

View 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