(patch) Initial implementation of the configuration library. (#1)
All checks were successful
/ Build and Release Library (push) Successful in 1m29s

Reviewed-on: #1
This commit is contained in:
Pat Garrity 2024-05-02 02:56:00 +00:00
parent f8928d24af
commit f127645860
26 changed files with 1307 additions and 332 deletions

View file

@ -0,0 +1,68 @@
on:
pull_request:
types: [opened, synchronize, reopened]
defaults:
run:
shell: bash
jobs:
library_snapshot:
runs-on: docker
container:
image: registry.garrity.co:8443/gs/ci-scala:latest
name: 'Build and Test Library Snapshot'
env:
GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }}
GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }}
steps:
- uses: actions/checkout@v4
name: 'Checkout Repository'
with:
fetch-depth: 0
- name: 'Pre-Commit'
run: |
pre-commit install
pre-commit run --all-files
- name: 'Prepare Versioned Build'
run: |
latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')"
latest_commit_message="$(git show -s --format=%s HEAD)"
if [[ "$latest_commit_message" == *"(major)"* ]]; then
export GS_RELEASE_TYPE="major"
elif [[ "$latest_commit_message" == *"(minor)"* ]]; then
export GS_RELEASE_TYPE="minor"
elif [[ "$latest_commit_message" == *"(patch)"* ]]; then
export GS_RELEASE_TYPE="patch"
elif [[ "$latest_commit_message" == *"(docs)"* ]]; then
export GS_RELEASE_TYPE="norelease"
elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then
export GS_RELEASE_TYPE="norelease"
else
export GS_RELEASE_TYPE="norelease"
fi
echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV
echo "Previous Git Tag: $latest_git_tag"
echo "Latest Commit: $latest_commit_message ($GS_RELEASE_TYPE) (SNAPSHOT)"
if [ "$GS_RELEASE_TYPE" = "norelease" ]; then
sbtn -Dsnapshot=true -Drelease="patch" semVerInfo
else
sbtn -Dsnapshot=true -Drelease="$GS_RELEASE_TYPE" semVerInfo
fi
- name: 'Unit Tests and Code Coverage'
run: |
sbtn clean
sbtn coverage
sbtn test
sbtn coverageReport
- name: 'Publish Snapshot'
run: |
echo "Testing env var propagation = ${{ env.GS_RELEASE_TYPE }}"
if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then
echo "Skipping publish due to GS_RELEASE_TYPE=norelease"
else
sbtn coverageOff
sbtn clean
sbtn compile
sbtn publish
fi

View file

@ -0,0 +1,84 @@
on:
push:
branches:
- main
defaults:
run:
shell: bash
jobs:
library_release:
runs-on: docker
container:
image: registry.garrity.co:8443/gs/ci-scala:latest
name: 'Build and Release Library'
env:
GS_MAVEN_USER: ${{ vars.GS_MAVEN_USER }}
GS_MAVEN_TOKEN: ${{ secrets.GS_MAVEN_TOKEN }}
steps:
- uses: actions/checkout@v4
name: 'Checkout Repository'
with:
fetch-depth: 0
- name: 'Pre-Commit'
run: |
pre-commit install
pre-commit run --all-files
- name: 'Prepare Versioned Build'
run: |
latest_git_tag="$(git describe --tags --abbrev=0 || echo 'No Tags')"
latest_commit_message="$(git show -s --format=%s HEAD)"
if [[ "$latest_commit_message" == *"(major)"* ]]; then
export GS_RELEASE_TYPE="major"
elif [[ "$latest_commit_message" == *"(minor)"* ]]; then
export GS_RELEASE_TYPE="minor"
elif [[ "$latest_commit_message" == *"(patch)"* ]]; then
export GS_RELEASE_TYPE="patch"
elif [[ "$latest_commit_message" == *"(docs)"* ]]; then
export GS_RELEASE_TYPE="norelease"
elif [[ "$latest_commit_message" == *"(norelease)"* ]]; then
export GS_RELEASE_TYPE="norelease"
else
export GS_RELEASE_TYPE="norelease"
fi
echo "GS_RELEASE_TYPE=$GS_RELEASE_TYPE" >> $GITHUB_ENV
echo "Previous Git Tag: $latest_git_tag"
echo "Latest Commit: $latest_commit_message"
echo "Selected Release Type: '$GS_RELEASE_TYPE'"
if [ "$GS_RELEASE_TYPE" = "norelease" ]; then
echo "Skipping all versioning for 'norelease' commit."
else
sbtn -Drelease="$GS_RELEASE_TYPE" semVerInfo
fi
- name: 'Unit Tests and Code Coverage'
run: |
if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then
echo "Skipping build/test for 'norelease' commit."
else
sbtn clean
sbtn coverage
sbtn test
sbtn coverageReport
fi
- name: 'Publish Release'
run: |
if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then
echo "Skipping publish for 'norelease' commit."
else
sbtn coverageOff
sbtn clean
sbtn semVerWriteVersionToFile
sbtn publish
fi
- name: 'Create Git Tag'
run: |
if [ "${{ env.GS_RELEASE_TYPE }}" = "norelease" ]; then
echo "Skipping Git tag for 'norelease' commit."
else
selected_version="$(cat .version)"
git tag "$selected_version"
git push origin "$selected_version"
fi

View file

@ -1,11 +1,16 @@
---
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
- id: fix-byte-order-marker
- id: mixed-line-ending
args: ['--fix=lf']
description: Enforces using only 'LF' line endings.
- id: trailing-whitespace
- repo: https://git.garrity.co/garrity-software/gs-pre-commit-scala
rev: v0.1.0
rev: v1.0.1
hooks:
- id: scalafmt

View file

@ -1,5 +1,5 @@
// See: https://github.com/scalameta/scalafmt/tags for the latest tags.
version = 3.7.17
version = 3.8.1
runner.dialect = scala3
maxColumn = 80

203
LICENSE
View file

@ -1,202 +1,9 @@
MIT License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright Patrick Garrity
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
1. Definitions.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -1,18 +1,28 @@
# gs-config
[License (Apache 2.0)](./LICENSE)
[GS Open Source](https://garrity.co/oss.html) |
[License (MIT)](./LICENSE)
Configuration library for Scala 3.
- [Usage](#usage)
- [Donate](#donate)
## Usage
This library is not yet published.
### Dependency
This artifact is available in the Garrity Software Maven repository.
```scala
object GS {
val Config: ModuleID =
"gs" %% "gs-config-v0" % "0.1.0"
}
externalResolvers +=
"Garrity Software Releases" at "https://maven.garrity.co/gs"
val GsConfig: ModuleID =
"gs" %% "gs-config-v0" % "$VERSION"
```
## Donate
Enjoy this project or want to help me achieve my [goals](https://garrity.co)?
Consider [Donating to Pat on Ko-fi](https://ko-fi.com/gspfm).

133
build.sbt
View file

@ -1,139 +1,32 @@
val scala3: String = "3.3.1"
ThisBuild / organizationName := "garrity software"
ThisBuild / organization := "gs"
ThisBuild / organizationHomepage := Some(url("https://garrity.co/"))
ThisBuild / scalaVersion := scala3
ThisBuild / versionScheme := Some("early-semver")
val scala3: String = "3.4.1"
externalResolvers := Seq(
"Garrity Software Releases" at "https://maven.garrity.co/releases"
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
val ProjectName: String = "gs-config"
val Description: String = "Garrity Software Configuration Library"
ThisBuild / scalaVersion := scala3
ThisBuild / versionScheme := Some("semver-spec")
ThisBuild / gsProjectName := "gs-config"
/**
* 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.
*/
def getProperty[A](
name: String,
conv: String => A
): Option[A] =
Option(System.getProperty(name)).map(conv)
/**
* 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.
*/
val ReleaseProperty: String = "release"
/**
* The value of `-Dversion=<value>`.
*
* @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.
*/
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
* 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.
*
* @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.
*
* @return The major version of the project.
*/
lazy val MajorVersion: String =
SelectedVersion.split('.').apply(0)
val sharedSettings = Seq(
lazy val sharedSettings = Seq(
scalaVersion := scala3,
version := SelectedVersion
)
lazy val publishSettings = Seq(
publishMavenStyle := true,
Test / publishArtifact := false,
pomIncludeRepository := Function.const(false),
scmInfo := Some(
ScmInfo(
url(s"https://git.garrity.co/garrity-software/$ProjectName"),
s"git@git.garrity.co:garrity-software/$ProjectName.git"
)
),
description := Description,
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")),
publishTo := {
val repo = "https://maven.garrity.co/"
if (SelectedVersion.endsWith("SNAPSHOT")) Some("snapshots" at repo + "snapshots")
else Some("releases" at repo + "releases")
}
version := semVerSelected.value
)
lazy val testSettings = Seq(
libraryDependencies ++= Seq(
"org.scalameta" %% "munit" % "1.0.0-M10" % Test
"org.scalameta" %% "munit" % "1.0.0-RC1" % Test
)
)
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(name := s"${gsProjectName.value}-v${semVerMajor.value}")
.settings(
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.5.2"
)
)
ThisBuild / scalacOptions ++= Seq(
"-encoding",
"utf8", // Set source file character encoding.
"-deprecation", // Emit warning and location for usages of deprecated APIs.
"-feature", // Emit warning and location for usages of features that should be imported explicitly.
"-explain", // Explain errors in more detail.
"-unchecked", // Enable additional warnings where generated code depends on assumptions.
"-explain-types", // Explain type errors in more detail.
"-Xfatal-warnings", // Fail the compilation if there are any warnings.
"-language:strictEquality", // Enable multiversal equality (require CanEqual)
"-Wunused:implicits", // Warn if an implicit parameter is unused.
"-Wunused:explicits", // Warn if an explicit parameter is unused.
"-Wunused:imports", // Warn if an import selector is not referenced.
"-Wunused:locals", // Warn if a local definition is unused.
"-Wunused:privates", // Warn if a private member is unused.
"-Ysafe-init" // Enable the experimental safe initialization check.
)

View file

@ -1 +1 @@
sbt.version=1.9.8
sbt.version=1.9.9

View file

@ -1 +1,33 @@
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.8")
def selectCredentials(): Credentials =
if ((Path.userHome / ".sbt" / ".credentials").exists())
Credentials(Path.userHome / ".sbt" / ".credentials")
else
Credentials.apply(
realm = "Reposilite",
host = "maven.garrity.co",
userName = sys.env
.get("GS_MAVEN_USER")
.getOrElse(
throw new RuntimeException(
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_USER environment variable."
)
),
passwd = sys.env
.get("GS_MAVEN_TOKEN")
.getOrElse(
throw new RuntimeException(
"You must either provide ~/.sbt/.credentials or specify the GS_MAVEN_TOKEN environment variable."
)
)
)
credentials += selectCredentials()
externalResolvers := Seq(
"Garrity Software Mirror" at "https://maven.garrity.co/releases",
"Garrity Software Releases" at "https://maven.garrity.co/gs"
)
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.0.11")
addSbtPlugin("gs" % "sbt-garrity-software" % "0.3.0")
addSbtPlugin("gs" % "sbt-gs-semver" % "0.3.0")

View file

@ -1,3 +0,0 @@
package gs.config
trait Configuration

View file

@ -0,0 +1,166 @@
package gs.config.v0
import cats.data.NonEmptyList
import cats.effect.Sync
import cats.syntax.all.*
import gs.config.v0.audit.ConfigManifest
import gs.config.v0.audit.ConfigQueryResult
import gs.config.v0.source.ConfigSource
import gs.config.v0.source.EnvironmentConfigSource
import gs.config.v0.source.MemoryConfigSource
/** Implementation of [[gs.config.Configuration]] that tracks every call to
* `getValue` and reports on whether the query succeeded or failed.
*
* @param sources
* List of sources to use, in order, when resolving keys.
* @param manifest
* The manifest used to audit configuration loaded by this class.
*/
final class AuditedConfiguration[F[_]: Sync](
val sources: NonEmptyList[ConfigSource[F]],
val manifest: ConfigManifest[F]
) extends BaseConfiguration[F]:
import AuditedConfiguration.Acc
override def getValue[A: Configurable](
key: ConfigKey[A]
): F[Either[ConfigError, A]] =
find(key)
.map { acc =>
acc -> (
acc.result match
case None => true -> handleMissingValue(key)
case Some(result) =>
false -> parse(key, result, acc.lastAttemptedSource)
)
}
.flatMap { case (acc, result) =>
audit(key, result._2, result._1, acc).as(result._2)
}
private def find[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, ?],
isDefault: Boolean,
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 =
if isDefault then
ConfigQueryResult.UsedDefault(
checkedSources = acc.sources.toList
)
else
ConfigQueryResult.Success(
source = acc.lastAttemptedSource,
rawValue = acc.result.getOrElse("")
)
)
object AuditedConfiguration:
/** Instantiate a new [[AuditedConfiguration]] based on the given list of
* sources.
*
* @param sources
* The list of [[ConfigSource]] backing this configuration.
* @return
* The new [[Configuration]] instance.
*/
def forSources[F[_]: Sync](
sources: NonEmptyList[ConfigSource[F]]
): F[AuditedConfiguration[F]] =
ConfigManifest
.standard[F]
.map(manifest =>
new AuditedConfiguration[F](
sources = sources,
manifest = manifest
)
)
def forSource[F[_]: Sync](source: ConfigSource[F]): Builder[F] =
Builder[F](NonEmptyList.of(source))
def forEnvironmentSource[F[_]: Sync]: Builder[F] =
Builder[F](NonEmptyList.of(new EnvironmentConfigSource[F]))
def forMemorySource[F[_]: Sync](configs: Map[String, String]): Builder[F] =
Builder[F](NonEmptyList.of(new MemoryConfigSource[F](configs)))
case class Builder[F[_]: Sync](sources: NonEmptyList[ConfigSource[F]]):
def withEnvironmentSource(): Builder[F] =
copy(sources = this.sources.append(new EnvironmentConfigSource[F]))
def withMemorySource(configs: Map[String, String]): Builder[F] =
copy(sources = this.sources.append(new MemoryConfigSource[F](configs)))
def withSource(source: ConfigSource[F]): Builder[F] =
copy(sources = this.sources.append(source))
def build(): F[AuditedConfiguration[F]] =
ConfigManifest
.standard[F]
.map(manifest =>
new AuditedConfiguration[F](
sources = sources,
manifest = manifest
)
)
/** Internal class for accumulating a result across sources.
*
* @param sources
* The seen sources.
* @param result
* The result, or `None` if no result has been found.
*/
private case class Acc(
sources: Vector[String],
result: Option[String]
):
def hasResult: Boolean = result.isDefined
def lastAttemptedSource: Option[String] = sources.lastOption
def withResult(r: String): Acc =
this.copy(sources, Some(r))
def appendSource(s: String): Acc =
this.copy(sources.appended(s), result)
private object Acc:
def empty[F[_]: Sync]: F[Acc] = Sync[F].pure(Acc(Vector.empty, None))
end AuditedConfiguration

View file

@ -0,0 +1,32 @@
package gs.config.v0
/** 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,
source: Option[String]
): Either[ConfigError, A] =
Configurable[A].parse(raw) match
case None =>
Left(
ConfigError.CannotParseValue(
configName = key.name,
candidateValue = raw,
source = source.getOrElse("")
)
)
case Some(parsed) => Right(parsed)

View file

@ -0,0 +1,35 @@
package gs.config.v0
/** Error hierarchy for the `gs-config` library. Indicates that something went
* wrong while attempting to load configuration values.
*/
sealed trait ConfigError
object ConfigError:
given CanEqual[ConfigError, ConfigError] = CanEqual.derived
/** Attempted to retreive the value for some [[ConfigKey]], but no value could
* be found.
*
* @param configName
* The name of the configuration value which was not found.
*/
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 configName
* The name of the configuration value which could not be parsed.
* @param candidateValue
* The raw value that could not be parsed.
* @param source
* The [[ConfigSource]] which provided the candidate value.
*/
case class CannotParseValue(
configName: ConfigName,
candidateValue: String,
source: String
) extends ConfigError
end ConfigError

View file

@ -0,0 +1,41 @@
package gs.config.v0
/** 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,54 @@
package gs.config.v0
/** 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
*
* - `unwrap()`
* - `toEnvironmentVariable()`
*/
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 unwrap(): 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()
end ConfigName

View file

@ -0,0 +1,56 @@
package gs.config.v0
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
/** @inheritDocs
*/
def parse(raw: String): Option[String] = Some(raw)
given Configurable[Int] with
/** @inheritDocs
*/
def parse(raw: String): Option[Int] = Try(raw.toInt).toOption
given Configurable[Long] with
/** @inheritDocs
*/
def parse(raw: String): Option[Long] = Try(raw.toLong).toOption
given Configurable[Boolean] with
/** @inheritDocs
*/
def parse(raw: String): Option[Boolean] = Try(raw.toBoolean).toOption
given Configurable[LocalDate] with
/** @inheritDocs
*/
def parse(raw: String): Option[LocalDate] =
Try(LocalDate.parse(raw)).toOption
given Configurable[Instant] with
/** @inheritDocs
*/
def parse(raw: String): Option[Instant] = Try(Instant.parse(raw)).toOption

View file

@ -0,0 +1,59 @@
package gs.config.v0
import cats.data.EitherT
import cats.effect.Sync
import gs.config.v0.audit.ConfigManifest
import gs.config.v0.source.ConfigSource
/** Interface for loading configuration. This type should not be used for any
* sensitive configuration such as secrets or private keys.
*/
trait Configuration[F[_]]:
/** Retrieve a value based on some key.
*
* @param key
* The key that identifies the piece of configuration to retrieve.
* @return
* The value, or an error if no value is present.
*/
def getValue[A: Configurable](key: ConfigKey[A]): F[Either[ConfigError, A]]
/** Retrieve a value based on some key. Return an `EitherT` as the response,
* rather than `F[Either[ConfigError, A]]`
*
* @param key
* The key that identifies the piece of configuration to retrieve.
* @return
* The value, or an error if no value is present. Expressed as an
* `EitherT`.
*/
def getValueT[A: Configurable](
key: ConfigKey[A]
): EitherT[F, ConfigError, A] = EitherT(getValue(key))
/** Get the manifest being used to track configuration queries.
*/
def manifest: ConfigManifest[F]
object Configuration:
/** Start building a new [[AuditedConfiguration]].
*
* @param source
* The first source relevant to the configuration.
* @return
* Fluent builder for the [[AuditedConfiguration]] class.
*/
def audited[F[_]: Sync](source: ConfigSource[F])
: AuditedConfiguration.Builder[F] =
AuditedConfiguration.forSource[F](source)
/** Create a new [[AuditedConfiguration]] that reads from the environment.
*
* @return
* The new [[Configuration]].
*/
def auditedEnvironmentOnly[F[_]: Sync]: F[AuditedConfiguration[F]] =
AuditedConfiguration.forEnvironmentSource[F].build()
end Configuration

View file

@ -0,0 +1,78 @@
package gs.config.v0.audit
import cats.effect.Ref
import cats.effect.Sync
import cats.syntax.all.*
import gs.config.v0.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[ConfigQueryResult]]]
/** Record a query for some [[ConfigName]] in this manifest.
*
* @param name
* The [[ConfigName]] that was queried.
* @param queryResult
* The [[ConfigQueryResult]] describing the result.
* @return
* Side-effect indicating that the query was recorded.
*/
def record(
name: ConfigName,
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[ConfigQueryResult]]]
) extends ConfigManifest[F]:
override def snapshot(): F[Map[ConfigName, List[ConfigQueryResult]]] =
manifest.get
override def record(
name: ConfigName,
query: ConfigQueryResult
): F[Unit] =
manifest.update(addQueryToName(name, query, _))
private def addQueryToName(
name: ConfigName,
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

View file

@ -0,0 +1,51 @@
package gs.config.v0.audit
import gs.config.v0.ConfigError
/** Describes queries used to find configuration. Used for auditing purposes and
* is captured by [[ConfigManifest]].
*/
sealed trait ConfigQueryResult
object ConfigQueryResult:
given CanEqual[ConfigQueryResult, ConfigQueryResult] = CanEqual.derived
/** 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 Success(
source: Option[String],
rawValue: String
) extends ConfigQueryResult
/** Represents a query for some configuration that did not find the value in
* any source, but was able to use a default value.
*
* @param checkedSources
* The list of sources that were checked before selecting the default
* value.
*/
case class UsedDefault(
checkedSources: List[String]
) extends ConfigQueryResult
/** 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. Note that a failure short-circuits lookup, so this list will
* not include any subsequent candidate sources.
* @param error
* The reason why this query failed.
*/
case class Failure(
sources: List[String],
error: ConfigError
) extends ConfigQueryResult
end ConfigQueryResult

View file

@ -0,0 +1,52 @@
package gs.config.v0.source
import cats.Applicative
import cats.effect.Sync
import gs.config.v0.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]:
/** @inheritDocs
*/
override def getValue(key: ConfigKey[?]): F[Option[String]] =
Applicative[F].pure(None)
/** @inheritDocs
*/
override val name: String = "empty"
end ConfigSource

View file

@ -0,0 +1,20 @@
package gs.config.v0.source
import cats.effect.Sync
import gs.config.v0.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]:
/** @inheritDocs
*/
override def getValue(
key: ConfigKey[?]
): F[Option[String]] =
Sync[F].delay(sys.env.get(key.name.toEnvironmentVariable()))
/** @inheritDocs
*/
override val name: String = "environment"

View file

@ -0,0 +1,28 @@
package gs.config.v0.source
import cats.Applicative
import gs.config.v0.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()
/** @inheritDocs
*/
override def getValue(
key: ConfigKey[?]
): F[Option[String]] =
Applicative[F].pure(configs.get(key.name.unwrap()))
/** @inheritDocs
*/
override lazy val name: String = s"in-memory-$id"

View file

@ -0,0 +1,24 @@
package gs.config.v0
class ConfigNameTests extends munit.FunSuite:
test("should express some name as an environment variable") {
val raw = "some.config-key"
val expected = "SOME_CONFIG_KEY"
val name = ConfigName(raw)
assertEquals(name.toEnvironmentVariable(), expected)
}
test("should unwrap some name as a string") {
val raw = "some.config-key"
val expected = raw
val name = ConfigName(raw)
assertEquals(name.unwrap(), expected)
}
test("should support equality") {
val raw = "some.config-key"
val name1 = ConfigName(raw)
val name2 = ConfigName(raw)
assertEquals(name1, name2)
}

View file

@ -0,0 +1,17 @@
package gs.config.v0
import cats.effect.IO
import cats.effect.unsafe.IORuntime
abstract class GsSuite extends munit.FunSuite:
given IORuntime = IORuntime.global
def iotest(
name: String
)(
body: => IO[Any]
)(
implicit
loc: munit.Location
): Unit =
test(new munit.TestOptions(name))(body.unsafeRunSync())

View file

@ -0,0 +1,345 @@
package gs.config.v0.audit
import cats.data.NonEmptyList
import cats.effect.IO
import gs.config.v0.AuditedConfiguration
import gs.config.v0.ConfigError
import gs.config.v0.ConfigKey
import gs.config.v0.ConfigName
import gs.config.v0.Configurable
import gs.config.v0.Configuration
import gs.config.v0.GsSuite
import gs.config.v0.source.ConfigSource
import gs.config.v0.source.MemoryConfigSource
import java.time.Instant
import java.time.LocalDate
class AuditedConfigurationTests extends GsSuite:
import AuditedConfigurationTests.*
given CanEqual[LocalDate, LocalDate] = CanEqual.derived
given CanEqual[Instant, Instant] = CanEqual.derived
iotest(
"should not return values, but should record attempts to find, when no config exists"
) {
for
config <- Configuration
.audited(ConfigSource.inMemory[IO](Map.empty))
.build()
string <- config.getValue(Keys.KString)
int <- config.getValue(Keys.KInt)
long <- config.getValue(Keys.KLong)
bool <- config.getValue(Keys.KBool)
localDate <- config.getValue(Keys.KLocalDate)
instant <- config.getValue(Keys.KInstant)
manifest <- config.manifest.snapshot()
yield
assertEquals(string, Left(ConfigError.MissingValue(Names.KString)))
assertEquals(int, Left(ConfigError.MissingValue(Names.KInt)))
assertEquals(long, Left(ConfigError.MissingValue(Names.KLong)))
assertEquals(bool, Left(ConfigError.MissingValue(Names.KBool)))
assertEquals(localDate, Left(ConfigError.MissingValue(Names.KLocalDate)))
assertEquals(instant, Left(ConfigError.MissingValue(Names.KInstant)))
assertMissing(config, Names.KString, manifest)
assertMissing(config, Names.KInt, manifest)
assertMissing(config, Names.KLong, manifest)
assertMissing(config, Names.KBool, manifest)
assertMissing(config, Names.KLocalDate, manifest)
assertMissing(config, Names.KInstant, manifest)
}
iotest(
"should not return values, but should record attempts to find, when no config exists, across multiple sources"
) {
for
config <- Configuration
.audited(ConfigSource.inMemory[IO](Map.empty))
.withMemorySource(Map.empty)
.withSource(ConfigSource.empty[IO])
.build()
string <- config.getValue(Keys.KString)
manifest <- config.manifest.snapshot()
yield
assertEquals(string, Left(ConfigError.MissingValue(Names.KString)))
assertEquals(
manifest.get(Names.KString),
Some(
List(
ConfigQueryResult.Failure(
sources = config.sources.toList.map(_.name),
error = ConfigError.MissingValue(Names.KString)
)
)
)
)
}
iotest("should find and audit a string value") {
testFound(Keys.KString, "test")
}
iotest("should find and audit a boolan value") {
testFound(Keys.KBool, true)
}
iotest("should find and audit an integer value") {
testFound(Keys.KInt, 11)
}
iotest("should find and audit a long value") {
testFound(Keys.KLong, 33L)
}
iotest("should find and audit a local date value") {
testFound(Keys.KLocalDate, LocalDate.now())
}
iotest("should find and audit an instant value") {
testFound(Keys.KInstant, Instant.now())
}
iotest("should find a value in the first source, skipping the next sources") {
val key = Keys.KString
val expectedValue = "value"
for
config <- Configuration
.audited(
ConfigSource
.inMemory[IO](Map(key.name.unwrap() -> expectedValue.toString()))
)
.withSource(ConfigSource.empty[IO])
.build()
value <- config.getValue(key)
manifest <- config.manifest.snapshot()
yield
assertEquals(value, Right(expectedValue))
assertSuccess(config, key.name, manifest, expectedValue.toString())
}
iotest("should instantiate for memory and environment sources") {
for
c1 <- AuditedConfiguration.forSource[IO](emptyMemorySource()).build()
c2 <- AuditedConfiguration.forEnvironmentSource[IO].build()
c3 <- AuditedConfiguration.forMemorySource[IO](Map.empty).build()
c4 <- AuditedConfiguration.forSources[IO](
NonEmptyList(emptyMemorySource(), List(emptyMemorySource()))
)
c5 <- AuditedConfiguration
.forEnvironmentSource[IO]
.withMemorySource(Map.empty)
.withEnvironmentSource()
.withSource(emptyMemorySource())
.build()
c6 <- Configuration.auditedEnvironmentOnly[IO]
yield
assertEquals(c1.sources.size, 1)
assertEquals(c2.sources.size, 1)
assertEquals(c3.sources.size, 1)
assertEquals(c4.sources.size, 2)
assertEquals(c5.sources.size, 4)
assertEquals(c6.sources.size, 1)
}
iotest("should audit the use of default values") {
val name = Names.KString
val defaultValue = "value"
val key = ConfigKey.WithDefaultValue(name, defaultValue)
for
config <- Configuration
.audited(ConfigSource.inMemory[IO](Map.empty))
.withMemorySource(Map.empty)
.build()
value <- config.getValue(key)
manifest <- config.manifest.snapshot()
yield
assertEquals(value, Right(defaultValue))
assertEquals(
manifest.get(name),
Some(
List(ConfigQueryResult.UsedDefault(config.sources.toList.map(_.name)))
)
)
}
iotest("should detect and audit parsing failures") {
val name = Names.KInt
val rawValue = "not-an-integer"
val key = ConfigKey.Required[Int](name)
for
config <- Configuration
.audited(ConfigSource.inMemory[IO](Map(name.unwrap() -> rawValue)))
.withMemorySource(Map.empty)
.build()
value <- config.getValue(key)
manifest <- config.manifest.snapshot()
yield
val expectedError = ConfigError.CannotParseValue(
configName = name,
candidateValue = rawValue,
source = config.sources.head.name
)
assertEquals(value, Left(expectedError))
assertEquals(
manifest.get(name),
Some(
List(
ConfigQueryResult.Failure(
sources = List(config.sources.head.name),
error = expectedError
)
)
)
)
}
iotest("should detect and audit parsing failures - EitherT") {
val name = Names.KInt
val rawValue = "not-an-integer"
val key = ConfigKey.Required[Int](name)
for
config <- Configuration
.audited(ConfigSource.inMemory[IO](Map(name.unwrap() -> rawValue)))
.withMemorySource(Map.empty)
.build()
value <- config.getValueT(key).value
manifest <- config.manifest.snapshot()
yield
val expectedError = ConfigError.CannotParseValue(
configName = name,
candidateValue = rawValue,
source = config.sources.head.name
)
assertEquals(value, Left(expectedError))
assertEquals(
manifest.get(name),
Some(
List(
ConfigQueryResult.Failure(
sources = List(config.sources.head.name),
error = expectedError
)
)
)
)
}
iotest("should audit multiple accesses of the same key") {
val key = Keys.KString
val expectedValue = "value"
for
config <- Configuration
.audited(
ConfigSource
.inMemory[IO](Map(key.name.unwrap() -> expectedValue.toString()))
)
.build()
v1 <- config.getValue(key)
v2 <- config.getValue(key)
v3 <- config.getValue(key)
manifest <- config.manifest.snapshot()
yield
assertEquals(v1, Right(expectedValue))
assertEquals(v2, Right(expectedValue))
assertEquals(v3, Right(expectedValue))
assertEquals(
manifest.get(key.name),
Some(
List(
ConfigQueryResult
.Success(Some(config.sources.head.name), expectedValue),
ConfigQueryResult
.Success(Some(config.sources.head.name), expectedValue),
ConfigQueryResult.Success(
Some(config.sources.head.name),
expectedValue
)
)
)
)
}
private def emptyMemorySource(): MemoryConfigSource[IO] =
MemoryConfigSource(Map.empty)
private def testFound[A: Configurable](
key: ConfigKey[A],
expectedValue: A
): IO[Any] =
for
config <- Configuration
.audited(
ConfigSource
.inMemory[IO](Map(key.name.unwrap() -> expectedValue.toString()))
)
.build()
value <- config.getValue(key)
manifest <- config.manifest.snapshot()
yield
assertEquals(value, Right(expectedValue))
assertSuccess(config, key.name, manifest, expectedValue.toString())
private def assertMissing(
config: AuditedConfiguration[IO],
name: ConfigName,
manifest: Map[ConfigName, List[ConfigQueryResult]]
): Unit =
assertEquals(
manifest.get(name),
Some(
List(
ConfigQueryResult.Failure(
sources = List(config.sources.head.name),
error = ConfigError.MissingValue(name)
)
)
)
)
private def assertSuccess(
config: AuditedConfiguration[IO],
name: ConfigName,
manifest: Map[ConfigName, List[ConfigQueryResult]],
expectedRawValue: String
): Unit =
assertEquals(
manifest.get(name),
Some(
List(
ConfigQueryResult.Success(
Some(config.sources.head.name),
expectedRawValue
)
)
)
)
object AuditedConfigurationTests:
object Names:
val KString: ConfigName = ConfigName("string")
val KInt: ConfigName = ConfigName("int")
val KLong: ConfigName = ConfigName("long")
val KBool: ConfigName = ConfigName("bool")
val KLocalDate: ConfigName = ConfigName("localdate")
val KInstant: ConfigName = ConfigName("instant")
end Names
object Keys:
val KString: ConfigKey[String] = ConfigKey.Required[String](Names.KString)
val KInt: ConfigKey[Int] = ConfigKey.Required[Int](Names.KInt)
val KLong: ConfigKey[Long] = ConfigKey.Required[Long](Names.KLong)
val KBool: ConfigKey[Boolean] = ConfigKey.Required[Boolean](Names.KBool)
val KLocalDate: ConfigKey[LocalDate] =
ConfigKey.Required[LocalDate](Names.KLocalDate)
val KInstant: ConfigKey[Instant] =
ConfigKey.Required[Instant](Names.KInstant)
end Keys
end AuditedConfigurationTests

View file

@ -0,0 +1,21 @@
package gs.config.v0.source
import cats.effect.IO
import gs.config.v0.ConfigKey
import gs.config.v0.ConfigName
import gs.config.v0.GsSuite
import java.util.UUID
class EnvironmentConfigSourceTests extends GsSuite:
test("should be named 'environment'") {
val source = ConfigSource.environment[IO]
assertEquals(source.name, "environment")
}
iotest("should query the environment for values") {
val source = ConfigSource.environment[IO]
val key =
ConfigKey.Required[String](ConfigName(UUID.randomUUID().toString()))
source.getValue(key).map(result => assertEquals(result, None))
}