diff --git a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs index 37cefae6cb..d6e800587a 100644 --- a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs +++ b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netcoreapp.cs @@ -6,14 +6,17 @@ namespace Microsoft.Extensions.Configuration public static partial class KeyPerFileConfigurationBuilderExtensions { public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, System.Action configureSource) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath) { throw null; } public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) { throw null; } } } namespace Microsoft.Extensions.Configuration.KeyPerFile { - public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider + public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider, System.IDisposable { public KeyPerFileConfigurationProvider(Microsoft.Extensions.Configuration.KeyPerFile.KeyPerFileConfigurationSource source) { } + public void Dispose() { } public override void Load() { } public override string ToString() { throw null; } } @@ -24,6 +27,8 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile public System.Func IgnoreCondition { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public string IgnorePrefix { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public bool Optional { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int ReloadDelay { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool ReloadOnChange { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } } } diff --git a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs index 37cefae6cb..d6e800587a 100644 --- a/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs +++ b/src/Configuration.KeyPerFile/ref/Microsoft.Extensions.Configuration.KeyPerFile.netstandard2.0.cs @@ -6,14 +6,17 @@ namespace Microsoft.Extensions.Configuration public static partial class KeyPerFileConfigurationBuilderExtensions { public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, System.Action configureSource) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath) { throw null; } public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional) { throw null; } + public static Microsoft.Extensions.Configuration.IConfigurationBuilder AddKeyPerFile(this Microsoft.Extensions.Configuration.IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) { throw null; } } } namespace Microsoft.Extensions.Configuration.KeyPerFile { - public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider + public partial class KeyPerFileConfigurationProvider : Microsoft.Extensions.Configuration.ConfigurationProvider, System.IDisposable { public KeyPerFileConfigurationProvider(Microsoft.Extensions.Configuration.KeyPerFile.KeyPerFileConfigurationSource source) { } + public void Dispose() { } public override void Load() { } public override string ToString() { throw null; } } @@ -24,6 +27,8 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile public System.Func IgnoreCondition { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public string IgnorePrefix { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public bool Optional { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public int ReloadDelay { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } + public bool ReloadOnChange { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } } public Microsoft.Extensions.Configuration.IConfigurationProvider Build(Microsoft.Extensions.Configuration.IConfigurationBuilder builder) { throw null; } } } diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs index 435ef9e155..e4c8dd58ee 100644 --- a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs @@ -10,6 +10,16 @@ namespace Microsoft.Extensions.Configuration /// public static class KeyPerFileConfigurationBuilderExtensions { + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// The path to the directory. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath) + => builder.AddKeyPerFile(directoryPath, optional: false, reloadOnChange: false); + /// /// Adds configuration using files from a directory. File names are used as the key, /// file contents are used as the value. @@ -19,6 +29,18 @@ namespace Microsoft.Extensions.Configuration /// Whether the directory is optional. /// The . public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath, bool optional) + => builder.AddKeyPerFile(directoryPath, optional, reloadOnChange: false); + + /// + /// Adds configuration using files from a directory. File names are used as the key, + /// file contents are used as the value. + /// + /// The to add to. + /// The path to the directory. + /// Whether the directory is optional. + /// Whether the configuration should be reloaded if the files are changed, added or removed. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath, bool optional, bool reloadOnChange) => builder.AddKeyPerFile(source => { // Only try to set the file provider if its not optional or the directory exists @@ -27,6 +49,7 @@ namespace Microsoft.Extensions.Configuration source.FileProvider = new PhysicalFileProvider(directoryPath); } source.Optional = optional; + source.ReloadOnChange = reloadOnChange; }); /// diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs index 2e33b9dfcd..f586896fc2 100644 --- a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs @@ -1,14 +1,18 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; +using Microsoft.Extensions.Primitives; namespace Microsoft.Extensions.Configuration.KeyPerFile { /// /// A that uses a directory's files as configuration key/values. /// - public class KeyPerFileConfigurationProvider : ConfigurationProvider + public class KeyPerFileConfigurationProvider : ConfigurationProvider, IDisposable { + private readonly IDisposable _changeTokenRegistration; + KeyPerFileConfigurationSource Source { get; set; } /// @@ -16,7 +20,21 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile /// /// The settings. public KeyPerFileConfigurationProvider(KeyPerFileConfigurationSource source) - => Source = source ?? throw new ArgumentNullException(nameof(source)); + { + Source = source ?? throw new ArgumentNullException(nameof(source)); + + if (Source.ReloadOnChange && Source.FileProvider != null) + { + _changeTokenRegistration = ChangeToken.OnChange( + () => Source.FileProvider.Watch("*"), + () => + { + Thread.Sleep(Source.ReloadDelay); + Load(reload: true); + }); + } + + } private static string NormalizeKey(string key) => key.Replace("__", ConfigurationPath.KeyDelimiter); @@ -27,15 +45,20 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile : value; /// - /// Loads the docker secrets. + /// Loads the configuration values. /// public override void Load() + { + Load(reload: false); + } + + private void Load(bool reload) { var data = new Dictionary(StringComparer.OrdinalIgnoreCase); if (Source.FileProvider == null) { - if (Source.Optional) + if (Source.Optional || reload) // Always optional on reload { Data = data; return; @@ -45,25 +68,32 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile } var directory = Source.FileProvider.GetDirectoryContents("/"); - if (!directory.Exists && !Source.Optional) + if (!directory.Exists) { + if (Source.Optional || reload) // Always optional on reload + { + Data = data; + return; + } throw new DirectoryNotFoundException("The root directory for the FileProvider doesn't exist and is not optional."); } - - foreach (var file in directory) + else { - if (file.IsDirectory) + foreach (var file in directory) { - continue; - } + if (file.IsDirectory) + { + continue; + } + + using var stream = file.CreateReadStream(); + using var streamReader = new StreamReader(stream); - using (var stream = file.CreateReadStream()) - using (var streamReader = new StreamReader(stream)) - { if (Source.IgnoreCondition == null || !Source.IgnoreCondition(file.Name)) { data.Add(NormalizeKey(file.Name), TrimNewLine(streamReader.ReadToEnd())); } + } } @@ -79,5 +109,11 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile /// The configuration name. public override string ToString() => $"{GetType().Name} for files in '{GetDirectoryName()}' ({(Source.Optional ? "Optional" : "Required")})"; + + /// + public void Dispose() + { + _changeTokenRegistration?.Dispose(); + } } } diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs index c32e948e82..2b64d5a8dd 100644 --- a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs @@ -37,6 +37,17 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile /// public bool Optional { get; set; } + /// + /// Determines whether the source will be loaded if the underlying file changes. + /// + public bool ReloadOnChange { get; set; } + + /// + /// Number of milliseconds that reload will wait before calling Load. This helps + /// avoid triggering reload before a file is completely written. Default is 250. + /// + public int ReloadDelay { get; set; } = 250; + /// /// Builds the for this source. /// diff --git a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs index 838e62222d..794f7abf23 100644 --- a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs +++ b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs @@ -217,6 +217,79 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test Assert.Equal("Foo", options.Text); } + [Fact] + public void ReloadConfigWhenReloadOnChangeIsTrue() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = true; + }).Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "NewSecretValue1"), + new TestFile("Secret3", "NewSecretValue3")); + + Assert.Equal("NewSecretValue1", config["Secret1"]); + Assert.Null(config["NewSecret2"]); + Assert.Equal("NewSecretValue3", config["Secret3"]); + } + + [Fact] + public void SameConfigWhenReloadOnChangeIsFalse() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = false; + }).Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "NewSecretValue1"), + new TestFile("Secret3", "NewSecretValue3")); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void NoFilesReloadWhenAddedFiles() + { + var testFileProvider = new TestFileProvider(); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.ReloadOnChange = true; + }).Build(); + + Assert.Empty(config.AsEnumerable()); + + testFileProvider.ChangeFiles( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + private sealed class MyOptions { public int Number { get; set; } @@ -227,17 +300,56 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test class TestFileProvider : IFileProvider { IDirectoryContents _contents; - + MockChangeToken _changeToken; + public TestFileProvider(params IFileInfo[] files) { _contents = new TestDirectoryContents(files); + _changeToken = new MockChangeToken(); } public IDirectoryContents GetDirectoryContents(string subpath) => _contents; public IFileInfo GetFileInfo(string subpath) => new TestFile("TestDirectory"); - public IChangeToken Watch(string filter) => throw new NotImplementedException(); + public IChangeToken Watch(string filter) => _changeToken; + + internal void ChangeFiles(params IFileInfo[] files) + { + _contents = new TestDirectoryContents(files); + _changeToken.RaiseCallback(); + } + } + + class MockChangeToken : IChangeToken + { + private Action _callback; + + public bool ActiveChangeCallbacks => true; + + public bool HasChanged => true; + + public IDisposable RegisterChangeCallback(Action callback, object state) + { + var disposable = new MockDisposable(); + _callback = () => callback(state); + return disposable; + } + + internal void RaiseCallback() + { + _callback?.Invoke(); + } + } + + class MockDisposable : IDisposable + { + public bool Disposed { get; set; } + + public void Dispose() + { + Disposed = true; + } } class TestDirectoryContents : IDirectoryContents @@ -291,7 +403,7 @@ namespace Microsoft.Extensions.Configuration.KeyPerFile.Test public Stream CreateReadStream() { - if(IsDirectory) + if (IsDirectory) { throw new InvalidOperationException("Cannot create stream from directory"); }