Skip to content

Commit 3fd41f1

Browse files
authored
Add StackExchange.Utils.Configuration (#15)
* Add StackExchange.Utils.Configuration This commit adds a helper library that performs substitution and prefixing for `IConfiguration`-based configuration sources. It allows a value in the configuration tree to reference other values within the configuration system using a placeholder syntax `${key}` or `${nested:key}`. In addition, prefixing allows a "namespace" prefix to be applied to subset of configuration, making it possible to segment configuration into logical areas. This is particularly useful for storing secrets in a different, secure location but in a way that it makes it easy to compose configuration like connection strings with dealing with it inside the application. E.g. consider the following JSON: **appsettings.json** ```json { "ConnectionStrings": { "Database": "Server=srv01;Database=db01;User ID=${secrets:Database:UserId};Password=${secrets:Database:Password}" } } ``` **secrets.json** ```json { "Database": { "UserId": "User123", "Password": "Password123!" } } ``` This instructs the substitution provider to lookup the keys `secrets:Database:UserId` and `secrets:Database:Password` from configuration again and replaces them in the value returned for `ConnectionStrings:Database`. To support this a `ConfigurationBuilder` is configured as follows: ```c# var configuration = new ConfigurationBuilder() .WithPrefix( "secrets", // everything in this configuration builder will be prefixed with "secrets:" c => c.AddJsonFile("secrets.json") ) .WithSubstitution( // values in this configuration builder will have substitutions // replaced prior to being returned to the caller c => c.AddJsonFile("appsettings.json") ) .Build(); ``` Here we're loading a JSON file called `secrets.json` (it could equally be any source supported by the `IConfiguration` system) and prefixing it with `secrets:`. Then, we load `appsettings.json` with support for substitutions. If a caller asks the `IConfiguration` that is produced for a connection string: ```c# var connectionString = configuration.GetConnectionString("Database"); ``` That value will be returned as `Server=srv01;Database=db01;User ID=User123;Password=Password123!`. - Build scripts updated to build all projects - Builds scripts updated to support Linux/MacOS - `.gitignore` tweaks for Rider - Move to `LangVersion = latest` - `version.json` in each packages directories - Use `Microsoft.NETFramework.ReferenceAssemblies` for net462 builds on Linux * Typos in doc * Re-enable Kestrel tests that were conditionally compiled out and fix the build breaks MacOS doesn't support a PKCS12 certificate without a password so `certificate.pfx` has been updated to add a password (`password`). Also MacOS does not support ALPN so HTTP/2 over TLS is not currently available - this commit skips those tests on MacOS. * Clarifying comment * Fix up build scripts to use `Build.csproj` instead of individual project files * Argument validation + tests * Support `Set` in the prefix and substitution providers, add tests
1 parent 311cf7a commit 3fd41f1

22 files changed

+1094
-49
lines changed

Readme.md

+73-1
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,77 @@ settings.ProfileRequest = request => MiniProfiler.Current.CustomTiming("http", r
5858
settings.ProfileGeneral = name => MiniProfiler.Current.Step(name);
5959
```
6060

61-
#### License
61+
### StackExchange.Utils.Configuration
62+
`StackExchange.Utils.Configuration` is a helper library that performs substitution and prefixing for `IConfiguration`-based configuration sources. It allows a value in the configuration tree to reference other values within the configuration system using a placeholder syntax `${key}` or `${nested:key}`. In addition, prefixing allows a "namespace" prefix to be applied to a subset of configuration, making it possible to segment configuration into logical areas.
63+
64+
This is particularly useful for storing secrets in a different, secure location but in a way that it makes it easy to compose configuration values like connection strings without dealing with it inside the application. E.g. consider the following files:
65+
66+
**appsettings.json**
67+
```json
68+
{
69+
"ConnectionStrings": {
70+
"Database": "Server=srv01;Database=db01;User ID=${secrets:Database:UserId};Password=${secrets:Database:Password}"
71+
}
72+
}
73+
```
74+
75+
**secrets.json**
76+
```json
77+
{
78+
"Database": {
79+
"UserId": "User123",
80+
"Password": "Password123!"
81+
}
82+
}
83+
```
84+
85+
This instructs the substitution provider to lookup the keys `secrets:Database:UserId` and `secrets:Database:Password` from the configuration system and replaces them in the value returned for `ConnectionStrings:Database`.
86+
87+
To support this a `ConfigurationBuilder` is configured as follows:
88+
89+
```c#
90+
var configuration = new ConfigurationBuilder()
91+
.WithPrefix(
92+
"secrets",
93+
// everything in this configuration builder will be prefixed with "secrets:"
94+
c => c.AddJsonFile("secrets.json")
95+
)
96+
.WithSubstitution(
97+
// values in this configuration builder will have substitutions
98+
// replaced prior to being returned to the caller
99+
c => c.AddJsonFile("appsettings.json")
100+
)
101+
.Build();
102+
```
103+
104+
Here we're loading a JSON file called `secrets.json` (it could equally be any source supported by the `IConfiguration` system - ideally something secure like Azure KeyVault or Hashicorp's Vault) and prefixing it with `secrets:`. Then, we load `appsettings.json` with support for substitutions. If a caller asks the `IConfiguration` that is produced for a connection string:
105+
106+
```c#
107+
var connectionString = configuration.GetConnectionString("Database");
108+
```
109+
110+
That value will be returned as `Server=srv01;Database=db01;User ID=User123;Password=Password123!`.
111+
112+
#### Substituting existing values
113+
In some cases it's useful to be able to substitute placeholders in existing strings. Support for that is provided by the `SubstitutionHelper`:
114+
115+
```c#
116+
var configuration = new ConfigurationBuilder()
117+
.WithPrefix(
118+
"secrets",
119+
// everything in this configuration builder will be prefixed with "secrets:"
120+
c => c.AddJsonFile("secrets.json")
121+
)
122+
.Build();
123+
124+
var value = "Server=srv01;Database=db01;User ID=${secrets:Database:UserId};Password=${secrets:Database:Password}";
125+
var valueWithSubstitution = SubstitutionHelper.ReplaceSubstitutionPlaceholders(value, configuration);
126+
```
127+
128+
This will do exactly the same as if the value was substituted within the configuration system itself - the returned value will be `Server=srv01;Database=db01;User ID=User123;Password=Password123!`.
129+
130+
#### Notes
131+
If a value has substitution placeholders that could not be replaced they are left intact - only placeholder keys that can be located in the configuration system are replaced.
132+
133+
### License
62134
StackExchange.Utils is licensed under the [MIT license](https://github.com/StackExchange/StackExchange.Utils/blob/master/LICENSE.txt).

StackExchange.Utils.sln

+8-1
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
2525
build.ps1 = build.ps1
2626
global.json = global.json
2727
nuget.config = nuget.config
28-
version.json = version.json
28+
build.sh = build.sh
2929
EndProjectSection
3030
EndProject
31+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StackExchange.Utils.Configuration", "src\StackExchange.Utils.Configuration\StackExchange.Utils.Configuration.csproj", "{7BADB8FA-9723-4B10-B71C-427F2431D4FE}"
32+
EndProject
3133
Global
3234
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3335
Debug|Any CPU = Debug|Any CPU
@@ -42,13 +44,18 @@ Global
4244
{33716E0F-FE40-4A7A-9F58-1026EF7EBCD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
4345
{33716E0F-FE40-4A7A-9F58-1026EF7EBCD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
4446
{33716E0F-FE40-4A7A-9F58-1026EF7EBCD2}.Release|Any CPU.Build.0 = Release|Any CPU
47+
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
48+
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Debug|Any CPU.Build.0 = Debug|Any CPU
49+
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Release|Any CPU.ActiveCfg = Release|Any CPU
50+
{7BADB8FA-9723-4B10-B71C-427F2431D4FE}.Release|Any CPU.Build.0 = Release|Any CPU
4551
EndGlobalSection
4652
GlobalSection(SolutionProperties) = preSolution
4753
HideSolutionNode = FALSE
4854
EndGlobalSection
4955
GlobalSection(NestedProjects) = preSolution
5056
{168B503A-428F-499D-99A7-8EFC47A5FEDF} = {F0CFAC4D-516B-45DC-8F66-D58E3B1C04E1}
5157
{33716E0F-FE40-4A7A-9F58-1026EF7EBCD2} = {9133A680-3A8F-4662-AA58-B59BBDD0A60E}
58+
{7BADB8FA-9723-4B10-B71C-427F2431D4FE} = {F0CFAC4D-516B-45DC-8F66-D58E3B1C04E1}
5259
EndGlobalSection
5360
GlobalSection(ExtensibilityGlobals) = postSolution
5461
SolutionGuid = {F211D702-85D2-4159-9B42-60B6177497B7}

appveyor.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ nuget:
1717
disable_publish_on_pr: true
1818

1919
build_script:
20-
- ps: .\build.ps1 -PullRequestNumber "$env:APPVEYOR_PULL_REQUEST_NUMBER" -CreatePackages $true
20+
- ps: ./build.ps1 -PullRequestNumber "$env:APPVEYOR_PULL_REQUEST_NUMBER" -CreatePackages $true
2121

2222
test: off
2323
artifacts:
24-
- path: .\.nupkgs\*.nupkg
24+
- path: ./.nupkgs/*.nupkg
2525

2626
deploy:
2727
- provider: NuGet

build.ps1

100644100755
+13-26
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,7 @@ Write-Host " CreatePackages: $CreatePackages"
1010
Write-Host " RunTests: $RunTests"
1111
Write-Host " dotnet --version:" (dotnet --version)
1212

13-
$packageOutputFolder = "$PSScriptRoot\.nupkgs"
14-
$projectsToBuild =
15-
'StackExchange.Utils.Http'
16-
13+
$packageOutputFolder = Join-Path $PSScriptRoot ".nupkgs"
1714
$testsToRun =
1815
'StackExchange.Utils.Tests'
1916

@@ -23,40 +20,30 @@ if ($PullRequestNumber) {
2320
}
2421

2522
Write-Host "Building solution..." -ForegroundColor "Magenta"
26-
dotnet restore ".\StackExchange.Utils.sln" /p:CI=true
27-
dotnet build ".\StackExchange.Utils.sln" -c Release /p:CI=true
23+
dotnet build ./Build.csproj -c Release /p:CI=true
2824
Write-Host "Done building." -ForegroundColor "Green"
2925

3026
if ($RunTests) {
31-
foreach ($project in $testsToRun) {
32-
Write-Host "Running tests: $project (all frameworks)" -ForegroundColor "Magenta"
33-
Push-Location ".\tests\$project"
34-
35-
dotnet test -c Release
36-
if ($LastExitCode -ne 0) {
37-
Write-Host "Error with tests, aborting build." -Foreground "Red"
38-
Pop-Location
39-
Exit 1
40-
}
41-
42-
Write-Host "Tests passed!" -ForegroundColor "Green"
43-
Pop-Location
27+
Write-Host "Running tests (all frameworks)" -ForegroundColor "Magenta"
28+
dotnet test ./Build.csproj -c Release
29+
if ($LastExitCode -ne 0) {
30+
Write-Host "Error with tests, aborting build." -Foreground "Red"
31+
Exit 1
4432
}
33+
Write-Host "Tests passed!" -ForegroundColor "Green"
4534
}
4635

4736
if ($CreatePackages) {
48-
mkdir -Force $packageOutputFolder | Out-Null
37+
if (!(Test-Path $packageOutputFolder)) {
38+
New-Item -ItemType Directory $packageOutputFolder | Out-Null
39+
}
4940
Write-Host "Clearing existing $packageOutputFolder..." -NoNewline
50-
Get-ChildItem $packageOutputFolder | Remove-Item
41+
Get-ChildItem $packageOutputFolder -ErrorAction Ignore | Remove-Item
5142
Write-Host "done." -ForegroundColor "Green"
5243

5344
Write-Host "Building all packages" -ForegroundColor "Green"
5445

55-
foreach ($project in $projectsToBuild) {
56-
Write-Host "Packing $project (dotnet pack)..." -ForegroundColor "Magenta"
57-
dotnet pack ".\src\$project\$project.csproj" --no-build -c Release /p:PackageOutputPath=$packageOutputFolder /p:NoPackageAnalysis=true /p:CI=true
58-
Write-Host ""
59-
}
46+
dotnet pack ./Build.csproj --no-build -c Release -o $packageOutputFolder /p:NoPackageAnalysis=true /p:CI=true
6047
}
6148

6249
Write-Host "Done."

build.sh

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/bin/sh
2+
pwsh -NoProfile -NoLogo -ExecutionPolicy unrestricted -Command "[System.Threading.Thread]::CurrentThread.CurrentCulture = ''; [System.Threading.Thread]::CurrentThread.CurrentUICulture = '';& './build.ps1' $*; exit $LASTEXITCODE"

src/Directory.Build.props

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<OutputPath>bin\$(Configuration)\</OutputPath>
66
<DebugSymbols>true</DebugSymbols>
77
<DebugType>embedded</DebugType>
8-
<LangVersion>7.3</LangVersion>
8+
<LangVersion>latest</LangVersion>
99
<GenerateDocumentationFile>true</GenerateDocumentationFile>
1010
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1111

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.Immutable;
4+
using System.Linq;
5+
using Microsoft.Extensions.Configuration;
6+
using Microsoft.Extensions.Primitives;
7+
8+
namespace StackExchange.Utils
9+
{
10+
internal abstract class CompositeConfigurationProvider : IConfigurationProvider
11+
{
12+
protected CompositeConfigurationProvider(IConfigurationRoot configurationRoot)
13+
{
14+
ConfigurationRoot = configurationRoot;
15+
}
16+
17+
protected IConfigurationRoot ConfigurationRoot { get; }
18+
19+
public virtual IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath)
20+
=> ConfigurationRoot.Providers.SelectMany(x => x.GetChildKeys(earlierKeys, parentPath));
21+
22+
public IChangeToken GetReloadToken() => new CompositeChangeToken(
23+
ConfigurationRoot.Providers.Select(x => x.GetReloadToken()).ToImmutableArray()
24+
);
25+
26+
private bool _initialLoad = true;
27+
public void Load()
28+
{
29+
if (_initialLoad)
30+
{
31+
_initialLoad = false;
32+
return;
33+
}
34+
ConfigurationRoot.Reload();
35+
}
36+
37+
public virtual void Set(string key, string value) => ConfigurationRoot[key] = value;
38+
39+
public virtual bool TryGet(string key, out string value)
40+
{
41+
value = ConfigurationRoot[key];
42+
return value != null;
43+
}
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using System;
2+
using Microsoft.Extensions.Configuration;
3+
4+
namespace StackExchange.Utils
5+
{
6+
/// <summary>
7+
/// Extension methods for <see cref="IConfigurationBuilder"/> to provide prefixing
8+
/// and substitution support in <see cref="IConfiguration"/>.
9+
/// </summary>
10+
public static class ConfigurationBuilderExtensions
11+
{
12+
/// <summary>
13+
/// Wraps a child <see cref="IConfigurationBuilder"/> with support for substituting values
14+
/// of the form '${secrets:Database:Password}' with configuration from elsewhere in the
15+
/// configuration system. The key in braces is looked and used to rewrite the value returned
16+
/// from configuration.
17+
/// </summary>
18+
public static IConfigurationBuilder WithSubstitution(this IConfigurationBuilder builder, Action<IConfigurationBuilder> action)
19+
{
20+
if (action == null) throw new ArgumentNullException(nameof(action));
21+
22+
var childBuilder = new ConfigurationBuilder();
23+
action(childBuilder);
24+
builder.Add(new SubstitutingConfigurationSource(childBuilder));
25+
return builder;
26+
}
27+
28+
/// <summary>
29+
/// Wraps a child <see cref="IConfigurationBuilder"/> with support for a prefix on all keys
30+
/// contained within it. This can be used to effectively namespace a set of configuration values
31+
/// and is useful when used in combination with <see cref="WithSubstitution"/>.
32+
/// </summary>
33+
public static IConfigurationBuilder WithPrefix(this IConfigurationBuilder builder, string prefix, Action<IConfigurationBuilder> action)
34+
{
35+
if (string.IsNullOrEmpty(prefix)) throw new ArgumentNullException(nameof(prefix));
36+
if (action == null) throw new ArgumentNullException(nameof(action));
37+
38+
var childBuilder = new ConfigurationBuilder();
39+
action(childBuilder);
40+
builder.Add(new PrefixedConfigurationSource(prefix, childBuilder));
41+
return builder;
42+
}
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Microsoft.Extensions.Configuration;
5+
6+
namespace StackExchange.Utils
7+
{
8+
internal class PrefixedConfigurationProvider : CompositeConfigurationProvider
9+
{
10+
private readonly string _prefixWithDelimiter;
11+
public const char Delimiter = ':';
12+
13+
public PrefixedConfigurationProvider(string prefix, IConfigurationRoot configurationRoot) : base(configurationRoot)
14+
{
15+
_prefixWithDelimiter = prefix + Delimiter;
16+
}
17+
18+
public override IEnumerable<string> GetChildKeys(IEnumerable<string> earlierKeys, string parentPath)
19+
{
20+
var earlierKeyList = earlierKeys.ToList();
21+
foreach (var provider in ConfigurationRoot.Providers)
22+
{
23+
foreach (var childKey in provider.GetChildKeys(earlierKeyList, parentPath))
24+
{
25+
yield return _prefixWithDelimiter + childKey;
26+
}
27+
}
28+
}
29+
30+
public override void Set(string key, string value)
31+
{
32+
if (!key.StartsWith(_prefixWithDelimiter) || key.Length == _prefixWithDelimiter.Length)
33+
{
34+
return;
35+
}
36+
37+
// TODO: make this moar efficient!
38+
// slice off the prefix so we can fetch from our underlying providers
39+
base.Set(key.AsSpan().Slice(_prefixWithDelimiter.Length).ToString(), value);
40+
}
41+
42+
public override bool TryGet(string key, out string value)
43+
{
44+
if (!key.StartsWith(_prefixWithDelimiter) || key.Length == _prefixWithDelimiter.Length)
45+
{
46+
value = null;
47+
return false;
48+
}
49+
50+
// TODO: make this moar efficient!
51+
// slice off the prefix so we can fetch from our underlying providers
52+
var keyWithoutPrefix = key.AsSpan().Slice(_prefixWithDelimiter.Length);
53+
return base.TryGet(keyWithoutPrefix.ToString(), out value);
54+
}
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using Microsoft.Extensions.Configuration;
2+
3+
namespace StackExchange.Utils
4+
{
5+
internal class PrefixedConfigurationSource : IConfigurationSource
6+
{
7+
private readonly IConfigurationBuilder _childBuilder;
8+
private readonly string _prefix;
9+
10+
public PrefixedConfigurationSource(string prefix, ConfigurationBuilder childBuilder)
11+
{
12+
_childBuilder = childBuilder;
13+
_prefix = prefix;
14+
}
15+
16+
public IConfigurationProvider Build(IConfigurationBuilder builder) => new PrefixedConfigurationProvider(_prefix, _childBuilder.Build());
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Library</OutputType>
5+
<TargetFramework>netcoreapp3.1</TargetFramework>
6+
</PropertyGroup>
7+
8+
<ItemGroup>
9+
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.6" />
10+
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="3.1.6" />
11+
</ItemGroup>
12+
13+
</Project>

0 commit comments

Comments
 (0)