More rework and expansion, have a complete audited implementation. Need more docs.
This commit is contained in:
		
							parent
							
								
									5a977acacb
								
							
						
					
					
						commit
						d51890f5bf
					
				
					 12 changed files with 274 additions and 81 deletions
				
			
		
							
								
								
									
										15
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										15
									
								
								README.md
									
										
									
									
									
								
							|  | @ -8,11 +8,18 @@ Configuration library for Scala 3. | |||
| 
 | ||||
| ## Usage | ||||
| 
 | ||||
| This library is not yet published. | ||||
| This artifact is available in the Garrity Software Maven repository. | ||||
| 
 | ||||
| ```scala | ||||
| object GS { | ||||
|   val Config: ModuleID = | ||||
| externalResolvers += | ||||
|   "Garrity Software Releases" at "https://maven.garrity.co/releases" | ||||
| 
 | ||||
| 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. | ||||
|  |  | |||
|  | @ -101,8 +101,8 @@ lazy val publishSettings = Seq( | |||
|   publishTo := { | ||||
|     val repo = "https://maven.garrity.co/" | ||||
|     if (SelectedVersion.endsWith("SNAPSHOT")) | ||||
|       Some("snapshots" at repo + "snapshots") | ||||
|     else Some("releases" at repo + "releases") | ||||
|       Some("Garrity Software Maven" at repo + "snapshots") | ||||
|     else Some("Garrity Software Maven" at repo + "releases") | ||||
|   } | ||||
| ) | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										88
									
								
								src/main/scala/gs/config/AuditedConfiguration.scala
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								src/main/scala/gs/config/AuditedConfiguration.scala
									
										
									
									
									
										Normal 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 | ||||
							
								
								
									
										32
									
								
								src/main/scala/gs/config/BaseConfiguration.scala
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/main/scala/gs/config/BaseConfiguration.scala
									
										
									
									
									
										Normal 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) | ||||
|  | @ -1,7 +1,5 @@ | |||
| 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. | ||||
|   */ | ||||
|  | @ -11,25 +9,25 @@ object ConfigError: | |||
|   /** Attempted to retreive the value for some [[ConfigKey]], but no value could | ||||
|     * be found. | ||||
|     * | ||||
|     * @param name | ||||
|     * @param configName | ||||
|     *   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 | ||||
|     * as the appropriate type. | ||||
|     * | ||||
|     * @param name | ||||
|     * @param configName | ||||
|     *   The name of the configuration value which could not be parsed. | ||||
|     * @param candidate | ||||
|     * @param candidateValue | ||||
|     *   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 | ||||
|     configName: ConfigName, | ||||
|     candidateValue: String, | ||||
|     sourceName: String | ||||
|   ) extends ConfigError | ||||
| 
 | ||||
| end ConfigError | ||||
|  |  | |||
|  | @ -1,24 +1,4 @@ | |||
| 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[_]]: | ||||
|   /** 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 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ package gs.config.audit | |||
| 
 | ||||
| import cats.effect.Ref | ||||
| import cats.effect.Sync | ||||
| import cats.syntax.all.* | ||||
| import gs.config.ConfigName | ||||
| 
 | ||||
| trait ConfigManifest[F[_]]: | ||||
|  | @ -12,44 +13,66 @@ trait ConfigManifest[F[_]]: | |||
|     * @return | ||||
|     *   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. | ||||
|     * | ||||
|     * @param name | ||||
|     *   The [[ConfigName]] that was queried. | ||||
|     * @param query | ||||
|     *   The [[ConfigQuery]] describing the result. | ||||
|     * @param queryResult | ||||
|     *   The [[ConfigQueryResult]] describing the result. | ||||
|     * @return | ||||
|     *   Side-effect indicating that the query was recorded. | ||||
|     */ | ||||
|   def record( | ||||
|     name: ConfigName, | ||||
|     query: ConfigQuery | ||||
|     queryResult: ConfigQueryResult | ||||
|   ): F[Unit] | ||||
| 
 | ||||
| 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 ( | ||||
|     private val manifest: Ref[F, Map[ConfigName, List[ConfigQuery]]] | ||||
|     private val manifest: Ref[F, Map[ConfigName, List[ConfigQueryResult]]] | ||||
|   ) extends ConfigManifest[F]: | ||||
| 
 | ||||
|     override def snapshot(): F[Map[ConfigName, List[ConfigQuery]]] = | ||||
|     override def snapshot(): F[Map[ConfigName, List[ConfigQueryResult]]] = | ||||
|       manifest.get | ||||
| 
 | ||||
|     override def record( | ||||
|       name: ConfigName, | ||||
|       query: ConfigQuery | ||||
|       query: ConfigQueryResult | ||||
|     ): F[Unit] = | ||||
|       manifest.update(addQueryToName(name, query, _)) | ||||
| 
 | ||||
|     private def addQueryToName( | ||||
|       name: ConfigName, | ||||
|       query: ConfigQuery, | ||||
|       state: Map[ConfigName, List[ConfigQuery]] | ||||
|     ): Map[ConfigName, List[ConfigQuery]] = | ||||
|       query: ConfigQueryResult, | ||||
|       state: Map[ConfigName, List[ConfigQueryResult]] | ||||
|     ): Map[ConfigName, List[ConfigQueryResult]] = | ||||
|       state.get(name) match | ||||
|         case Some(queries) => state.updated(name, queries ++ 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 | ||||
|  |  | |||
|  | @ -5,9 +5,9 @@ import gs.config.ConfigError | |||
| /** Describes queries used to find configuration. Used for auditing purposes and | ||||
|   * is captured by [[ConfigManifest]]. | ||||
|   */ | ||||
| sealed trait ConfigQuery | ||||
| sealed trait ConfigQueryResult | ||||
| 
 | ||||
| object ConfigQuery: | ||||
| object ConfigQueryResult: | ||||
| 
 | ||||
|   /** Represents a query for some configuration that completed successfully. | ||||
|     * | ||||
|  | @ -16,10 +16,10 @@ object ConfigQuery: | |||
|     * @param rawValue | ||||
|     *   The raw value that the source returned. | ||||
|     */ | ||||
|   case class SuccessfulQuery( | ||||
|     source: ConfigSource, | ||||
|   case class Success( | ||||
|     source: String, | ||||
|     rawValue: String | ||||
|   ) extends ConfigQuery | ||||
|   ) extends ConfigQueryResult | ||||
| 
 | ||||
|   /** Represents a query for some configuration that failed. | ||||
|     * | ||||
|  | @ -29,9 +29,9 @@ object ConfigQuery: | |||
|     * @param error | ||||
|     *   The reason why this query failed. | ||||
|     */ | ||||
|   case class FailedQuery( | ||||
|     sources: List[ConfigSource], | ||||
|   case class Failure( | ||||
|     sources: List[String], | ||||
|     error: ConfigError | ||||
|   ) | ||||
|   ) extends ConfigQueryResult | ||||
| 
 | ||||
| end ConfigQuery | ||||
| end ConfigQueryResult | ||||
|  | @ -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 | ||||
							
								
								
									
										48
									
								
								src/main/scala/gs/config/source/ConfigSource.scala
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/main/scala/gs/config/source/ConfigSource.scala
									
										
									
									
									
										Normal 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 | ||||
|  | @ -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" | ||||
							
								
								
									
										27
									
								
								src/main/scala/gs/config/source/MemoryConfigSource.scala
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/main/scala/gs/config/source/MemoryConfigSource.scala
									
										
									
									
									
										Normal 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" | ||||
		Loading…
	
	Add table
		
		Reference in a new issue