File: src\Components\Common\AzureComponent.cs
Web Access
Project: src\src\Components\Aspire.Azure.Storage.Queues\Aspire.Azure.Storage.Queues.csproj (Aspire.Azure.Storage.Queues)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Diagnostics;
using Azure.Core;
using Azure.Core.Extensions;
using Microsoft.Extensions.Azure;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
 
namespace Aspire.Azure.Common;
 
internal abstract class AzureComponent<TSettings, TClient, TClientOptions>
    where TSettings : class, new()
    where TClient : class
    where TClientOptions : class
{
    protected virtual string[] ActivitySourceNames => new[] { $"{typeof(TClient).Namespace}.*" };
 
    protected virtual string[] MetricSourceNames => new[] { $"{typeof(TClient).Namespace}.*" };
 
    // There would be no need for Get* methods if TSettings had a common base type or if it was implementing a shared interface.
    // TSettings is a public type and we don't have a shared package yet, but we may reconsider the approach in near future.
    protected abstract bool GetHealthCheckEnabled(TSettings settings);
 
    protected abstract bool GetMetricsEnabled(TSettings settings);
 
    protected abstract bool GetTracingEnabled(TSettings settings);
 
    protected abstract TokenCredential? GetTokenCredential(TSettings settings);
 
    protected abstract void BindSettingsToConfiguration(TSettings settings, IConfiguration configuration);
 
    protected abstract void BindClientOptionsToConfiguration(IAzureClientBuilder<TClient, TClientOptions> clientBuilder, IConfiguration configuration);
 
    protected abstract IAzureClientBuilder<TClient, TClientOptions> AddClient(
        AzureClientFactoryBuilder azureFactoryBuilder, TSettings settings, string connectionName,
        string configurationSectionName);
 
    protected abstract IHealthCheck CreateHealthCheck(TClient client, TSettings settings);
 
    internal void AddClient(
        IHostApplicationBuilder builder,
        string configurationSectionName,
        Action<TSettings>? configureSettings,
        Action<IAzureClientBuilder<TClient, TClientOptions>>? configureClientBuilder,
        string connectionName,
        string? serviceKey)
    {
        ArgumentNullException.ThrowIfNull(builder);
 
        var configSection = builder.Configuration.GetSection(configurationSectionName);
 
        var settings = new TSettings();
        // Bind both top-level and named configuration sections to the settings object
        // to allow connection-specific settings.
        BindSettingsToConfiguration(settings, configSection);
        BindSettingsToConfiguration(settings, configSection.GetSection(connectionName));
        // Support service key-based binding for clients that support it (e.g. WebPubSubServiceClient).
        var serviceKeySection = configSection.GetSection($"{connectionName}:{serviceKey}");
        if (serviceKeySection.Exists())
        {
            BindSettingsToConfiguration(settings, serviceKeySection);
        }
 
        Debug.Assert(settings is IConnectionStringSettings, $"The settings object should implement {nameof(IConnectionStringSettings)}.");
        if (settings is IConnectionStringSettings csSettings &&
            builder.Configuration.GetConnectionString(connectionName) is string connectionString)
        {
            csSettings.ParseConnectionString(connectionString);
        }
 
        configureSettings?.Invoke(settings);
 
        if (!string.IsNullOrEmpty(serviceKey))
        {
            // When named client registration is used (.WithName), Microsoft.Extensions.Azure
            // TRIES to register a factory for given client type and later
            // a call to serviceProvider.GetService<TClient> throws InvalidOperationException:
            // "Unable to find client registration with type 'SecretClient' and name 'Default'."
            // It's not desired, as Microsoft.Extensions.DependencyInjection keyed services
            // factory methods just return null in such cases.
            // To align the behavior across the Components, a null factory is registered up-front.
            builder.Services.AddSingleton<TClient>(static _ => null!);
        }
 
        builder.Services.AddAzureClients(azureFactoryBuilder =>
        {
            var clientBuilder = AddClient(azureFactoryBuilder, settings, connectionName, configurationSectionName);
 
            if (GetTokenCredential(settings) is { } credential)
            {
                clientBuilder.WithCredential(credential);
            }
 
            BindClientOptionsToConfiguration(clientBuilder, configSection.GetSection("ClientOptions"));
            BindClientOptionsToConfiguration(clientBuilder, configSection.GetSection($"{connectionName}:ClientOptions"));
 
            configureClientBuilder?.Invoke(clientBuilder);
 
            if (!string.IsNullOrEmpty(serviceKey))
            {
                // Set the name for the client registration.
                clientBuilder.WithName(serviceKey);
 
                // To resolve named clients IAzureClientFactory{TClient}.CreateClient needs to be used.
                builder.Services.AddKeyedSingleton(serviceKey,
                    static (serviceProvider, serviceKey) => serviceProvider.GetRequiredService<IAzureClientFactory<TClient>>().CreateClient((string)serviceKey!));
            }
        });
 
        if (GetHealthCheckEnabled(settings))
        {
            var namePrefix = $"Azure_{typeof(TClient).Name}";
 
            builder.TryAddHealthCheck(new HealthCheckRegistration(
                serviceKey is null ? namePrefix : $"{namePrefix}_{serviceKey}",
                serviceProvider =>
                {
                    // From https://devblogs.microsoft.com/azure-sdk/lifetime-management-and-thread-safety-guarantees-of-azure-sdk-net-clients/:
                    // "The main rule of Azure SDK client lifetime management is: treat clients as singletons".
                    // So it's fine to root the client via the health check.
                    var client = serviceKey is null
                        ? serviceProvider.GetRequiredService<TClient>()
                        : serviceProvider.GetRequiredKeyedService<TClient>(serviceKey);
 
                    return CreateHealthCheck(client, settings);
                },
                failureStatus: default,
                tags: default,
                timeout: default));
        }
 
        if (GetMetricsEnabled(settings))
        {
            builder.Services.AddOpenTelemetry()
                .WithMetrics(meterBuilder => meterBuilder.AddMeter(MetricSourceNames));
        }
 
        if (GetTracingEnabled(settings))
        {
            builder.Services.AddOpenTelemetry()
                .WithTracing(traceBuilder => traceBuilder.AddSource(ActivitySourceNames));
        }
    }
}
 
internal interface IConnectionStringSettings
{
    void ParseConnectionString(string? connectionString);
}