(patch) Initial implementation of the configuration library. (#1)
All checks were successful
/ Build and Release Library (push) Successful in 1m29s
All checks were successful
/ Build and Release Library (push) Successful in 1m29s
Reviewed-on: #1
This commit is contained in:
parent
f8928d24af
commit
f127645860
26 changed files with 1307 additions and 332 deletions
68
.forgejo/workflows/pull_request.yaml
Normal file
68
.forgejo/workflows/pull_request.yaml
Normal 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
|
84
.forgejo/workflows/release.yaml
Normal file
84
.forgejo/workflows/release.yaml
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
203
LICENSE
|
@ -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.
|
||||
|
|
22
README.md
22
README.md
|
@ -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
133
build.sbt
|
@ -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.
|
||||
)
|
||||
|
|
|
@ -1 +1 @@
|
|||
sbt.version=1.9.8
|
||||
sbt.version=1.9.9
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
package gs.config
|
||||
|
||||
trait Configuration
|
166
src/main/scala/gs/config/v0/AuditedConfiguration.scala
Normal file
166
src/main/scala/gs/config/v0/AuditedConfiguration.scala
Normal 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
|
32
src/main/scala/gs/config/v0/BaseConfiguration.scala
Normal file
32
src/main/scala/gs/config/v0/BaseConfiguration.scala
Normal 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)
|
35
src/main/scala/gs/config/v0/ConfigError.scala
Normal file
35
src/main/scala/gs/config/v0/ConfigError.scala
Normal 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
|
41
src/main/scala/gs/config/v0/ConfigKey.scala
Normal file
41
src/main/scala/gs/config/v0/ConfigKey.scala
Normal 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]
|
54
src/main/scala/gs/config/v0/ConfigName.scala
Normal file
54
src/main/scala/gs/config/v0/ConfigName.scala
Normal 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
|
56
src/main/scala/gs/config/v0/Configurable.scala
Normal file
56
src/main/scala/gs/config/v0/Configurable.scala
Normal 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
|
59
src/main/scala/gs/config/v0/Configuration.scala
Normal file
59
src/main/scala/gs/config/v0/Configuration.scala
Normal 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
|
78
src/main/scala/gs/config/v0/audit/ConfigManifest.scala
Normal file
78
src/main/scala/gs/config/v0/audit/ConfigManifest.scala
Normal 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
|
51
src/main/scala/gs/config/v0/audit/ConfigQueryResult.scala
Normal file
51
src/main/scala/gs/config/v0/audit/ConfigQueryResult.scala
Normal 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
|
52
src/main/scala/gs/config/v0/source/ConfigSource.scala
Normal file
52
src/main/scala/gs/config/v0/source/ConfigSource.scala
Normal 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
|
|
@ -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"
|
28
src/main/scala/gs/config/v0/source/MemoryConfigSource.scala
Normal file
28
src/main/scala/gs/config/v0/source/MemoryConfigSource.scala
Normal 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"
|
24
src/test/scala/gs/config/v0/ConfigNameTests.scala
Normal file
24
src/test/scala/gs/config/v0/ConfigNameTests.scala
Normal 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)
|
||||
}
|
17
src/test/scala/gs/config/v0/GsSuite.scala
Normal file
17
src/test/scala/gs/config/v0/GsSuite.scala
Normal 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())
|
|
@ -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
|
|
@ -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))
|
||||
}
|
Loading…
Add table
Reference in a new issue