diff --git a/src/Configuration.KeyPerFile/Directory.Build.props b/src/Configuration.KeyPerFile/Directory.Build.props new file mode 100644 index 0000000000..2082380096 --- /dev/null +++ b/src/Configuration.KeyPerFile/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + true + configuration + + diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs new file mode 100644 index 0000000000..435ef9e155 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationBuilderExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration.KeyPerFile; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.Configuration +{ + /// + /// Extension methods for registering with . + /// + 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. + /// Whether the directory is optional. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, string directoryPath, bool optional) + => builder.AddKeyPerFile(source => + { + // Only try to set the file provider if its not optional or the directory exists + if (!optional || Directory.Exists(directoryPath)) + { + source.FileProvider = new PhysicalFileProvider(directoryPath); + } + source.Optional = optional; + }); + + /// + /// 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. + /// Configures the source. + /// The . + public static IConfigurationBuilder AddKeyPerFile(this IConfigurationBuilder builder, Action configureSource) + => builder.Add(configureSource); + } +} diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs new file mode 100644 index 0000000000..4748895744 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationProvider.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + /// + /// A that uses a directory's files as configuration key/values. + /// + public class KeyPerFileConfigurationProvider : ConfigurationProvider + { + KeyPerFileConfigurationSource Source { get; set; } + + /// + /// Initializes a new instance. + /// + /// The settings. + public KeyPerFileConfigurationProvider(KeyPerFileConfigurationSource source) + => Source = source ?? throw new ArgumentNullException(nameof(source)); + + private static string NormalizeKey(string key) + => key.Replace("__", ConfigurationPath.KeyDelimiter); + + private static string TrimNewLine(string value) + => value.EndsWith(Environment.NewLine) + ? value.Substring(0, value.Length - Environment.NewLine.Length) + : value; + + /// + /// Loads the docker secrets. + /// + public override void Load() + { + Data = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (Source.FileProvider == null) + { + if (Source.Optional) + { + return; + } + else + { + throw new DirectoryNotFoundException("A non-null file provider for the directory is required when this source is not optional."); + } + } + + var directory = Source.FileProvider.GetDirectoryContents("/"); + if (!directory.Exists && !Source.Optional) + { + throw new DirectoryNotFoundException("The root directory for the FileProvider doesn't exist and is not optional."); + } + + foreach (var file in directory) + { + if (file.IsDirectory) + { + continue; + } + + 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())); + } + } + } + } + } +} diff --git a/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs new file mode 100644 index 0000000000..c32e948e82 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/KeyPerFileConfigurationSource.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using Microsoft.Extensions.FileProviders; + +namespace Microsoft.Extensions.Configuration.KeyPerFile +{ + /// + /// An used to configure . + /// + public class KeyPerFileConfigurationSource : IConfigurationSource + { + /// + /// Constructor; + /// + public KeyPerFileConfigurationSource() + => IgnoreCondition = s => IgnorePrefix != null && s.StartsWith(IgnorePrefix); + + /// + /// The FileProvider whos root "/" directory files will be used as configuration data. + /// + public IFileProvider FileProvider { get; set; } + + /// + /// Files that start with this prefix will be excluded. + /// Defaults to "ignore.". + /// + public string IgnorePrefix { get; set; } = "ignore."; + + /// + /// Used to determine if a file should be ignored using its name. + /// Defaults to using the IgnorePrefix. + /// + public Func IgnoreCondition { get; set; } + + /// + /// If false, will throw if the directory doesn't exist. + /// + public bool Optional { get; set; } + + /// + /// Builds the for this source. + /// + /// The . + /// A + public IConfigurationProvider Build(IConfigurationBuilder builder) + => new KeyPerFileConfigurationProvider(this); + } +} diff --git a/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj b/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj new file mode 100644 index 0000000000..4eb19f3293 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/Microsoft.Extensions.Configuration.KeyPerFile.csproj @@ -0,0 +1,14 @@ + + + + Configuration provider that uses files in a directory for Microsoft.Extensions.Configuration. + netstandard2.0 + false + + + + + + + + diff --git a/src/Configuration.KeyPerFile/src/README.md b/src/Configuration.KeyPerFile/src/README.md new file mode 100644 index 0000000000..29952e9139 --- /dev/null +++ b/src/Configuration.KeyPerFile/src/README.md @@ -0,0 +1,2 @@ + +This is a configuration provider that uses a directory's files as data. A file's name is the key and the contents are the value. diff --git a/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs new file mode 100644 index 0000000000..d55387a404 --- /dev/null +++ b/src/Configuration.KeyPerFile/test/KeyPerFileTests.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.Extensions.Configuration.KeyPerFile.Test +{ + public class KeyPerFileTests + { + [Fact] + public void DoesNotThrowWhenOptionalAndNoSecrets() + { + new ConfigurationBuilder().AddKeyPerFile(o => o.Optional = true).Build(); + } + + [Fact] + public void DoesNotThrowWhenOptionalAndDirectoryDoesntExist() + { + new ConfigurationBuilder().AddKeyPerFile("nonexistent", true).Build(); + } + + [Fact] + public void ThrowsWhenNotOptionalAndDirectoryDoesntExist() + { + var e = Assert.Throws(() => new ConfigurationBuilder().AddKeyPerFile("nonexistent", false).Build()); + Assert.Contains("The directory name", e.Message); + } + + [Fact] + public void CanLoadMultipleSecrets() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanLoadMultipleSecretsWithDirectory() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2"), + new TestFile("directory")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue1", config["Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanLoadNestedKeys() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret0__Secret1__Secret2__Key", "SecretValue2"), + new TestFile("Secret0__Secret1__Key", "SecretValue1"), + new TestFile("Secret0__Key", "SecretValue0")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Equal("SecretValue0", config["Secret0:Key"]); + Assert.Equal("SecretValue1", config["Secret0:Secret1:Key"]); + Assert.Equal("SecretValue2", config["Secret0:Secret1:Secret2:Key"]); + } + + [Fact] + public void CanIgnoreFilesWithDefault() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => o.FileProvider = testFileProvider) + .Build(); + + Assert.Null(config["ignore.Secret0"]); + Assert.Null(config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanTurnOffDefaultIgnorePrefixWithCondition() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnoreCondition = null; + }) + .Build(); + + Assert.Equal("SecretValue0", config["ignore.Secret0"]); + Assert.Equal("SecretValue1", config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanIgnoreAllWithCondition() + { + var testFileProvider = new TestFileProvider( + new TestFile("Secret0", "SecretValue0"), + new TestFile("Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnoreCondition = s => true; + }) + .Build(); + + Assert.Empty(config.AsEnumerable()); + } + + [Fact] + public void CanIgnoreFilesWithCustomIgnore() + { + var testFileProvider = new TestFileProvider( + new TestFile("meSecret0", "SecretValue0"), + new TestFile("meSecret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnorePrefix = "me"; + }) + .Build(); + + Assert.Null(config["meSecret0"]); + Assert.Null(config["meSecret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + + [Fact] + public void CanUnIgnoreDefaultFiles() + { + var testFileProvider = new TestFileProvider( + new TestFile("ignore.Secret0", "SecretValue0"), + new TestFile("ignore.Secret1", "SecretValue1"), + new TestFile("Secret2", "SecretValue2")); + + var config = new ConfigurationBuilder() + .AddKeyPerFile(o => + { + o.FileProvider = testFileProvider; + o.IgnorePrefix = null; + }) + .Build(); + + Assert.Equal("SecretValue0", config["ignore.Secret0"]); + Assert.Equal("SecretValue1", config["ignore.Secret1"]); + Assert.Equal("SecretValue2", config["Secret2"]); + } + } + + class TestFileProvider : IFileProvider + { + IDirectoryContents _contents; + + public TestFileProvider(params IFileInfo[] files) + { + _contents = new TestDirectoryContents(files); + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return _contents; + } + + public IFileInfo GetFileInfo(string subpath) + { + throw new NotImplementedException(); + } + + public IChangeToken Watch(string filter) + { + throw new NotImplementedException(); + } + } + + class TestDirectoryContents : IDirectoryContents + { + List _list; + + public TestDirectoryContents(params IFileInfo[] files) + { + _list = new List(files); + } + + public bool Exists + { + get + { + return true; + } + } + + public IEnumerator GetEnumerator() + { + return _list.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } + + //TODO: Probably need a directory and file type. + class TestFile : IFileInfo + { + private string _name; + private string _contents; + + public bool Exists + { + get + { + return true; + } + } + + public bool IsDirectory + { + get; + } + + public DateTimeOffset LastModified + { + get + { + throw new NotImplementedException(); + } + } + + public long Length + { + get + { + throw new NotImplementedException(); + } + } + + public string Name + { + get + { + return _name; + } + } + + public string PhysicalPath + { + get + { + throw new NotImplementedException(); + } + } + + public TestFile(string name) + { + _name = name; + IsDirectory = true; + } + + public TestFile(string name, string contents) + { + _name = name; + _contents = contents; + } + + public Stream CreateReadStream() + { + if(IsDirectory) + { + throw new InvalidOperationException("Cannot create stream from directory"); + } + + return new MemoryStream(Encoding.UTF8.GetBytes(_contents)); + } + } +} \ No newline at end of file diff --git a/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj new file mode 100644 index 0000000000..634056a345 --- /dev/null +++ b/src/Configuration.KeyPerFile/test/Microsoft.Extensions.Configuration.KeyPerFile.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(StandardTestTfms) + + + + + + +