From 67f5adc2a9e881fddf364629d059b81b3dd22e1f Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Sat, 23 Dec 2023 20:41:45 -0600 Subject: [PATCH] Adding substantial functionality and documentation. --- .pre-commit-config.yaml | 4 +- build.sbt | 56 ++++++++-------- src/main/scala/gs/config/ConfigError.scala | 35 ++++++++++ src/main/scala/gs/config/ConfigKey.scala | 41 ++++++++++++ src/main/scala/gs/config/ConfigName.scala | 66 +++++++++++++++++++ src/main/scala/gs/config/Configurable.scala | 46 +++++++++++++ src/main/scala/gs/config/Configuration.scala | 23 ++++++- .../gs/config/audit/ConfigManifest.scala | 55 ++++++++++++++++ .../scala/gs/config/audit/ConfigQuery.scala | 37 +++++++++++ .../scala/gs/config/audit/ConfigSource.scala | 26 ++++++++ 10 files changed, 360 insertions(+), 29 deletions(-) create mode 100644 src/main/scala/gs/config/ConfigError.scala create mode 100644 src/main/scala/gs/config/ConfigKey.scala create mode 100644 src/main/scala/gs/config/ConfigName.scala create mode 100644 src/main/scala/gs/config/Configurable.scala create mode 100644 src/main/scala/gs/config/audit/ConfigManifest.scala create mode 100644 src/main/scala/gs/config/audit/ConfigQuery.scala create mode 100644 src/main/scala/gs/config/audit/ConfigSource.scala diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b97acc..d84ba64 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ --- repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala - rev: v0.1.0 + rev: v0.1.3 hooks: - id: scalafmt diff --git a/build.sbt b/build.sbt index c094cfd..a9e3bc7 100644 --- a/build.sbt +++ b/build.sbt @@ -13,12 +13,14 @@ externalResolvers := Seq( val ProjectName: String = "gs-config" 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 conv The conversion function to the output type. - * @return The converted value, or `None` if no value exists. + * @param name + * The property name. + * @param conv + * The conversion function to the output type. + * @return + * The converted value, or `None` if no value exists. */ def getProperty[A]( name: String, @@ -26,53 +28,50 @@ def getProperty[A]( ): Option[A] = Option(System.getProperty(name)).map(conv) -/** - * Use `sbt -Dversion=` to provide the version, minus the SNAPSHOT +/** Use `sbt -Dversion=` to provide the version, minus the SNAPSHOT * modifier. This is the typical approach for producing releases. */ 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" -/** - * The value of `-Dversion=`. +/** The value of `-Dversion=`. * - * @return The version passed as input to SBT. + * @return + * The version passed as input to SBT. */ lazy val InputVersion: Option[String] = getProperty(VersionProperty, identity) -/** - * @return "-SNAPSHOT" if this is NOT a release, empty string otherwise. +/** @return + * "-SNAPSHOT" if this is NOT a release, empty string otherwise. */ lazy val Modifier: String = if (getProperty(ReleaseProperty, _.toBoolean).getOrElse(false)) "" 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. */ val DefaultVersion: String = "0.1.0-SNAPSHOT" -/** - * This is the output version of the published artifact. If this build is not - * a release, the suffix "-SNAPSHOT" will be appended. +/** This is the output version of the published artifact. If this build is not a + * release, the suffix "-SNAPSHOT" will be appended. * - * @return The project version. + * @return + * The project version. */ lazy val SelectedVersion: String = InputVersion .map(v => s"$v$Modifier") .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 = SelectedVersion.split('.').apply(0) @@ -96,10 +95,13 @@ lazy val publishSettings = Seq( licenses := List( "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 := { 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") } ) @@ -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(publishSettings) .settings(testSettings) .settings(name := s"$ProjectName-v$MajorVersion") .settings( libraryDependencies ++= Seq( + "org.typelevel" %% "cats-effect" % "3.5.2" ) ) diff --git a/src/main/scala/gs/config/ConfigError.scala b/src/main/scala/gs/config/ConfigError.scala new file mode 100644 index 0000000..47599fc --- /dev/null +++ b/src/main/scala/gs/config/ConfigError.scala @@ -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 diff --git a/src/main/scala/gs/config/ConfigKey.scala b/src/main/scala/gs/config/ConfigKey.scala new file mode 100644 index 0000000..92a9d5e --- /dev/null +++ b/src/main/scala/gs/config/ConfigKey.scala @@ -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] diff --git a/src/main/scala/gs/config/ConfigName.scala b/src/main/scala/gs/config/ConfigName.scala new file mode 100644 index 0000000..8c00574 --- /dev/null +++ b/src/main/scala/gs/config/ConfigName.scala @@ -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 diff --git a/src/main/scala/gs/config/Configurable.scala b/src/main/scala/gs/config/Configurable.scala new file mode 100644 index 0000000..85711e6 --- /dev/null +++ b/src/main/scala/gs/config/Configurable.scala @@ -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 diff --git a/src/main/scala/gs/config/Configuration.scala b/src/main/scala/gs/config/Configuration.scala index 873f36d..da31c38 100644 --- a/src/main/scala/gs/config/Configuration.scala +++ b/src/main/scala/gs/config/Configuration.scala @@ -1,3 +1,24 @@ 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 diff --git a/src/main/scala/gs/config/audit/ConfigManifest.scala b/src/main/scala/gs/config/audit/ConfigManifest.scala new file mode 100644 index 0000000..5aa4359 --- /dev/null +++ b/src/main/scala/gs/config/audit/ConfigManifest.scala @@ -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 diff --git a/src/main/scala/gs/config/audit/ConfigQuery.scala b/src/main/scala/gs/config/audit/ConfigQuery.scala new file mode 100644 index 0000000..b9f0f12 --- /dev/null +++ b/src/main/scala/gs/config/audit/ConfigQuery.scala @@ -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 diff --git a/src/main/scala/gs/config/audit/ConfigSource.scala b/src/main/scala/gs/config/audit/ConfigSource.scala new file mode 100644 index 0000000..d5e94ba --- /dev/null +++ b/src/main/scala/gs/config/audit/ConfigSource.scala @@ -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