diff --git a/Aspire.sln b/Aspire.sln index fe5408bd274..843c24bac61 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -665,6 +665,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Docker.Tests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Kubernetes.Tests", "tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj", "{582B06FD-CC56-4C58-8138-D92F8FE62BBD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Data.AppConfiguration", "src\Components\Aspire.Azure.Data.AppConfiguration\Aspire.Azure.Data.AppConfiguration.csproj", "{34E74A65-3891-49AC-9C3E-8D049E078D4D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AzureAppConfiguration", "AzureAppConfiguration", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppHost", "playground\AzureAppConfiguration\AppHost\AppHost.csproj", "{426E8FEA-7077-4433-959C-E5B1E5F26CC0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerService", "playground\AzureAppConfiguration\WorkerService\WorkerService.csproj", "{5B26B478-95AF-9F3E-D5E7-D7535855470F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Data.AppConfiguration.Tests", "tests\Aspire.Azure.Data.AppConfiguration.Tests\Aspire.Azure.Data.AppConfiguration.Tests.csproj", "{4C902423-42B5-40E9-82FA-36D2439EE631}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql", "src\Components\Aspire.Azure.Npgsql\Aspire.Azure.Npgsql.csproj", "{417C3703-058A-210D-7E9A-28198EFDB25F}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.Tests", "tests\Aspire.Azure.Npgsql.Tests\Aspire.Azure.Npgsql.Tests.csproj", "{201765B1-37A1-9F5B-DD7F-730347046BE1}" @@ -3901,6 +3910,54 @@ Global {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x64.Build.0 = Release|Any CPU {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.ActiveCfg = Release|Any CPU {582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.Build.0 = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|x64.ActiveCfg = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|x64.Build.0 = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|x86.ActiveCfg = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Debug|x86.Build.0 = Debug|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|Any CPU.Build.0 = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|x64.ActiveCfg = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|x64.Build.0 = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|x86.ActiveCfg = Release|Any CPU + {34E74A65-3891-49AC-9C3E-8D049E078D4D}.Release|x86.Build.0 = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|x64.ActiveCfg = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|x64.Build.0 = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|x86.ActiveCfg = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Debug|x86.Build.0 = Debug|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|Any CPU.Build.0 = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|x64.ActiveCfg = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|x64.Build.0 = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|x86.ActiveCfg = Release|Any CPU + {426E8FEA-7077-4433-959C-E5B1E5F26CC0}.Release|x86.Build.0 = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|x64.ActiveCfg = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|x64.Build.0 = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Debug|x86.Build.0 = Debug|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|Any CPU.Build.0 = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|x64.ActiveCfg = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|x64.Build.0 = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|x86.ActiveCfg = Release|Any CPU + {5B26B478-95AF-9F3E-D5E7-D7535855470F}.Release|x86.Build.0 = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|x64.ActiveCfg = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|x64.Build.0 = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|x86.ActiveCfg = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Debug|x86.Build.0 = Debug|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|Any CPU.Build.0 = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|x64.ActiveCfg = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|x64.Build.0 = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|x86.ActiveCfg = Release|Any CPU + {4C902423-42B5-40E9-82FA-36D2439EE631}.Release|x86.Build.0 = Release|Any CPU {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.Build.0 = Debug|Any CPU {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -4280,6 +4337,11 @@ Global {3AE2ED5B-4EC7-4E6D-A61D-C68B837E5FA7} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {43B560D6-F158-4A4C-8E43-981056EB9038} = {830A89EC-4029-4753-B25A-068BAE37DEC7} {582B06FD-CC56-4C58-8138-D92F8FE62BBD} = {830A89EC-4029-4753-B25A-068BAE37DEC7} + {34E74A65-3891-49AC-9C3E-8D049E078D4D} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {426E8FEA-7077-4433-959C-E5B1E5F26CC0} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {5B26B478-95AF-9F3E-D5E7-D7535855470F} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {4C902423-42B5-40E9-82FA-36D2439EE631} = {C424395C-1235-41A4-BF55-07880A04368C} {417C3703-058A-210D-7E9A-28198EFDB25F} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {201765B1-37A1-9F5B-DD7F-730347046BE1} = {C424395C-1235-41A4-BF55-07880A04368C} {192747A2-9338-DECF-5C8C-28EB8E13829B} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} diff --git a/Directory.Packages.props b/Directory.Packages.props index 2e2794c489d..66fca7a0a09 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + @@ -29,6 +30,7 @@ + @@ -85,6 +87,7 @@ + diff --git a/playground/AzureAppConfiguration/AppHost/AppHost.csproj b/playground/AzureAppConfiguration/AppHost/AppHost.csproj new file mode 100644 index 00000000000..808aadd7893 --- /dev/null +++ b/playground/AzureAppConfiguration/AppHost/AppHost.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net9.0 + enable + enable + true + 6624a704-3e83-4d16-826d-964ae9acb07c + + + + + + + + + + + + diff --git a/playground/AzureAppConfiguration/AppHost/Program.cs b/playground/AzureAppConfiguration/AppHost/Program.cs new file mode 100644 index 00000000000..6d5c7fefa99 --- /dev/null +++ b/playground/AzureAppConfiguration/AppHost/Program.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +var builder = DistributedApplication.CreateBuilder(args); + +var appConfig = builder + .AddAzureAppConfiguration("appconfig"); + +builder.AddProject("workerservice") + .WithReference(appConfig); + +builder.Build().Run(); diff --git a/playground/AzureAppConfiguration/AppHost/Properties/launchSettings.json b/playground/AzureAppConfiguration/AppHost/Properties/launchSettings.json new file mode 100644 index 00000000000..aed4d5d94cf --- /dev/null +++ b/playground/AzureAppConfiguration/AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17091;http://localhost:15133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21146", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22084" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15133", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19209", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20168" + } + } + } +} diff --git a/playground/AzureAppConfiguration/AppHost/appsettings.Development.json b/playground/AzureAppConfiguration/AppHost/appsettings.Development.json new file mode 100644 index 00000000000..0c208ae9181 --- /dev/null +++ b/playground/AzureAppConfiguration/AppHost/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/playground/AzureAppConfiguration/AppHost/appsettings.json b/playground/AzureAppConfiguration/AppHost/appsettings.json new file mode 100644 index 00000000000..57ec55662a3 --- /dev/null +++ b/playground/AzureAppConfiguration/AppHost/appsettings.json @@ -0,0 +1,15 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + }, + "Azure": { + "SubscriptionId": "", + "AllowResourceGroupCreation": true, + "ResourceGroup": "", + "Location": "" + } +} diff --git a/playground/AzureAppConfiguration/WorkerService/Program.cs b/playground/AzureAppConfiguration/WorkerService/Program.cs new file mode 100644 index 00000000000..0d7dcba2b7a --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/Program.cs @@ -0,0 +1,22 @@ +using Microsoft.FeatureManagement; +using WorkerService; + +var builder = Host.CreateApplicationBuilder(args); + +builder.AddServiceDefaults(); +builder.AddAzureAppConfigurationClient("appConfig"); +builder.Services.AddHostedService(); +builder.Configuration.AddAzureAppConfiguration( + "appconfig", + configureOptions: options => { + options.UseFeatureFlags(); + options.ConfigureRefresh(refresh => + { + refresh.RegisterAll(); + }); + builder.Services.AddSingleton(options.GetRefresher()); + }); +builder.Services.AddFeatureManagement(); + +var host = builder.Build(); +host.Run(); diff --git a/playground/AzureAppConfiguration/WorkerService/Properties/launchSettings.json b/playground/AzureAppConfiguration/WorkerService/Properties/launchSettings.json new file mode 100644 index 00000000000..153357d5e1c --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "WorkerService": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/playground/AzureAppConfiguration/WorkerService/Worker.cs b/playground/AzureAppConfiguration/WorkerService/Worker.cs new file mode 100644 index 00000000000..de69640b7f0 --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/Worker.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.FeatureManagement; + +namespace WorkerService; + +public class Worker : BackgroundService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IConfigurationRefresher _refresher; + private readonly IVariantFeatureManager _featureManager; + + public Worker(ILogger logger, IConfiguration configuration, IConfigurationRefresher refresher, IVariantFeatureManager featureManager) + { + _logger = logger; + _configuration = configuration; + _refresher = refresher; + _featureManager = featureManager; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await _refresher.TryRefreshAsync(stoppingToken); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now); + _logger.LogInformation("Message from App Config: {message}", _configuration["message"]); + _logger.LogInformation("Beta feature flag is enabled: {enabled}", await _featureManager.IsEnabledAsync("Beta", stoppingToken)); + } + await Task.Delay(10000, stoppingToken); + } + } +} diff --git a/playground/AzureAppConfiguration/WorkerService/WorkerService.csproj b/playground/AzureAppConfiguration/WorkerService/WorkerService.csproj new file mode 100644 index 00000000000..0f1e48c81ff --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/WorkerService.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + dotnet-WorkerService-a204435a-f29d-40a7-b698-e883da9db8da + + + + + + + + + + + diff --git a/playground/AzureAppConfiguration/WorkerService/appsettings.Development.json b/playground/AzureAppConfiguration/WorkerService/appsettings.Development.json new file mode 100644 index 00000000000..b2dcdb67421 --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/playground/AzureAppConfiguration/WorkerService/appsettings.json b/playground/AzureAppConfiguration/WorkerService/appsettings.json new file mode 100644 index 00000000000..b2dcdb67421 --- /dev/null +++ b/playground/AzureAppConfiguration/WorkerService/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/AppConfigurationComponent.cs b/src/Components/Aspire.Azure.Data.AppConfiguration/AppConfigurationComponent.cs new file mode 100644 index 00000000000..cedc1dc9fb5 --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/AppConfigurationComponent.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Azure.Core; +using Azure.Core.Extensions; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Azure.Data.AppConfiguration; +internal sealed class AppConfigurationComponent : AzureComponent +{ + protected override IAzureClientBuilder AddClient( + AzureClientFactoryBuilder azureFactoryBuilder, AzureDataAppConfigurationSettings settings, + string connectionName, string configurationSectionName) + { + return azureFactoryBuilder.AddClient((options, cred, _) => + { + if (settings.Endpoint is null) + { + throw new InvalidOperationException($"Endpoint is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'Endpoint' key in the '{configurationSectionName}' configuration section."); + } + + return new ConfigurationClient(settings.Endpoint, cred, options); + }); + } + + protected override IHealthCheck CreateHealthCheck(ConfigurationClient client, AzureDataAppConfigurationSettings settings) + { + return new AzureAppConfigurationHealthCheck(client); + } + + protected override void BindClientOptionsToConfiguration(IAzureClientBuilder clientBuilder, IConfiguration configuration) + { +#pragma warning disable IDE0200 // Remove unnecessary lambda expression - needed so the ConfigBinder Source Generator works + clientBuilder.ConfigureOptions(options => configuration.Bind(options)); +#pragma warning restore IDE0200 + } + + protected override void BindSettingsToConfiguration(AzureDataAppConfigurationSettings settings, IConfiguration configuration) + { + configuration.Bind(settings); + } + + protected override bool GetHealthCheckEnabled(AzureDataAppConfigurationSettings settings) + => !settings.DisableHealthChecks; + + protected override TokenCredential? GetTokenCredential(AzureDataAppConfigurationSettings settings) + => settings.Credential; + + protected override bool GetMetricsEnabled(AzureDataAppConfigurationSettings settings) + => false; + + protected override bool GetTracingEnabled(AzureDataAppConfigurationSettings settings) + => !settings.DisableTracing; +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/Aspire.Azure.Data.AppConfiguration.csproj b/src/Components/Aspire.Azure.Data.AppConfiguration/Aspire.Azure.Data.AppConfiguration.csproj new file mode 100644 index 00000000000..16964cb4bff --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/Aspire.Azure.Data.AppConfiguration.csproj @@ -0,0 +1,30 @@ + + + + $(DefaultTargetFramework) + true + $(ComponentAzurePackageTags) configuration appconfiguration + A client for Azure App Configuration that integrates with Aspire. + $(SharedDir)AzureAppConfig_256x.png + $(NoWarn);SYSLIB1100;SYSLIB1101 + + false + + + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/AspireAppConfigurationExtensions.cs b/src/Components/Aspire.Azure.Data.AppConfiguration/AspireAppConfigurationExtensions.cs new file mode 100644 index 00000000000..8d0d68b29b0 --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/AspireAppConfigurationExtensions.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Data.AppConfiguration; +using Azure.Core.Extensions; +using Azure.Data.AppConfiguration; +using Azure.Identity; +using Microsoft.Extensions.Configuration.AzureAppConfiguration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Aspire.Azure.Common; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering and configuring Azure App Configuration in a .NET Aspire application. +/// +public static class AspireAppConfigurationExtensions +{ + internal const string DefaultConfigSectionName = "Aspire:Azure:Data:AppConfiguration"; + + /// + /// Registers as a singleton in the services provided by the . + /// Enables retries, corresponding health check, logging and telemetry. + /// + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Data:AppConfiguration" section. + /// Thrown when mandatory is not provided. + public static void AddAzureAppConfigurationClient( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + new AppConfigurationComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName, serviceKey: null); + } + + /// + /// Registers as a singleton for given in the services provided by the . + /// Enables retries, corresponding health check, logging and telemetry. + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection information from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Data:AppConfiguration" section. + /// Thrown when mandatory is not provided. + public static void AddKeyedAzureAppConfigurationClient( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action>? configureClientBuilder = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(name); + + new AppConfigurationComponent().AddClient(builder, DefaultConfigSectionName, configureSettings, configureClientBuilder, connectionName: name, serviceKey: name); + } + + /// + /// Adds the Azure App Configuration to be configuration values in the . + /// + /// The to add the secrets to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Azure:Data:AppConfiguration" section. + /// Thrown when mandatory is not provided. + public static IConfigurationBuilder AddAzureAppConfiguration( + this IConfigurationManager configurationManager, + string connectionName, + Action? configureSettings = null, + Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(configurationManager); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + var configSection = configurationManager.GetSection(DefaultConfigSectionName); + + var settings = new AzureDataAppConfigurationSettings(); + configSection.Bind(settings); + + if (configurationManager.GetConnectionString(connectionName) is string connectionString) + { + ((IConnectionStringSettings)settings).ParseConnectionString(connectionString); + } + + configureSettings?.Invoke(settings); + + if (settings.Endpoint is null) + { + throw new InvalidOperationException($"Endpoint is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'Endpoint' key in the '{DefaultConfigSectionName}' configuration section."); + } + + return configurationManager.AddAzureAppConfiguration(options => + { + options.Connect(settings.Endpoint, settings.Credential ?? new DefaultAzureCredential()); + configureOptions?.Invoke(options); + }); + } +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/AssemblyInfo.cs b/src/Components/Aspire.Azure.Data.AppConfiguration/AssemblyInfo.cs new file mode 100644 index 00000000000..7e07a0171cc --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/AssemblyInfo.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire; +using Aspire.Azure.Data.AppConfiguration; +using Azure.Data.AppConfiguration; + +[assembly: ConfigurationSchema("Aspire:Azure:Data:AppConfiguration", typeof(AzureDataAppConfigurationSettings))] +[assembly: ConfigurationSchema("Aspire:Azure:Data:AppConfiguration:ClientOptions", typeof(ConfigurationClientOptions), exclusionPaths: ["Default"])] + +[assembly: LoggingCategories( + "Azure", + "Azure.Core", + "Azure.Identity", + "Microsoft.Extensions.Configuration.AzureAppConfiguration.Refresh")] diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/AzureAppConfigurationHealthCheck.cs b/src/Components/Aspire.Azure.Data.AppConfiguration/AzureAppConfigurationHealthCheck.cs new file mode 100644 index 00000000000..76c9e52791c --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/AzureAppConfigurationHealthCheck.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Aspire.Azure.Data.AppConfiguration; + +internal sealed class AzureAppConfigurationHealthCheck : IHealthCheck +{ + private readonly ConfigurationClient _client; + + public AzureAppConfigurationHealthCheck(ConfigurationClient client) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + } + + /// + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var selector = new SettingSelector + { + KeyFilter = "__UNEXISTED_KEY__", + LabelFilter = null + }; + AsyncPageable pageableSettings = _client.GetConfigurationSettingsAsync(selector, cancellationToken); + await foreach (var page in pageableSettings.AsPages().ConfigureAwait(false)) + { + _ = page.GetRawResponse(); // If healthy, the response should be 200 and with empty content + } + + return HealthCheckResult.Healthy(); + } + catch (RequestFailedException ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, exception: ex); + } + } +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/AzureDataAppConfigurationSettings.cs b/src/Components/Aspire.Azure.Data.AppConfiguration/AzureDataAppConfigurationSettings.cs new file mode 100644 index 00000000000..7c31238f87a --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/AzureDataAppConfigurationSettings.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Azure.Common; +using Azure.Core; + +namespace Aspire.Azure.Data.AppConfiguration; + +/// +/// Provides the client configuration settings for connecting to Azure App Configuration. +/// +public sealed class AzureDataAppConfigurationSettings : IConnectionStringSettings +{ + /// + /// A to the App Config store on which the client operates. Appears as "Endpoint" in the Azure portal. + /// This is likely to be similar to "https://{store_name}.azconfig.io". + /// + public Uri? Endpoint { get; set; } + + /// + /// Gets or sets the credential used to authenticate to the Azure App Configuration. + /// + public TokenCredential? Credential { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the App Configuration health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableTracing { get; set; } + + void IConnectionStringSettings.ParseConnectionString(string? connectionString) + { + if (!string.IsNullOrEmpty(connectionString) && + Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + Endpoint = uri; + } + } +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/ConfigurationSchema.json b/src/Components/Aspire.Azure.Data.AppConfiguration/ConfigurationSchema.json new file mode 100644 index 00000000000..7823e7410ca --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/ConfigurationSchema.json @@ -0,0 +1,145 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Azure": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Azure.Core": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Azure.Identity": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.Extensions.Configuration.AzureAppConfiguration.Refresh": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "type": "object", + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Azure": { + "type": "object", + "properties": { + "Data": { + "type": "object", + "properties": { + "AppConfiguration": { + "type": "object", + "properties": { + "ClientOptions": { + "type": "object", + "properties": { + "Diagnostics": { + "type": "object", + "properties": { + "ApplicationId": { + "type": "string", + "description": "Gets or sets the value sent as the first part of \"User-Agent\" headers for all requests issues by this client. Defaults to 'Azure.Core.DiagnosticsOptions.DefaultApplicationId'." + }, + "DefaultApplicationId": { + "type": "string", + "description": "Gets or sets the default application id. Default application id would be set on all instances." + }, + "IsDistributedTracingEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating whether distributed tracing activities ('System.Diagnostics.Activity') are going to be created for the clients methods calls and HTTP calls." + }, + "IsLoggingContentEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating if request or response content should be logged." + }, + "IsLoggingEnabled": { + "type": "boolean", + "description": "Get or sets value indicating whether HTTP pipeline logging is enabled." + }, + "IsTelemetryEnabled": { + "type": "boolean", + "description": "Gets or sets value indicating whether the \"User-Agent\" header containing 'Azure.Core.DiagnosticsOptions.ApplicationId', client library package name and version, 'System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription' and 'System.Runtime.InteropServices.RuntimeInformation.OSDescription' should be sent. The default value can be controlled process wide by setting AZURE_TELEMETRY_DISABLED to true, false, 1 or 0." + }, + "LoggedContentSizeLimit": { + "type": "integer", + "description": "Gets or sets value indicating maximum size of content to log in bytes. Defaults to 4096." + }, + "LoggedHeaderNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets a list of header names that are not redacted during logging." + }, + "LoggedQueryParameters": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Gets a list of query parameter names that are not redacted during logging." + } + }, + "description": "Gets the client diagnostic options." + }, + "Retry": { + "type": "object", + "properties": { + "Delay": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The delay between retry attempts for a fixed approach or the delay on which to base calculations for a backoff-based approach. If the service provides a Retry-After response header, the next retry will be delayed by the duration specified by the header value." + }, + "MaxDelay": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The maximum permissible delay between retry attempts when the service does not provide a Retry-After response header. If the service provides a Retry-After response header, the next retry will be delayed by the duration specified by the header value." + }, + "MaxRetries": { + "type": "integer", + "description": "The maximum number of retry attempts before giving up." + }, + "Mode": { + "enum": [ + "Fixed", + "Exponential" + ], + "description": "The approach to use for calculating retry delays." + }, + "NetworkTimeout": { + "type": "string", + "pattern": "^-?(\\d{1,7}|((\\d{1,7}[\\.:])?(([01]?\\d|2[0-3]):[0-5]?\\d|([01]?\\d|2[0-3]):[0-5]?\\d:[0-5]?\\d)(\\.\\d{1,7})?))$", + "description": "The timeout applied to an individual network operations." + } + }, + "description": "Gets the client retry options." + } + }, + "description": "Options that allow users to configure the requests sent to the App Configuration service." + }, + "DisableHealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the App Configuration health check is disabled or not.", + "default": false + }, + "DisableTracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is disabled or not.", + "default": false + }, + "Endpoint": { + "type": "string", + "format": "uri", + "description": "A 'System.Uri' to the App Config store on which the client operates. Appears as \"Endpoint\" in the Azure portal. This is likely to be similar to \"https://{store_name}.azconfig.io\"." + } + }, + "description": "Provides the client configuration settings for connecting to Azure App Configuration." + } + } + } + } + } + } + } + } +} diff --git a/src/Components/Aspire.Azure.Data.AppConfiguration/README.md b/src/Components/Aspire.Azure.Data.AppConfiguration/README.md new file mode 100644 index 00000000000..1481e280188 --- /dev/null +++ b/src/Components/Aspire.Azure.Data.AppConfiguration/README.md @@ -0,0 +1,195 @@ +# Aspire.Azure.Data.AppConfiguration + +Retrieves configuration settings from Azure App Configuration to use in your application. Registers a [ConfigurationClient](https://learn.microsoft.com/dotnet/api/azure.data.appconfiguration.configurationclient) in the DI container for connecting to Azure App Configuration. Enables corresponding health checks, logging and telemetry. + +## Getting started + +### Prerequisites + +- Azure subscription - [create one for free](https://azure.microsoft.com/free/) +- Azure App Configuration - [create one](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-azure-app-configuration-create). + +### Install the package + +Install the .NET Aspire Azure App Configuration library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Azure.Data.AppConfiguration +``` + +## Usage examples + +### Add App Configuration to configuration + +In the _Program.cs_ file of your project, call the `builder.Configuration.AddAzureAppConfiguration` extension method to add key-values from Azure App Configuration to the application's Configuration. The method takes a connection name parameter. + +```csharp +builder.Configuration.AddAzureAppConfiguration("appConfig"); +``` + +You can then retrieve a key-value through normal `IConfiguration` APIS. For example, to retrieve a key-value from a Web API controller: + +```csharp +public MyController(IConfiguration configuration) +{ + string someValue = configuration["someKey"]; +} +``` + +#### Use feature flags + +To use feature flags, install the Feature Management library: + +```dotnetcli +dotnet add package Microsoft.FeatureManagement +``` + +App Configuration will not load feature flags by default. To load feature flags, you can pass the `Action configureOptions` delegate when calling `builder.Configuration.AddAzureAppConfiguration`. + +```csharp +builder.Configuration.AddAzureAppConfiguration("appConfig", configureOptions: options => options.UseFeatureFlags()); + +// Register feature management services +builder.Services.AddFeatureManagement(); +``` + +You can then use `IVariantFeatureManager` to evaluate feature flags in your application: + +```csharp +private readonly IVariantFeatureManager _featureManager; + +public MyController(IVariantFeatureManager featureManager) +{ + _featureManager = featureManager; +} + +[HttpGet] +public async Task Get() +{ + if (await _featureManager.IsEnabledAsync("NewFeature")) + { + return Ok("New feature is enabled!"); + } + + return Ok("Using standard implementation."); +} +``` + +For information about using the Feature Management library, please go to the [documentation](https://learn.microsoft.com/azure/azure-app-configuration/feature-management-dotnet-reference). + +### Use the ConfigurationClient + +Alternatively, you can use a `ConfigurationClient` to retrieve the configuration settings on demand. In the _Program.cs_ file of your project, call the `AddAzureAppConfigurationClient` extension method to register a `ConfigurationClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddAzureAppConfigurationClient("appConfig"); +``` + +You can then retrieve the `ConfigurationClient` instance using dependency injection. For example, to retrieve the client from a Web API controller: + +```csharp +private readonly ConfigurationClient _client; + +public MyController(ConfigurationClient client) +{ + _client = client; +} +``` + +See the [Azure.Data.AppConfiguration documentation](https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/appconfiguration/Azure.Data.AppConfiguration/README.md) for examples on using the `ConfigurationClient`. + +## Configuration + +The .NET Aspire Azure App Configuration library provides multiple options to configure the Azure App Configuration connection based on the requirements and conventions of your project. Note that the App Config `Endpoint` is required to be supplied. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddAzureAppConfigurationClient()`: + +```csharp +builder.AddAzureAppConfigurationClient("appConfigConnectionName"); +``` + +And then the App Config endpoint will be retrieved from the `ConnectionStrings` configuration section. The App Config store URI which works with the `AzureDataAppConfigurationSettings.Credential` property to establish a connection. If no credential is configured, the [DefaultAzureCredential](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential) is used. + +```json +{ + "ConnectionStrings": { + "appConfigConnectionName": "https://{store_name}.azconfig.io" + } +} +``` + +### Use configuration providers + +The .NET Aspire Azure App Configuration library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `AzureDataAppConfigurationSettings` and `ConfigurationClientOptions` from configuration by using the `Aspire:Azure:Data:AppConfiguration` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Azure": { + "Data": { + "AppConfiguration": { + "DisableHealthChecks": true, + "DisableTracing": false, + "ClientOptions": { + "Diagnostics": { + "ApplicationId": "myapp" + } + } + } + } + } + } +} +``` + +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp +builder.AddAzureAppConfigurationClient("appConfig", configureSettings: settings => settings.DisableHealthChecks = true); +``` + +You can also setup the [ConfigurationClientOptions](https://learn.microsoft.com/dotnet/api/azure.data.appconfiguration.configurationclientoptions) using the optional `Action> configureClientBuilder` parameter of the `AddAzureAppConfigurationClient` method. For example, to set the first part of "User-Agent" headers for all requests issues by this client: + +```csharp +builder.AddAzureAppConfigurationClient("appConfig", configureClientBuilder: clientBuilder => clientBuilder.ConfigureOptions(options => options.Diagnostics.ApplicationId = "myapp")); +``` + +## AppHost extensions + +In your AppHost project, install the Aspire Azure App Configuration Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.Azure.AppConfiguration +``` + +Then, in the _Program.cs_ file of `AppHost`, add a App Configuration connection and consume the connection using the following methods: + +```csharp +// Service registration +var appConfig = builder.ExecutionContext.IsPublishMode + ? builder.AddAzureAppConfiguration("appConfig") + : builder.AddConnectionString("appConfig"); + +// Service consumption +var myService = builder.AddProject() + .WithReference(appConfig); +``` + +The `AddAzureAppConfiguration` method adds an Azure App Configuration resource to the builder. Or `AddConnectionString` can be used to read connection information from the AppHost's configuration under the `ConnectionStrings:appConfig` config key. The `WithReference` method passes that connection information into a connection string named `appConfig` in the `MyService` project. In the _Program.cs_ file of `MyService`, the connection can be consumed using: + +```csharp +builder.AddAzureAppConfigurationClient("appConfig"); +``` + +## Additional documentation + +* https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/appconfiguration/Azure.Data.AppConfiguration/README.md +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index dcf29bfb7af..fd03ae12992 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -12,6 +12,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | Microsoft.EntityFrameworkCore.SqlServer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | MongoDB.Driver | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.AI.OpenAI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Azure.Data.AppConfiguration | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Data.Tables | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Messaging.EventHubs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | | | Azure.Messaging.WebPubSub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | diff --git a/tests/Aspire.Azure.Data.AppConfiguration.Tests/Aspire.Azure.Data.AppConfiguration.Tests.csproj b/tests/Aspire.Azure.Data.AppConfiguration.Tests/Aspire.Azure.Data.AppConfiguration.Tests.csproj new file mode 100644 index 00000000000..dc252aa4c7c --- /dev/null +++ b/tests/Aspire.Azure.Data.AppConfiguration.Tests/Aspire.Azure.Data.AppConfiguration.Tests.csproj @@ -0,0 +1,21 @@ + + + + $(DefaultTargetFramework) + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Aspire.Azure.Data.AppConfiguration.Tests/AspireAppConfigurationExtensionsTests.cs b/tests/Aspire.Azure.Data.AppConfiguration.Tests/AspireAppConfigurationExtensionsTests.cs new file mode 100644 index 00000000000..3426ac1f9ac --- /dev/null +++ b/tests/Aspire.Azure.Data.AppConfiguration.Tests/AspireAppConfigurationExtensionsTests.cs @@ -0,0 +1,209 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text; +using Azure.Core; +using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Data.AppConfiguration.Tests; + +public class AspireAppConfigurationExtensionsTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AppConfigEndpointCanBeSetInCode(bool useKeyed) + { + var endpoint = new Uri(ConformanceTests.Endpoint); + var mockTransport = new MockTransport(CreateResponse("""{}""")); + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:appConfig", "https://unused.azconfig.io/") + ]); + + if (useKeyed) + { + builder.AddKeyedAzureAppConfigurationClient( + "appConfig", + settings => { + settings.Endpoint = endpoint; + settings.Credential = new EmptyTokenCredential(); + }, + clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + + } + else + { + builder.AddAzureAppConfigurationClient( + "appConfig", + settings => { + settings.Endpoint = endpoint; + settings.Credential = new EmptyTokenCredential(); + }, + clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + } + + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetRequiredKeyedService("appConfig") : + host.Services.GetRequiredService(); + + // ConfigurationClient doesn't have a public property to get the endpoint, so we can't verify it directly. + // Make a request to trigger the transport and record the URI + client.GetConfigurationSetting("test-key"); + + Assert.NotEmpty(mockTransport.Requests); + var request = mockTransport.Requests[0]; + Assert.StartsWith(endpoint.ToString(), request.Uri.ToString()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ConnectionNameWinsOverConfigSection(bool useKeyed) + { + var mockTransport = new MockTransport(CreateResponse("""{}""")); + + var builder = Host.CreateEmptyApplicationBuilder(null); + + var key = useKeyed ? "secrets" : null; + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Azure:Data:AppConfiguration", key, "Endpoint"), "unused"), + new KeyValuePair("ConnectionStrings:appConfig", ConformanceTests.Endpoint) + ]); + + if (useKeyed) + { + builder.AddKeyedAzureAppConfigurationClient( + "appConfig", + settings => settings.Credential = new EmptyTokenCredential(), + clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + + } + else + { + builder.AddAzureAppConfigurationClient( + "appConfig", + settings => settings.Credential = new EmptyTokenCredential(), + clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + } + + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetRequiredKeyedService("appConfig") : + host.Services.GetRequiredService(); + + // ConfigurationClient doesn't have a public property to get the endpoint, so we can't verify it directly. + // Make a request to trigger the transport and record the URI + client.GetConfigurationSetting("test-key"); + + Assert.NotEmpty(mockTransport.Requests); + var request = mockTransport.Requests[0]; + var endpoint = new Uri(ConformanceTests.Endpoint); + Assert.StartsWith(endpoint.ToString(), request.Uri.ToString()); + } + + [Fact] + public void AddsAppConfigurationKeyValuesToConfig() + { + var mockTransport = new MockTransport(CreateResponse(""" + { + "items": [ + { + "key": "test-key-1", + "value": "test-value-1" + }, + { + "key": "test-key-2", + "value": "test-value-2" + } + ] + } + """)); + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:appConfig", ConformanceTests.Endpoint) + ]); + + builder.Configuration.AddAzureAppConfiguration( + "appConfig", + settings => settings.Credential = new EmptyTokenCredential(), + configureOptions: options => options.ConfigureClientOptions( + clientOptions => clientOptions.Transport = mockTransport)); + + Assert.Equal("test-value-1", builder.Configuration["test-key-1"]); + Assert.Equal("test-value-2", builder.Configuration["test-key-2"]); + } + + [Fact] + public void CanAddMultipleKeyedServices() + { + var mockTransport = new MockTransport( + CreateResponse("""{}"""), + CreateResponse("""{}"""), + CreateResponse("""{}""")); + + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:appConfig1", "https://aspiretests1.vault.azconfig.io/"), + new KeyValuePair("ConnectionStrings:appConfig2", "https://aspiretests2.vault.azconfig.io/"), + new KeyValuePair("ConnectionStrings:appConfig3", "https://aspiretests3.vault.azconfig.io/") + ]); + + builder.AddAzureAppConfigurationClient("appConfig1", settings => settings.Credential = new EmptyTokenCredential(), clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + builder.AddKeyedAzureAppConfigurationClient("appConfig2", settings => settings.Credential = new EmptyTokenCredential(), clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + builder.AddKeyedAzureAppConfigurationClient("appConfig3", settings => settings.Credential = new EmptyTokenCredential(), clientBuilder => clientBuilder.ConfigureOptions(options => options.Transport = mockTransport)); + + using var host = builder.Build(); + + // Unkeyed services don't work with keyed services. See https://github.com/dotnet/aspire/issues/3890 + //var client1 = host.Services.GetRequiredService(); + var client2 = host.Services.GetRequiredKeyedService("appConfig2"); + var client3 = host.Services.GetRequiredKeyedService("appConfig3"); + + //Assert.NotSame(client1, client2); + //Assert.NotSame(client1, client3); + Assert.NotSame(client2, client3); + + // ConfigurationClient doesn't have a public property to get the endpoint, so we can't verify it directly. + // Make a request to trigger the transport and record the URI + //client1.GetConfigurationSetting("test-key"); + //Assert.StartsWith("https://aspiretests1.vault.azconfig.io/", mockTransport.Requests.Last().Uri.ToString()); + client2.GetConfigurationSetting("test-key"); + Assert.StartsWith("https://aspiretests2.vault.azconfig.io/", mockTransport.Requests.Last().Uri.ToString()); + client3.GetConfigurationSetting("test-key"); + Assert.StartsWith("https://aspiretests3.vault.azconfig.io/", mockTransport.Requests.Last().Uri.ToString()); + } + + private static MockResponse CreateResponse(string content) + { + var buffer = Encoding.UTF8.GetBytes(content); + var response = new MockResponse(200) + { + ClientRequestId = Guid.NewGuid().ToString(), + ContentStream = new MemoryStream(buffer), + }; + + return response; + } + + internal sealed class EmptyTokenCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new AccessToken(string.Empty, DateTimeOffset.MaxValue); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(new AccessToken(string.Empty, DateTimeOffset.MaxValue)); + } + } +} diff --git a/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConfigurationTests.cs b/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..094fecb72d4 --- /dev/null +++ b/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConfigurationTests.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Azure.Data.AppConfiguration.Tests; + +public class ConfigurationTests +{ + [Fact] + public void EndpointUriIsNullByDefault() + => Assert.Null(new AzureDataAppConfigurationSettings().Endpoint); + + [Fact] + public void HealthCheckIsEnabledByDefault() + => Assert.False(new AzureDataAppConfigurationSettings().DisableHealthChecks); + + [Fact] + public void TracingIsEnabledByDefault() + => Assert.False(new AzureDataAppConfigurationSettings().DisableTracing); +} diff --git a/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..47289e904a7 --- /dev/null +++ b/tests/Aspire.Azure.Data.AppConfiguration.Tests/ConformanceTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Azure.Data.AppConfiguration; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Data.AppConfiguration.Tests; + +public class ConformanceTests : ConformanceTests +{ + public const string Endpoint = "https://aspiretests.azconfig.io/"; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.Data.AppConfiguration.ConfigurationClient"; + + protected override string[] RequiredLogCategories => new string[] { "Azure.Core" }; + + protected override bool SupportsKeyedRegistrations => true; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Azure": { + "Data": { + "AppConfiguration": { + "Endpoint": "http://YOUR_URI", + "DisableHealthChecks": true, + "DisableTracing": false, + "ClientOptions": { + "DisableChallengeResourceVerification": true, + "Retry": { + "Mode": "Exponential", + "Delay": "00:03" + } + } + } + } + } + } + } + """; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[] + { + new(CreateConfigKey("Aspire:Azure:Data:AppConfiguration", key, "Endpoint"), Endpoint), + new(CreateConfigKey("Aspire:Azure:Data:AppConfiguration", key, "ClientOptions:Retry:MaxRetries"), "0") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddAzureAppConfigurationClient("appConfig", configure); + } + else + { + builder.AddKeyedAzureAppConfigurationClient(key, configure); + } + } + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Azure": { "Data":{ "AppConfiguration": { "Endpoint": "YOUR_URI"}}}}}""", "Value does not match format \"uri\""), + ("""{"Aspire": { "Azure": { "Data":{ "AppConfiguration": { "Endpoint": "http://YOUR_URI", "DisableHealthChecks": "true"}}}}}""", "Value is \"string\" but should be \"boolean\""), + ("""{"Aspire": { "Azure": { "Data":{ "AppConfiguration": { "Endpoint": "http://YOUR_URI", "ClientOptions": {"Retry": {"Mode": "Fast"}}}}}}}""", "Value should match one of the values specified by the enum"), + ("""{"Aspire": { "Azure": { "Data":{ "AppConfiguration": { "Endpoint": "http://YOUR_URI", "ClientOptions": {"Retry": {"NetworkTimeout": "3S"}}}}}}}""", "The string value is not a match for the indicated regular expression") + }; + + protected override void SetHealthCheck(AzureDataAppConfigurationSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetMetrics(AzureDataAppConfigurationSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetTracing(AzureDataAppConfigurationSettings options, bool enabled) + => options.DisableTracing = !enabled; + + protected override void TriggerActivity(ConfigurationClient service) + => service.GetConfigurationSetting("*", null); + + [Fact] + public void TracingEnablesTheRightActivitySource() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + + [Fact] + public void TracingEnablesTheRightActivitySource_Keyed() + => RemoteExecutor.Invoke(() => ActivitySourceTest(key: "key")).Dispose(); +} diff --git a/tests/Aspire.Azure.Data.AppConfiguration.Tests/DataAppConfigurationPublicApiTests.cs b/tests/Aspire.Azure.Data.AppConfiguration.Tests/DataAppConfigurationPublicApiTests.cs new file mode 100644 index 00000000000..fb9c2c2e10d --- /dev/null +++ b/tests/Aspire.Azure.Data.AppConfiguration.Tests/DataAppConfigurationPublicApiTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Azure.Data.AppConfiguration.Tests; + +public class DataAppConfigurationPublicApiTests +{ + [Fact] + public void AddAzureKeyVaultClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string connectionName = "appConfig"; + + var action = () => builder.AddAzureAppConfigurationClient(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddAzureAppConfigurationClientShouldThrowWhenConnectionNameIsNullOrEmpty(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + var connectionName = isNull ? null! : string.Empty; + + var action = () => builder.AddAzureAppConfigurationClient(connectionName); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } + + [Fact] + public void AddKeyedAzureAppConfigurationClientShouldThrowWhenBuilderIsNull() + { + IHostApplicationBuilder builder = null!; + const string name = "appConfig"; + + var action = () => builder.AddKeyedAzureAppConfigurationClient(name); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(builder), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddKeyedAzureAppConfigurationClientShouldThrowWhenNameIsNullOrEmpty(bool isNull) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + var name = isNull ? null! : string.Empty; + + var action = () => builder.AddKeyedAzureAppConfigurationClient(name); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(name), exception.ParamName); + } + + [Fact] + public void AddAzureAppConfigurationShouldThrowWhenConfigurationManagerIsNull() + { + IConfigurationManager configurationManager = null!; + const string connectionName = "appConfig"; + + var action = () => configurationManager.AddAzureAppConfiguration(connectionName); + + var exception = Assert.Throws(action); + Assert.Equal(nameof(configurationManager), exception.ParamName); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void AddAzureAppConfigurationShouldThrowWhenConnectionNameIsNullOrEmpty(bool isNull) + { + var configurationManager = new ConfigurationManager(); + var connectionName = isNull ? null! : string.Empty; + + var action = () => configurationManager.AddAzureAppConfiguration(connectionName); + + var exception = isNull + ? Assert.Throws(action) + : Assert.Throws(action); + Assert.Equal(nameof(connectionName), exception.ParamName); + } +}