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 + + + + + + + +