diff --git a/CHANGELOG.md b/CHANGELOG.md index 906959d..6851ca2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,5 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `FileSize` utility with method to display bytes in a human-readable way - `CultureInfo` extension method to get the language code of a culture - `IEnumerable` extension methods +- `IConfiguration` extension methods +- `IServiceCollection` extension methods - `IFormFile` extension method (AspNetCore package only) - `DbSet` extension methods for ISortableEntity interface (EntityFrameworkCore package only) diff --git a/Neolution.Utilities.UnitTests/Extensions/IConfigurationExtensionsTests.cs b/Neolution.Utilities.UnitTests/Extensions/IConfigurationExtensionsTests.cs new file mode 100644 index 0000000..c052258 --- /dev/null +++ b/Neolution.Utilities.UnitTests/Extensions/IConfigurationExtensionsTests.cs @@ -0,0 +1,211 @@ +namespace Neolution.Utilities.UnitTests.Extensions; + +using Microsoft.Extensions.Configuration; + +/// +/// Unit tests for the class. +/// +public class IConfigurationExtensionsTests +{ + /// + /// Test that given the null configuration when get options called then throws argument null exception. + /// + [Fact] + public void GivenNullConfiguration_WhenGetOptionsCalled_ThenThrowsArgumentNullException() + { + // Arrange + IConfiguration? configuration = null; + + // Act + var act = () => configuration!.GetOptions(); + + // Assert + var ex = Should.Throw(act); + ex.ParamName.ShouldBe("config"); + } + + /// + /// Test that given the missing section when get options called then throws invalid operation exception. + /// + [Fact] + public void GivenMissingSection_WhenGetOptionsCalled_ThenThrowsInvalidOperationException() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([]) // No section for SampleOptions + .Build(); + + // Act + var act = () => configuration.GetOptions(); + + // Assert + var ex = Should.Throw(act); + ex.Message.ShouldBe("Could not find configuration section 'SampleOptions'"); + } + + /// + /// Test that given the valid section when get options called then binds and returns options. + /// + [Fact] + public void GivenValidSection_WhenGetOptionsCalled_ThenBindsAndReturnsOptions() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "TestName", + ["SampleOptions:Level"] = "42", + }) + .Build(); + + // Act + var options = configuration.GetOptions(); + + // Assert + options.Name.ShouldBe("TestName"); + options.Level.ShouldBe(42); + } + + /// + /// Test that given the section with no matching properties when get options called then returns instance with defaults. + /// + [Fact] + public void GivenSectionWithNoMatchingProperties_WhenGetOptionsCalled_ThenReturnsInstanceWithDefaults() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Irrelevant"] = "ignored", + }) + .Build(); + + // Act + var options = configuration.GetOptions(); + + // Assert + options.Name.ShouldBeNull(); + options.Level.ShouldBe(0); + } + + /// + /// Test that given the section with invalid numeric value when get options called then throws invalid operation exception. + /// + [Fact] + public void GivenSectionWithInvalidNumericValue_WhenGetOptionsCalled_ThenThrowsInvalidOperationException() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "Something", + ["SampleOptions:Level"] = "not-an-int", + }) + .Build(); + + // Act + var act = () => configuration.GetOptions(); + + // Assert + var ex = Should.Throw(act); + ex.Message.ShouldBe("Failed to convert configuration value at 'SampleOptions:Level' to type 'System.Int32'."); + } + + /// + /// Test that given the valid section with different casing when get options called then binds successfully. + /// + [Fact] + public void GivenValidSectionWithDifferentCasing_WhenGetOptionsCalled_ThenBindsSuccessfully() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + // lower-case section name to assert case-insensitive lookup + ["sampleoptions:Name"] = "CaseTest", + ["sampleoptions:Level"] = "7", + }) + .Build(); + + // Act + var options = configuration.GetOptions(); + + // Assert + options.Name.ShouldBe("CaseTest"); + options.Level.ShouldBe(7); + } + + /// + /// Test that given the null configuration when get section called then throws argument null exception. + /// + [Fact] + public void GivenNullConfiguration_WhenGetSectionCalled_ThenThrowsArgumentNullException() + { + // Arrange + IConfiguration? configuration = null; + + // Act + var act = () => configuration!.GetSection(); + + // Assert + var ex = Should.Throw(act); + ex.ParamName.ShouldBe("config"); + } + + /// + /// Test that given the existing section when get section called then returns section. + /// + [Fact] + public void GivenExistingSection_WhenGetSectionCalled_ThenReturnsSection() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "X", + }) + .Build(); + + // Act + var section = configuration.GetSection(); + + // Assert + section.Key.ShouldBe("SampleOptions"); + section.Exists().ShouldBeTrue(); + } + + /// + /// Test that given the missing section when get section called then returns non existing section. + /// + [Fact] + public void GivenMissingSection_WhenGetSectionCalled_ThenReturnsNonExistingSection() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([]) + .Build(); + + // Act + var section = configuration.GetSection(); + + // Assert + section.Key.ShouldBe("SampleOptions"); + section.Exists().ShouldBeFalse(); + } + + /// + /// The sample options class used for testing. + /// + public class SampleOptions + { + /// + /// Gets or sets the name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the level. + /// + public int Level { get; set; } + } +} diff --git a/Neolution.Utilities.UnitTests/Extensions/IServiceCollectionExtensionsTests.cs b/Neolution.Utilities.UnitTests/Extensions/IServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..a4f69e5 --- /dev/null +++ b/Neolution.Utilities.UnitTests/Extensions/IServiceCollectionExtensionsTests.cs @@ -0,0 +1,167 @@ +namespace Neolution.Utilities.UnitTests.Extensions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +/// +/// Unit tests for the class. +/// +public class IServiceCollectionExtensionsTests +{ + /// + /// Test that given the null service collection when add options called then throws argument null exception. + /// + [Fact] + public void GivenNullServiceCollection_WhenAddOptionsCalled_ThenThrowsArgumentNullException() + { + // Arrange + ServiceCollection? serviceCollection = null; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["SampleOptions:Name"] = "X" }) + .Build(); + + // Act + var act = () => serviceCollection!.AddOptions(configuration); + + // Assert + var ex = Should.Throw(act); + ex.ParamName.ShouldBe("serviceCollection"); + } + + /// + /// Test that given the null configuration when add options called then throws argument null exception. + /// + [Fact] + public void GivenNullConfiguration_WhenAddOptionsCalled_ThenThrowsArgumentNullException() + { + // Arrange + var serviceCollection = new ServiceCollection(); + IConfiguration? configuration = null; + + // Act + var act = () => serviceCollection.AddOptions(configuration!); + + // Assert + var ex = Should.Throw(act); + ex.ParamName.ShouldBe("configuration"); + } + + /// + /// Test that given the configuration with options section when add options called then options are configured. + /// + [Fact] + public void GivenConfigurationWithOptionsSection_WhenAddOptionsCalled_ThenOptionsAreConfigured() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "Configured", + ["SampleOptions:Level"] = "7", + }) + .Build(); + var serviceCollection = new ServiceCollection(); + + // Act + serviceCollection.AddOptions(configuration); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var options = provider.GetRequiredService>().Value; + options.Name.ShouldBe("Configured"); + options.Level.ShouldBe(7); + } + + /// + /// Test that given the configuration without options section when add options called then options use defaults. + /// + [Fact] + public void GivenConfigurationWithoutOptionsSection_WhenAddOptionsCalled_ThenOptionsUseDefaults() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([]) + .Build(); + var serviceCollection = new ServiceCollection(); + + // Act + serviceCollection.AddOptions(configuration); + var provider = serviceCollection.BuildServiceProvider(); + + // Assert + var options = provider.GetRequiredService>().Value; + options.Name.ShouldBeNull(); + options.Level.ShouldBe(0); + } + + /// + /// Test that given the service collection when add options called then same instance is returned for fluent chaining. + /// + [Fact] + public void GivenServiceCollection_WhenAddOptionsCalled_ThenReturnsSameInstance() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([]) + .Build(); + var services = new ServiceCollection(); + + // Act + var returned = services.AddOptions(configuration); + + // Assert + returned.ShouldBeSameAs(services); + } + + /// + /// Test that given multiple registrations with different configurations when add options called twice then last registration wins. + /// + [Fact] + public void GivenMultipleRegistrations_WhenAddOptionsCalledTwice_ThenLastRegistrationWins() + { + // Arrange + var configuration1 = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "First", + ["SampleOptions:Level"] = "1", + }) + .Build(); + var configuration2 = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["SampleOptions:Name"] = "Second", + ["SampleOptions:Level"] = "2", + }) + .Build(); + + var services = new ServiceCollection(); + + // Act + services.AddOptions(configuration1); + services.AddOptions(configuration2); + var provider = services.BuildServiceProvider(); + + // Assert + var value = provider.GetRequiredService>().Value; + value.Name.ShouldBe("Second"); + value.Level.ShouldBe(2); + } + + /// + /// The sample options class used for testing. + /// + public class SampleOptions + { + /// + /// Gets or sets the name. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the level. + /// + public int Level { get; set; } + } +} diff --git a/Neolution.Utilities.UnitTests/Neolution.Utilities.UnitTests.csproj b/Neolution.Utilities.UnitTests/Neolution.Utilities.UnitTests.csproj index c483b02..229f30d 100644 --- a/Neolution.Utilities.UnitTests/Neolution.Utilities.UnitTests.csproj +++ b/Neolution.Utilities.UnitTests/Neolution.Utilities.UnitTests.csproj @@ -12,6 +12,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + + all @@ -30,4 +32,13 @@ + + + + + + + + + diff --git a/Neolution.Utilities.UnitTests/packages.lock.json b/Neolution.Utilities.UnitTests/packages.lock.json index fd478be..22d68f6 100644 --- a/Neolution.Utilities.UnitTests/packages.lock.json +++ b/Neolution.Utilities.UnitTests/packages.lock.json @@ -8,6 +8,25 @@ "resolved": "6.0.4", "contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg==" }, + "Microsoft.Extensions.Configuration": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "0J/9YNXTMWSZP2p2+nvl8p71zpSwokZXZuJW+VjdErkegAnFdO1XlqtA62SJtgVYHdKu3uPxJHcMR/r35HwFBA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Direct", + "requested": "[8.0.1, )", + "resolved": "8.0.1", + "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, "Microsoft.NET.Test.Sdk": { "type": "Direct", "requested": "[17.13.0, )", @@ -140,6 +159,53 @@ "resolved": "17.13.0", "contentHash": "9LIUy0y+DvUmEPtbRDw6Bay3rzwqFV8P4efTrK4CZhQle3M/QwLPjISghfcolmEGAPWxuJi6m98ZEfk4VR4Lfg==" }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.0", @@ -1168,7 +1234,10 @@ } }, "neolution.utilities": { - "type": "Project" + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Options.ConfigurationExtensions": "[8.0.0, )" + } } } } diff --git a/Neolution.Utilities/Extensions/IConfigurationExtensions.cs b/Neolution.Utilities/Extensions/IConfigurationExtensions.cs new file mode 100644 index 0000000..2312c1f --- /dev/null +++ b/Neolution.Utilities/Extensions/IConfigurationExtensions.cs @@ -0,0 +1,39 @@ +namespace Neolution.Utilities.Extensions; + +using Microsoft.Extensions.Configuration; + +/// +/// The IConfiguration extension methods. +/// +public static class IConfigurationExtensions +{ + /// + /// Gets the strongly typed options. + /// + /// The options type. + /// The configuration. + /// The options. + public static TOptions GetOptions(this IConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + var section = config.GetSection(typeof(TOptions).Name); + if (!section.Exists()) + { + throw new InvalidOperationException($"Could not find configuration section '{typeof(TOptions).Name}'"); + } + + return section.Get() ?? throw new InvalidOperationException($"Could not create '{typeof(TOptions).Name}'"); + } + + /// + /// Gets the section by the specified options type. + /// + /// The options type. + /// The configuration. + /// The configuration section. + public static IConfigurationSection GetSection(this IConfiguration config) + { + ArgumentNullException.ThrowIfNull(config); + return config.GetSection(typeof(T).Name); + } +} diff --git a/Neolution.Utilities/Extensions/IServiceCollectionExtensions.cs b/Neolution.Utilities/Extensions/IServiceCollectionExtensions.cs new file mode 100644 index 0000000..98c86fe --- /dev/null +++ b/Neolution.Utilities/Extensions/IServiceCollectionExtensions.cs @@ -0,0 +1,26 @@ +namespace Neolution.Utilities.Extensions; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +/// +/// The IServiceCollection extension methods. +/// +public static class IServiceCollectionExtensions +{ + /// + /// Adds the strongly typed options. + /// + /// The type of the options. + /// The service collection. + /// The configuration. + /// The service collection + public static IServiceCollection AddOptions(this IServiceCollection serviceCollection, IConfiguration configuration) + where TOptions : class + { + ArgumentNullException.ThrowIfNull(serviceCollection); + ArgumentNullException.ThrowIfNull(configuration); + serviceCollection.Configure(configuration.GetSection()); + return serviceCollection; + } +} diff --git a/Neolution.Utilities/Neolution.Utilities.csproj b/Neolution.Utilities/Neolution.Utilities.csproj index 250a70f..c11f153 100644 --- a/Neolution.Utilities/Neolution.Utilities.csproj +++ b/Neolution.Utilities/Neolution.Utilities.csproj @@ -1,6 +1,7 @@  + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Utilities/packages.lock.json b/Neolution.Utilities/packages.lock.json index 838eaca..14bab91 100644 --- a/Neolution.Utilities/packages.lock.json +++ b/Neolution.Utilities/packages.lock.json @@ -2,6 +2,19 @@ "version": 1, "dependencies": { "net8.0": { + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Direct", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "0f4DMRqEd50zQh+UyJc+/HiBsZ3vhAQALgdkcQEalSH1L2isdC7Yj54M3cyo5e+BeO5fcBQ7Dxly8XiBBcvRgw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0", + "Microsoft.Extensions.Configuration.Binder": "8.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, "Neolution.CodeAnalysis": { "type": "Direct", "requested": "[3.2.1, )", @@ -12,6 +25,41 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "3lE/iLSutpgX1CC0NOW70FJoGARRHbyKmG7dc0klnUZ9Dd9hS6N/POPWhKhMLCEuNN5nXEY5agmlFtH562vqhQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "mBMoXLsr5s1y2zOHWmKsE9veDcx8h1x/c3rz4baEdQKTeDcmQAPNbB54Pi/lhFO3K431eEq6PFbMgLaa6PHFfA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "8.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + }, "SonarAnalyzer.CSharp": { "type": "Transitive", "resolved": "9.20.0.85982",