diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/Allegro.Extensions.Configuration.Abstractions.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/Allegro.Extensions.Configuration.Abstractions.csproj new file mode 100644 index 0000000..2e556e3 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/Allegro.Extensions.Configuration.Abstractions.csproj @@ -0,0 +1,13 @@ + + + + true + Allegro.Extensions.Configuration + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/IConfigurationProviderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/IConfigurationProviderWrapper.cs new file mode 100644 index 0000000..5dab578 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/IConfigurationProviderWrapper.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration +{ + /// + /// wrapper, that simplifies traversing the providers' graph + /// by exposing the property. + /// + public interface IConfigurationProviderWrapper : IConfigurationProvider + { + /// + /// Inner being wrapped. + /// + IConfigurationProvider Inner { get; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ISensitiveConfigurationProviderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ISensitiveConfigurationProviderWrapper.cs new file mode 100644 index 0000000..3ecf2a8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ISensitiveConfigurationProviderWrapper.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration +{ + /// + /// wrapper that marks the inner provider as sensitive + /// (meaning that it may contain secret values). + /// + public interface ISensitiveConfigurationProviderWrapper : IConfigurationProviderWrapper + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ITraversableChainedConfigurationProviderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ITraversableChainedConfigurationProviderWrapper.cs new file mode 100644 index 0000000..1363dad --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Abstractions/ITraversableChainedConfigurationProviderWrapper.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration +{ + /// + /// wrapper, that simplifies traversing the providers' graph + /// by exposing the property. + /// + public interface ITraversableChainedConfigurationProviderWrapper : IConfigurationProvider + { + /// + /// Inner being wrapped. + /// + IConfigurationRoot ConfigurationRoot { get; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Allegro.Extensions.Configuration.Api.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Allegro.Extensions.Configuration.Api.csproj new file mode 100644 index 0000000..1ef66be --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Allegro.Extensions.Configuration.Api.csproj @@ -0,0 +1,19 @@ + + + false + enable + $(NoWarn);1591 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Configuration/SecretsConfiguration.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Configuration/SecretsConfiguration.cs new file mode 100644 index 0000000..9cb9a2f --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Configuration/SecretsConfiguration.cs @@ -0,0 +1,6 @@ +namespace Allegro.Extensions.Configuration.Api.Configuration; + +public class SecretsConfiguration +{ + public string SecretsPath { get; set; } = null!; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/GlobalConfigurationController.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/GlobalConfigurationController.cs new file mode 100644 index 0000000..1202b76 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/GlobalConfigurationController.cs @@ -0,0 +1,34 @@ +using Allegro.Extensions.Configuration.Api.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Allegro.Extensions.Configuration.Api.Controllers +{ + [ApiController] + [Route("global-configuration")] + public class GlobalConfigurationController : ControllerBase + { + private readonly IGlobalConfigurationProvider _globalConfigurationProvider; + + public GlobalConfigurationController(IGlobalConfigurationProvider globalConfigurationProvider) + { + _globalConfigurationProvider = globalConfigurationProvider; + } + + [HttpGet("context-groups")] + public IActionResult GetGlobalConfiguration([FromQuery] string? serviceName) + { + return Ok(_globalConfigurationProvider.GetGlobalConfiguration(serviceName)); + } + + [HttpGet("context-groups/{contextGroupName}/contexts/{contextName}")] + public IActionResult GetGlobalConfigurationContext( + [FromRoute] string contextGroupName, + [FromRoute] string contextName) + { + return File( + _globalConfigurationProvider.GetContext(contextGroupName, contextName), + "application/json", + contextName); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/SecretsController.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/SecretsController.cs new file mode 100644 index 0000000..0af7225 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Controllers/SecretsController.cs @@ -0,0 +1,30 @@ +using Allegro.Extensions.AspNetCore.Attributes; +using Allegro.Extensions.Configuration.Api.Services; +using Allegro.Extensions.Configuration.DataContracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Allegro.Extensions.Configuration.Api.Controllers +{ + [ApiController] + [Authorize] + [Route("secrets")] + [SkipOnProd] + public class SecretsController : ControllerBase + { + private readonly ISecretsProvider _secretsProvider; + + public SecretsController(ISecretsProvider secretsProvider) + { + _secretsProvider = secretsProvider; + } + + [HttpPost] + public IActionResult GetSecrets([FromBody] GetSecretsRequest request) + { + return Content( + _secretsProvider.GetSecretsAsJson(request.KeyVaultPrefixes), + "application/json"); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Exceptions/NotAllowedOutsideTestEnvException.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Exceptions/NotAllowedOutsideTestEnvException.cs new file mode 100644 index 0000000..bda7cb2 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Exceptions/NotAllowedOutsideTestEnvException.cs @@ -0,0 +1,8 @@ +namespace Allegro.Extensions.Configuration.Api.Exceptions; + +public class NotAllowedOutsideTestEnvException : Exception +{ + public NotAllowedOutsideTestEnvException() : base("Not allowed outside of a test environment") + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Properties/launchSettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Properties/launchSettings.json new file mode 100644 index 0000000..3b41a3b --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Properties/launchSettings.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "Vabank.Confeature.Service": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:6001;http://localhost:6000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "GlobalConfiguration__ContextGroups__0__Name": "vabank-configuration", + "GlobalConfiguration__ContextGroups__0__Path": "/Users/szymon.adach/Repos/global-config" + } + } + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/GlobalConfigurationProvider.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/GlobalConfigurationProvider.cs new file mode 100644 index 0000000..730e5fa --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/GlobalConfigurationProvider.cs @@ -0,0 +1,66 @@ +using Allegro.Extensions.Configuration.DataContracts; +using Allegro.Extensions.Configuration.Extensions; +using Allegro.Extensions.Configuration.GlobalConfiguration; +using Microsoft.Extensions.Options; + +namespace Allegro.Extensions.Configuration.Api.Services; + +public interface IGlobalConfigurationProvider +{ + GetGlobalConfigurationResponse GetGlobalConfiguration(string? serviceName = null); + Stream GetContext(string contextGroupName, string contextName); +} + +public class GlobalConfigurationProvider : IGlobalConfigurationProvider +{ + private readonly ContextGroupsConfiguration _configuration; + + public GlobalConfigurationProvider(IOptions configuration) + { + _configuration = configuration.Value; + } + + public GetGlobalConfigurationResponse GetGlobalConfiguration(string? serviceName = null) + { +#pragma warning disable CSE001 + var result = new GetGlobalConfigurationResponse(); +#pragma warning restore CSE001 + + foreach (var contextGroupConfiguration in _configuration.ContextGroups) + { + var contexts = GetContextsList(contextGroupConfiguration, serviceName).OrderBy(c => c).ToList(); + if (contexts.Count == 0) + { + continue; + } + + result.ContextGroups.Add( + new ContextGroupModel { Name = contextGroupConfiguration.Name, Contexts = contexts }); + } + + return result; + } + + public Stream GetContext(string contextGroupName, string contextName) + { + var contextGroup = _configuration.ContextGroups + .Single(x => x.Name.Equals(contextGroupName, StringComparison.OrdinalIgnoreCase)); + return File.OpenRead(Path.Combine(contextGroup.Path, $"{contextName}.json")); + } + + private static IEnumerable GetContextsList( + ContextGroupConfiguration contextGroupConfiguration, + string? serviceName) + { + foreach (var file in Directory.EnumerateFiles(contextGroupConfiguration.Path, "*.json")) + { + if (!string.IsNullOrEmpty(serviceName) && + !ConfigurationContextExtensions.IsServiceListedForContext(File.OpenRead(file), serviceName)) + { + continue; + } + + yield return Path.GetFileNameWithoutExtension(file); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/SecretsProvider.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/SecretsProvider.cs new file mode 100644 index 0000000..894071e --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/Services/SecretsProvider.cs @@ -0,0 +1,52 @@ +using Allegro.Extensions.Configuration.Api.Configuration; +using Allegro.Extensions.Configuration.Api.Exceptions; +using Allegro.Extensions.Configuration.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace Allegro.Extensions.Configuration.Api.Services; + +public interface ISecretsProvider +{ + string GetSecretsAsJson(IEnumerable keyVaultPrefixes); +} + +public class SecretsProvider : ISecretsProvider +{ + private readonly EnvironmentConfiguration _environmentConfiguration; + private readonly SecretsConfiguration _secretsConfiguration; + + public SecretsProvider( + IOptions environmentConfiguration, + IOptions secretsConfiguration) + { + _environmentConfiguration = environmentConfiguration.Value; + _secretsConfiguration = secretsConfiguration.Value; + } + + public string GetSecretsAsJson(IEnumerable keyVaultPrefixes) + { + if (!_environmentConfiguration.IsTestEnvironment) + { + throw new NotAllowedOutsideTestEnvException(); + } + + var secretsConfiguration = + new ConfigurationBuilder() + .AddKeyPerFile(_secretsConfiguration.SecretsPath) + .Build(); + + var prefixes = NormalizeKeyVaultPrefixes(keyVaultPrefixes); + return JsonConvert.SerializeObject( + secretsConfiguration + .AsEnumerable() + .Where(x => prefixes.Any(p => x.Key.StartsWith(p, StringComparison.InvariantCultureIgnoreCase))) + .ToDictionary(x => x.Key, x => x.Value)); + } + + private static string[] NormalizeKeyVaultPrefixes(IEnumerable prefixes) + => prefixes + .Select(p => p.Replace("--", ConfigurationPath.KeyDelimiter)) + .ToArray(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/StartupExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/StartupExtensions.cs new file mode 100644 index 0000000..75821c6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Api/StartupExtensions.cs @@ -0,0 +1,24 @@ +using Allegro.Extensions.Configuration.Api.Services; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Extensions; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable ConvertClosureToMethodGroup + +namespace Allegro.Extensions.Configuration.Api; + +public static class StartupExtensions +{ + /// + /// Registers Confeature V2 dependencies for a Fallback Service. + /// + public static IServiceCollection AddConfeatureFallbackService( + this IServiceCollection services, + ConfeatureOptions confeatureOptions) + { + services.AddConfeature(confeatureOptions); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/Allegro.Extensions.Configuration.Client.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/Allegro.Extensions.Configuration.Client.csproj new file mode 100644 index 0000000..af5d443 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/Allegro.Extensions.Configuration.Client.csproj @@ -0,0 +1,19 @@ + + + + true + $(NoWarn);1591 + + + + + + + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/ConfeatureServiceClient.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/ConfeatureServiceClient.cs new file mode 100644 index 0000000..0044968 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/ConfeatureServiceClient.cs @@ -0,0 +1,67 @@ +using System.Net.Http; +using Allegro.Extensions.Configuration.DataContracts; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; +using RestEase; + +// ReSharper disable UnusedMember.Global + +namespace Allegro.Extensions.Configuration.Client; + +public interface IConfeatureServiceClientRest +{ + [Get("global-configuration/context-groups")] + Task GetGlobalConfiguration([Query] string serviceName); + + [Get("global-configuration/context-groups/{contextGroupName}/contexts/{contextName}")] + Task GetGlobalConfigurationContext([Path] string contextGroupName, [Path] string contextName); +} + +public interface IConfeatureServiceClient +{ + Task GetGlobalConfiguration(string serviceName); + Task GetGlobalConfigurationContext(string contextGroupName, string contextName); +} + +public class ConfeatureServiceClient : IConfeatureServiceClient +{ + private readonly IConfeatureServiceClientRest _restClient; + + private static JsonSerializerSettings SerializerSettings + { + get + { + var settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + } + }; + settings.Converters.Add(new StringEnumConverter + { + NamingStrategy = new CamelCaseNamingStrategy() + }); + return settings; + } + } + + public ConfeatureServiceClient(HttpClient httpClient) + { + _restClient = + new RestClient(httpClient) { JsonSerializerSettings = SerializerSettings } + .For(); + } + + public Task GetGlobalConfiguration(string serviceName) + { + return _restClient.GetGlobalConfiguration(serviceName); + } + + public Task GetGlobalConfigurationContext(string contextGroupName, string contextName) + { + return _restClient.GetGlobalConfigurationContext(contextGroupName, contextName); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/StartupExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/StartupExtensions.cs new file mode 100644 index 0000000..370c2cf --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Client/StartupExtensions.cs @@ -0,0 +1,22 @@ +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable UnusedMember.Global +// ReSharper disable UnusedType.Global + +namespace Allegro.Extensions.Configuration.Client; + +public static class StartupExtensions +{ + public static IServiceCollection AddConfeatureServiceClient( + this IServiceCollection services, + Func httpClientFactory) + { + return services.AddSingleton( + sp => + { + var httpClient = httpClientFactory(sp); + return new ConfeatureServiceClient(httpClient); + }); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/Allegro.Extensions.Configuration.DataContracts.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/Allegro.Extensions.Configuration.DataContracts.csproj new file mode 100644 index 0000000..91b68ed --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/Allegro.Extensions.Configuration.DataContracts.csproj @@ -0,0 +1,8 @@ + + + + true + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetGlobalConfigurationResponse.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetGlobalConfigurationResponse.cs new file mode 100644 index 0000000..caf7ee6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetGlobalConfigurationResponse.cs @@ -0,0 +1,28 @@ +namespace Allegro.Extensions.Configuration.DataContracts; + +/// +/// Global configuration response +/// +public class GetGlobalConfigurationResponse +{ + /// + /// List of context groups + /// + public List ContextGroups { get; init; } = new(); +} + +/// +/// Context group representation +/// +public class ContextGroupModel +{ + /// + /// Name of the Context Group + /// + public string Name { get; init; } = null!; + + /// + /// Contexts defined within the context group + /// + public List Contexts { get; init; } = new(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetSecretsRequest.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetSecretsRequest.cs new file mode 100644 index 0000000..6809495 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.DataContracts/GetSecretsRequest.cs @@ -0,0 +1,12 @@ +namespace Allegro.Extensions.Configuration.DataContracts; + +/// +/// Get secrets request +/// +public class GetSecretsRequest +{ + /// + /// List of key vault prefixes to include in response + /// + public List KeyVaultPrefixes { get; init; } = new(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Allegro.Extensions.Configuration.Demo.FallbackService.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Allegro.Extensions.Configuration.Demo.FallbackService.csproj new file mode 100644 index 0000000..09ba5b1 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Allegro.Extensions.Configuration.Demo.FallbackService.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + + + + + + + + <_ContentIncludedByDefault Remove="global-config\TestGlobalConfig.json" /> + + + + + Always + + + \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Program.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Program.cs new file mode 100644 index 0000000..fca52b3 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Program.cs @@ -0,0 +1,43 @@ +using Allegro.Extensions.Configuration.Api; +using Allegro.Extensions.Configuration.Api.Services; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Extensions; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Allegro.Extensions.Configuration.Demo.FallbackService; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Basic Confeature settings + var confeatureOptions = new ConfeatureOptions + { + IsEnabled = true, + ServiceName = "demo", + AuthorizationPolicy = null, + }; + + // Add the global configuration source + builder.Configuration.AddGlobalConfiguration( + confeatureOptions, + builder.Environment); + + // Register the Confeature + builder.Services.AddConfeatureFallbackService(confeatureOptions); + + builder.Services.AddControllers() + .AddApplicationPart(typeof(ISecretsProvider).Assembly); + builder.Services.AddHttpClient(); + builder.Services.AddEndpointsApiExplorer(); + + var app = builder.Build(); + + app.UseAuthorization(); + app.MapControllers(); + app.Run(); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Properties/launchSettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Properties/launchSettings.json new file mode 100644 index 0000000..134e5f0 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Allegro.Extensions.Configuration.Demo.FallbackService": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5284", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.Development.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.Development.json new file mode 100644 index 0000000..e86e05d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "GlobalConfiguration": { + "ContextGroups": [ + { + "Name": "global-config", + "Path": "global-config" + } + ] + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/appsettings.json @@ -0,0 +1,2 @@ +{ +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/global-config/TestGlobalConfig.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/global-config/TestGlobalConfig.json new file mode 100644 index 0000000..e34f2ce --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo.FallbackService/global-config/TestGlobalConfig.json @@ -0,0 +1,11 @@ +{ + "config": { + "Test": 5, + "Message": "Hello from global config" + }, + "metadata": { + "services": [ + "demo" + ] + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Allegro.Extensions.Configuration.Demo.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Allegro.Extensions.Configuration.Demo.csproj new file mode 100644 index 0000000..0fa851a --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Allegro.Extensions.Configuration.Demo.csproj @@ -0,0 +1,22 @@ + + + + net6.0 + enable + enable + + + + + + + + + + + + + Always + + + \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestGlobalConfig.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestGlobalConfig.cs new file mode 100644 index 0000000..02317bc --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestGlobalConfig.cs @@ -0,0 +1,10 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +namespace Allegro.Extensions.Configuration.Demo.Configuration; + +[GlobalConfigurationContext("TestGlobalConfig")] +public class TestGlobalConfig : IGlobalConfigurationMarker +{ + public int Test { get; set; } + + public string? Message { get; set; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestLocalConfig.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestLocalConfig.cs new file mode 100644 index 0000000..6b021c6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Configuration/TestLocalConfig.cs @@ -0,0 +1,11 @@ +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +namespace Allegro.Extensions.Configuration.Demo.Configuration; + +public class TestLocalConfig : IConfigurationMarker +{ + public int Test { get; set; } + + public string? Message { get; set; } + + public string? Secret { get; set; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/TestController.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/TestController.cs new file mode 100644 index 0000000..3f1df90 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/TestController.cs @@ -0,0 +1,35 @@ +using Allegro.Extensions.Configuration.Demo.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Allegro.Extensions.Configuration.Demo.Controllers; + +[ApiController] +[Route("[controller]")] +public class TestController : ControllerBase +{ + private readonly IOptions _testGlobalConfig; + private readonly IOptions _testLocalConfig; + + public TestController( + IOptions testGlobalConfig, + IOptions testLocalConfig) + { + _testGlobalConfig = testGlobalConfig; + _testLocalConfig = testLocalConfig; + } + + [HttpGet("global")] + public Task GetGlobal() + { + return Task.FromResult(_testGlobalConfig.Value); + } + + [HttpGet("local")] + public Task GetLocal() + { + return Task.FromResult(_testLocalConfig.Value); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/WeatherForecastController.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/WeatherForecastController.cs new file mode 100644 index 0000000..1f70277 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Controllers/WeatherForecastController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Allegro.Extensions.Configuration.Demo.Controllers; + +public record WeatherForecast(DateTime Date, int TemperatureC, string Summary); + +[ApiController] +[Route("[controller]")] +public class WeatherForecastController : ControllerBase +{ + private static readonly string[] Summaries = new[] + { + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + }; + + [HttpGet(Name = "GetWeatherForecast")] + public Task> Get() + { + return Task.FromResult>( + Enumerable.Range(1, 5).Select( + index => new WeatherForecast( + DateTime.Now.AddDays(index), + Random.Shared.Next(-20, 55), + Summaries[Random.Shared.Next(Summaries.Length)])) + .ToArray()); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Program.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Program.cs new file mode 100644 index 0000000..4de5f79 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Program.cs @@ -0,0 +1,50 @@ +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Demo.Configuration; +using Allegro.Extensions.Configuration.Extensions; +using Allegro.Extensions.Configuration.Wrappers; + +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Allegro.Extensions.Configuration.Demo; + +public class Program +{ + public static void Main(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + // Basic Confeature settings + var confeatureOptions = new ConfeatureOptions + { + IsEnabled = true, + ServiceName = "demo", + AuthorizationPolicy = null, + }; + + // This is just an example of marking configuration source as sensitive. + // In real-world scenario this would be something like a K8S Secret mounted on volume. + // Values from this source will not be visible in /configuration endpoint response. + builder.Configuration.WrapSensitive().AddJsonFile("appsettings.Secret.json"); + + // Add the global configuration source + builder.Configuration.AddGlobalConfiguration( + confeatureOptions, + builder.Environment); + + // Register the Confeature and all configuration classes (available later using IOptions<> pattern) + builder.Services + .AddConfeature(confeatureOptions) + .RegisterConfig(builder.Configuration, "TestConfig") + .RegisterGlobalConfig(builder.Configuration, confeatureOptions); + + builder.Services.AddControllers(); + builder.Services.AddHttpClient(); + builder.Services.AddEndpointsApiExplorer(); + + var app = builder.Build(); + + app.UseAuthorization(); + app.MapControllers(); + app.Run(); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Properties/launchSettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Properties/launchSettings.json new file mode 100644 index 0000000..fce5896 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "Allegro.Extensions.Configuration.Demo": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5283", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Development.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Development.json new file mode 100644 index 0000000..e86e05d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "GlobalConfiguration": { + "ContextGroups": [ + { + "Name": "global-config", + "Path": "global-config" + } + ] + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Secret.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Secret.json new file mode 100644 index 0000000..cda6286 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.Secret.json @@ -0,0 +1,5 @@ +{ + "TestConfig": { + "Secret": "P@ssw0rd" + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.json new file mode 100644 index 0000000..8bbef1a --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/appsettings.json @@ -0,0 +1,6 @@ +{ + "TestConfig": { + "Test": 10, + "Message": "Hello from local config!" + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/global-config/TestGlobalConfig.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/global-config/TestGlobalConfig.json new file mode 100644 index 0000000..e34f2ce --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Demo/global-config/TestGlobalConfig.json @@ -0,0 +1,11 @@ +{ + "config": { + "Test": 5, + "Message": "Hello from global config" + }, + "metadata": { + "services": [ + "demo" + ] + } +} diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/Allegro.Extensions.Configuration.FluentValidation.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/Allegro.Extensions.Configuration.FluentValidation.csproj new file mode 100644 index 0000000..ac7c1fa --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/Allegro.Extensions.Configuration.FluentValidation.csproj @@ -0,0 +1,24 @@ + + + + Confeature v2 fluent validations library + enable + + $(NoWarn);1591 + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/StartupExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/StartupExtensions.cs new file mode 100644 index 0000000..cbea1c4 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.FluentValidation/StartupExtensions.cs @@ -0,0 +1,43 @@ +using Allegro.Extensions.Configuration.Services; +using Allegro.Extensions.Validators; +using FluentValidation; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Allegro.Extensions.Configuration.FluentValidation; + +public static class StartupExtensions +{ + /// + /// Registers the configuration using the configuration section name + /// passed as the sectionName. If sectionName is null, configuration root will be used. + /// This extension also enables fluent validation for the configuration. + /// + /// Configuration class. It should implement + /// the interface. + /// Validator class + public static IServiceCollection RegisterConfig( + this IServiceCollection services, + IConfiguration configuration, + string? sectionName = null) + where TOptions : class, IConfigurationMarker + where TValidator : class, IValidator + { + var configurationSection = + !string.IsNullOrWhiteSpace(sectionName) + ? configuration.GetRequiredSection(sectionName) + : configuration as IConfigurationSection; + + services.AddScoped, TValidator>(); + + services.Configure(cr => cr.RegisterOptions(configurationSection?.Path)); + services + .AddOptions() + .ValidateDataAnnotations() + .ValidateFluentValidation() + .ValidateOnStart() + .Bind(configurationSection ?? configuration, c => c.BindNonPublicProperties = true); + + return services; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Allegro.Extensions.Configuration.Tests.Integration.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Allegro.Extensions.Configuration.Tests.Integration.csproj new file mode 100644 index 0000000..747847d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Allegro.Extensions.Configuration.Tests.Integration.csproj @@ -0,0 +1,18 @@ + + + net6.0 + false + $(NoWarn);1591 + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/DemoSmokeTestsFixture.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/DemoSmokeTestsFixture.cs new file mode 100644 index 0000000..e3c7dad --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/DemoSmokeTestsFixture.cs @@ -0,0 +1,16 @@ +using Allegro.Extensions.Configuration.Demo; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Vabank.Confeature.Tests; + +namespace Vabank.Confeature.Integration.Tests; + +public class DemoSmokeTestsFixture : ConfeatureSmokeTest +{ + public DemoSmokeTestsFixture(WebApplicationFactory factory) + : base( + factory.WithWebHostBuilder( + cfg => cfg.ConfigureServices(sc => sc.Configure(o => o.SomeValue = 1)))) + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactory.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..9cbf6dd --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactory.cs @@ -0,0 +1,35 @@ +using Allegro.Extensions.Configuration.Demo; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Vabank.Confeature.Integration.Tests; + +namespace Allegro.Extensions.Configuration.Tests.Integration.Helpers; + +public class CustomWebApplicationFactory : WebApplicationFactory +{ + public string AspNetEnvironment { get; set; } = Environments.Development; + + /// + /// If error on dispose please relate to https://github.com/djluck/prometheus-net.DotNetRuntime/issues/65 + /// and catch it in Dispose as a workaround + /// + protected override IHostBuilder CreateHostBuilder() + { + Environment.SetEnvironmentVariable("IntegrationTesting", "true"); + // we want to control the environment with UseEnvironment instead of env var + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", ""); + + return base.CreateHostBuilder() + .ConfigureServices( + services => + { + services.AddSingleton(); + }) + .ConfigureWebHostDefaults( + webBuilder => + webBuilder.UseEnvironment(AspNetEnvironment) + ); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactoryCollection.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactoryCollection.cs new file mode 100644 index 0000000..8724507 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Helpers/CustomWebApplicationFactoryCollection.cs @@ -0,0 +1,16 @@ +using Xunit; + +namespace Allegro.Extensions.Configuration.Tests.Integration.Helpers; + +/// +/// This class has no code, and is never instantiated. Its purpose is simply +/// to be the place to apply [CollectionDefinition] and all the +/// ICollectionFixture interfaces. +/// +[CollectionDefinition(Name)] +#pragma warning disable CA1711 +public class CustomWebApplicationFactoryCollection : ICollectionFixture +#pragma warning restore CA1711 +{ + public const string Name = "Options Registration Validator collection"; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/OptionsRegistrationValidatorTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/OptionsRegistrationValidatorTests.cs new file mode 100644 index 0000000..f51fd24 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/OptionsRegistrationValidatorTests.cs @@ -0,0 +1,83 @@ +using Allegro.Extensions.Configuration; +using Allegro.Extensions.Configuration.Tests.Integration.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Vabank.Confeature.Integration.Tests; + +[Collection(CustomWebApplicationFactoryCollection.Name)] +public class OptionsRegistrationValidatorTests +{ + private const string ShouldSkipTests = "Integration tests skipped as they're not working on the build agents"; + + private readonly CustomWebApplicationFactory _factory; + + // Set this to null in order to enable integration tests + + public OptionsRegistrationValidatorTests(CustomWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact(Skip = ShouldSkipTests)] + public void ShouldThrowAnException_WhenConfigurationDtoIsNotRegistered() + { + // Arrange + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_HOST", ""); + var func = () => _factory.CreateClient(); + + // Act & Assert + func + .Should() + .Throw(because: "Configuration DTO is not registered properly") + .WithMessage($"*{nameof(TestService)}*{nameof(TestConfig)}*DisableOptionsRegistrationValidation*"); + } + + [Fact(Skip = ShouldSkipTests)] + public void ShouldSkipValidation_WhenEnvironmentVariableIsSet() + { + // Arrange + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_HOST", ""); + Environment.SetEnvironmentVariable("DisableOptionsRegistrationValidation", bool.TrueString); + var func = () => _factory.CreateClient(); + + // Act & Assert + func + .Should() + .NotThrow(because: "Validation is turned off using the feature flag"); + } + + [Fact(Skip = ShouldSkipTests)] + public void ShouldSkipGenericTypes() + { + // Arrange + // We're adding the TestConfig options registration to prevent them from causing an exception + // We only want to check the generic IOptions validation behaviour + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_HOST", ""); + var func = () => _factory + .WithWebHostBuilder(cfg => cfg.ConfigureServices(sc => sc.Configure(o => o.SomeValue = 1))) + .CreateClient(); + + // Act & Assert + func + .Should() + .NotThrow(because: "Generic types should be skipped"); + } +} + +public class TestService +{ + public TestService(IOptions testConfig) { } +} + +public class TestConfig : IConfigurationMarker +{ + public int SomeValue { get; set; } +} + +public class GenericTestService where T : TestConfig +{ + public GenericTestService(IOptions testConfig) { } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Properties/launchSettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Properties/launchSettings.json new file mode 100644 index 0000000..5a98a0a --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Vabank.Confeature.Integration.Tests": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:53245;http://localhost:53246" + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/paket.references b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/paket.references new file mode 100644 index 0000000..351e661 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Integration/paket.references @@ -0,0 +1,7 @@ +FluentAssertions +Microsoft.AspNetCore.Mvc.Testing +Microsoft.NET.Test.Sdk +Moq +Serilog.Sinks.XUnit +xunit +xunit.runner.visualstudio \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Allegro.Extensions.Configuration.Tests.Unit.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Allegro.Extensions.Configuration.Tests.Unit.csproj new file mode 100644 index 0000000..3e62c2f --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Allegro.Extensions.Configuration.Tests.Unit.csproj @@ -0,0 +1,40 @@ + + + net6.0 + false + $(NoWarn);1591 + + + + PreserveNewest + + + + + true + PreserveNewest + PreserveNewest + + + true + Always + PreserveNewest + + + + PreserveNewest + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ConfigurationHelperTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ConfigurationHelperTests.cs new file mode 100644 index 0000000..49aa94d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ConfigurationHelperTests.cs @@ -0,0 +1,174 @@ +// using Allegro.Extensions.Configuration.Extensions; +// using Allegro.Extensions.Configuration.Models; +// using FinAi.Platform.Demo.Configuration; +// +// namespace Vabank.Confeature.Tests.Unit; +// +// using System; +// using System.Linq; +// using DeepEqual.Syntax; +// using FinAi.Platform; +// using FluentAssertions; +// using Microsoft.Extensions.Configuration; +// using Microsoft.Extensions.Configuration.Json; +// using Microsoft.Extensions.DependencyInjection; +// using Vabank.Confeature.Extensions; +// using Vabank.Confeature.Models; +// using Vabank.Confeature.Services; +// using Xunit; +// +// public class ConfigurationHelperTests +// { +// [Fact] +// public void TestJsonMetadata() +// { +// // arrange +// var services = ConfigureServices(b => b +// .AddJsonFile("appsettings.json") +// .AddJsonFile("appsettings.Development.json")); +// +// // act +// var response = ConfigurationHelper.GetConfiguration(services); +// +// // assert +// response.Providers.Should().HaveCount(2); +// +// var appsettings = +// response.Providers.Single(x => x.Value.DisplayName == "appsettings.json"); +// var appsettingsDevelopment = +// response.Providers.Single(x => x.Value.DisplayName == "appsettings.Development.json"); +// +// appsettings.Value.ShouldDeepEqual(new ConfigurationProviderMetadata( +// "appsettings.json", +// nameof(JsonConfigurationProvider), +// "appsettings.json", +// false, +// true)); +// appsettingsDevelopment.Value.ShouldDeepEqual(new ConfigurationProviderMetadata( +// "appsettings.Development.json", +// nameof(JsonConfigurationProvider), +// "appsettings.Development.json", +// false, +// true)); +// } +// +// [Fact] +// public void TestSecretProvider() +// { +// // arrange +// var services = ConfigureServices(b => b +// .AddJsonFile("appsettings.json") +// .WrapSensitive() +// .AddJsonFile("appsettings.Development.json")); +// +// // act +// var response = ConfigurationHelper.GetConfiguration(services); +// +// // assert +// response.Providers.Should().HaveCount(2); +// +// var appsettings = +// response.Providers.Single(x => x.Value.DisplayName == "appsettings.json"); +// var appsettingsDevelopment = +// response.Providers.Single(x => x.Value.DisplayName == "appsettings.Development.json"); +// +// appsettings.Value.IsSecret.Should().Be(false); +// appsettingsDevelopment.Value.IsSecret.Should().Be(true); +// +// var appsettingsValues = response.Configuration +// .SelectMany(x => x.Value) +// .Where(x => x.ProviderId == appsettings.Key) +// .ToList(); +// var appsettingsDevelopmentValues = response.Configuration +// .SelectMany(x => x.Value) +// .Where(x => x.ProviderId == appsettingsDevelopment.Key) +// .ToList(); +// +// appsettingsValues.Should().NotBeEmpty(); +// appsettingsValues.ForEach(x => x.Value.Should().NotBeNull()); +// +// appsettingsDevelopmentValues.Should().NotBeEmpty(); +// appsettingsDevelopmentValues.ForEach(x => x.Value.Should().BeNull()); +// } +// +// [Fact] +// public void TestScheduledValueCalculation() +// { +// // arrange +// var services = ConfigureServices(b => b +// .AddJsonFile("appsettings.json")); +// +// // act +// var response = ConfigurationHelper.GetConfiguration(services); +// var scheduledValue = response.Configuration +// .FirstOrDefault( +// kv => kv.Key == $"{nameof(DemoConfig)}:" + +// $"{nameof(DemoConfig.ScheduledIntegerFlag)}:" + +// $"{nameof(ScheduledConfigurationWrapper.Value)}").Value; +// var configurationSchedulesConfigs = response.Configuration +// .Where( +// kv => kv.Key.StartsWith( +// $"{nameof(DemoConfig)}:" + +// $"{nameof(DemoConfig.ScheduledIntegerFlag)}:" + +// $"{nameof(ScheduledConfigurationWrapper.Schedules)}:", +// StringComparison.OrdinalIgnoreCase)); +// +// // assert +// response.Providers.Should().HaveCount(1); +// +// scheduledValue.Should().NotBeNull(); +// scheduledValue.Should().HaveCount(1); +// scheduledValue[0].Value.Should().NotBeNull(); +// scheduledValue[0].Value.Should().Be("55"); +// +// configurationSchedulesConfigs.Should().HaveCount(0); // configuration schedules are removed from configuration keys +// } +// +// [Fact] +// public void TestSecretScheduledValueCalculation() +// { +// // arrange +// var services = ConfigureServices(b => b +// .WrapSensitive().AddJsonFile("appsettings.json")); +// +// // act +// var response = ConfigurationHelper.GetConfiguration(services); +// var scheduledValue = response.Configuration +// .FirstOrDefault( +// kv => kv.Key == $"{nameof(DemoConfig)}:" + +// $"{nameof(DemoConfig.ScheduledIntegerFlag)}:" + +// $"{nameof(ScheduledConfigurationWrapper.Value)}") +// .Value; +// var configurationSchedulesConfigs = response.Configuration +// .Where( +// kv => kv.Key.StartsWith( +// $"{nameof(DemoConfig)}:" + +// $"{nameof(DemoConfig.ScheduledIntegerFlag)}:" + +// $"{nameof(ScheduledConfigurationWrapper.Schedules)}:", +// StringComparison.OrdinalIgnoreCase)); +// +// // assert +// response.Providers.Should().HaveCount(1); +// +// scheduledValue.Should().NotBeNull(); +// scheduledValue.Should().HaveCount(1); +// scheduledValue[0].Value.Should().BeNull(); +// scheduledValue[0].AdditionalInfo.Should().Be("Schedule evaluation"); +// +// // configuration schedules are removed from configuration keys +// configurationSchedulesConfigs.Should().HaveCount(0); +// } +// +// private static IServiceProvider ConfigureServices(Action configure) +// { +// var configurationBuilder = new ConfigurationBuilder(); +// configure(configurationBuilder); +// var configuration = configurationBuilder.Build(); +// +// var services = new ServiceCollection(); +// services.AddSingleton(configuration); +// services.RegisterConfig(configuration, nameof(DemoConfig)); +// +// return services.BuildServiceProvider(); +// } +// } \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateLessThanValidationTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateLessThanValidationTests.cs new file mode 100644 index 0000000..9e6ca5c --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateLessThanValidationTests.cs @@ -0,0 +1,134 @@ +using System.ComponentModel.DataAnnotations; +using Allegro.Extensions.Configuration.Validation; +using FluentAssertions; +using Xunit; + +namespace Vabank.Confeature.Tests.Unit; + +public class DateLessThanValidationTests +{ + [Fact] + public void IsValidTest() + { + // arrange +#pragma warning disable CSE001 + var dateRange = new DateRangeTest + { + StartDate = new DateTimeOffset(new DateTime(2022, 02, 21, 14, 44, 0)), + EndDate = new DateTimeOffset(new DateTime(2022, 02, 21, 15, 0, 0)) + }; +#pragma warning restore CSE001 + + var dateLessThanAttribute = new DateLessThanAttribute("EndDate"); + + // act + var validationResult = dateLessThanAttribute.GetValidationResult( + dateRange.StartDate, + new ValidationContext(dateRange)); + + // assert + validationResult.Should().BeSameAs(ValidationResult.Success); + } + + [Fact] + public void IsNotValidTest() + { + // arrange +#pragma warning disable CSE001 + var dateRange = new DateRangeTest + { + StartDate = new DateTimeOffset(new DateTime(2022, 03, 21, 14, 44, 0)), + EndDate = new DateTimeOffset(new DateTime(2022, 02, 21, 15, 0, 0)) + }; +#pragma warning restore CSE001 + + var dateLessThanAttribute = + new DateLessThanAttribute("EndDate") { ErrorMessage = "StartDate must be before EndDate" }; + + // act + var validationResult = dateLessThanAttribute.GetValidationResult( + dateRange.StartDate, + new ValidationContext(dateRange)); + + // assert + validationResult.Should().NotBeNull(); + validationResult!.ErrorMessage.Should().NotBeNull(); + validationResult!.ErrorMessage.Should().Be(dateLessThanAttribute.ErrorMessage); + } + + [Fact] + public void PropertyNotFoundTest() + { + // arrange +#pragma warning disable CSE001 + var dateRange = new DateRangeTest + { + StartDate = new DateTimeOffset(new DateTime(2022, 02, 21, 14, 44, 0)), + EndDate = new DateTimeOffset(new DateTime(2022, 02, 21, 15, 0, 0)) + }; +#pragma warning restore CSE001 + + var dateLessThanAttribute = new DateLessThanAttribute("Foo"); + + // act + Action act = () => dateLessThanAttribute.GetValidationResult( + dateRange.StartDate, + new ValidationContext(dateRange)); + + // assert + act.Should().Throw().WithMessage("Property not found (Parameter 'Foo')"); + } + + [Fact] + public void PropertyWrongTypeTest() + { + // arrange +#pragma warning disable CSE001 + var dateRange = new DateRangeTest + { + StartDate = new DateTimeOffset(new DateTime(2022, 02, 21, 14, 44, 0)), + EndDate = new DateTimeOffset(new DateTime(2022, 02, 21, 15, 0, 0)) + }; +#pragma warning restore CSE001 + + var dateLessThanAttribute = new DateLessThanAttribute("WrongPropertyType"); + + // act + Action act = () => dateLessThanAttribute.GetValidationResult( + dateRange.StartDate, + new ValidationContext(dateRange)); + + // assert + act.Should().Throw().WithMessage("Property is not a DateTimeOffset (Parameter 'WrongPropertyType')"); + } + + [Fact] + public void ValueWrongTypeTest() + { + // arrange +#pragma warning disable CSE001 + var dateRange = new DateRangeTest + { + StartDate = new DateTimeOffset(new DateTime(2022, 02, 21, 14, 44, 0)), + EndDate = new DateTimeOffset(new DateTime(2022, 02, 21, 15, 0, 0)) + }; +#pragma warning restore CSE001 + + var dateLessThanAttribute = new DateLessThanAttribute("EndDate"); + + // act + Action act = () => dateLessThanAttribute.GetValidationResult( + "wrong object type", + new ValidationContext(dateRange)); + + // assert + act.Should().Throw().WithMessage("'wrong object type' is not a DateTimeOffset (Parameter 'value')"); + } + + private class DateRangeTest + { + public DateTimeOffset StartDate { get; init; } + public DateTimeOffset EndDate { get; init; } + public int WrongPropertyType { get; init; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateOverlapValidationTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateOverlapValidationTests.cs new file mode 100644 index 0000000..71d2032 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/DateOverlapValidationTests.cs @@ -0,0 +1,200 @@ +using Allegro.Extensions.Configuration.Validation; +using FluentAssertions; +using FluentAssertions.Common; +using Xunit; + +namespace Vabank.Confeature.Tests.Unit; + +public class DateOverlapValidationTests +{ + [Theory] +#pragma warning disable MA0005 +#pragma warning disable CA1825 + [MemberData(nameof(Data))] +#pragma warning restore CA1825 +#pragma warning restore MA0005 + public void IsValidTest(bool isValidExpected, IEnumerable dates) + { + // arrange + var validationAttribute = new DateOverlapValidationAttribute("StartDate", "EndDate"); + + // act + var isValid = validationAttribute.IsValid(dates); + + // assert + isValid.Should().Be(isValidExpected); + } + + public static IEnumerable Data => + new List + { + new object[] + { + true, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 01, 02).ToDateTimeOffset() + } + } + }, + new object[] + { + true, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 03, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 03, 02).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 01, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 01, 02).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 04, 02).ToDateTimeOffset() + } + } + }, + new object[] + { + false, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 03, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 03, 02).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 01, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 01, 10).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 01, 03).ToDateTimeOffset(), + EndDate = new DateTime(2022, 01, 05).ToDateTimeOffset() + } + } + }, + new object[] + { + false, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01, 15, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 02, 02, 15, 00, 00).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 03).ToDateTimeOffset(), + EndDate = new DateTime(2022, 05, 05).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 02, 02, 10, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 03, 10, 15, 00, 00).ToDateTimeOffset() + } + } + }, + new object[] + { + true, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01, 15, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 02, 02, 15, 00, 00).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 03).ToDateTimeOffset(), + EndDate = new DateTime(2022, 05, 05).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 02, 02, 15, 00, 01).ToDateTimeOffset(), + EndDate = new DateTime(2022, 03, 10, 15, 00, 00).ToDateTimeOffset() + } + } + }, + new object[] + { + false, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01, 15, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 02, 02, 15, 00, 00).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 03).ToDateTimeOffset(), + EndDate = new DateTime(2022, 05, 05).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 02, 01, 15, 00, 01).ToDateTimeOffset() + } + } + }, + new object[] + { + false, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01, 15, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 02, 02, 15, 00, 00).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 03).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 02, 01, 15, 00, 01).ToDateTimeOffset() + } + } + }, + new object[] + { + true, + new DateRangeTest[] + { + new() + { + StartDate = new DateTime(2022, 01, 01, 15, 00, 00).ToDateTimeOffset(), + EndDate = new DateTime(2022, 02, 02, 15, 00, 00).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 04, 03).ToDateTimeOffset() + }, + new() + { + StartDate = new DateTime(2022, 02, 03).ToDateTimeOffset(), + EndDate = new DateTime(2022, 04, 02).ToDateTimeOffset() + } + } + } + }; + + public class DateRangeTest + { + public DateTimeOffset StartDate { get; init; } + public DateTimeOffset? EndDate { get; init; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ScheduledConfigurationWrapperTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ScheduledConfigurationWrapperTests.cs new file mode 100644 index 0000000..e615e6d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ScheduledConfigurationWrapperTests.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using Allegro.Extensions.Configuration; +using Allegro.Extensions.Configuration.Validation; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Vabank.Confeature.Tests.Unit; + +public class ScheduledConfigurationWrapperTests +{ + [Fact] + public void ShouldReturnDefaultValueWhenNoSchedulesAreDefined() + { + // Arrange + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); +#pragma warning disable CSE001 + var dto = new ValidationTestConfig(); +#pragma warning restore CSE001 + configuration.Bind("ScheduledConfigWrapperConfig", dto); + var validationResults = new List(); + + // Act + var isValid = Validator.TryValidateObject( + dto, + new ValidationContext(dto, serviceProvider: null, items: null), + validationResults, + validateAllProperties: true); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.ScheduledIntegerFlag!.Value.Should().Be(1); + } + + [Fact] + public void ShouldReturnScheduledValueIfNoEndDateIsSpecified() + { + // Arrange + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); +#pragma warning disable CSE001 + var dto = new NoEndDateTestConfig(); +#pragma warning restore CSE001 + configuration.Bind("ScheduledConfigWrapperConfig", dto); + + // Act & assert + dto.ScheduledStringFlag!.Value.Should().Be("b"); + } + + private class ValidationTestConfig + { + [ValidateObject] + public ScheduledConfigurationWrapper? ScheduledIntegerFlag { get; set; } + } + + private class NoEndDateTestConfig + { + [ValidateObject] + public ScheduledConfigurationWrapper? ScheduledStringFlag { get; set; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/GlobalConfigurationProviderTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/GlobalConfigurationProviderTests.cs new file mode 100644 index 0000000..c0be978 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/GlobalConfigurationProviderTests.cs @@ -0,0 +1,130 @@ +using Allegro.Extensions.Configuration.Api.Services; +using Allegro.Extensions.Configuration.DataContracts; +using Allegro.Extensions.Configuration.GlobalConfiguration; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Vabank.Confeature.Tests.Unit.Service; + +public class GlobalConfigurationProviderTests +{ + [Fact] + public void ShouldReturnListWithAllContextGroupsAndContextsWhenNoServiceNamePassed() + { + // arrange + var sut = CreateSut(new ContextGroupsConfiguration + { + ContextGroups = new List + { + CreateContextGroup("vabank-configuration"), + CreateContextGroup("care-configuration"), + CreateContextGroup("other-configuration"), + }, + }); + + // act + var response = sut.GetGlobalConfiguration(); + + // assert + response.Should().NotBeNull(); + response.Should().BeEquivalentTo(new GetGlobalConfigurationResponse + { + ContextGroups = new List + { + new() + { + Name = "vabank-configuration", + Contexts = new List + { + "platform", + "pump", + }, + }, + new() + { + Name = "care-configuration", + Contexts = new List + { + "notifications", + "offers", + }, + }, + new() + { + Name = "other-configuration", + Contexts = new List + { + "other", + }, + }, + }, + }); + } + + [Fact] + public void ShouldReturnEmptyListWhenNoContextGroups() + { + // arrange +#pragma warning disable CSE001 + var sut = CreateSut(new ContextGroupsConfiguration()); +#pragma warning restore CSE001 + + // act + var response = sut.GetGlobalConfiguration(); + + // assert + response.Should().NotBeNull(); + response.ContextGroups.Should().BeEmpty(); + } + + [Fact] + public void ShouldReturnListWithFilteredContextGroupsAndContextsWhenServiceNamePassed() + { + // arrange + var sut = CreateSut(new ContextGroupsConfiguration + { + ContextGroups = new List + { + CreateContextGroup("vabank-configuration"), + CreateContextGroup("care-configuration"), + CreateContextGroup("other-configuration"), + }, + }); + + // act + var response = sut.GetGlobalConfiguration("confeaturev2"); + + // assert + response.Should().NotBeNull(); + response.Should().BeEquivalentTo(new GetGlobalConfigurationResponse + { + ContextGroups = new List + { + new() + { + Name = "vabank-configuration", + Contexts = new List + { + "platform", + "pump", + }, + }, + new() + { + Name = "care-configuration", + Contexts = new List + { + "offers", + }, + }, + }, + }); + } + + private static GlobalConfigurationProvider CreateSut(ContextGroupsConfiguration configuration) => + new(new OptionsWrapper(configuration)); + + private static ContextGroupConfiguration CreateContextGroup(string name) => + new() { Name = name, Path = Path.Combine("Service", "test-contexts", name) }; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/notifications.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/notifications.json new file mode 100644 index 0000000..54b5ac6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/notifications.json @@ -0,0 +1,16 @@ +{ + "config": { + "key": "value20", + "arr": [ + "test1", + "test2" + ], + "obj": { + "id": 1, + "key": "value" + } + }, + "metadata": { + "services": [] + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/offers.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/offers.json new file mode 100644 index 0000000..83b5f87 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/care-configuration/offers.json @@ -0,0 +1,12 @@ +{ + "config": { + "platform-key": "value-20" + }, + "metadata": { + "restartServices": true, + "services": [ + "platform-demo", + "confeaturev2" + ] + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/other-configuration/other.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/other-configuration/other.json new file mode 100644 index 0000000..54b5ac6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/other-configuration/other.json @@ -0,0 +1,16 @@ +{ + "config": { + "key": "value20", + "arr": [ + "test1", + "test2" + ], + "obj": { + "id": 1, + "key": "value" + } + }, + "metadata": { + "services": [] + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/platform.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/platform.json new file mode 100644 index 0000000..83b5f87 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/platform.json @@ -0,0 +1,12 @@ +{ + "config": { + "platform-key": "value-20" + }, + "metadata": { + "restartServices": true, + "services": [ + "platform-demo", + "confeaturev2" + ] + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/pump.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/pump.json new file mode 100644 index 0000000..32fd786 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/Service/test-contexts/vabank-configuration/pump.json @@ -0,0 +1,16 @@ +{ + "config": { + "key": "value20", + "arr": [ + "test1", + "test2" + ], + "obj": { + "id": 1, + "key": "value" + } + }, + "metadata": { + "services": ["*"] + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/StringExtensionsTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/StringExtensionsTests.cs new file mode 100644 index 0000000..3474322 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/StringExtensionsTests.cs @@ -0,0 +1,30 @@ +// using System; +// using FluentAssertions; +// using Vabank.Confeature.Extensions; +// using Xunit; +// +// namespace Vabank.Confeature.Tests.Unit; +// +// public class StringExtensionsTests +// { +// [Theory] +// [InlineData("c:/Program Files/context.env.json", "context")] +// [InlineData("/Users/jan.dzban/Repos/global-config/platform.dev.json", "platform")] +// [InlineData("./platform.dev.json", "platform")] +// [InlineData("platform.dev.json", "platform")] +// public void ShouldFetchContext_WhenFilePathIsValid(string filePath, string contextName) +// { +// filePath.ToContextName().Should().Be(contextName); +// } +// +// [Fact] +// public void ShouldThrow_WhenFilePathIsNull() +// { +// // Arrange +// string filePath = null; +// var act = () => filePath.ToContextName(); +// +// // Act and assert +// act.Should().Throw(); +// } +// } \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ValidateObjectAttributeTests.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ValidateObjectAttributeTests.cs new file mode 100644 index 0000000..fdcffd4 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/ValidateObjectAttributeTests.cs @@ -0,0 +1,148 @@ +using System.ComponentModel.DataAnnotations; +using Allegro.Extensions.Configuration.Validation; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Xunit; + +namespace Vabank.Confeature.Tests.Unit; + +public class ValidateObjectAttributeTests +{ + [Fact] + public void ShouldReturnValidResponse_ForValidConfigurations_WithList() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("ValidConfig", out TestConfig dto, out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.ConfigList.Should().HaveCount(2); + dto.ConfigList![0].Should().BeEquivalentTo(new InnerConfig { SomeValue = 1, OtherValue = "a" }); + dto.ConfigList[1].Should().BeEquivalentTo(new InnerConfig { SomeValue = 2, OtherValue = "b" }); + } + + [Fact] + public void ShouldReturnValidResponse_ForValidConfigurations_WithDictionary() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("ValidDictionary", out TestConfigWithDictionary dto, out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.ConfigDict.Should().HaveCount(2); + dto.ConfigDict!["item1"].Should().BeEquivalentTo(new InnerConfig { SomeValue = 1, OtherValue = "a" }); + dto.ConfigDict["item2"].Should().BeEquivalentTo(new InnerConfig { SomeValue = 2, OtherValue = "b" }); + } + + [Fact] + public void ShouldReturnNonValidResponse_ForInvalidConfigurations_WithList() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("InvalidConfig", out TestConfig dto, out var validationResults); + + // Assert + isValid.Should().BeFalse(); + validationResults.Should().HaveCount(1); + validationResults.First().ErrorMessage.Should().ContainAll("Index [0]", "Index [1]"); + } + + [Fact] + public void ShouldReturnNonValidResponse_ForInvalidConfigurations_WithDictionary() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("InvalidDictionary", out TestConfigWithDictionary dto, out var validationResults); + + // Assert + isValid.Should().BeFalse(); + validationResults.Should().HaveCount(1); + validationResults.First().ErrorMessage.Should().ContainAll("Index [0]", "Index [1]"); + } + + [Fact] + public void ShouldReturnValidResponse_ForConfiguration_WithEmptyLists() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("ConfigWithEmptyList", out TestConfig dto, out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.ConfigList.Should().BeNullOrEmpty(); + } + + [Fact] + public void ShouldReturnValidResponse_ForConfiguration_WithEmptyDictionary() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("ConfigWithEmptyDictionary", out TestConfigWithDictionary dto, out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.ConfigDict.Should().BeNullOrEmpty(); + } + + [Fact] + public void ShouldRespectSkipNullObjectValidationFlag() + { + // Arrange & Act + var isValid = ValidateConfigurationDto("NullTestConfig", out NullTestConfig dto, out var validationResults); + + // Assert + isValid.Should().BeTrue(); + validationResults.Should().BeEmpty(); + dto.NullConfig.Should().BeNull(); + } + + private static bool ValidateConfigurationDto( + string subSectionName, + out T dto, + out List validationResults) where T : new() + { + dto = new T(); + validationResults = new List(); + + var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.ValidateObjectTests.json").Build(); + configuration.Bind($"ValidateObjectWithLists:{subSectionName}", dto); + + return Validator.TryValidateObject( + dto, + new ValidationContext(dto, serviceProvider: null, items: null), + validationResults, + validateAllProperties: true); + } + + // ReSharper disable once ClassNeverInstantiated.Local + private class InnerConfig + { +#pragma warning disable SA1133 + [Required, Range(0, 5)] +#pragma warning restore SA1133 + public int SomeValue { get; set; } + [Required(AllowEmptyStrings = false)] + [RegularExpression(@"^[a-z]{1}$")] + public string? OtherValue { get; set; } + } + + private class TestConfig + { + [ValidateObject] + // ReSharper disable once CollectionNeverUpdated.Local + public List? ConfigList { get; set; } + } + + private class NullTestConfig + { + [ValidateObject(SkipNullObjectValidation = true)] + public InnerConfig? NullConfig { get; set; } + } + + private class TestConfigWithDictionary + { + [ValidateObject] + // ReSharper disable once CollectionNeverUpdated.Local + public IDictionary? ConfigDict { get; set; } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.Development.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.Development.json new file mode 100644 index 0000000..7b2571c --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "DemoConfig": { + "BooleanFlag": false + }, + "SecondConfig": { + "StringFlag": "Test" + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.ValidateObjectTests.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.ValidateObjectTests.json new file mode 100644 index 0000000..5da179b --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.ValidateObjectTests.json @@ -0,0 +1,53 @@ +{ + "ValidateObjectWithLists": { + "ValidConfig": { + "ConfigList": [{ + "SomeValue": 1, + "OtherValue": "a" + }, { + "SomeValue": 2, + "OtherValue": "b" + }] + }, + "ValidDictionary": { + "ConfigDict": { + "item1": { + "SomeValue": 1, + "OtherValue": "a" + }, + "item2": { + "SomeValue": 2, + "OtherValue": "b" + } + } + }, + "InvalidConfig": { + "ConfigList": [{ + "SomeValue": 6, + "OtherValue": "a" + }, { + "SomeValue": 2, + "OtherValue": "bbbbbb" + }] + }, + "InvalidDictionary": { + "ConfigDict": { + "item1": { + "SomeValue": 6, + "OtherValue": "a" + }, + "item2": { + "SomeValue": 2, + "OtherValue": "bbbbbb" + } + } + }, + "ConfigWithEmptyList": { + "ConfigList": [] + }, + "ConfigWithEmptyDictionary": { + "ConfigDict": {} + }, + "NullTestConfig": {} + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.json b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.json new file mode 100644 index 0000000..26f29ae --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests.Unit/appsettings.json @@ -0,0 +1,33 @@ +{ + "DemoConfig": { + "StringFlag": "Hello world", + "BooleanFlag": true, + "IntegerFlag": 4, + "ScheduledIntegerFlag": { + "DefaultValue": 1, + "Schedules": [{ + "StartDate": "2022-01-16T09:01:43.511Z", + "EndDate": "2022-01-17T19:55:43.511Z", + "ScheduledValue": 5 + }, { + "StartDate": "2022-02-18T09:01:43.511Z", + "ScheduledValue": 55 + }] + } + }, + "SecondConfig": { + "IntegerFlag": 1 + }, + "ScheduledConfigWrapperConfig": { + "ScheduledIntegerFlag": { + "DefaultValue": 1 + }, + "ScheduledStringFlag": { + "DefaultValue": "a", + "Schedules": [{ + "StartDate": "2022-10-07T09:01:43.511Z", + "ScheduledValue": "b" + }] + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/Allegro.Extensions.Configuration.Tests.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/Allegro.Extensions.Configuration.Tests.csproj new file mode 100644 index 0000000..1d068b8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/Allegro.Extensions.Configuration.Tests.csproj @@ -0,0 +1,20 @@ + + + + true + $(NoWarn);1591 + enable + + + + + + + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/ConfeatureSmokeTest.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/ConfeatureSmokeTest.cs new file mode 100644 index 0000000..626f1d5 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/ConfeatureSmokeTest.cs @@ -0,0 +1,41 @@ +using System.Net; +using System.Net.Http.Json; +using Allegro.Extensions.Configuration.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Vabank.Confeature.Tests; + +// ReSharper disable MemberCanBePrivate.Global + +/// +/// Derive from this class to include a simple smoke test for the confeature integration. +/// +/// Entrypoint of your ASP.NET Core application, most likely the Program class. +public abstract class ConfeatureSmokeTest : IClassFixture> + where T : class +{ + protected readonly WebApplicationFactory Factory; + + protected ConfeatureSmokeTest(WebApplicationFactory factory) => Factory = factory; + + [Fact] + public async Task Should_GetNonEmptyResponse_WhenQueryingConfigurationEndpoint() + { + // Arrange + // for integration tests running on kubernetes-hosted build agents + Environment.SetEnvironmentVariable("KUBERNETES_SERVICE_HOST", ""); + var client = Factory.CreateClient(); + + // Act + var response = await client.GetAsync("/configuration"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadFromJsonAsync(); + content.Should().NotBeNull(); + content?.Configuration.Should().NotBeNullOrEmpty(); + content?.Providers.Should().NotBeNullOrEmpty(); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationCorrectnessFixture.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationCorrectnessFixture.cs new file mode 100644 index 0000000..2bbdab8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationCorrectnessFixture.cs @@ -0,0 +1,141 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Allegro.Extensions.Configuration.Validation; +using Microsoft.Extensions.Configuration; +using Xunit.Abstractions; +using Xunit.Sdk; + +namespace Allegro.Extensions.Configuration.Tests; + +/// +/// Global configuration correctness tests base class. To be used inside repositories with global contexts. +/// +public abstract class GlobalConfigurationCorrectnessFixture : GlobalConfigurationFixtureBase +{ + protected GlobalConfigurationCorrectnessFixture(ITestOutputHelper output, string repoRootPath) + : base(output, repoRootPath) + { + } + + protected void Validate() + { + ValidateDataAnnotations(); + ValidateServicesOrder(); + } + + protected void CheckForValidationAttributesPresence() + { + foreach (var configurationType in ConfigurationTypes) + { + CheckInternal(configurationType); + } + } + + private void ValidateDataAnnotations() + { + foreach (var type in ConfigurationTypes) + { + Output.WriteLine($"Currently testing: {type}"); + +#pragma warning disable CA2201 + var cfg = Activator.CreateInstance(type) ?? throw new Exception($"Could not create type: {type}"); +#pragma warning restore CA2201 + + var attribute = type.GetCustomAttribute(); + Configuration.Bind(attribute!.SectionName, cfg); + var ctx = new ValidationContext(cfg!); + var results = new List(); + if (!Validator.TryValidateObject(cfg!, ctx, results, validateAllProperties: true)) + { + var sb = new StringBuilder(); + foreach (var validationResult in results) + { + sb.Append(validationResult.ErrorMessage); + sb.Append(", "); + Output.WriteLine(validationResult.ErrorMessage); + } + + throw new XunitException(sb.ToString()); + } + } + } + + private void ValidateServicesOrder() + { + foreach (var configurationFile in ConfigurationFiles) + { + try + { + using var fileStream = new FileStream( + configurationFile.FullName, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite); + using var jsonDocument = JsonDocument.Parse(fileStream); + if (!jsonDocument.RootElement.TryGetProperty("metadata", out var metadataElement) || + !metadataElement.TryGetProperty("services", out var servicesElement)) + { +#pragma warning disable CA2201 + throw new Exception( + $"[{configurationFile.Name}]: Configuration file should contain .metadata.services list."); +#pragma warning restore CA2201 + } + + var services = servicesElement + .EnumerateArray() + .Select( + x => x.GetString() ?? +#pragma warning disable CA2201 + throw new Exception($"[{configurationFile.Name}]: Service name cannot be null.")) +#pragma warning restore CA2201 + .ToList(); + + for (var i = 1; i < services.Count; i++) + { + if (StringComparer.OrdinalIgnoreCase.Compare(services[i], services[i - 1]) < 0) + { +#pragma warning disable CA2201 + throw new Exception( + $"[{configurationFile.Name}]: Invalid services order. '{services[i - 1]}' precedes '{services[i]}'."); +#pragma warning restore CA2201 + } + } + } + catch (Exception e) + { +#pragma warning disable CA2201 + throw new Exception($"[{configurationFile.Name}]: Error reading configuration file: {e.Message}", e); +#pragma warning restore CA2201 + } + } + } + + private static void CheckInternal(Type configurationType) + { + var properties = configurationType.GetProperties(); + foreach (var propertyInfo in properties) + { + // do not validate properties that are read-only or marked with NoValidationOnDeploy + if (!propertyInfo.CanWrite || + propertyInfo.GetCustomAttribute() is not null) + { + continue; + } + + // property is not valid if it has 0 validation attributes and is not marked with the [NoValidationOnDeploy] + if (!propertyInfo.GetCustomAttributes().Any()) + throw new XunitException( + $"Type: {configurationType} has no attributes defined for property: {propertyInfo}. " + + $"If this property should not be validated during configuration deployment, use the {nameof(NoValidationOnDeployAttribute)}"); + + // property is of user-defined type - we need to validate it recursively + if (propertyInfo.PropertyType.IsClass && + propertyInfo.PropertyType.Assembly.FullName == configurationType.Assembly.FullName) + { + CheckInternal(propertyInfo.PropertyType); + } + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationFixtureBase.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationFixtureBase.cs new file mode 100644 index 0000000..8d52f48 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/GlobalConfigurationFixtureBase.cs @@ -0,0 +1,211 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Extensions; +using Allegro.Extensions.Configuration.GlobalConfiguration; +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Allegro.Extensions.Configuration.Tests; + +/// +/// Global configuration tests base class. +/// +public abstract class GlobalConfigurationFixtureBase : IDisposable +{ + private const string TestAllEnvironments = "all"; + + protected readonly ITestOutputHelper Output; + protected readonly IReadOnlyList ConfigurationTypes; + protected readonly IReadOnlyList ConfigurationFiles; + + private IConfiguration? _configuration; + + protected IConfiguration Configuration + { +#pragma warning disable CA2201 + get => _configuration ?? throw new Exception( + $"You need to call {nameof(PrepareConfiguration)} before accessing {nameof(Configuration)}."); +#pragma warning restore CA2201 + private set => _configuration = value; + } + + private static readonly string SelectedTestEnvironment; + private readonly string _configurationFilesDir; + + #region ctors + static GlobalConfigurationFixtureBase() + { + SelectedTestEnvironment = Environment.GetEnvironmentVariable("TEST_ENV") ?? TestAllEnvironments; + } + + protected GlobalConfigurationFixtureBase(ITestOutputHelper output, string repoRootPath) + { + Output = output; + + LoadReferencedAssembly(); + + _configurationFilesDir = Path.Combine(Directory.GetCurrentDirectory(), "config"); + ConfigurationFiles = Directory + .EnumerateFiles(Path.Combine(repoRootPath, "cfg"), "*.json", SearchOption.AllDirectories) +#pragma warning disable CA1310 + .Where(f => f.EndsWith("dev.json") || f.EndsWith("uat.json") || f.EndsWith("xyz.json")) +#pragma warning restore CA1310 + .Where(f => f.Split(Path.DirectorySeparatorChar).All(p => !p.Equals("bin", StringComparison.OrdinalIgnoreCase))) + .Select(f => new FileInfo(f)) + .ToList(); + ConfigurationTypes = AppDomain + .CurrentDomain + .GetAssemblies() + .SelectMany(a => a.GetTypes()) + .Where(t => t.GetCustomAttribute() is not null) + .ToList(); + + CopyFiles(); + } + #endregion + + /// + /// Dispose method + /// + public void Dispose() + { + GC.SuppressFinalize(this); + } + + /// + /// To be used as an input for test methods. + /// + /// Environments to be tested + public static IEnumerable GetTestingEnvironments() + { + if (SelectedTestEnvironment == TestAllEnvironments) + { + yield return new object[] { new TestingEnvironment("dev") }; + yield return new object[] { new TestingEnvironment("uat") }; + yield return new object[] { new TestingEnvironment("xyz") }; + } + else + { + yield return new object[] { new TestingEnvironment(SelectedTestEnvironment) }; + } + } + + /// + /// Prepares the instance with contexts for given environment. + /// + /// Name of the environment + /// Name of the context group + protected void PrepareConfiguration(string testEnvironmentName, string contextGroupName) + { + var configurationBuilder = new ConfigurationBuilder(); + Environment.SetEnvironmentVariable("IntegrationTesting", "true"); + configurationBuilder.Add( + new ConfeatureConfigurationSource( + new ContextGroupsConfiguration + { + ContextGroups = new List() + { + new() + { + Name = contextGroupName, + Path = Path.Combine(_configurationFilesDir, testEnvironmentName) + } + } + }, +#pragma warning disable CSE001 + new ConfeatureOptions())); +#pragma warning restore CSE001 + Configuration = configurationBuilder.Build(); + } + + /// + /// Gets the configuration section for given context. Use this for unit testing the context. + /// + /// Name of the environment + /// Name of the context + /// Configuration section for the context + protected IConfigurationSection GetContextSection(string environmentName, string contextName) + { + var fileName = $"{contextName}.{environmentName}.json"; + var file = ConfigurationFiles.SingleOrDefault(x => x.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase)); + + if (file == null) + { +#pragma warning disable CA2201 + throw new Exception($"File '{fileName}' not found."); +#pragma warning restore CA2201 + } + + var sectionName = contextName; + using var fileStream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var jsonDocument = JsonDocument.Parse(fileStream); + if (jsonDocument.RootElement.TryGetProperty("metadata", out var metadataElement) && + metadataElement.TryGetProperty("sectionName", out var sectionNameElement)) + { + sectionName = sectionNameElement.GetString() ?? sectionName; + } + + return Configuration.GetSection(sectionName); + } + + /// + /// Gets the DTO for the given context. Use this for unit testing the context. + /// + /// Name of the environment + /// Name of the context + /// DTO for the context + protected T GetContext(string environmentName, string contextName) where T : class, IGlobalConfigurationMarker + { + var services = new ServiceCollection(); +#pragma warning disable CSE001 + services.RegisterGlobalConfig(Configuration, new ConfeatureOptions()); +#pragma warning restore CSE001 + return services.BuildServiceProvider().GetRequiredService(); + } + + private void CopyFiles() + { + void CopyFileIfNewer(FileInfo source, string destination) + { + var destinationInfo = new FileInfo(destination); + if (!destinationInfo.Exists || destinationInfo.LastWriteTime < source.LastWriteTime) + { + File.Copy(source.FullName, destination, overwrite: true); + } + } + +#pragma warning disable MA0009 + var regex = new Regex("(.*)\\.(?.*)\\.json"); +#pragma warning restore MA0009 + foreach (var file in ConfigurationFiles) + { + var match = regex.Match(file.Name); + if (!match.Success) + continue; + + var env = match.Groups["env"]; + var path = Path.Combine(_configurationFilesDir, env.Value); + Directory.CreateDirectory(path); + CopyFileIfNewer(file, Path.Combine(path, file.Name)); + } + } + + private static void LoadReferencedAssembly() + { + var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToList(); + var loadedPaths = loadedAssemblies + .Where(a => !a.IsDynamic) + .Select(a => a.Location).ToArray(); + + var referencedPaths = Directory.GetFiles(AppDomain.CurrentDomain.BaseDirectory, "*.dll"); + var toLoad = referencedPaths.Where(r => !loadedPaths.Contains(r, StringComparer.InvariantCultureIgnoreCase)).ToList(); + + toLoad.ForEach(path => loadedAssemblies.Add(AppDomain.CurrentDomain.Load(AssemblyName.GetAssemblyName(path)))); + } + + public record TestingEnvironment(string Name); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/paket.references b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/paket.references new file mode 100644 index 0000000..351e661 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Tests/paket.references @@ -0,0 +1,7 @@ +FluentAssertions +Microsoft.AspNetCore.Mvc.Testing +Microsoft.NET.Test.Sdk +Moq +Serilog.Sinks.XUnit +xunit +xunit.runner.visualstudio \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Allegro.Extensions.Configuration.Validation.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Allegro.Extensions.Configuration.Validation.csproj new file mode 100644 index 0000000..beb3274 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Allegro.Extensions.Configuration.Validation.csproj @@ -0,0 +1,19 @@ + + + + enable + + $(NoWarn);1591 + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + Allegro.Extensions.Configuration + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/ConfigurationMarkers.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/ConfigurationMarkers.cs new file mode 100644 index 0000000..b14150f --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/ConfigurationMarkers.cs @@ -0,0 +1,15 @@ +namespace Allegro.Extensions.Configuration; + +/// +/// Dummy marker interface for local configuration classes +/// +public interface IConfigurationMarker +{ +} + +/// +/// Dummy marker interface for global configuration classes +/// +public interface IGlobalConfigurationMarker : IConfigurationMarker +{ +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfiguration/MergeWithConfigurationSectionAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfiguration/MergeWithConfigurationSectionAttribute.cs new file mode 100644 index 0000000..154cb2c --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfiguration/MergeWithConfigurationSectionAttribute.cs @@ -0,0 +1,17 @@ +namespace Allegro.Extensions.Configuration.GlobalConfiguration; + +/// +/// Marks that this global configuration DTO should also be bind with the given configuration section +/// (for example with secrets from KV or local appsettings). +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] +public class MergeWithServiceConfigurationSectionAttribute : Attribute +{ + public string SectionName { get; init; } + + /// Section path to merge with. Null or empty string to merge with root. + public MergeWithServiceConfigurationSectionAttribute(string sectionName) + { + SectionName = sectionName; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfigurationContextAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfigurationContextAttribute.cs new file mode 100644 index 0000000..0647858 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/GlobalConfigurationContextAttribute.cs @@ -0,0 +1,65 @@ +// ReSharper disable ClassNeverInstantiated.Global + +namespace Allegro.Extensions.Configuration; + +/// +/// Binds the class marked with the attribute with the configuration context +/// defined by the +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class GlobalConfigurationContextAttribute : Attribute +{ + /// Name of the section to be bind with the DTO. Can contain ':' separator. + public GlobalConfigurationContextAttribute(string sectionName) + { + if (string.IsNullOrWhiteSpace(sectionName)) + throw new ArgumentNullException(nameof(sectionName)); + + SectionName = sectionName; + } + + /// Context group name + /// Context name + /// For backward compatibility - ignored + [Obsolete("For backward compatibility")] + public GlobalConfigurationContextAttribute(string contextGroup, string context, string section) + : this(contextGroup, context) + { + } + + /// Context group name + /// Context name + [Obsolete( + "Please use GlobalConfigurationContextAttribute($\"{contextGroup}:{context}\") for backward compatibility. " + + "For new contexts, pass SectionName defined in context's json files.")] + public GlobalConfigurationContextAttribute(string contextGroup, string context) + { + if (string.IsNullOrWhiteSpace(contextGroup)) + throw new ArgumentNullException(nameof(contextGroup)); + if (string.IsNullOrWhiteSpace(context)) + throw new ArgumentNullException(nameof(context)); + +#pragma warning disable CS0618 + ContextGroup = contextGroup; + Context = context; +#pragma warning restore CS0618 + SectionName = $"{contextGroup}:{context}"; + } + + /// + /// Global configuration context group name + /// + public string SectionName { get; } + + /// + /// Global configuration context group name + /// + [Obsolete($"Please use {nameof(SectionName)} instead")] + public string? ContextGroup { get; } + + /// + /// Global configuration context name + /// + [Obsolete($"Please use {nameof(SectionName)} instead")] + public string? Context { get; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DataAnnotationValidateOptionsNestedMembers.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DataAnnotationValidateOptionsNestedMembers.cs new file mode 100644 index 0000000..914fac5 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DataAnnotationValidateOptionsNestedMembers.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Microsoft.Extensions.Options; + +namespace Allegro.Extensions.Configuration.Validation; + +/// +/// By default Validator does not validate nested members, this validator recursively validates +/// all members of options instance (all the way down). +/// +/// Options type +public class DataAnnotationValidateOptionsNestedMembers : IValidateOptions + where TOptions : class +{ + public ValidateOptionsResult Validate(string? name, TOptions options) + { + return Validate(options); + } + + private ValidateOptionsResult Validate(object? objectToValidate) + { + if (objectToValidate == null) + { + return ValidateOptionsResult.Skip; + } + + var validationResults = new List(); + if (!Validator.TryValidateObject( + objectToValidate, + new ValidationContext(objectToValidate, serviceProvider: null, items: null), + validationResults, + validateAllProperties: true)) + { + var errors = new List(validationResults.Count); + foreach (var r in validationResults) + { + errors.Add( + $"DataAnnotation validation failed for members: '{string.Join(",", r.MemberNames)}' " + + $"with the error: '{r.ErrorMessage}'."); + } + + return ValidateOptionsResult.Fail(errors); + } + + if (objectToValidate is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + var validationResult = Validate(item); + if (validationResult.Failed) + { + return validationResult; + } + } + } + + var properties = objectToValidate.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Where(p => p.PropertyType.IsClass && !p.PropertyType.FullName!.StartsWith("System.", StringComparison.InvariantCulture)); + foreach (var property in properties) + { + var instance = property.GetValue(objectToValidate); + + if (instance == null) + { + return ValidateOptionsResult.Skip; + } + + var validationResult = Validate(instance); + if (validationResult.Failed) + { + return validationResult; + } + } + + return ValidateOptionsResult.Success; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateLessThanAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateLessThanAttribute.cs new file mode 100644 index 0000000..81be865 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateLessThanAttribute.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; + +namespace Allegro.Extensions.Configuration.Validation; + +/// +/// Validation attribute used to compare DateTimeOffset a property marked with it against other DateTimeOffset property +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class DateLessThanAttribute : ValidationAttribute +{ + private readonly string _comparisonProperty; + + public DateLessThanAttribute(string comparisonProperty) + { + _comparisonProperty = comparisonProperty; + } + + protected override ValidationResult IsValid(object? value, ValidationContext validationContext) + { + ErrorMessage = ErrorMessageString; + + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value is not DateTimeOffset currentValue) + { + throw new ArgumentException($"'{value}' is not a DateTimeOffset", nameof(value)); + } + + var property = validationContext.ObjectType.GetProperty(_comparisonProperty); + + if (property is null) + { + throw new ArgumentException($"Property not found", _comparisonProperty); + } + + if ((property.GetValue(validationContext.ObjectInstance) ?? DateTimeOffset.MaxValue) is not DateTimeOffset + comparisonValue) + { + throw new ArgumentException("Property is not a DateTimeOffset", _comparisonProperty); + } + + return currentValue > comparisonValue ? new ValidationResult(ErrorMessage) : ValidationResult.Success!; + } + + public override bool RequiresValidationContext => true; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateOverlapValidationAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateOverlapValidationAttribute.cs new file mode 100644 index 0000000..88a37fe --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/DateOverlapValidationAttribute.cs @@ -0,0 +1,84 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +namespace Allegro.Extensions.Configuration.Validation; + +/// +/// Validation attribute used to validate whether date ranges in enumerable do not overlap +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class DateOverlapValidationAttribute : ValidationAttribute +{ + private readonly string _startDatePropertyName; + private readonly string _endDatePropertyName; + + public DateOverlapValidationAttribute(string startDateProperty, string endDateProperty) + { + _startDatePropertyName = startDateProperty; + _endDatePropertyName = endDateProperty; + } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + ErrorMessage = ErrorMessageString; + + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + if (value is not IEnumerable enumerable) + { + throw new ArgumentException($"'{value}' is not an IEnumerable", nameof(value)); + } + + var elementType = enumerable.GetType().GetElementType(); + if (elementType is null) + { + throw new ArgumentNullException(nameof(value), $"Could not infer element type for '{value} enumerable'"); + } + + var startDateProperty = elementType.GetProperty(_startDatePropertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (startDateProperty is null) + { + throw new ArgumentNullException(_startDatePropertyName, $"Could not find start date property {_startDatePropertyName} in {elementType}"); + } + + var endDateProperty = elementType.GetProperty(_endDatePropertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (endDateProperty is null) + { + throw new ArgumentNullException(_endDatePropertyName, $"Could not find end date property {_endDatePropertyName} in {elementType}"); + } + + var dateRanges = new List<(DateTimeOffset StartDate, DateTimeOffset EndDate)>(); + + foreach (var item in enumerable) + { + if ((startDateProperty.GetValue(item) ?? DateTimeOffset.MinValue) is not DateTimeOffset startDate) + { + throw new ArgumentException($"{_startDatePropertyName} is not a DateTimeOffset", _startDatePropertyName); + } + + if ((endDateProperty.GetValue(item) ?? DateTimeOffset.MaxValue) is not DateTimeOffset endDate) + { + throw new ArgumentException($"{_endDatePropertyName} is not a DateTimeOffset", _endDatePropertyName); + } + + dateRanges.Add((startDate, endDate)); + } + + dateRanges = dateRanges.OrderBy(d => d.StartDate).ToList(); + + for (var i = 0; i < dateRanges.Count - 1; i++) + { + if (dateRanges[i].StartDate < dateRanges[i + 1].EndDate && + dateRanges[i + 1].StartDate < dateRanges[i].EndDate) + { + return new ValidationResult(ErrorMessage); + } + } + + return ValidationResult.Success; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/NoValidationOnDeployAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/NoValidationOnDeployAttribute.cs new file mode 100644 index 0000000..13b51df --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/NoValidationOnDeployAttribute.cs @@ -0,0 +1,19 @@ +namespace Allegro.Extensions.Configuration.Validation; + +/// +/// Marker attribute to explicitly indicate fields and properties that should not be validated +/// during configuration deployment. It does not influence the validation on service startup. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public class NoValidationOnDeployAttribute : Attribute +{ +} + +/// +/// Marker attribute to explicitly indicate fields and properties that should not be validated. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +[Obsolete($"Please use {nameof(NoValidationOnDeployAttribute)} instead")] +public class NoValidationAttribute : NoValidationOnDeployAttribute +{ +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/ValidateObjectAttribute.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/ValidateObjectAttribute.cs new file mode 100644 index 0000000..c2209fa --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.Validation/Validation/ValidateObjectAttribute.cs @@ -0,0 +1,100 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Allegro.Extensions.Configuration.Validation; + +/// +/// Validates nested objects. Null objects are treated as invalid, unless they're of type that implements +/// the IEnumerable interface or SkipNullObjectValidation is set to true. +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public class ValidateObjectAttribute : ValidationAttribute +{ + public bool SkipNullObjectValidation { get; set; } + + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var implementsEnumerable = ImplementsEnumerable(validationContext); + + if (value is null) + { + if (implementsEnumerable || SkipNullObjectValidation) + return ValidationResult.Success; + throw new ArgumentNullException(nameof(value)); + } + + List results = new(); + ValidationContext context = new(value, null, null); + + Validator.TryValidateObject(value, context, results, validateAllProperties: true); + + if (implementsEnumerable) + { + results = ValidateEnumerable(value, results); + } + + return results.Count != 0 ? BuildCompositeResult(validationContext.DisplayName, results) : ValidationResult.Success; + } + + private static bool ImplementsEnumerable(ValidationContext validationContext) + { + return validationContext + .ObjectType + .GetMember( + validationContext.MemberName ?? + throw new ArgumentException($"{nameof(validationContext.MemberName)} cannot be null", nameof(validationContext))) + .GetType() + .GetInterfaces() + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + } + + private static List ValidateEnumerable(object value, List results) + { + var idx = 0; + + if (value is IDictionary dictionary) + { + value = dictionary.Values; + } + + foreach (var item in value as IEnumerable ?? Enumerable.Empty()) + { + List innerResults = new(); + ValidationContext innerContext = new(item, null, null); + + Validator.TryValidateObject(item, innerContext, innerResults, validateAllProperties: true); + results.AddRange( + innerResults.Select( + res => new ValidationResult($"Index [{idx++}]: {res.ErrorMessage}", res.MemberNames))); + } + + return results; + } + + private static ValidationResult BuildCompositeResult(string contextDisplayName, List results) + { + StringBuilder sb = new($"{contextDisplayName} validation failed. "); + var compositeResults = new CompositeValidationResult(string.Empty); + + results.ForEach(r => + { + compositeResults.AddResult(r); + sb.Append(r.ErrorMessage); + }); + compositeResults.ErrorMessage = sb.ToString(); + + return compositeResults; + } +} + +internal class CompositeValidationResult : ValidationResult +{ + private readonly List _results = new(); + + public IEnumerable Results => _results; + + public CompositeValidationResult(string errorMessage) : base(errorMessage) { } + + public void AddResult(ValidationResult validationResult) { _results.Add(validationResult); } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.sln b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.sln new file mode 100644 index 0000000..ac3e535 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.sln @@ -0,0 +1,100 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration", "Allegro.Extensions.Configuration\Allegro.Extensions.Configuration.csproj", "{30D9202F-01A9-42EE-B001-B5FE531272DB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Client", "Allegro.Extensions.Configuration.Client\Allegro.Extensions.Configuration.Client.csproj", "{3E4FC0C5-0421-423F-8910-BB75679C0AD2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.DataContracts", "Allegro.Extensions.Configuration.DataContracts\Allegro.Extensions.Configuration.DataContracts.csproj", "{3A030154-39F8-4FCF-87FE-9BF11B9C855A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.FluentValidation", "Allegro.Extensions.Configuration.FluentValidation\Allegro.Extensions.Configuration.FluentValidation.csproj", "{E7BE9091-3935-4AF5-AC81-82F66388A5AF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Api", "Allegro.Extensions.Configuration.Api\Allegro.Extensions.Configuration.Api.csproj", "{699FDDCC-C2D2-4FFE-B753-E83DDA92742C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Validation", "Allegro.Extensions.Configuration.Validation\Allegro.Extensions.Configuration.Validation.csproj", "{16D5616B-5434-4729-A401-E8FD0B5F5269}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Abstractions", "Allegro.Extensions.Configuration.Abstractions\Allegro.Extensions.Configuration.Abstractions.csproj", "{FCF472CC-A278-40E5-90A5-58A9ACBD614B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{481202ED-BACC-4139-891E-1A0F44E5045F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{472E42F5-2281-4D90-835B-5C24C996B6B5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Tests.Unit", "Allegro.Extensions.Configuration.Tests.Unit\Allegro.Extensions.Configuration.Tests.Unit.csproj", "{3B73D532-6155-4533-B71E-9B193678779F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Demo", "Allegro.Extensions.Configuration.Demo\Allegro.Extensions.Configuration.Demo.csproj", "{3A135BA9-F302-4A69-A144-7711C5B0BE3F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Tests.Integration", "Allegro.Extensions.Configuration.Tests.Integration\Allegro.Extensions.Configuration.Tests.Integration.csproj", "{121DD59B-3DF3-4E3F-AB1C-CA8977CADD15}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Tests", "Allegro.Extensions.Configuration.Tests\Allegro.Extensions.Configuration.Tests.csproj", "{163927CB-2D1B-4F76-9168-F24E413B0172}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Allegro.Extensions.Configuration.Demo.FallbackService", "Allegro.Extensions.Configuration.Demo.FallbackService\Allegro.Extensions.Configuration.Demo.FallbackService.csproj", "{7469554F-424A-4A2B-AF22-8158DDB69663}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {30D9202F-01A9-42EE-B001-B5FE531272DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {30D9202F-01A9-42EE-B001-B5FE531272DB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {30D9202F-01A9-42EE-B001-B5FE531272DB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {30D9202F-01A9-42EE-B001-B5FE531272DB}.Release|Any CPU.Build.0 = Release|Any CPU + {3E4FC0C5-0421-423F-8910-BB75679C0AD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E4FC0C5-0421-423F-8910-BB75679C0AD2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E4FC0C5-0421-423F-8910-BB75679C0AD2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E4FC0C5-0421-423F-8910-BB75679C0AD2}.Release|Any CPU.Build.0 = Release|Any CPU + {3A030154-39F8-4FCF-87FE-9BF11B9C855A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A030154-39F8-4FCF-87FE-9BF11B9C855A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A030154-39F8-4FCF-87FE-9BF11B9C855A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A030154-39F8-4FCF-87FE-9BF11B9C855A}.Release|Any CPU.Build.0 = Release|Any CPU + {E7BE9091-3935-4AF5-AC81-82F66388A5AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7BE9091-3935-4AF5-AC81-82F66388A5AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7BE9091-3935-4AF5-AC81-82F66388A5AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7BE9091-3935-4AF5-AC81-82F66388A5AF}.Release|Any CPU.Build.0 = Release|Any CPU + {699FDDCC-C2D2-4FFE-B753-E83DDA92742C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {699FDDCC-C2D2-4FFE-B753-E83DDA92742C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {699FDDCC-C2D2-4FFE-B753-E83DDA92742C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {699FDDCC-C2D2-4FFE-B753-E83DDA92742C}.Release|Any CPU.Build.0 = Release|Any CPU + {16D5616B-5434-4729-A401-E8FD0B5F5269}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16D5616B-5434-4729-A401-E8FD0B5F5269}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16D5616B-5434-4729-A401-E8FD0B5F5269}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16D5616B-5434-4729-A401-E8FD0B5F5269}.Release|Any CPU.Build.0 = Release|Any CPU + {FCF472CC-A278-40E5-90A5-58A9ACBD614B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FCF472CC-A278-40E5-90A5-58A9ACBD614B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FCF472CC-A278-40E5-90A5-58A9ACBD614B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FCF472CC-A278-40E5-90A5-58A9ACBD614B}.Release|Any CPU.Build.0 = Release|Any CPU + {3B73D532-6155-4533-B71E-9B193678779F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B73D532-6155-4533-B71E-9B193678779F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B73D532-6155-4533-B71E-9B193678779F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B73D532-6155-4533-B71E-9B193678779F}.Release|Any CPU.Build.0 = Release|Any CPU + {3A135BA9-F302-4A69-A144-7711C5B0BE3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A135BA9-F302-4A69-A144-7711C5B0BE3F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A135BA9-F302-4A69-A144-7711C5B0BE3F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A135BA9-F302-4A69-A144-7711C5B0BE3F}.Release|Any CPU.Build.0 = Release|Any CPU + {121DD59B-3DF3-4E3F-AB1C-CA8977CADD15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {121DD59B-3DF3-4E3F-AB1C-CA8977CADD15}.Debug|Any CPU.Build.0 = Debug|Any CPU + {121DD59B-3DF3-4E3F-AB1C-CA8977CADD15}.Release|Any CPU.ActiveCfg = Release|Any CPU + {121DD59B-3DF3-4E3F-AB1C-CA8977CADD15}.Release|Any CPU.Build.0 = Release|Any CPU + {163927CB-2D1B-4F76-9168-F24E413B0172}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {163927CB-2D1B-4F76-9168-F24E413B0172}.Debug|Any CPU.Build.0 = Debug|Any CPU + {163927CB-2D1B-4F76-9168-F24E413B0172}.Release|Any CPU.ActiveCfg = Release|Any CPU + {163927CB-2D1B-4F76-9168-F24E413B0172}.Release|Any CPU.Build.0 = Release|Any CPU + {7469554F-424A-4A2B-AF22-8158DDB69663}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7469554F-424A-4A2B-AF22-8158DDB69663}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7469554F-424A-4A2B-AF22-8158DDB69663}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7469554F-424A-4A2B-AF22-8158DDB69663}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {30D9202F-01A9-42EE-B001-B5FE531272DB} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {FCF472CC-A278-40E5-90A5-58A9ACBD614B} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {699FDDCC-C2D2-4FFE-B753-E83DDA92742C} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {3E4FC0C5-0421-423F-8910-BB75679C0AD2} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {3A030154-39F8-4FCF-87FE-9BF11B9C855A} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {E7BE9091-3935-4AF5-AC81-82F66388A5AF} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {16D5616B-5434-4729-A401-E8FD0B5F5269} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {3B73D532-6155-4533-B71E-9B193678779F} = {472E42F5-2281-4D90-835B-5C24C996B6B5} + {3A135BA9-F302-4A69-A144-7711C5B0BE3F} = {481202ED-BACC-4139-891E-1A0F44E5045F} + {121DD59B-3DF3-4E3F-AB1C-CA8977CADD15} = {472E42F5-2281-4D90-835B-5C24C996B6B5} + {163927CB-2D1B-4F76-9168-F24E413B0172} = {472E42F5-2281-4D90-835B-5C24C996B6B5} + {7469554F-424A-4A2B-AF22-8158DDB69663} = {481202ED-BACC-4139-891E-1A0F44E5045F} + EndGlobalSection +EndGlobal diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj new file mode 100644 index 0000000..154c242 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj @@ -0,0 +1,35 @@ + + + enable + + $(NoWarn);1591 + true + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj.DotSettings b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj.DotSettings new file mode 100644 index 0000000..306b00d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/ConfeatureOptions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/ConfeatureOptions.cs new file mode 100644 index 0000000..6978ba8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/ConfeatureOptions.cs @@ -0,0 +1,8 @@ +namespace Allegro.Extensions.Configuration.Configuration; + +public class ConfeatureOptions +{ + public bool IsEnabled { get; set; } = true; + public string? ServiceName { get; set; } + public string? AuthorizationPolicy { get; set; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/RegistrationValidatorOptions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/RegistrationValidatorOptions.cs new file mode 100644 index 0000000..496b2fa --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Configuration/RegistrationValidatorOptions.cs @@ -0,0 +1,7 @@ +namespace Allegro.Extensions.Configuration.Configuration; + +public class RegistrationValidatorOptions +{ + public List AssemblyPrefixesToValidate { get; set; } = new(); + public List NamespacesToIgnore { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/AuthorizationPolicyNotFoundException.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/AuthorizationPolicyNotFoundException.cs new file mode 100644 index 0000000..35a357d --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/AuthorizationPolicyNotFoundException.cs @@ -0,0 +1,9 @@ +namespace Allegro.Extensions.Configuration.Exceptions; + +public class AuthorizationPolicyNotFoundException : Exception +{ + public AuthorizationPolicyNotFoundException(string policy) + : base($"Authorization policy '{policy}' not found") + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/ConfeatureClientNotConfiguredException.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/ConfeatureClientNotConfiguredException.cs new file mode 100644 index 0000000..66f59ea --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/ConfeatureClientNotConfiguredException.cs @@ -0,0 +1,9 @@ +namespace Allegro.Extensions.Configuration.Exceptions; + +public class ConfeatureClientNotConfiguredException : Exception +{ + public ConfeatureClientNotConfiguredException() : base( + "[Confeature] Cannot use fallback service - confeature client not configured.") + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/GlobalContextSectionNameConflictException.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/GlobalContextSectionNameConflictException.cs new file mode 100644 index 0000000..4bbcee0 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/GlobalContextSectionNameConflictException.cs @@ -0,0 +1,13 @@ +namespace Allegro.Extensions.Configuration.Exceptions; + +public class GlobalContextSectionNameConflictException : Exception +{ + public GlobalContextSectionNameConflictException( + string contextName, + string sectionName) + : base( + $"[Confeature] Error when loading global context '{contextName}': global contexts " + + $"with same section name '{sectionName}' already loaded.") + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/InvalidProviderTypeException.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/InvalidProviderTypeException.cs new file mode 100644 index 0000000..5bfbf13 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Exceptions/InvalidProviderTypeException.cs @@ -0,0 +1,8 @@ +namespace Allegro.Extensions.Configuration.Exceptions; + +public class InvalidProviderTypeException : Exception +{ + public InvalidProviderTypeException(Type type) : base($"Invalid type {type.Name}") + { + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfeatureApplicationBuilderExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfeatureApplicationBuilderExtensions.cs new file mode 100644 index 0000000..396c1ea --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfeatureApplicationBuilderExtensions.cs @@ -0,0 +1,142 @@ +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Exceptions; +using Allegro.Extensions.Configuration.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization.Policy; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Allegro.Extensions.Configuration.Extensions; + +public static class ConfeatureAppBuilderExtensions +{ + private const string IdempotencyKey = nameof(UseConfeature); + + private static readonly PathString ConfigurationPathString = new("/configuration"); + private static readonly PathString ConfigurationProviderPathString = new("/configuration/provider"); + + /// + /// Configures Confeature middleware that serves the configuration endpoint. + /// This invocation is optional - when not called, the Confeature middleware will still be injected as the last + /// middleware. Use this method to add the middleware higher in the middlewares' order. + /// + /// The to add the middleware to. + /// The original instance. + public static IApplicationBuilder UseConfeature(this IApplicationBuilder appBuilder) + { + if (appBuilder.Properties.ContainsKey(IdempotencyKey)) + { + return appBuilder; + } + + var confeatureOptions = appBuilder.ApplicationServices.GetRequiredService(); + if (!confeatureOptions.IsEnabled) + { + return appBuilder; + } + + OptionsRegistrationValidator.Validate(appBuilder.ApplicationServices); + + appBuilder.Properties[IdempotencyKey] = true; + + return appBuilder + .MapWhen( + ctx => IsValidConfeatureRequestPath(ctx, ConfigurationProviderPathString), + app => app.UseMiddleware()) + .MapWhen( + ctx => IsValidConfeatureRequestPath(ctx, ConfigurationPathString), + app => app.UseMiddleware()); + } + + private static bool IsValidConfeatureRequestPath(HttpContext ctx, string confeaturePath) + => ctx.Request.Path.StartsWithSegments( + confeaturePath, + StringComparison.OrdinalIgnoreCase, + out var remaining) && + (!remaining.HasValue || remaining.Value == "/"); +} + +internal class ConfeaturePerProviderMiddleware +{ + public ConfeaturePerProviderMiddleware(RequestDelegate next) + { + } + +#pragma warning disable CA1822 + public async Task InvokeAsync( + HttpContext context, + IConfigurationPrinter configurationPrinter) +#pragma warning restore CA1822 + { + if (!await ConfeatureMiddlewareHelper.IsAuthorized(context)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var providerContent = configurationPrinter.GetRawProviderContent( + context.Request.Query["type"], + context.Request.Query["key"]); + + if (providerContent is null) + context.Response.StatusCode = StatusCodes.Status404NotFound; + else + await context.Response.WriteAsync(providerContent, context.RequestAborted); + } +} + +internal class ConfeatureGenericMiddleware +{ + public ConfeatureGenericMiddleware(RequestDelegate next) + { + } + +#pragma warning disable CA1822 + public async Task InvokeAsync( + HttpContext context, + IConfigurationPrinter configurationPrinter) +#pragma warning restore CA1822 + { + if (!await ConfeatureMiddlewareHelper.IsAuthorized(context)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await context.Response.WriteAsJsonAsync(configurationPrinter.GetConfiguration(), context.RequestAborted); + } +} + +internal static class ConfeatureMiddlewareHelper +{ + internal static async Task IsAuthorized(HttpContext context) + { + var hostEnvironment = context.RequestServices.GetService(); + if (hostEnvironment?.IsDevelopment() == true) + { + // do not authorize on local env / tests + return true; + } + + var confeatureOptions = context.RequestServices.GetRequiredService(); + if (confeatureOptions.AuthorizationPolicy == null) + { + return true; + } + + var policyProvider = context.RequestServices.GetRequiredService(); + var policyEvaluator = context.RequestServices.GetRequiredService(); + var policy = await policyProvider.GetPolicyAsync(confeatureOptions.AuthorizationPolicy) + ?? throw new AuthorizationPolicyNotFoundException(confeatureOptions.AuthorizationPolicy); + var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, context); + var authorizationResult = await policyEvaluator.AuthorizeAsync( + policy, + authenticateResult, + context, + "configuration"); + + return authorizationResult.Succeeded; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationBuilderExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..e423212 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationBuilderExtensions.cs @@ -0,0 +1,33 @@ +using System.Net.Http; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.GlobalConfiguration; +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace Allegro.Extensions.Configuration.Extensions; + +public static class ConfigurationBuilderExtensions +{ + public static ConfigurationManager AddGlobalConfiguration( + this ConfigurationManager builder, + ConfeatureOptions confeatureOptions, + IHostEnvironment hostEnvironment, + Func? fallbackServiceHandler = null) + { + var contextGroupsConfig = builder.GetSection(ContextGroupsConfiguration.SectionName).Get() +#pragma warning disable CSE001 + ?? new ContextGroupsConfiguration(); +#pragma warning restore CSE001 + var fallbackUri = builder.GetValue("Confeature:FallbackUri"); + ((IConfigurationBuilder)builder).Add( + new ConfeatureConfigurationSource( + contextGroupsConfig, + !string.IsNullOrEmpty(fallbackUri) ? new Uri(fallbackUri) : null, + hostEnvironment.IsDevelopment(), + fallbackServiceHandler, + confeatureOptions)); + + return builder; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationContextExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationContextExtensions.cs new file mode 100644 index 0000000..3be1a28 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationContextExtensions.cs @@ -0,0 +1,23 @@ +using System.Text.Json; + +namespace Allegro.Extensions.Configuration.Extensions; + +public static class ConfigurationContextExtensions +{ + public static bool IsServiceListedForContext(Stream jsonStream, string serviceName) + { + using var document = JsonDocument.Parse(jsonStream); + if (!document.RootElement.TryGetProperty("metadata", out var metadataProperty)) + { + return false; + } + + if (!metadataProperty.TryGetProperty("services", out var servicesProperty)) + { + return false; + } + + return servicesProperty.EnumerateArray() + .Any(x => x.GetString() == serviceName || x.GetString() == "*"); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationExtensions.cs new file mode 100644 index 0000000..8b851c4 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationExtensions.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Extensions; + +public static class ConfigurationExtensions +{ + /// + /// Gets a configuration sub-section with the specified global configuration context. + /// + /// Configuration to get the section from + /// Confeature options + /// Global configuration context + /// The . + /// + /// If no matching sub-section is found with the specified key or the service is not subscribed to the context, + /// an exception is raised. + /// + public static IConfigurationSection GetGlobalSection( + this IConfiguration configuration, + ConfeatureOptions confeatureOptions) + where TGlobalContext : IGlobalConfigurationMarker + { + var attr = typeof(TGlobalContext).GetCustomAttribute(); + if (attr is null) +#pragma warning disable CA2201 + throw new Exception($"Global configuration DTO {typeof(TGlobalContext)} " + + $"should be marked with the {nameof(GlobalConfigurationContextAttribute)}"); +#pragma warning restore CA2201 + + var serviceName = confeatureOptions.ServiceName; + var section = configuration.GetSection(attr.SectionName); + + if (!IsServiceSubscribedToGlobalConfiguration(section, serviceName)) + { + var additionalInfo = string.Empty; + + if (!confeatureOptions.IsEnabled) + { + additionalInfo = + $"In order to use global contexts, ConfeatureV2 must be enabled. " + + $"Please refer to the docs: https://c.qxlint/Confeature-Docs"; + } + else if (string.IsNullOrEmpty(serviceName)) + { + additionalInfo = + "Looks like you might be running the service locally or executing integration tests. " + + "Are you sure the ASPNETCORE_ENVIRONMENT is set to Development?"; + } + + throw new InvalidOperationException( + $"Service {serviceName} is not subscribed to global configuration " + + $"section: {attr.SectionName}. " + additionalInfo); + } + + return section; + } + + private static bool IsServiceSubscribedToGlobalConfiguration( + IConfigurationSection section, + string? serviceName) + { + var configServiceName = section.GetValue(ConfeatureConfigurationProvider.ServiceMetadataKeySuffix); + if (serviceName == configServiceName || configServiceName == ConfeatureConfigurationProvider.AllServicesMarker) + return true; + return false; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationProviderExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationProviderExtensions.cs new file mode 100644 index 0000000..d5f8e61 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/ConfigurationProviderExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Configuration; + +// ReSharper disable ConvertClosureToMethodGroup + +namespace Allegro.Extensions.Configuration.Extensions; + +internal static class ConfigurationProviderExtensions +{ + internal static HashSet GetFullKeyNames( + this IConfigurationProvider provider, + string? keyPrefix = null, + HashSet? initialKeys = null) + { + initialKeys ??= new HashSet(); + foreach (var key in provider.GetChildKeys(Enumerable.Empty(), keyPrefix).Distinct()) + { + var childKeyPrefix = string.IsNullOrWhiteSpace(keyPrefix) ? key : $"{keyPrefix}:{key}"; + + GetFullKeyNames(provider, childKeyPrefix, initialKeys); + + if (!initialKeys.Any(k => k.StartsWith(childKeyPrefix, StringComparison.OrdinalIgnoreCase))) + { + initialKeys.Add(childKeyPrefix); + } + } + + return initialKeys; + } + + internal static IConfigurationProvider GetInnermostProvider(this IConfigurationProvider provider) + { + while (provider is IConfigurationProviderWrapper wrapper) + { + provider = wrapper.Inner; + } + + return provider; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StartupExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StartupExtensions.cs new file mode 100644 index 0000000..6c5bd36 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StartupExtensions.cs @@ -0,0 +1,82 @@ +using System.Reflection; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.GlobalConfiguration; +using Allegro.Extensions.Configuration.Models; +using Allegro.Extensions.Configuration.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +// ReSharper disable ConvertClosureToMethodGroup + +namespace Allegro.Extensions.Configuration.Extensions; + +public static class StartupExtensions +{ + /// + /// Registers Confeature V2 dependencies. + /// + public static IServiceCollection AddConfeature( + this IServiceCollection services, + ConfeatureOptions confeatureOptions) + { + services.AddTransient(); + services.AddSingleton(); + services.AddSingleton(confeatureOptions); + services.AddOptions().BindConfiguration(ContextGroupsConfiguration.SectionName); + services.AddOptions(); // TODO set in platform + return services; + } + + /// + /// Registers the configuration using the configuration section name + /// passed as the sectionName. If sectionName is null, configuration root will be used. + /// + /// Configuration class. It should implement + /// the interface. + public static IServiceCollection RegisterConfig( + this IServiceCollection services, + IConfiguration configuration, + string? sectionName = null) + where T : class, IConfigurationMarker + { + var configurationSection = + !string.IsNullOrWhiteSpace(sectionName) + ? configuration.GetRequiredSection(sectionName) + : configuration as IConfigurationSection; + + services.Configure(cr => cr.RegisterOptions(configurationSection?.Path)); + services + .AddOptions() + .ValidateDataAnnotations() + .ValidateOnStart() + .Bind(configurationSection ?? configuration, c => c.BindNonPublicProperties = true); + + return services; + } + + /// + /// Registers the global configuration. + /// + /// Type that implements the interface + /// and has the attribute. + /// Thrown when no is found + /// Thrown when sectionName from the + /// is null + public static IServiceCollection RegisterGlobalConfig( + this IServiceCollection services, + IConfiguration configuration, + ConfeatureOptions confeatureOptions) + where T : class, IGlobalConfigurationMarker + { + var section = configuration.GetGlobalSection(confeatureOptions); + + var mergeAttrs = typeof(T).GetCustomAttributes(); + foreach (var mergeAttr in mergeAttrs) + { + services.RegisterConfig(configuration, mergeAttr.SectionName); + } + + return services.RegisterConfig(section); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StringExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StringExtensions.cs new file mode 100644 index 0000000..9c1d573 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Extensions/StringExtensions.cs @@ -0,0 +1,10 @@ +namespace Allegro.Extensions.Configuration.Extensions; + +internal static class StringExtensions +{ + internal static string ToContextName(this string filePath) + { + ArgumentNullException.ThrowIfNull(filePath); + return filePath.Split('/').LastOrDefault()?.Split('.').FirstOrDefault() ?? string.Empty; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ConfeatureLoggingHostedService.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ConfeatureLoggingHostedService.cs new file mode 100644 index 0000000..ede48a6 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ConfeatureLoggingHostedService.cs @@ -0,0 +1,78 @@ +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Allegro.Extensions.Configuration.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration; + +public class ConfeatureLoggingHostedService : IHostedService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IServiceProvider _serviceProvider; + private readonly ConfeatureOptions _confeatureOptions; + + public ConfeatureLoggingHostedService( + ILogger logger, + IConfiguration configuration, + IServiceProvider serviceProvider, + ConfeatureOptions confeatureOptions) + { + _logger = logger; + _configuration = configuration; + _serviceProvider = serviceProvider; + _confeatureOptions = confeatureOptions; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + if (!_confeatureOptions.IsEnabled) + { + return Task.CompletedTask; + } + + if (_configuration is IConfigurationRoot configurationRoot && + configurationRoot.Providers + .FirstOrDefault( + x => + x is ConfeatureConfigurationProvider) is ConfeatureConfigurationProvider confeatureProvider) + { + foreach (var action in confeatureProvider.ToBeLogged.Deferred) + { + action(_logger); + } + } + + var configurationPrinter = _serviceProvider.GetRequiredService(); + var configurationResponse = configurationPrinter.GetConfiguration(); + + foreach (var (providerId, providerMetadata) in configurationResponse.Providers) + { +#pragma warning disable CA1848 + _logger.LogInformation("[Confeature] ConfigurationProvider {ProviderId}: {@ProviderMetadata}", providerId, providerMetadata); +#pragma warning restore CA1848 + } + + foreach (var (key, values) in configurationResponse.Configuration) + { + if (!values.Any()) + { + continue; + } + +#pragma warning disable CA1848 + _logger.LogInformation("[Confeature] ConfigurationKey {Key}: {@Value}", key, values.First()); +#pragma warning restore CA1848 + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ContextGroupsConfiguration.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ContextGroupsConfiguration.cs new file mode 100644 index 0000000..1c0fae3 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/ContextGroupsConfiguration.cs @@ -0,0 +1,33 @@ +namespace Allegro.Extensions.Configuration.GlobalConfiguration; + +/// +/// Contains list of the context groups configuration +/// +public class ContextGroupsConfiguration +{ + /// + /// Configuration section name that this class should be bound to + /// + public const string SectionName = "GlobalConfiguration"; + + /// + /// List of per-context-group configs + /// + public List ContextGroups { get; init; } = new(); +} + +/// +/// Global configuration context group meta-config +/// +public class ContextGroupConfiguration +{ + /// + /// Filesystem path to the files containing the configuration + /// + public string Path { get; init; } = null!; + + /// + /// Name of the context group + /// + public string Name { get; init; } = null!; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationProvider.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationProvider.cs new file mode 100644 index 0000000..7cc2100 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationProvider.cs @@ -0,0 +1,264 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using Allegro.Extensions.Configuration.Client; +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Exceptions; +using Allegro.Extensions.Configuration.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Polly; +using Polly.Retry; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration.Provider; + +/// +/// Global configuration provider. Can be used to read the global configuration from the files in the path given +/// by the or using the fallback service. +/// +internal class ConfeatureConfigurationProvider : ConfigurationProvider, ITraversableChainedConfigurationProviderWrapper +{ + internal static readonly string AllServicesMarker = "*"; + internal static readonly string ServiceMetadataKeySuffix = "metadata:service"; + + private readonly ContextGroupsConfiguration _configuration; + + private readonly IConfeatureServiceClient? _confeatureClient; + private readonly AsyncRetryPolicy _retryPolicy; + private readonly string _serviceName; + private readonly bool _loadAllContexts; + private readonly ISet _loadedSections = new HashSet(StringComparer.OrdinalIgnoreCase); + + public IConfigurationRoot ConfigurationRoot { get; private set; } = + new ConfigurationRoot(new List()); + public DeferredConfeatureLogger ToBeLogged { get; } = new(); + private ILogger DeferredLogger => ToBeLogged; + + public ConfeatureConfigurationProvider( + ContextGroupsConfiguration configuration, + HttpClient? httpClient, + bool isDevelopment, + ConfeatureOptions confeatureOptions) + { + _configuration = configuration; + Random jitter = new(); + _retryPolicy = Policy + .Handle() + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(1.5, retryAttempt)) + + TimeSpan.FromMilliseconds(jitter.Next(0, 300))); + + _confeatureClient = httpClient != null ? new ConfeatureServiceClient(httpClient) : null; + _loadAllContexts = isDevelopment; // TODO || cloudAppInfo.IntegrationTesting; + _serviceName = confeatureOptions.ServiceName switch + { + // on local machine or in integration tests, fallback to fetching all global contexts + null when _loadAllContexts => string.Empty, + // on the cluster we expect ApplicationName to be present + null => throw new InvalidOperationException( + $"{nameof(confeatureOptions.ServiceName)} is required but has no value."), + _ => confeatureOptions.ServiceName + }; + } + + // These attributes will be useful when we turn on the analyzers + [SuppressMessage( + "Usage", + "VSTHRD002", + MessageId = "Avoid problematic synchronous waits", + Justification = "Cannot use await in a overriding void method")] + [SuppressMessage( + "Async", + "AsyncifyInvocation", + MessageId = "Use Task Async", + Justification = "Cannot use await in a overriding void method")] + public override void Load() + { + ConfigurationRoot = new ConfigurationRoot(LoadContexts()); + + // the providers are iterated to prevent duplicates - dictionary will not allow them + Data = ConfigurationRoot + .Providers + .Aggregate( + new Dictionary(StringComparer.OrdinalIgnoreCase), + (dict, provider) => + dict + .Concat( + provider.GetFullKeyNames() + .Select( + key => + { + provider.TryGet(key, out var value); + return new KeyValuePair( + key, + value); + })) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.OrdinalIgnoreCase)); + } + + private IList LoadContexts() + { + if (string.IsNullOrEmpty(_serviceName) && _confeatureClient != null) + { + // locally in Development always use fallback service + return LoadFromFallbackService().GetAwaiter().GetResult(); + } + + var invalidContextGroup = _configuration.ContextGroups.Find(cg => !Directory.Exists(cg.Path)); + if (invalidContextGroup is not null) + { +#pragma warning disable CA1848 + DeferredLogger.LogError( + "[Confeature] Could not find directory: {InvalidContextGroupPath} " + + "for context group: {InvalidContextGroupName}", + invalidContextGroup.Path, + invalidContextGroup.Name); +#pragma warning restore CA1848 + return LoadFromFallbackService().GetAwaiter().GetResult(); + } + + try + { + var configurationProviders = new List(); + foreach (var contextGroup in _configuration.ContextGroups) + { + foreach (var fileName in Directory.EnumerateFiles( + contextGroup.Path, + "*.json", + SearchOption.AllDirectories)) + { + // ignore hidden OS files + if (fileName.Split(Path.DirectorySeparatorChar).Any(part => part.StartsWith(".", StringComparison.Ordinal))) + { + continue; + } + + // ignore contexts that current service is not subscribing to + if (!string.IsNullOrEmpty(_serviceName) && + !ConfigurationContextExtensions.IsServiceListedForContext( + File.OpenRead(fileName), + _serviceName)) + { + continue; + } + + configurationProviders.Add(LoadFromFile(fileName, contextGroup.Name)); + } + } + + return configurationProviders; + } + catch (IOException e) + { +#pragma warning disable CA1848 + DeferredLogger.LogError( + e, + "Caught an exception when trying to read the global config, falling back to the HTTP"); +#pragma warning restore CA1848 + return LoadFromFallbackService().GetAwaiter().GetResult(); + } + } + + private async Task> LoadFromFallbackService() + { + if (_confeatureClient == null) + { + throw new ConfeatureClientNotConfiguredException(); + } + +#pragma warning disable CA1848 + DeferredLogger.LogInformation("[Confeature] Using fallback service to retrieve the configuration"); +#pragma warning restore CA1848 + + var globalConfig = await _retryPolicy.ExecuteAsync(() => _confeatureClient.GetGlobalConfiguration(_serviceName)); + + var taskList = new List>(globalConfig.ContextGroups.Sum(cg => cg.Contexts.Count)); + + foreach (var contextGroup in globalConfig.ContextGroups) + { + foreach (var context in contextGroup.Contexts) + { + taskList.Add( + _retryPolicy.ExecuteAsync( + async () => (await _confeatureClient.GetGlobalConfigurationContext(contextGroup.Name, context), + context, + contextGroup.Name))); + } + } + + await Task.WhenAll(taskList); + + var configurationProviders = new List(); + foreach (var task in taskList) + { + var (configStream, contextName, contextGroupName) = await task; + var contextDict = JsonConfigurationFileParser.Parse(configStream); + + configurationProviders.Add( + new ConfeatureContextConfigurationProvider( + ToDictionaryWithContextPrefixedKeys(contextDict, contextName), + contextName, + contextGroupName)); + } + +#pragma warning disable CA1848 + DeferredLogger.LogInformation("[Confeature] Configuration loaded from the fallback service successfully"); +#pragma warning restore CA1848 + + return configurationProviders; + } + + private IConfigurationProvider LoadFromFile(string fileName, string contextGroup) + { + return new ConfeatureContextConfigurationProvider( + ToDictionaryWithContextPrefixedKeys( + JsonConfigurationFileParser.Parse(File.OpenRead(fileName)), + fileName.ToContextName()), + fileName.ToContextName(), + contextGroup); + } + + private IDictionary ToDictionaryWithContextPrefixedKeys( + IDictionary dict, + string context) + { + string RemovePrefix(string str, string prefix) => + str.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + ? str[prefix.Length..] + : str; + + if (!dict.TryGetValue("metadata:sectionName", out var sectionName) || string.IsNullOrWhiteSpace(sectionName)) + { + sectionName = context; + } + + if (_loadedSections.Contains(sectionName)) + { + throw new GlobalContextSectionNameConflictException(context, sectionName); + } + + _loadedSections.Add(sectionName); + + var metadataToAdd = dict + .Where( + x => x.Key.StartsWith("metadata:deploymentInfo", StringComparison.OrdinalIgnoreCase) || + (x.Key.StartsWith("metadata:services", StringComparison.OrdinalIgnoreCase) && + (x.Value == _serviceName || x.Value == AllServicesMarker))); + + var retDct = dict + .Where(x => !x.Key.StartsWith("metadata", StringComparison.OrdinalIgnoreCase)) + .Union(metadataToAdd) + .Select(x => x.Key.StartsWith("metadata:services", StringComparison.OrdinalIgnoreCase) + ? (ServiceMetadataKeySuffix, _serviceName) + : (x.Key, x.Value)) + .Select(x => ($"{sectionName}:{RemovePrefix(x.Item1, "config:")}", x.Item2)) + .ToDictionary(x => x.Item1, x => x.Item2, StringComparer.OrdinalIgnoreCase); + + if (_loadAllContexts) + { + retDct[$"{sectionName}:{ServiceMetadataKeySuffix}"] = "*"; + } + + return retDct; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationSource.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationSource.cs new file mode 100644 index 0000000..52b7f47 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureConfigurationSource.cs @@ -0,0 +1,71 @@ +using System.Net.Http; +using Allegro.Extensions.Configuration.Configuration; +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration.Provider; + +/// +/// Source of global configuration key/values for the Confeature +/// +public sealed class ConfeatureConfigurationSource : IConfigurationSource +{ + private readonly ContextGroupsConfiguration _configuration; + private readonly Uri? _fallbackUri; + private readonly bool _isDevelopment; + private readonly Func? _authHandler; + private readonly ConfeatureOptions _confeatureOptions; + + /// (Meta)configuration for thee configuration source + /// Confeature options + public ConfeatureConfigurationSource( + ContextGroupsConfiguration configuration, + ConfeatureOptions confeatureOptions) + { + _configuration = configuration; + _confeatureOptions = confeatureOptions; + _isDevelopment = false; + } + + /// (Meta)configuration for thee configuration source + /// URI of the fallback service that is used + /// in case of the config map/file system issues + /// Indicates whether the service is run on the local development environment + /// Authentication handler to be used when connecting to the fallback config service + /// Confeature options + public ConfeatureConfigurationSource( + ContextGroupsConfiguration configuration, + Uri? fallbackUri, + bool isDevelopment, + Func? authHandler, + ConfeatureOptions confeatureOptions) + { + _configuration = configuration; + _fallbackUri = fallbackUri; + _isDevelopment = isDevelopment; + _authHandler = authHandler; + _confeatureOptions = confeatureOptions; + } + + /// + /// Builds the new Confeature configuration provider + /// + /// Confeature configuration provider + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new ConfeatureConfigurationProvider( + _configuration, + Create(_fallbackUri, _authHandler), + _isDevelopment, + _confeatureOptions); + } + + private static HttpClient? Create(Uri? uri, Func? authHandler) + { + if (uri == null) + { + return null; + } + + return new HttpClient(authHandler?.Invoke(new HttpClientHandler()) ?? new HttpClientHandler()) { BaseAddress = uri }; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureContextConfigurationProvider.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureContextConfigurationProvider.cs new file mode 100644 index 0000000..bd7cc2a --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/ConfeatureContextConfigurationProvider.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration.Provider; + +/// +/// Global configuration provider. Can be used to read the global configuration from the files in the path given +/// by the or using the fallback service. +/// +internal class ConfeatureContextConfigurationProvider : ConfigurationProvider +{ + public string ContextName { get; } + public string ContextGroupName { get; } + + public ConfeatureContextConfigurationProvider( + IDictionary data, + string contextName, + string contextGroupName) + { + Data = data; + ContextName = contextName; + ContextGroupName = contextGroupName; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/DeferredConfeatureLogger.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/DeferredConfeatureLogger.cs new file mode 100644 index 0000000..c8d37b8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/DeferredConfeatureLogger.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Logging; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration.Provider; + +/// +/// implementation that collects all logger invocations to be executed later, +/// on an actual logger instance. It prints the log events immediately on the console. +/// +internal class DeferredConfeatureLogger : ILogger +{ + private readonly List> _deferred = new(); + public IReadOnlyList> Deferred => _deferred.AsReadOnly(); + + public IDisposable BeginScope(TState state) + { +#pragma warning disable MA0025 + throw new NotImplementedException(); +#pragma warning restore MA0025 + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Console.WriteLine(formatter(state, exception)); + + if (exception != null) + { + Console.WriteLine(exception); + } + + _deferred.Add(logger => logger.Log(logLevel, eventId, state, exception, formatter)); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/JsonConfigurationFileParser.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/JsonConfigurationFileParser.cs new file mode 100644 index 0000000..7ec6f65 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/GlobalConfiguration/Provider/JsonConfigurationFileParser.cs @@ -0,0 +1,99 @@ +using System.Globalization; +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.GlobalConfiguration.Provider; + +internal sealed class JsonConfigurationFileParser +{ + private JsonConfigurationFileParser() { } + + private readonly Dictionary _data = new(StringComparer.OrdinalIgnoreCase); + private readonly Stack _paths = new(); + + public static IDictionary Parse(Stream input) + => new JsonConfigurationFileParser().ParseStream(input); + + private IDictionary ParseStream(Stream input) + { + var jsonDocumentOptions = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, + }; + + using (var reader = new StreamReader(input)) + using (var doc = JsonDocument.Parse(reader.ReadToEnd(), jsonDocumentOptions)) + { + if (doc.RootElement.ValueKind != JsonValueKind.Object) + { + throw new FormatException(); + } + + VisitElement(doc.RootElement); + } + + return _data; + } + + private void VisitElement(JsonElement element) + { + var isEmpty = true; + + foreach (var property in element.EnumerateObject()) + { + isEmpty = false; + EnterContext(property.Name); + VisitValue(property.Value); + ExitContext(); + } + + if (isEmpty && _paths.Count > 0) + { + _data[_paths.Peek()] = null!; + } + } + + private void VisitValue(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + VisitElement(value); + break; + + case JsonValueKind.Array: + var index = 0; + foreach (var arrayElement in value.EnumerateArray()) + { + EnterContext(index.ToString(CultureInfo.InvariantCulture)); + VisitValue(arrayElement); + ExitContext(); + index++; + } + + break; + + case JsonValueKind.Number: + case JsonValueKind.String: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + var key = _paths.Peek(); + if (_data.ContainsKey(key)) + { + throw new FormatException(); + } + + _data[key] = value.ToString(); + break; + + default: + throw new FormatException(); + } + } + + private void EnterContext(string context) => + _paths.Push(_paths.Count > 0 ? _paths.Peek() + ConfigurationPath.KeyDelimiter + context : context); + + private void ExitContext() => _paths.Pop(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationProviderMetadata.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationProviderMetadata.cs new file mode 100644 index 0000000..d96ef19 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationProviderMetadata.cs @@ -0,0 +1,16 @@ +namespace Allegro.Extensions.Configuration.Models; + +/// +/// Metadata of configuration provider +/// +/// Friendly name of the provider +/// Provider's type name +/// Optional provider-specific key, such as path to file or URL to external service +/// Does provider hold sensitive data +/// Does provider expose it's raw content +public record ConfigurationProviderMetadata( + string DisplayName, + string Type, + string? Key, + bool IsSecret, + bool IsRawContentAvailable); \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationResponse.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationResponse.cs new file mode 100644 index 0000000..5a44e6e --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ConfigurationResponse.cs @@ -0,0 +1,15 @@ +namespace Allegro.Extensions.Configuration.Models; + +/// +/// Response with service's configuration +/// +/// +/// Configuration keys with all available values from all providers. When more than one value per key, +/// the values are sorted by decreasing priority. The first value is the one accessible from IConfiguration. +/// +/// +/// All configuration providers used by the service +/// +public record ConfigurationResponse( + IDictionary> Configuration, + IDictionary Providers); \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/EnvironmentConfiguration.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/EnvironmentConfiguration.cs new file mode 100644 index 0000000..204ab42 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/EnvironmentConfiguration.cs @@ -0,0 +1,9 @@ +namespace Allegro.Extensions.Configuration.Models; + +public class EnvironmentConfiguration : IConfigurationMarker +{ + /// + /// Indicates whether service is run in the local, dev, uat or production environment. + /// + public bool IsTestEnvironment { get; set; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ValueWithSource.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ValueWithSource.cs new file mode 100644 index 0000000..1238aab --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Models/ValueWithSource.cs @@ -0,0 +1,14 @@ +namespace Allegro.Extensions.Configuration.Models; + +/// +/// Configuration value with information about its source. +/// +/// Value of the configuration key +/// ID of the provider for this value (references ConfigurationResponse.Providers) +/// The name of the configuration class that uses this value (if any) +/// Additional info (if any) available for value +public record ValueWithSource( + string? Value, + string ProviderId, + string? ConfigurationClass, + string? AdditionalInfo); \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ConfigurationSchedule.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ConfigurationSchedule.cs new file mode 100644 index 0000000..aa3b294 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ConfigurationSchedule.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using Allegro.Extensions.Configuration.Validation; + +namespace Allegro.Extensions.Configuration; + +/// +/// Used to determine configuration schedules +/// +/// Wrapped configuration value type +public class ConfigurationSchedule +{ + /// + /// Value to be used between and + /// + [Required] + public T? ScheduledValue { get; init; } + + /// + /// Start date of range. Has to be less than + /// + [Required] + [DateLessThan(nameof(EndDate), ErrorMessage = "StartDate must be before EndDate")] + public DateTimeOffset StartDate { get; init; } + + /// + /// End date of range. If , then DateTimeOffset.MaxValue is used + /// + public DateTimeOffset? EndDate { get; init; } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ScheduledConfigurationWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ScheduledConfigurationWrapper.cs new file mode 100644 index 0000000..e29bff9 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/ScheduledConfiguration/ScheduledConfigurationWrapper.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Allegro.Extensions.Configuration.Validation; + +namespace Allegro.Extensions.Configuration; + +/// +/// Wrapper used to schedule configuration changes +/// +/// Use the attribute on fields +/// of ScheduledConfigurationWrapper type +/// Wrapped configuration value type +[Serializable] +public class ScheduledConfigurationWrapper +{ + /// + /// Fallback used when current date is not in range of any schedule + /// + [Required] + public T? DefaultValue { get; set; } + + /// + /// Configuration schedules containing date ranges and associated value. + /// + [Required] + [DateOverlapValidation( + nameof(ConfigurationSchedule.StartDate), + nameof(ConfigurationSchedule.EndDate), + ErrorMessage = "Scheduled dates cannot overlap")] + public ConfigurationSchedule[] Schedules { get; set; } = Array.Empty>(); + + /// + /// Current calculated value + /// + public T? Value + { + get + { + var now = DateTimeOffset.UtcNow; + + foreach (var schedule in Schedules.OrderByDescending(c => c.StartDate)) + { + if (now >= schedule.StartDate && (!schedule.EndDate.HasValue || now < schedule.EndDate)) + { + return schedule.ScheduledValue; + } + } + + return DefaultValue; + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfeatureStartupFilter.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfeatureStartupFilter.cs new file mode 100644 index 0000000..26bd035 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfeatureStartupFilter.cs @@ -0,0 +1,17 @@ +using Allegro.Extensions.Configuration.Extensions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +namespace Allegro.Extensions.Configuration.Services; + +internal class ConfeatureStartupFilter : IStartupFilter +{ + public Action Configure(Action next) + { + return app => + { + next(app); + app.UseConfeature(); + }; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigRegistry.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigRegistry.cs new file mode 100644 index 0000000..20a0713 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigRegistry.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using Microsoft.Extensions.Options; + +namespace Allegro.Extensions.Configuration.Services; + +/// +/// Holds information about registered configuration class +/// +/// Type with configuration +/// IOptionsMonitor wrapper for the configuration class +/// Name of the configuration section used to register the configuration or configuration key +internal record ConfigRegistration( + Type ConfigurationType, + Type OptionsType, + string Prefix); + +/// +/// Holds information about all registered configuration types +/// +internal class ConfigRegistry +{ + private readonly List _registrations = new(); + + /// + /// All registered configurations wrapped in IOptionsSnapshot + /// + internal IEnumerable OptionsTypes => + _registrations.Select(t => t.OptionsType); + + /// + /// Registers configuration type + /// + /// Name of the configuration section used to register the configuration (or null for root) + /// Type with configuration + internal void RegisterOptions(string? sectionName = null) + { + var configurationType = typeof(TConfiguration); + + if (sectionName != null) + { + _registrations.Add( + new ConfigRegistration( + configurationType, + typeof(IOptionsMonitor), + sectionName)); + } + else + { + // when binding to root, we need to register all properties individually + foreach (var property in configurationType.GetProperties( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + _registrations.Add( + new ConfigRegistration( + configurationType, + typeof(IOptionsMonitor), + property.Name)); + } + } + } + + /// + /// Searches for the configuration type registered using specified configuration key + /// + /// The key used to register the configuration type + /// Configuration registration info + internal ConfigRegistration? TryFindTypeByKey(string key) + => _registrations.FirstOrDefault( + x => key.StartsWith($"{x.Prefix}:", StringComparison.InvariantCultureIgnoreCase) || + key.Equals(x.Prefix, StringComparison.OrdinalIgnoreCase)); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationHelper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationHelper.cs new file mode 100644 index 0000000..3f0c121 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationHelper.cs @@ -0,0 +1,222 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using Allegro.Extensions.Configuration.Extensions; +using Allegro.Extensions.Configuration.Models; +using Allegro.Extensions.Configuration.Services.ProviderHandlers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +// ReSharper disable ConvertClosureToMethodGroup + +namespace Allegro.Extensions.Configuration.Services; + +internal static class ConfigurationHelper +{ + private static readonly ConcurrentDictionary ConfigurationTypeValuePropDict = new(); + + internal static ConfigurationResponse GetConfiguration(IServiceProvider services) + { + var configuration = services.GetRequiredService(); + var configRegistry = services.GetRequiredService>(); + var providersMetadata = GetProvidersMetadata(configuration); + var keyValues = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + foreach (var (providerId, (provider, metadata)) in providersMetadata) + { + var keys = provider.GetFullKeyNames(); + foreach (var key in keys) + { + string? value = null; + if (!metadata.IsSecret) + { + provider.TryGet(key, out value); + } + + if (!keyValues.ContainsKey(key)) + { + keyValues[key] = new List(); + } + + var configurationClass = configRegistry.Value.TryFindTypeByKey(key); + + keyValues[key].Insert( + 0, + new ValueWithSource( + value, + providerId, + configurationClass?.ConfigurationType.Name, + null)); + } + } + + keyValues = CalculateScheduledValues(services, configRegistry, keyValues, providersMetadata); + + return new ConfigurationResponse( + keyValues, + providersMetadata.ToDictionary( + x => x.Key, + x => x.Value.Metadata)); + } + + internal static string? GetRawProviderContent(IServiceProvider services, string type, string key) + { + var configuration = services.GetRequiredService(); + if (configuration is not IConfigurationRoot configurationRoot) + { + return null; + } + + foreach (var provider in configurationRoot.Providers) + { + var providerHandler = ProviderHandlerFactory.GetProviderHandler(configuration, provider); + var metadata = providerHandler.GetMetadata(); + if (metadata.Type != type || + metadata.Key != key) + { + continue; + } + + if (metadata.IsSecret) + { + return null; + } + + return providerHandler.GetRawContent(); + } + + return null; + } + + private static Dictionary + GetProvidersMetadata(IConfiguration configuration) + { + var dictionary = new Dictionary(); + + if (configuration is not IConfigurationRoot configurationRoot) + { + return dictionary; + } + + var providers = configurationRoot.Providers + .SelectMany( + provider => provider is ITraversableChainedConfigurationProviderWrapper chained + ? chained.ConfigurationRoot.Providers + : new[] { provider }) + .ToList(); + for (var i = 0; i < providers.Count; i++) + { + dictionary[i.ToString(CultureInfo.InvariantCulture)] = ( + providers[i], + ProviderHandlerFactory.GetProviderHandler(configuration, providers[i]).GetMetadata()); + } + + return dictionary; + } + + private static Dictionary> CalculateScheduledValues( + IServiceProvider services, + IOptions configRegistry, + Dictionary> keyValues, + Dictionary + providersMetadata) + { + var logger = new Lazy( + () => services.GetRequiredService>>()); + var scheduledValueInfix = $":{nameof(ScheduledConfigurationWrapper.Schedules)}:"; + var scheduledValuesCandidates = keyValues + .Where(x => x.Key.Contains(scheduledValueInfix, StringComparison.InvariantCultureIgnoreCase)) + .Where(x => !string.IsNullOrEmpty(x.Value.First().ConfigurationClass)) + .ToList(); + + var keyValuePairs = keyValues.ToList(); + + foreach (var candidate in scheduledValuesCandidates) + { + try + { + var keyToWrapper = candidate.Key[..candidate.Key.IndexOf( + scheduledValueInfix, + StringComparison.InvariantCultureIgnoreCase)]; + var keyToValue = $"{keyToWrapper}:{nameof(ScheduledConfigurationWrapper.Value)}"; + if (keyValues.ContainsKey(keyToValue)) + { + keyValuePairs.Remove(candidate); + continue; + } + + var configurationClass = configRegistry.Value.TryFindTypeByKey(keyToWrapper); + if (configurationClass == null) + { + continue; + } + + var wrapperPropertyName = keyToWrapper.Split(":").Last(); + var property = configurationClass.ConfigurationType.GetProperty(wrapperPropertyName); + if (property == null) + { + continue; + } + + var configurationObj = GetOptionsValue(configurationClass, services); + var scheduledWrapper = property.GetValue(configurationObj); + var scheduledWrapperValueProperty = scheduledWrapper?.GetType() + .GetProperty(nameof(ScheduledConfigurationWrapper.Value)); + if (scheduledWrapperValueProperty == null) + { + continue; + } + + var value = scheduledWrapperValueProperty.GetValue(scheduledWrapper); + var index = keyValuePairs.IndexOf(candidate); + + var originalProviderId = candidate.Value.First().ProviderId; + var isSecret = providersMetadata.ContainsKey(originalProviderId) && + providersMetadata[originalProviderId].Metadata.IsSecret; + + var valueWithSources = new List + { + new( + isSecret || value == null ? null : JsonConvert.SerializeObject(value), + originalProviderId, + configurationClass.ConfigurationType.Name, + "Schedule evaluation"), + }; + + keyValuePairs.Remove(candidate); + keyValuePairs.RemoveAll( + pair => pair.Key == + $"{keyToWrapper}:{nameof(ScheduledConfigurationWrapper.DefaultValue)}"); + + keyValuePairs.Insert( + index, + new KeyValuePair>( + keyToValue, valueWithSources)); + keyValues.Add(keyToValue, valueWithSources); + } + catch (Exception e) + { +#pragma warning disable CA1848 + logger.Value.LogError( + e, + "Unable to extract Value of ScheduledConfigurationWrapper for '{Key}'", + candidate.Key); +#pragma warning restore CA1848 + } + } + + return new Dictionary>( + keyValuePairs, + keyValues.Comparer); + } + + private static object GetOptionsValue(ConfigRegistration registration, IServiceProvider services) + { + var options = services.GetRequiredService(registration.OptionsType); + var type = options.GetType(); + var valueProp = ConfigurationTypeValuePropDict.GetOrAdd(type, t => t.GetProperty("CurrentValue")); + return valueProp?.GetValue(options) ?? options; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationPrinter.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationPrinter.cs new file mode 100644 index 0000000..1575e85 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ConfigurationPrinter.cs @@ -0,0 +1,41 @@ +using Allegro.Extensions.Configuration.Models; + +namespace Allegro.Extensions.Configuration.Services; + +/// +/// Defines methods for retrieving the configuration ready to be pretty-printed on the UI +/// +public interface IConfigurationPrinter +{ + /// + /// Returns all registered configurations from all providers, including scheduled values + /// and KeyVault secrets (secrets' values are not shown). + /// + ConfigurationResponse GetConfiguration(); + + /// + /// Returns string containing the raw view of the provider (e.g. full JSON file for the JsonConfigurationProvider) + /// + string? GetRawProviderContent(string type, string key); +} + +/// +public sealed class ConfigurationPrinter : IConfigurationPrinter +{ + private readonly IServiceProvider _services; + + /// Service provider for retrieving the registered configuration objects + public ConfigurationPrinter(IServiceProvider services) => _services = services; + + /// + public ConfigurationResponse GetConfiguration() + { + return ConfigurationHelper.GetConfiguration(_services); + } + + /// + public string? GetRawProviderContent(string type, string key) + { + return ConfigurationHelper.GetRawProviderContent(_services, type, key); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/OptionsRegistrationValidator.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/OptionsRegistrationValidator.cs new file mode 100644 index 0000000..9631f66 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/OptionsRegistrationValidator.cs @@ -0,0 +1,115 @@ +using Allegro.Extensions.Configuration.Configuration; +using Allegro.Extensions.Configuration.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Allegro.Extensions.Configuration.Services; + +internal static class OptionsRegistrationValidator +{ + internal static void Validate(IServiceProvider serviceProvider) + { + if (IsDisabled()) + return; + + var optionsType = typeof(IOptions<>); + var postConfigureOptionsType = typeof(IPostConfigureOptions<>); + var configureOptionsType = typeof(IConfigureOptions<>); + + var options = serviceProvider.GetRequiredService>().Value; + + var constructorParameterTypes = AppDomain + .CurrentDomain + .GetAssemblies() + .Where( + a => a.FullName is not null && + options.AssemblyPrefixesToValidate.Any(p => + a.FullName.StartsWith(p, StringComparison.OrdinalIgnoreCase))) + .SelectMany(a => a.GetExportedTypes()) + .SelectMany( + type => type + .GetConstructors() + .SelectMany(ctor => ctor.GetParameters().Select(paramInfo => paramInfo.ParameterType)), + (ctorType, paramType) => new CtorWithParam(ctorType, paramType)); + + var optionValueTypes = constructorParameterTypes + .Where(item => IsAssignableToGenericType(item.ParamType, optionsType)) + .Select(item => item with { ParamType = item.ParamType.GetGenericArguments().Single() }) + // check only options marked with the Confeature interface + .Where(item => item.ParamType.GetInterface(nameof(IConfigurationMarker)) is not null) + .Distinct(); + + var scope = serviceProvider.CreateScope(); + var postConfiguration = optionValueTypes + // For now we only validate simple types as it's rather cumbersome to make complex generic config types + .Where(item => !item.ParamType.IsGenericType && !item.ParamType.ContainsGenericParameters) + .Select( + item => new + { + item.CtorType, + OptionsType = item.ParamType, + ConfigureTypes = scope.ServiceProvider.GetServices(configureOptionsType.MakeGenericType(item.ParamType)), + PostConfigureTypes = scope.ServiceProvider.GetServices(postConfigureOptionsType.MakeGenericType(item.ParamType)) + }); + + var emptyConfigurations = postConfiguration + .Where(item => item.ConfigureTypes.Any() == false && item.PostConfigureTypes.Any() == false) + .Select(item => new CtorWithParam(item.CtorType, item.OptionsType)) + .Where(item => item.ParamType != typeof(EnvironmentConfiguration)) + // Exclude any *optional* Platform configuration, e.g. AccessTokenRefreshHealthCheckOptions, MetricsOptions + .Where(type => options.NamespacesToIgnore.All(ns => type.ParamType.Namespace?.StartsWith(ns, StringComparison.OrdinalIgnoreCase) == false)); + + if (emptyConfigurations.Any()) + { + throw new OptionsNotRegisteredException(emptyConfigurations); + } + } + + // stackoverflow.com/questions/74616/how-to-detect-if-type-is-another-generic-type/1075059#1075059 + private static bool IsAssignableToGenericType(Type givenType, Type genericType) + { + var interfaceTypes = givenType.GetInterfaces(); + if (interfaceTypes.Any(it => it.IsGenericType && it.GetGenericTypeDefinition() == genericType)) + return true; + + if (givenType.IsGenericType && givenType.GetGenericTypeDefinition() == genericType) + return true; + + var baseType = givenType.BaseType; + if (baseType == null) + return false; + + return IsAssignableToGenericType(baseType, genericType); + } + + private static bool IsDisabled() + { + const string envVarName = "DisableOptionsRegistrationValidation"; + var isDisabledStr = Environment.GetEnvironmentVariable(envVarName) ?? + bool.FalseString; + + if (!bool.TryParse(isDisabledStr, out var isDisabled)) + { + Console.WriteLine($"Could not parse the environment variable {envVarName} to a boolean"); + return true; + } + + Console.WriteLine($"OptionsRegistrationValidation is currently {(isDisabled ? "disabled" : "enabled")}"); + return isDisabled; + } + + private class OptionsNotRegisteredException : Exception + { + public OptionsNotRegisteredException(IEnumerable types) + : base($"Following configuration types were not registered properly:{Environment.NewLine}" + + $"{string.Join($", {Environment.NewLine}", types)}.{Environment.NewLine}" + + "Please use the RegisterConfig method for config DTOs registration. " + + $"If you think this is a bug, please contact the Aard team on the #help-aard Slack channel " + + $"or disable the {nameof(OptionsRegistrationValidator)} by setting the " + + $"DisableOptionsRegistrationValidation environment variable to true.") + { + } + } + + private readonly record struct CtorWithParam(Type CtorType, Type ParamType); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ConfeatureContextProviderHandler.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ConfeatureContextProviderHandler.cs new file mode 100644 index 0000000..f0bfec9 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ConfeatureContextProviderHandler.cs @@ -0,0 +1,20 @@ +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal class ConfeatureContextProviderHandler : GenericProviderHandler +{ + public ConfeatureContextProviderHandler(IConfigurationProvider provider) + : base(provider) + { + } + + public override string GetRawContent() => throw new NotSupportedException(); + + protected override string GetDisplayName() => $"Global ({Provider.ContextName})"; + + protected override string? GetKey() => $"{Provider.ContextGroupName};{Provider.ContextName}"; + + protected override bool GetIsRawContentAvailable() => false; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/GenericProviderHandler.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/GenericProviderHandler.cs new file mode 100644 index 0000000..5455122 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/GenericProviderHandler.cs @@ -0,0 +1,55 @@ +using Allegro.Extensions.Configuration.Exceptions; +using Allegro.Extensions.Configuration.Models; +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal class GenericProviderHandler : IProviderHandler + where TProvider : IConfigurationProvider +{ + private readonly string? _displayName; + protected readonly TProvider Provider; + protected readonly bool IsSecret; + + public GenericProviderHandler(IConfigurationProvider provider) + { + IsSecret = provider is ISensitiveConfigurationProviderWrapper; + while (provider is IConfigurationProviderWrapper providerWrapper) + { + provider = providerWrapper.Inner; + IsSecret = IsSecret || providerWrapper is ISensitiveConfigurationProviderWrapper; + } + + if (provider is not TProvider castedProvider) + { + throw new InvalidProviderTypeException(provider.GetType()); + } + + Provider = castedProvider; + } + + public GenericProviderHandler(IConfigurationProvider provider, string displayName) + : this(provider) + { + _displayName = displayName; + } + + public ConfigurationProviderMetadata GetMetadata() + { + return new ConfigurationProviderMetadata( + GetDisplayName(), + typeof(TProvider).Name, + GetKey(), + IsSecret, + GetIsRawContentAvailable()); + } + + public virtual string GetRawContent() => throw new NotSupportedException(); + + protected virtual string GetDisplayName() + => _displayName ?? Provider!.ToString() ?? Provider!.GetType().Name; + + protected virtual string? GetKey() => null; + + protected virtual bool GetIsRawContentAvailable() => false; +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/IProviderHandler.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/IProviderHandler.cs new file mode 100644 index 0000000..d60e4de --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/IProviderHandler.cs @@ -0,0 +1,9 @@ +using Allegro.Extensions.Configuration.Models; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal interface IProviderHandler +{ + ConfigurationProviderMetadata GetMetadata(); + string GetRawContent(); +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/JsonProviderHandler.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/JsonProviderHandler.cs new file mode 100644 index 0000000..0ce65e3 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/JsonProviderHandler.cs @@ -0,0 +1,44 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal class JsonProviderHandler : GenericProviderHandler +{ + private static readonly Regex JsonRegex = new( + @"JsonConfigurationProvider for '(?.*)' \((.*)\)", + RegexOptions.Compiled | RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(1)); + + private readonly string _path; + + public JsonProviderHandler(IConfigurationProvider provider) + : base(provider) + { + var jsonRegexMatch = JsonRegex.Match(Provider.ToString()); + _path = jsonRegexMatch.Success ? jsonRegexMatch.Groups["filename"].Value : Provider.Source.Path; + } + + public override string GetRawContent() + { + using var fileStream = Provider.Source.FileProvider.GetFileInfo(Provider.Source.Path).CreateReadStream(); + using var streamReader = new StreamReader(fileStream); + return streamReader.ReadToEnd(); + } + + protected override string GetDisplayName() + { + return _path; + } + + protected override string GetKey() + { + return _path; + } + + protected override bool GetIsRawContentAvailable() + { + return true; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/KeyPerFileProviderHandler.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/KeyPerFileProviderHandler.cs new file mode 100644 index 0000000..537897c --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/KeyPerFileProviderHandler.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.KeyPerFile; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal class KeyPerFileProviderHandler : GenericProviderHandler +{ + private const string KeyVaultSecretsVolumeMountKey = "KeyVault:SecretsVolumeMount"; + private const string ServiceDiscoverySecretsVolumeMountKey = "ServiceDiscovery:SecretsVolumeMount"; + + private static readonly Regex KeyPerFileRegex = new( + @"KeyPerFileConfigurationProvider for files in '(?.*)' \((?.*)\)", + RegexOptions.Compiled | RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(1)); + + private readonly string _displayName = "Key per file"; + private readonly string? _path; + + public KeyPerFileProviderHandler( + IConfiguration configuration, + IConfigurationProvider provider) + : base(provider) + { + var keyVaultSecretsVolumeMount = configuration[KeyVaultSecretsVolumeMountKey]; + var serviceDiscoverySecretsVolumeMount = configuration[ServiceDiscoverySecretsVolumeMountKey]; + + var keyPerFileRegexMatch = KeyPerFileRegex.Match(Provider.ToString()); + if (!keyPerFileRegexMatch.Success) + { + return; + } + + _path = keyPerFileRegexMatch.Groups["path"].Value; + + if (keyPerFileRegexMatch.Groups["path"].Value.StartsWith( + keyVaultSecretsVolumeMount, + StringComparison.InvariantCultureIgnoreCase)) + { + _displayName = "Key Vault (mount)"; + } + else if (keyPerFileRegexMatch.Groups["path"].Value.StartsWith( + serviceDiscoverySecretsVolumeMount, + StringComparison.InvariantCultureIgnoreCase)) + { + _displayName = "Service Discovery (mount)"; + } + else + { + _displayName = $"Key per file (path: '{keyPerFileRegexMatch.Groups["path"].Value}')"; + } + } + + protected override string GetDisplayName() + { + return _displayName; + } + + protected override string? GetKey() + { + return _path; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ProviderHandlerFactory.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ProviderHandlerFactory.cs new file mode 100644 index 0000000..6077c83 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Services/ProviderHandlers/ProviderHandlerFactory.cs @@ -0,0 +1,35 @@ +using Allegro.Extensions.Configuration.Extensions; +using Allegro.Extensions.Configuration.GlobalConfiguration.Provider; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.EnvironmentVariables; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.Configuration.KeyPerFile; + +namespace Allegro.Extensions.Configuration.Services.ProviderHandlers; + +internal static class ProviderHandlerFactory +{ + internal static IProviderHandler GetProviderHandler( + IConfiguration configuration, + IConfigurationProvider provider) + { + return provider.GetInnermostProvider() switch + { + ConfeatureContextConfigurationProvider => new ConfeatureContextProviderHandler(provider), + JsonConfigurationProvider => new JsonProviderHandler(provider), + ChainedConfigurationProvider => + new GenericProviderHandler( + provider, + "Chained"), + EnvironmentVariablesConfigurationProvider => + new GenericProviderHandler( + provider, + "Env vars"), + KeyPerFileConfigurationProvider => + new KeyPerFileProviderHandler( + configuration, + provider), + _ => new GenericProviderHandler(provider), + }; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderExtensions.cs new file mode 100644 index 0000000..c9fee70 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + public static class ConfigurationBuilderExtensions + { + /// + /// Returns a new that automatically wraps all configuration providers + /// with a wrapper returned by the . + /// + /// Existing + /// Factory of the wrapper + /// New that will automatically wrap all newly added providers + public static IConfigurationBuilder Wrap( + this IConfigurationBuilder builder, + Func wrapperFactory) + => new ConfigurationBuilderWrapper(builder, wrapperFactory); + + /// + /// Returns a new that automatically wraps all configuration providers + /// with a , which marks the inner provider as sensitive + /// (meaning that it may contain secret values). + /// + /// Existing + /// + /// New that will automatically wrap all newly added providers + /// using . + /// + public static IConfigurationBuilder WrapSensitive(this IConfigurationBuilder builder) + => builder.Wrap(inner => new SensitiveConfigurationProviderWrapper(inner)); + + public static IConfigurationBuilder AddTraversableConfiguration( + this IConfigurationBuilder builder, + IConfigurationRoot configurationRoot) + { + return builder.Add(new TraversableChainedConfigurationSource(configurationRoot)); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderWrapper.cs new file mode 100644 index 0000000..dc4118f --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationBuilderWrapper.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// Wrapper for that automatically wraps all newly added configuration providers + /// using provided . + /// + public class ConfigurationBuilderWrapper : IConfigurationBuilder + { + /// + /// Inner being wrapped. + /// + public IConfigurationBuilder Inner { get; } + + /// + /// Factory of the wrapper. + /// + public Func WrapperFactory { get; } + + public ConfigurationBuilderWrapper( + IConfigurationBuilder inner, + Func wrapperFactory) + { + Inner = inner; + WrapperFactory = wrapperFactory; + } + + public override string? ToString() => Inner.ToString(); + + public IConfigurationBuilder Add(IConfigurationSource source) => + Inner.Add(new ConfigurationSourceWrapper(source, WrapperFactory)); + + public IConfigurationRoot Build() => Inner.Build(); + + public IDictionary Properties => Inner.Properties; + + public IList Sources => Inner.Sources; + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationProviderWrapperBase.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationProviderWrapperBase.cs new file mode 100644 index 0000000..91d9dcc --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationProviderWrapperBase.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// Base class for wrappers, that simplifies traversing the providers' graph + /// by exposing the property. + /// + public abstract class ConfigurationProviderWrapperBase : IConfigurationProvider, IConfigurationProviderWrapper + { + /// + /// Inner being wrapped. + /// + public IConfigurationProvider Inner { get; } + + protected ConfigurationProviderWrapperBase(IConfigurationProvider inner) + { + Inner = inner; + } + + public override string? ToString() + { + return Inner.ToString(); + } + + public virtual IEnumerable GetChildKeys(IEnumerable earlierKeys, string parentPath) + => Inner.GetChildKeys(earlierKeys, parentPath); + + public virtual IChangeToken GetReloadToken() + => Inner.GetReloadToken(); + + public virtual void Load() + => Inner.Load(); + + public virtual void Set(string key, string value) + => Inner.Set(key, value); + + public virtual bool TryGet(string key, out string value) + => Inner.TryGet(key, out value); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationSourceWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationSourceWrapper.cs new file mode 100644 index 0000000..34a88db --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/ConfigurationSourceWrapper.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// Wrapper for that automatically wraps the + /// using provided . + /// + public class ConfigurationSourceWrapper : IConfigurationSource + { + /// + /// Inner being wrapped. + /// + public IConfigurationSource Inner { get; } + + /// + /// Factory of the wrapper. + /// + public Func WrapperFactory { get; } + + public ConfigurationSourceWrapper( + IConfigurationSource inner, + Func wrapperFactory) + { + Inner = inner; + WrapperFactory = wrapperFactory; + } + + public override string? ToString() => Inner.ToString(); + + public IConfigurationProvider Build(IConfigurationBuilder builder) => WrapperFactory(Inner.Build(builder)); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/KeyPerFileConfigurationExtensions.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/KeyPerFileConfigurationExtensions.cs new file mode 100644 index 0000000..0066ece --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/KeyPerFileConfigurationExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.FileProviders; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + public static class KeyPerFileConfigurationExtensions + { + public static IConfigurationBuilder AddKeyPerFileFiltered( + this IConfigurationBuilder configurationBuilder, + string path, + params string[] allowedKeyPrefixes) + { + return configurationBuilder.AddKeyPerFile(source => + { + source.FileProvider = new PhysicalFileProvider(path); + source.Optional = false; + source.IgnoreCondition = name => + allowedKeyPrefixes.All(prefix => + !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + }); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/SensitiveConfigurationProviderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/SensitiveConfigurationProviderWrapper.cs new file mode 100644 index 0000000..17c5eaf --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/SensitiveConfigurationProviderWrapper.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// wrapper that marks the inner provider as sensitive + /// (meaning that it may contain secret values). + /// + public class SensitiveConfigurationProviderWrapper : + ConfigurationProviderWrapperBase, + ISensitiveConfigurationProviderWrapper + { + public SensitiveConfigurationProviderWrapper(IConfigurationProvider inner) : base(inner) + { + } + + public override string? ToString() + { + return $"{Inner} (sensitive)"; + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationProviderWrapper.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationProviderWrapper.cs new file mode 100644 index 0000000..23afa7e --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationProviderWrapper.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Primitives; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// wrapper on that + /// exposes the IConfigurationRoot being wrapped. + /// + public class TraversableChainedConfigurationProviderWrapper : ITraversableChainedConfigurationProviderWrapper + { + public IConfigurationRoot ConfigurationRoot { get; } + + private readonly IConfigurationProvider _inner; + + public TraversableChainedConfigurationProviderWrapper(IConfigurationRoot configurationRoot) + { + ConfigurationRoot = configurationRoot; + _inner = new ChainedConfigurationProvider( + new ChainedConfigurationSource + { + Configuration = configurationRoot + }); + } + + public IEnumerable GetChildKeys(IEnumerable earlierKeys, string parentPath) + => _inner.GetChildKeys(earlierKeys, parentPath); + + public IChangeToken GetReloadToken() + => _inner.GetReloadToken(); + + public void Load() + => _inner.Load(); + + public void Set(string key, string value) + => _inner.Set(key, value); + + public bool TryGet(string key, out string value) + => _inner.TryGet(key, out value); + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationSource.cs b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationSource.cs new file mode 100644 index 0000000..fc50c02 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Allegro.Extensions.Configuration/Wrappers/TraversableChainedConfigurationSource.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Configuration; + +namespace Allegro.Extensions.Configuration.Wrappers +{ + /// + /// for . + /// + public class TraversableChainedConfigurationSource : IConfigurationSource + { + private readonly IConfigurationRoot _configurationRoot; + + public TraversableChainedConfigurationSource(IConfigurationRoot configurationRoot) + { + _configurationRoot = configurationRoot; + } + + public IConfigurationProvider Build(IConfigurationBuilder builder) + { + return new TraversableChainedConfigurationProviderWrapper(_configurationRoot); + } + } +} \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/CHANGELOG.md b/src/Allegro.Extensions.Configuration/CHANGELOG.md new file mode 100644 index 0000000..0edcae9 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres +to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2023-10-18 + +### Added + +* Initiated Allegro.Extensions.Configuration project diff --git a/src/Allegro.Extensions.Configuration/Dockerfile.Demo b/src/Allegro.Extensions.Configuration/Dockerfile.Demo new file mode 100644 index 0000000..d16fcad --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Dockerfile.Demo @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY global.json ./ +COPY ./src/*.props ./ +COPY ./src/*.targets ./ +COPY ./src/Allegro.Extensions.Configuration ./Allegro.Extensions.Configuration +WORKDIR /source/Allegro.Extensions.Configuration +RUN dotnet publish -p:SolutionName=Allegro.Extensions.Configuration -c Release -o /app Allegro.Extensions.Configuration.Demo/Allegro.Extensions.Configuration.Demo.csproj + +FROM mcr.microsoft.com/dotnet/aspnet:6.0 +WORKDIR /app +COPY --from=build /app ./ +EXPOSE 80 +ENTRYPOINT ["dotnet", "Allegro.Extensions.Configuration.Demo.dll"] \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/Dockerfile.FallbackService b/src/Allegro.Extensions.Configuration/Dockerfile.FallbackService new file mode 100644 index 0000000..dd107aa --- /dev/null +++ b/src/Allegro.Extensions.Configuration/Dockerfile.FallbackService @@ -0,0 +1,16 @@ +FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +WORKDIR /source + +# copy csproj and restore as distinct layers +COPY global.json ./ +COPY ./src/*.props ./ +COPY ./src/*.targets ./ +COPY ./src/Allegro.Extensions.Configuration ./Allegro.Extensions.Configuration +WORKDIR /source/Allegro.Extensions.Configuration +RUN dotnet publish -p:SolutionName=Allegro.Extensions.Configuration -c Release -o /app Allegro.Extensions.Configuration.Demo.FallbackService/Allegro.Extensions.Configuration.Demo.FallbackService.csproj + +FROM mcr.microsoft.com/dotnet/aspnet:6.0 +WORKDIR /app +COPY --from=build /app ./ +EXPOSE 80 +ENTRYPOINT ["dotnet", "Allegro.Extensions.Configuration.Demo.FallbackService.dll"] \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/README.md b/src/Allegro.Extensions.Configuration/README.md new file mode 100644 index 0000000..51d5bd8 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/README.md @@ -0,0 +1,3 @@ +# Allegro.Extensions.Configuration + +This library contains Allegro Pay configuration extensions. \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/docker-compose.yml b/src/Allegro.Extensions.Configuration/docker-compose.yml new file mode 100644 index 0000000..af86d40 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/docker-compose.yml @@ -0,0 +1,17 @@ +services: + fallback: + image: confeature/fallback + build: + context: ../../ + dockerfile: src/Allegro.Extensions.Configuration/Dockerfile.FallbackService + ports: + - 8083:80 + demo: + image: confeature/demo + build: + context: ../../ + dockerfile: src/Allegro.Extensions.Configuration/Dockerfile.Demo + ports: + - 8082:80 + environment: + - Confeature__FallbackUri=http://localhost:8083/ \ No newline at end of file diff --git a/src/Allegro.Extensions.Configuration/version.xml b/src/Allegro.Extensions.Configuration/version.xml new file mode 100644 index 0000000..581c1f4 --- /dev/null +++ b/src/Allegro.Extensions.Configuration/version.xml @@ -0,0 +1,5 @@ + + + 1.0.0 + + \ No newline at end of file