diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 0a60896f3e..f81607bc90 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -52,13 +52,11 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting // Private right now because we don't have much reason to expose it. This can be exposed // in the future if we want to give people a choice between CreateDefault and something // less opinionated. - Configuration = new ConfigurationBuilder(); + Configuration = new WebAssemblyHostConfiguration(); RootComponents = new RootComponentMappingCollection(); Services = new ServiceCollection(); Logging = new LoggingBuilder(Services); - Logging.SetMinimumLevel(LogLevel.Warning); - // Retrieve required attributes from JSRuntimeInvoker InitializeNavigationManager(jsRuntimeInvoker); InitializeDefaultServices(); @@ -111,10 +109,10 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting } /// - /// Gets an that can be used to customize the application's - /// configuration sources. + /// Gets an that can be used to customize the application's + /// configuration sources and read configuration attributes. /// - public IConfigurationBuilder Configuration { get; } + public WebAssemblyHostConfiguration Configuration { get; } /// /// Gets the collection of root component mappings configured for the application. @@ -177,8 +175,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting public WebAssemblyHost Build() { // Intentionally overwrite configuration with the one we're creating. - var configuration = Configuration.Build(); - Services.AddSingleton(configuration); + Services.AddSingleton(Configuration); // A Blazor application always runs in a scope. Since we want to make it possible for the user // to configure services inside *that scope* inside their startup code, we create *both* the @@ -186,7 +183,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting var services = _createServiceProvider(); var scope = services.GetRequiredService().CreateScope(); - return new WebAssemblyHost(services, scope, configuration, RootComponents.ToArray()); + return new WebAssemblyHost(services, scope, Configuration, RootComponents.ToArray()); } internal void InitializeDefaultServices() diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostConfiguration.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostConfiguration.cs new file mode 100644 index 0000000000..07c23e6a6d --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostConfiguration.cs @@ -0,0 +1,188 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting +{ + /// + /// WebAssemblyHostConfiguration is a class that implements the interface of an IConfiguration, + /// IConfigurationRoot, and IConfigurationBuilder. It can be used to simulatneously build + /// and read from a configuration object. + /// + public class WebAssemblyHostConfiguration : IConfiguration, IConfigurationRoot, IConfigurationBuilder + { + private readonly List _providers = new List(); + private readonly List _sources = new List(); + + private readonly List _changeTokenRegistrations = new List(); + private ConfigurationReloadToken _changeToken = new ConfigurationReloadToken(); + + /// + /// Gets the sources used to obtain configuration values. + /// + IList IConfigurationBuilder.Sources => new ReadOnlyCollection(_sources.ToList()); + + /// + /// Gets the providers used to obtain configuration values. + /// + IEnumerable IConfigurationRoot.Providers => new ReadOnlyCollection(_providers.ToList()); + + /// + /// Gets a key/value collection that can be used to share data between the + /// and the registered instances. + /// + // In this implementation, this largely exists as a way to satisfy the + // requirements of the IConfigurationBuilder and is not populated by + // the WebAssemblyHostConfiguration with any meaningful info. + IDictionary IConfigurationBuilder.Properties { get; } = new Dictionary(); + + /// + public string this[string key] + { + get + { + // Iterate through the providers in reverse to extract + // the value from the most recently inserted provider. + for (var i = _providers.Count - 1; i >= 0; i--) + { + var provider = _providers[i]; + + if (provider.TryGet(key, out var value)) + { + return value; + } + } + + return null; + } + set + { + if (_providers.Count == 0) + { + throw new InvalidOperationException("Can only set property if at least one provider has been inserted."); + } + + foreach (var provider in _providers) + { + provider.Set(key, value); + } + + } + } + + /// + /// Gets a configuration sub-section with the specified key. + /// + /// The key of the configuration section. + /// The . + /// + /// This method will never return null. If no matching sub-section is found with the specified key, + /// an empty will be returned. + /// + public IConfigurationSection GetSection(string key) => new ConfigurationSection(this, key); + + /// + /// Gets the immediate descendant configuration sub-sections. + /// + /// The configuration sub-sections. + IEnumerable IConfiguration.GetChildren() + { + return _providers + .SelectMany(s => s.GetChildKeys(Enumerable.Empty(), null)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(key => this.GetSection(key)) + .ToList(); + } + + /// + /// Returns a that can be used to observe when this configuration is reloaded. + /// + /// The . + public IChangeToken GetReloadToken() => _changeToken; + + /// + /// Force the configuration values to be reloaded from the underlying sources. + /// + public void Reload() + { + foreach (var provider in _providers) + { + provider.Load(); + } + RaiseChanged(); + } + + private void RaiseChanged() + { + var previousToken = Interlocked.Exchange(ref _changeToken, new ConfigurationReloadToken()); + previousToken.OnReload(); + } + + /// + /// Adds a new configuration source, retrieves the provider for the source, and + /// adds a change listener that triggers a reload of the provider whenever a change + /// is detected. + /// + /// The configuration source to add. + /// The same . + public IConfigurationBuilder Add(IConfigurationSource source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + // Ads this source and its associated provider to the source + // and provider references in this class. We make sure to load + // the data from the provider so that values are properly initialized. + _sources.Add(source); + var provider = source.Build(this); + provider.Load(); + + // Add a handler that will detect when the the configuration + // provider has reloaded data. This will invoke the RaiseChanged + // method which maps changes in individual providers to the change + // token on the WebAssemblyHostConfiguration object. + _changeTokenRegistrations.Add(ChangeToken.OnChange(() => provider.GetReloadToken(), () => RaiseChanged())); + + // We keep a list of providers in this class so that we can map + // set and get methods on this class to the set and get methods + // on the individual configuration providers. + _providers.Add(provider); + return this; + } + + /// + /// Builds an with keys and values from the set of providers registered in + /// . + /// + /// An with keys and values from the registered providers. + public IConfigurationRoot Build() + { + return this; + } + + /// + public void Dispose() + { + // dispose change token registrations + foreach (var registration in _changeTokenRegistrations) + { + registration.Dispose(); + } + + // dispose providers + foreach (var provider in _providers) + { + (provider as IDisposable)?.Dispose(); + } + } + } +} diff --git a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostConfigurationTest.cs b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostConfigurationTest.cs new file mode 100644 index 0000000000..922293fa0a --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyHostConfigurationTest.cs @@ -0,0 +1,230 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting +{ + public class WebAssemblyHostConfigurationTest + { + [Fact] + public void CanSetAndGetConfigurationValue() + { + // Arrange + var initialData = new Dictionary() { + { "color", "blue" }, + { "type", "car" }, + { "wheels:year", "2008" }, + { "wheels:count", "4" }, + { "wheels:brand", "michelin" }, + { "wheels:brand:type", "rally" }, + }; + var memoryConfig = new MemoryConfigurationSource { InitialData = initialData }; + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memoryConfig); + configuration["type"] = "car"; + configuration["wheels:count"] = "6"; + + // Assert + Assert.Equal("car", configuration["type"]); + Assert.Equal("blue", configuration["color"]); + Assert.Equal("6", configuration["wheels:count"]); + } + + [Fact] + public void SettingValueUpdatesAllProviders() + { + // Arrange + var initialData = new Dictionary() { { "color", "blue" } }; + var source1 = new MemoryConfigurationSource { InitialData = initialData }; + var source2 = new CustomizedTestConfigurationSource(); + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(source1); + configuration.Add(source2); + configuration["type"] = "car"; + + // Assert + Assert.Equal("car", configuration["type"]); + IConfigurationRoot root = configuration; + Assert.All(root.Providers, provider => + { + provider.TryGet("type", out var value); + Assert.Equal("car", value); + }); + } + + [Fact] + public void CanGetChildren() + { + // Arrange + var initialData = new Dictionary() { { "color", "blue" } }; + var memoryConfig = new MemoryConfigurationSource { InitialData = initialData }; + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memoryConfig); + IConfiguration readableConfig = configuration; + var children = readableConfig.GetChildren(); + + // Assert + Assert.NotNull(children); + Assert.NotEmpty(children); + } + + [Fact] + public void CanGetSection() + { + // Arrange + var initialData = new Dictionary() { + { "color", "blue" }, + { "type", "car" }, + { "wheels:year", "2008" }, + { "wheels:count", "4" }, + { "wheels:brand", "michelin" }, + { "wheels:brand:type", "rally" }, + }; + var memoryConfig = new MemoryConfigurationSource { InitialData = initialData }; + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memoryConfig); + var section = configuration.GetSection("wheels").AsEnumerable(makePathsRelative: true).ToDictionary(k => k.Key, v => v.Value); + + // Assert + Assert.Equal(4, section.Count); + Assert.Equal("2008", section["year"]); + Assert.Equal("4", section["count"]); + Assert.Equal("michelin", section["brand"]); + Assert.Equal("rally", section["brand:type"]); + } + + [Fact] + public void CanDisposeProviders() + { + // Arrange + var initialData = new Dictionary() { { "color", "blue" } }; + var memoryConfig = new MemoryConfigurationSource { InitialData = initialData }; + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memoryConfig); + Assert.Equal("blue", configuration["color"]); + var exception = Record.Exception(() => configuration.Dispose()); + + // Assert + Assert.Null(exception); + } + + [Fact] + public void CanSupportDeeplyNestedConfigs() + { + // Arrange + var dic1 = new Dictionary() + { + {"Mem1", "Value1"}, + {"Mem1:", "NoKeyValue1"}, + {"Mem1:KeyInMem1", "ValueInMem1"}, + {"Mem1:KeyInMem1:Deep1", "ValueDeep1"} + }; + var dic2 = new Dictionary() + { + {"Mem2", "Value2"}, + {"Mem2:", "NoKeyValue2"}, + {"Mem2:KeyInMem2", "ValueInMem2"}, + {"Mem2:KeyInMem2:Deep2", "ValueDeep2"} + }; + var dic3 = new Dictionary() + { + {"Mem3", "Value3"}, + {"Mem3:", "NoKeyValue3"}, + {"Mem3:KeyInMem3", "ValueInMem3"}, + {"Mem3:KeyInMem4", "ValueInMem4"}, + {"Mem3:KeyInMem3:Deep3", "ValueDeep3"}, + {"Mem3:KeyInMem3:Deep4", "ValueDeep4"} + }; + var memConfigSrc1 = new MemoryConfigurationSource { InitialData = dic1 }; + var memConfigSrc2 = new MemoryConfigurationSource { InitialData = dic2 }; + var memConfigSrc3 = new MemoryConfigurationSource { InitialData = dic3 }; + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memConfigSrc1); + configuration.Add(memConfigSrc2); + configuration.Add(memConfigSrc3); + + // Assert + var dict = configuration.GetSection("Mem1").AsEnumerable(makePathsRelative: true).ToDictionary(k => k.Key, v => v.Value); + Assert.Equal(3, dict.Count); + Assert.Equal("NoKeyValue1", dict[""]); + Assert.Equal("ValueInMem1", dict["KeyInMem1"]); + Assert.Equal("ValueDeep1", dict["KeyInMem1:Deep1"]); + + var dict2 = configuration.GetSection("Mem2").AsEnumerable(makePathsRelative: true).ToDictionary(k => k.Key, v => v.Value); + Assert.Equal(3, dict2.Count); + Assert.Equal("NoKeyValue2", dict2[""]); + Assert.Equal("ValueInMem2", dict2["KeyInMem2"]); + Assert.Equal("ValueDeep2", dict2["KeyInMem2:Deep2"]); + + var dict3 = configuration.GetSection("Mem3").AsEnumerable(makePathsRelative: true).ToDictionary(k => k.Key, v => v.Value); + Assert.Equal(5, dict3.Count); + Assert.Equal("NoKeyValue3", dict3[""]); + Assert.Equal("ValueInMem3", dict3["KeyInMem3"]); + Assert.Equal("ValueInMem4", dict3["KeyInMem4"]); + Assert.Equal("ValueDeep3", dict3["KeyInMem3:Deep3"]); + Assert.Equal("ValueDeep4", dict3["KeyInMem3:Deep4"]); + } + + [Fact] + public void NewConfigurationProviderOverridesOldOneWhenKeyIsDuplicated() + { + // Arrange + var dic1 = new Dictionary() + { + {"Key1:Key2", "ValueInMem1"} + }; + var dic2 = new Dictionary() + { + {"Key1:Key2", "ValueInMem2"} + }; + var memConfigSrc1 = new MemoryConfigurationSource { InitialData = dic1 }; + var memConfigSrc2 = new MemoryConfigurationSource { InitialData = dic2 }; + + var configuration = new WebAssemblyHostConfiguration(); + + // Act + configuration.Add(memConfigSrc1); + configuration.Add(memConfigSrc2); + + // Assert + Assert.Equal("ValueInMem2", configuration["Key1:Key2"]); + } + + private class CustomizedTestConfigurationProvider : ConfigurationProvider + { + public CustomizedTestConfigurationProvider(string key, string value) + => Data.Add(key, value.ToUpper()); + + public override void Set(string key, string value) + { + Data[key] = value; + } + } + + private class CustomizedTestConfigurationSource : IConfigurationSource + { + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new CustomizedTestConfigurationProvider("initialKey", "initialValue"); + } + } + } +} diff --git a/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs b/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs index 23fb14ed10..3dc2a0a11c 100644 --- a/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs +++ b/src/Components/test/E2ETest/Tests/WebAssemblyConfigurationTest.cs @@ -47,6 +47,22 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("Development key3-value", _appElement.FindElement(By.Id("key3")).Text); } + [Fact] + public void WebAssemblyConfiguration_ReloadingWorks() + { + // Verify values from the default 'appsettings.json' are read. + Browser.Equal("Default key1-value", () => _appElement.FindElement(By.Id("key1")).Text); + + // Change the value of key1 using the form in the UI + var input = _appElement.FindElement(By.Id("key1-input")); + input.SendKeys("newValue"); + var submit = _appElement.FindElement(By.Id("trigger-change")); + submit.Click(); + + // Asser that the value of the key has been updated + Browser.Equal("newValue", () => _appElement.FindElement(By.Id("key1")).Text); + } + [Fact] public void WebAssemblyHostingEnvironment_Works() { diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index f6c755d21c..56b7245ae7 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -18,6 +18,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/ConfigurationComponent.razor b/src/Components/test/testassets/BasicTestApp/ConfigurationComponent.razor index 5f40b677ca..13b95fb037 100644 --- a/src/Components/test/testassets/BasicTestApp/ConfigurationComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/ConfigurationComponent.razor @@ -8,3 +8,18 @@
@HostEnvironment.Environment
+ +

+ + +

+ +@code { + string newKey1 { get; set; } + + void TriggerChange() + { + Config["key1"] = newKey1; + } +} + diff --git a/src/Components/test/testassets/BasicTestApp/Program.cs b/src/Components/test/testassets/BasicTestApp/Program.cs index 6a1a24d7f9..b0ad4fbd44 100644 --- a/src/Components/test/testassets/BasicTestApp/Program.cs +++ b/src/Components/test/testassets/BasicTestApp/Program.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Components.WebAssembly.Http; using Microsoft.AspNetCore.Components.WebAssembly.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Configuration; using Microsoft.JSInterop; namespace BasicTestApp @@ -45,8 +46,9 @@ namespace BasicTestApp policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false)); }); - builder.Logging.Services.AddSingleton(s => new PrependMessageLoggerProvider("Custom logger", s.GetService())); - builder.Logging.SetMinimumLevel(LogLevel.Information); + builder.Logging.Services.AddSingleton(s => + new PrependMessageLoggerProvider(builder.Configuration["Logging:PrependMessage:Message"], s.GetService())); + builder.Logging.AddConfiguration(builder.Configuration); var host = builder.Build(); ConfigureCulture(host); diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/appsettings.json b/src/Components/test/testassets/BasicTestApp/wwwroot/appsettings.json index 7b07b04091..1e5d60a721 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/appsettings.json +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/appsettings.json @@ -1,4 +1,12 @@ { "key1": "Default key1-value", - "key2": "Default key2-value" + "key2": "Default key2-value", + "Logging": { + "PrependMessage": { + "Message": "Custom logger", + "LogLevel": { + "Default": "Information" + } + } + } }