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: | ||||
|   - 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 | ||||
|  |  | |||
							
								
								
									
										56
									
								
								build.sbt
									
										
									
									
									
								
							
							
						
						
									
										56
									
								
								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=<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. | ||||
|   */ | ||||
| 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=<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] = | ||||
|   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" | ||||
|     ) | ||||
|   ) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										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 | ||||
| 
 | ||||
| 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