From 32923515dbf6d0c1d6a33e9433f72d1d61bb82ec Mon Sep 17 00:00:00 2001
From: fraliv13 <5892139+fraliv13@users.noreply.github.com>
Date: Thu, 28 Apr 2022 12:56:15 +0300
Subject: [PATCH] dotenv configuration provider (#223)
* dotenv configuration provider
* Documentation
---
NBB.sln | 14 ++
.../DotEnvConfigurationExtensions.cs | 90 ++++++++++++
.../DotEnvConfigurationProvider.cs | 91 ++++++++++++
.../DotEnvConfigurationSource.cs | 30 ++++
.../NBB.Core.Configuration.csproj | 16 ++
src/Core/NBB.Core.Configuration/README.md | 41 ++++++
.../DotEnvConfigurationTests.cs | 139 ++++++++++++++++++
.../NBB.Core.Configuration.Tests.csproj | 26 ++++
8 files changed, 447 insertions(+)
create mode 100644 src/Core/NBB.Core.Configuration/DotEnvConfigurationExtensions.cs
create mode 100644 src/Core/NBB.Core.Configuration/DotEnvConfigurationProvider.cs
create mode 100644 src/Core/NBB.Core.Configuration/DotEnvConfigurationSource.cs
create mode 100644 src/Core/NBB.Core.Configuration/NBB.Core.Configuration.csproj
create mode 100644 src/Core/NBB.Core.Configuration/README.md
create mode 100644 test/UnitTests/Core/NBB.Core.Configuration.Tests/DotEnvConfigurationTests.cs
create mode 100644 test/UnitTests/Core/NBB.Core.Configuration.Tests/NBB.Core.Configuration.Tests.csproj
diff --git a/NBB.sln b/NBB.sln
index 9b0053b9..f0198ff6 100644
--- a/NBB.sln
+++ b/NBB.sln
@@ -398,6 +398,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Serilog", "Serilog", "{33AE
src\Tools\Serilog\README.md = src\Tools\Serilog\README.md
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NBB.Core.Configuration", "src\Core\NBB.Core.Configuration\NBB.Core.Configuration.csproj", "{2C7CA0BD-98AD-4CC1-B496-94ED49636466}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NBB.Core.Configuration.Tests", "test\UnitTests\Core\NBB.Core.Configuration.Tests\NBB.Core.Configuration.Tests.csproj", "{2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -892,6 +896,14 @@ Global
{81E535EC-ACB5-4EE4-A173-06983B40F80F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{81E535EC-ACB5-4EE4-A173-06983B40F80F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{81E535EC-ACB5-4EE4-A173-06983B40F80F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2C7CA0BD-98AD-4CC1-B496-94ED49636466}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2C7CA0BD-98AD-4CC1-B496-94ED49636466}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2C7CA0BD-98AD-4CC1-B496-94ED49636466}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2C7CA0BD-98AD-4CC1-B496-94ED49636466}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1057,6 +1069,8 @@ Global
{C964F164-C8BF-487A-9D24-5E169A7E950F} = {33AEB049-EC66-4728-9B22-CC0F282D3E73}
{81E535EC-ACB5-4EE4-A173-06983B40F80F} = {2177F9CE-CD18-4D61-8DAE-0E27D3C44AB3}
{33AEB049-EC66-4728-9B22-CC0F282D3E73} = {EF374BA2-61FC-41FB-8229-5187DCBAA63A}
+ {2C7CA0BD-98AD-4CC1-B496-94ED49636466} = {14726095-DA28-43A6-A9A9-F16C605932E1}
+ {2A91E54D-F63C-4E92-9A6D-F15C7C55B0D7} = {29B7593C-60F4-41DC-A883-4976FF467927}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {23A42379-616A-43EF-99BC-803DF151F54E}
diff --git a/src/Core/NBB.Core.Configuration/DotEnvConfigurationExtensions.cs b/src/Core/NBB.Core.Configuration/DotEnvConfigurationExtensions.cs
new file mode 100644
index 00000000..71f43ef1
--- /dev/null
+++ b/src/Core/NBB.Core.Configuration/DotEnvConfigurationExtensions.cs
@@ -0,0 +1,90 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.FileProviders;
+using NBB.Core.Configuration.DotEnv;
+
+namespace Microsoft.Extensions.Configuration
+{
+ ///
+ /// Extension methods for adding .
+ ///
+ public static class DotEnvConfigurationExtensions
+ {
+ ///
+ /// Adds the DotEnv configuration provider at to .
+ ///
+ /// The to add to.
+ /// Path relative to the base path stored in
+ /// of .
+ /// The .
+ public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, string path)
+ {
+ return AddDotEnvFile(builder, provider: null, path: path, optional: false, reloadOnChange: false);
+ }
+
+ ///
+ /// Adds the DotEnv configuration provider at to .
+ ///
+ /// The to add to.
+ /// Path relative to the base path stored in
+ /// of .
+ /// Whether the file is optional.
+ /// The .
+ public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, string path, bool optional)
+ {
+ return AddDotEnvFile(builder, provider: null, path: path, optional: optional, reloadOnChange: false);
+ }
+
+ ///
+ /// Adds the DotEnv configuration provider at to .
+ ///
+ /// The to add to.
+ /// Path relative to the base path stored in
+ /// of .
+ /// Whether the file is optional.
+ /// Whether the configuration should be reloaded if the file changes.
+ /// The .
+ public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, string path, bool optional, bool reloadOnChange)
+ {
+ return AddDotEnvFile(builder, provider: null, path: path, optional: optional, reloadOnChange: reloadOnChange);
+ }
+
+ ///
+ /// Adds a DotEnv configuration source to .
+ ///
+ /// The to add to.
+ /// The to use to access the file.
+ /// Path relative to the base path stored in
+ /// of .
+ /// Whether the file is optional.
+ /// Whether the configuration should be reloaded if the file changes.
+ /// The .
+ public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, IFileProvider? provider, string path, bool optional, bool reloadOnChange)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentException("Invalid DotEnv file path", nameof(path));
+ }
+
+ return builder.AddDotEnvFile(s =>
+ {
+ s.FileProvider = provider;
+ s.Path = path;
+ s.Optional = optional;
+ s.ReloadOnChange = reloadOnChange;
+ s.ResolveFileProvider();
+ });
+ }
+
+ ///
+ /// Adds a DotEnv configuration source to .
+ ///
+ /// The to add to.
+ /// Configures the source.
+ /// The .
+ public static IConfigurationBuilder AddDotEnvFile(this IConfigurationBuilder builder, Action? configureSource)
+ => builder.Add(configureSource);
+
+ }
+}
diff --git a/src/Core/NBB.Core.Configuration/DotEnvConfigurationProvider.cs b/src/Core/NBB.Core.Configuration/DotEnvConfigurationProvider.cs
new file mode 100644
index 00000000..772c6e9f
--- /dev/null
+++ b/src/Core/NBB.Core.Configuration/DotEnvConfigurationProvider.cs
@@ -0,0 +1,91 @@
+// Copyright (c) TotalSoft.
+// This source code is licensed under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace NBB.Core.Configuration.DotEnv
+{
+ ///
+ /// An DotEnv file based .
+ /// Files are simple line structures with environment variable declarations as key-value pairs
+ ///
+ ///
+ /// key1=value1
+ /// key2 = " value2 "
+ /// # comment
+ ///
+ public class DotEnvConfigurationProvider : FileConfigurationProvider
+ {
+ ///
+ /// Initializes a new instance with the specified source.
+ ///
+ /// The source settings.
+ public DotEnvConfigurationProvider(DotEnvConfigurationSource source) : base(source) { }
+
+ ///
+ /// Loads the DotEnv data from a stream.
+ ///
+ /// The stream to read.
+ public override void Load(Stream stream)
+ => Data = Read(stream);
+
+ ///
+ /// Read a stream of DotEnv values into a key/value dictionary.
+ ///
+ /// The stream of DotEnv data.
+ /// The which was read from the stream.
+ private static IDictionary Read(Stream stream)
+ {
+ var data = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ using (var reader = new StreamReader(stream))
+ {
+ string sectionPrefix = string.Empty;
+
+ while (reader.Peek() != -1)
+ {
+ string rawLine = reader.ReadLine()!;
+ string line = rawLine.Trim();
+
+ // Ignore blank lines
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+ // Ignore comments
+ if (line[0] is '#')
+ {
+ continue;
+ }
+
+ // key = value OR "value"
+ int separator = line.IndexOf('=');
+ if (separator < 0)
+ {
+ throw new FormatException($"Unrecognized line formmat: {rawLine}");
+ }
+
+ string key = sectionPrefix + line.Substring(0, separator).Trim();
+ string value = line.Substring(separator + 1).Trim();
+
+ // Remove quotes
+ if (value.Length > 1 && value[0] == '"' && value[value.Length - 1] == '"')
+ {
+ value = value.Substring(1, value.Length - 2);
+ }
+
+ if (data.ContainsKey(key))
+ {
+ throw new FormatException($"Duplicated key: {key}");
+ }
+
+ var normalizedKey = Normalize(key);
+ data[normalizedKey] = value;
+ }
+ }
+ return data;
+ }
+
+ private static string Normalize(string key)
+ => key.Replace("__", ConfigurationPath.KeyDelimiter);
+ }
+}
diff --git a/src/Core/NBB.Core.Configuration/DotEnvConfigurationSource.cs b/src/Core/NBB.Core.Configuration/DotEnvConfigurationSource.cs
new file mode 100644
index 00000000..fb9c0511
--- /dev/null
+++ b/src/Core/NBB.Core.Configuration/DotEnvConfigurationSource.cs
@@ -0,0 +1,30 @@
+// Copyright (c) TotalSoft.
+// This source code is licensed under the MIT license.
+
+using Microsoft.Extensions.Configuration;
+
+namespace NBB.Core.Configuration.DotEnv
+{
+ ///
+ /// Represents a DotEnv file as an .
+ /// Files are simple line structures with environment variable declarations as key-value pairs
+ ///
+ ///
+ /// key1=value1
+ /// key2 = " value2 "
+ /// # comment
+ ///
+ public class DotEnvConfigurationSource : FileConfigurationSource
+ {
+ ///
+ /// Builds the for this source.
+ ///
+ /// The .
+ /// An
+ public override IConfigurationProvider Build(IConfigurationBuilder builder)
+ {
+ EnsureDefaults(builder);
+ return new DotEnvConfigurationProvider(this);
+ }
+ }
+}
diff --git a/src/Core/NBB.Core.Configuration/NBB.Core.Configuration.csproj b/src/Core/NBB.Core.Configuration/NBB.Core.Configuration.csproj
new file mode 100644
index 00000000..3c018e5a
--- /dev/null
+++ b/src/Core/NBB.Core.Configuration/NBB.Core.Configuration.csproj
@@ -0,0 +1,16 @@
+
+
+
+ net6.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Core/NBB.Core.Configuration/README.md b/src/Core/NBB.Core.Configuration/README.md
new file mode 100644
index 00000000..c2981f6a
--- /dev/null
+++ b/src/Core/NBB.Core.Configuration/README.md
@@ -0,0 +1,41 @@
+# NBB.Core.Configuration
+
+This package contains configuration extensions.
+
+## NuGet install
+```
+dotnet add package NBB.Core.Configuration
+```
+
+## DotEnv configuration provider
+Adds support for configuration files that contain environment variable declarations.
+
+### Registration
+```csharp
+var builder = new ConfigurationBuilder();
+builder.AddDotEnvFile("config/env.txt", optional: false, reloadOnChange: true);
+```
+
+Parameters:
+* `path` - The path of the configuration file relative to the base path.
+* `optional` - Whether the file is optional.
+* `reloadOnChange` - Whether the configuration should be reloaded if the file changes.
+
+### Config file example
+```ini
+# Serilog
+Serilog__MinimumLevel__Default = Information
+Serilog__MinimumLevel__Override__Microsoft = Warning
+
+# MultiTenancy
+MultiTenancy__Enabled = true
+MultiTenancy__Tenants__BCR__ConnectionStrings__App_Database__User_Name = TestUser
+MultiTenancy__Tenants__BCR__Description = "My description "
+```
+
+Notes:
+* Each line should contain the key and the value separated by character `=`
+* For hierarchical settings, the "__" separator should be used.
+* Comments can be added in the file by starting the line with character `#`.
+* Empty lines and whitespace around keys/values are ignored.
+* Values that start or end with whitespace should be surounded by quotes. The quotes will not be part of the value.
diff --git a/test/UnitTests/Core/NBB.Core.Configuration.Tests/DotEnvConfigurationTests.cs b/test/UnitTests/Core/NBB.Core.Configuration.Tests/DotEnvConfigurationTests.cs
new file mode 100644
index 00000000..08f967de
--- /dev/null
+++ b/test/UnitTests/Core/NBB.Core.Configuration.Tests/DotEnvConfigurationTests.cs
@@ -0,0 +1,139 @@
+using FluentAssertions;
+using Microsoft.Extensions.Configuration;
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace NBB.Core.Configuration.Tests
+{
+ public class DotEnvConfigurationTests : IDisposable
+ {
+ // setup
+ public DotEnvConfigurationTests()
+ {
+ Directory.CreateDirectory("config");
+ }
+
+ // teardown
+ public void Dispose()
+ {
+ Directory.Delete("config", recursive: true);
+ }
+
+ [Fact]
+ public void Should_read_simple_value()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt", @"JAEGER_DISABLED=false");
+
+ // Act
+ configurationManager.AddDotEnvFile("config/env.txt");
+
+ // Assert
+ configurationManager["JAEGER_DISABLED"].Should().Be("false");
+ }
+
+ [Fact]
+ public void Should_read_hierarchic_value()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt", @"MultiTenancy__Tenants__BCR__TenentId=test1");
+
+ // Act
+ configurationManager.AddDotEnvFile("config/env.txt");
+
+ // Assert
+ configurationManager.GetSection("MultiTenancy")["Tenants:BCR:TenentId"].Should().Be("test1");
+ }
+
+ [Fact]
+ public void Should_throw_when_file_not_found()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+
+ // Act
+ Action act = () => configurationManager.AddDotEnvFile("config/wrong.txt", optional: false);
+
+ // Assert
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public async Task Shoud_reload_after_file_changed()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt", @"JAEGER_DISABLED=false");
+
+ configurationManager.AddDotEnvFile("config/env.txt", optional: false, reloadOnChange: true);
+ var before = configurationManager["JAEGER_DISABLED"];
+
+ // Act
+ File.WriteAllText("config/env.txt", @"JAEGER_DISABLED=true");
+ await Task.Delay(500);
+ var after = configurationManager["JAEGER_DISABLED"];
+
+ // Assert
+ before.Should().Be("false");
+ after.Should().Be("true");
+ }
+
+ [Fact]
+ public void Should_remove_quotes()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt", @"Key1=""mystring""");
+
+ // Act
+ configurationManager.AddDotEnvFile("config/env.txt");
+
+ // Assert
+ configurationManager["Key1"].Should().Be("mystring");
+ }
+
+ [Fact]
+ public void Should_ignore_white_space()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt",
+ @"
+ Key1 = Value1
+
+ Key2 =Value2
+ ");
+
+ // Act
+ configurationManager.AddDotEnvFile("config/env.txt");
+
+ // Assert
+ configurationManager.GetChildren().Should().HaveCount(2);
+ configurationManager["Key1"].Should().Be("Value1");
+ configurationManager["Key2"].Should().Be("Value2");
+ }
+
+ [Fact]
+ public void Should_ignore_comments()
+ {
+ // Arrange
+ var configurationManager = new ConfigurationManager();
+ File.WriteAllText("config/env.txt",
+ @"
+ # My comment
+ Key1=Value1
+ ");
+
+ // Act
+ configurationManager.AddDotEnvFile("config/env.txt");
+
+ // Assert
+ configurationManager.GetChildren().Should().HaveCount(1);
+ configurationManager["Key1"].Should().Be("Value1");
+ }
+ }
+}
diff --git a/test/UnitTests/Core/NBB.Core.Configuration.Tests/NBB.Core.Configuration.Tests.csproj b/test/UnitTests/Core/NBB.Core.Configuration.Tests/NBB.Core.Configuration.Tests.csproj
new file mode 100644
index 00000000..33eded98
--- /dev/null
+++ b/test/UnitTests/Core/NBB.Core.Configuration.Tests/NBB.Core.Configuration.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net6.0
+ enable
+
+ false
+
+
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+