Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Azure ServiceBus package #559

Draft
wants to merge 1 commit into
base: development
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<ItemGroup>
<PackageVersion Include="Azure.Data.Tables" Version="12.9.1" />
<PackageVersion Include="Azure.Identity" Version="1.13.1" />
<PackageVersion Include="Azure.Messaging.ServiceBus" Version="7.18.2" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.23.0" />
<PackageVersion Include="Azure.Storage.Queues" Version="12.21.0" />
<PackageVersion Include="ClickHouse.Client" Version="7.9.1" />
Expand Down Expand Up @@ -50,6 +51,7 @@
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.1.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.1.0" />
<PackageVersion Include="Testcontainers.Redpanda" Version="4.1.0" />
<PackageVersion Include="Testcontainers.ServiceBus" Version="4.1.0" />
<PackageVersion Include="TngTech.ArchUnitNET.xUnit" Version="0.11.1" />
<PackageVersion Include="Verify.Xunit" Version="28.6.0" />
<PackageVersion Include="xunit" Version="2.9.2" />
Expand Down
15 changes: 15 additions & 0 deletions HealthChecks.sln
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetEvolve.HealthChecks.Test
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetEvolve.HealthChecks.Tests.Architecture", "tests\NetEvolve.HealthChecks.Tests.Architecture\NetEvolve.HealthChecks.Tests.Architecture.csproj", "{17BCA132-1FBB-46C1-B6A1-60F64969383D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetEvolve.HealthChecks.Azure.ServiceBus", "src\NetEvolve.HealthChecks.Azure.ServiceBus\NetEvolve.HealthChecks.Azure.ServiceBus.csproj", "{6133570F-FF54-480D-8979-B0787022EA6A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -315,6 +317,18 @@ Global
{17BCA132-1FBB-46C1-B6A1-60F64969383D}.Release|x64.Build.0 = Release|Any CPU
{17BCA132-1FBB-46C1-B6A1-60F64969383D}.Release|x86.ActiveCfg = Release|Any CPU
{17BCA132-1FBB-46C1-B6A1-60F64969383D}.Release|x86.Build.0 = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|x64.ActiveCfg = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|x64.Build.0 = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|x86.ActiveCfg = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Debug|x86.Build.0 = Debug|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|Any CPU.Build.0 = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|x64.ActiveCfg = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|x64.Build.0 = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|x86.ActiveCfg = Release|Any CPU
{6133570F-FF54-480D-8979-B0787022EA6A}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -340,6 +354,7 @@ Global
{66406BE8-0281-4C95-B90B-20CAE4516A16} = {E412EC77-2022-4A1D-AAC1-FDF1A4A45827}
{2B089420-E791-44E7-B471-F6F527B33E1C} = {E412EC77-2022-4A1D-AAC1-FDF1A4A45827}
{17BCA132-1FBB-46C1-B6A1-60F64969383D} = {E412EC77-2022-4A1D-AAC1-FDF1A4A45827}
{6133570F-FF54-480D-8979-B0787022EA6A} = {EF615D18-42E2-48A4-8EBA-E652DC574C56}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {28B4CC2B-39E8-49C0-9687-78121BD83A53}
Expand Down
103 changes: 103 additions & 0 deletions src/NetEvolve.HealthChecks.Azure.ServiceBus/ClientCreation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using global::Azure.Core;
using global::Azure.Identity;
using global::Azure.Messaging.ServiceBus;
using global::Azure.Messaging.ServiceBus.Administration;
using Microsoft.Extensions.DependencyInjection;

internal static class ClientCreation
{
private static ConcurrentDictionary<string, ServiceBusClient>? _serviceBusClients;
private static ConcurrentDictionary<
string,
ServiceBusAdministrationClient
>? _serviceBusAdministrationClients;

internal static ServiceBusClient GetClient<TOptions>(
string name,
TOptions options,
IServiceProvider serviceProvider
)
where TOptions : ServiceBusOptionsBase
{
if (options.Mode == ClientCreationMode.ServiceProvider)
{
return serviceProvider.GetRequiredService<ServiceBusClient>();
}

if (_serviceBusClients is null)
{
_serviceBusClients = new ConcurrentDictionary<string, ServiceBusClient>(
StringComparer.OrdinalIgnoreCase
);
}

return _serviceBusClients.GetOrAdd(name, _ => CreateClient(options, serviceProvider));
}

internal static ServiceBusAdministrationClient GetAdministrationClient<TOptions>(
string name,
TOptions options,
IServiceProvider serviceProvider
)
where TOptions : ServiceBusOptionsBase
{
if (options.Mode == ClientCreationMode.ServiceProvider)
{
return serviceProvider.GetRequiredService<ServiceBusAdministrationClient>();
}

if (_serviceBusAdministrationClients is null)
{
_serviceBusAdministrationClients = new ConcurrentDictionary<
string,
ServiceBusAdministrationClient
>(StringComparer.OrdinalIgnoreCase);
}

return _serviceBusAdministrationClients.GetOrAdd(
name,
_ => CreateAdministrationClient(options, serviceProvider)
);
}

private static ServiceBusClient CreateClient<TOptions>(
TOptions options,
IServiceProvider serviceProvider
)
where TOptions : ServiceBusOptionsBase =>
options.Mode switch
{
ClientCreationMode.ServiceProvider =>
serviceProvider.GetRequiredService<ServiceBusClient>(),
ClientCreationMode.DefaultAzureCredentials => new ServiceBusClient(
options.FullyQualifiedNamespace,
serviceProvider.GetService<TokenCredential>() ?? new DefaultAzureCredential()
),
ClientCreationMode.ConnectionString => new ServiceBusClient(options.ConnectionString),
_ => throw new UnreachableException($"Invalid client creation mode `{options.Mode}`."),
};

private static ServiceBusAdministrationClient CreateAdministrationClient<TOptions>(
TOptions options,
IServiceProvider serviceProvider
)
where TOptions : ServiceBusOptionsBase =>
options.Mode switch
{
ClientCreationMode.ServiceProvider =>
serviceProvider.GetRequiredService<ServiceBusAdministrationClient>(),
ClientCreationMode.DefaultAzureCredentials => new ServiceBusAdministrationClient(
options.FullyQualifiedNamespace,
serviceProvider.GetService<TokenCredential>() ?? new DefaultAzureCredential()
),
ClientCreationMode.ConnectionString => new ServiceBusAdministrationClient(
options.ConnectionString
),
_ => throw new UnreachableException($"Invalid client creation mode `{options.Mode}`."),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using System;
using global::Azure.Messaging.ServiceBus;

/// <summary>
/// Describes the mode to create or retrieve a <see cref="ServiceBusClient"/>.
/// </summary>
public enum ClientCreationMode
{
/// <summary>
/// The default mode. The <see cref="ServiceBusClient"/> is loading the preregistered instance from the <see cref="IServiceProvider"/>.
/// </summary>
ServiceProvider = 0,

DefaultAzureCredentials,

Check warning on line 16 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ClientCreationMode.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / CodeQL / Run CodeQL

Missing XML comment for publicly visible type or member 'ClientCreationMode.DefaultAzureCredentials'

Check failure on line 16 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ClientCreationMode.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / Tests / Testing .NET solution

Missing XML comment for publicly visible type or member 'ClientCreationMode.DefaultAzureCredentials'
ConnectionString,

Check warning on line 17 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ClientCreationMode.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / CodeQL / Run CodeQL

Missing XML comment for publicly visible type or member 'ClientCreationMode.ConnectionString'

Check failure on line 17 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ClientCreationMode.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / Tests / Testing .NET solution

Missing XML comment for publicly visible type or member 'ClientCreationMode.ConnectionString'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using NetEvolve.Arguments;
using NetEvolve.HealthChecks.Abstractions;

/// <summary>
/// Extensions methods for <see cref="IHealthChecksBuilder"/> with custom Health Checks.
/// </summary>
public static class DependencyInjectionExtensions
{
private static readonly string[] _defaultTags = ["storage", "azure", "servicebus"];

/// <summary>
/// Adds a health check for an Azure Service Bus Queue.
/// </summary>
/// <param name="builder">The <see cref="IHealthChecksBuilder"/>.</param>
/// <param name="name">The name of the <see cref="ServiceBusQueueHealthCheck"/>.</param>
/// <param name="options">An optional action to configure.</param>
/// <param name="tags">A list of additional tags that can be used to filter sets of health checks. Optional.</param>
/// <exception cref="ArgumentNullException">The <paramref name="builder"/> is <see langword="null" />.</exception>
/// <exception cref="ArgumentNullException">The <paramref name="name"/> is <see langword="null" />.</exception>
/// <exception cref="ArgumentException">The <paramref name="name"/> is <see langword="null" /> or <c>whitespace</c>.</exception>
/// <exception cref="ArgumentException">The <paramref name="name"/> is already in use.</exception>
/// <exception cref="ArgumentNullException">The <paramref name="tags"/> is <see langword="null" />.</exception>
public static IHealthChecksBuilder AddServiceBusQueueHealthCheck(
[NotNull] this IHealthChecksBuilder builder,
[NotNull] string name,
Action<ServiceBusQueueOptions>? options = null,
params string[] tags
)
{
ArgumentNullException.ThrowIfNull(builder);
Argument.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(tags);

if (!builder.IsServiceTypeRegistered<ServiceBusQueueMarker>())
{
_ = builder
.Services.AddSingleton<ServiceBusQueueMarker>()
.AddSingleton<ServiceBusQueueHealthCheck>()
.ConfigureOptions<ServiceBusQueueOptionsConfigure>();
}

if (builder.IsNameAlreadyUsed<ServiceBusQueueHealthCheck>(name))
{
throw new ArgumentException($"Name `{name}` already in use.", nameof(name), null);
}

if (options is not null)
{
_ = builder.Services.Configure(name, options);
}

return builder.AddCheck<ServiceBusQueueHealthCheck>(
name,
HealthStatus.Unhealthy,
_defaultTags.Union(tags, StringComparer.OrdinalIgnoreCase)
);
}

private sealed partial class ServiceBusQueueMarker { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>$(_ProjectTargetFrameworks)</TargetFrameworks>

<Description>Contains HealthChecks for Azure Service Bus.</Description>
<PackageTags>$(PackageTags);azure;servicebus;</PackageTags>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Messaging.ServiceBus" />
<PackageReference Include="NetEvolve.Arguments" />
<PackageReference Include="NetEvolve.Extensions.Tasks" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\NetEvolve.HealthChecks.Abstractions\NetEvolve.HealthChecks.Abstractions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

public abstract class ServiceBusOptionsBase

Check warning on line 3 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusOptionsBase.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / CodeQL / Run CodeQL

Missing XML comment for publicly visible type or member 'ServiceBusOptionsBase'

Check failure on line 3 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusOptionsBase.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / Tests / Testing .NET solution

Missing XML comment for publicly visible type or member 'ServiceBusOptionsBase'
{
/// <summary>
/// Gets or sets the client creation mode, default is <see cref="ClientCreationMode.ServiceProvider"/>.
/// </summary>
public ClientCreationMode Mode { get; set; }

/// <summary>
/// Gets or sets the azure service bus connection string.
/// </summary>
public string? ConnectionString { get; set; }

/// <summary>
/// Gets or sets the fully qualified namespace.
/// </summary>
public string FullyQualifiedNamespace { get; set; }

Check warning on line 18 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusOptionsBase.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / CodeQL / Run CodeQL

Non-nullable property 'FullyQualifiedNamespace' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

Check failure on line 18 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusOptionsBase.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / Tests / Testing .NET solution

Non-nullable property 'FullyQualifiedNamespace' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// The timeout to use when connecting and executing tasks against database.
/// Default is 100 milliseconds.
/// </summary>
public int Timeout { get; set; } = 100;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using NetEvolve.Extensions.Tasks;
using NetEvolve.HealthChecks.Abstractions;

internal sealed class ServiceBusQueueHealthCheck
: ConfigurableHealthCheckBase<ServiceBusQueueOptions>
{
private readonly IServiceProvider _serviceProvider;

public ServiceBusQueueHealthCheck(
IServiceProvider serviceProvider,
IOptionsMonitor<ServiceBusQueueOptions> optionsMonitor
)
: base(optionsMonitor) => _serviceProvider = serviceProvider;

protected override ValueTask<HealthCheckResult> ExecuteHealthCheckAsync(
string name,
HealthStatus failureStatus,
ServiceBusQueueOptions options,
CancellationToken cancellationToken
) =>
options.EnablePeekMode
? ExecutePeekHealthCheckAsync(name, options, cancellationToken)
: ExecuteHealthCheckAsync(name, options, cancellationToken);

private async ValueTask<HealthCheckResult> ExecuteHealthCheckAsync(
string name,
ServiceBusQueueOptions options,
CancellationToken cancellationToken
)
{
var client = ClientCreation.GetAdministrationClient(name, options, _serviceProvider);

var (isValid, queue) = await client
.GetQueueAsync(options.QueueName, cancellationToken: cancellationToken)
.WithTimeoutAsync(options.Timeout, cancellationToken)
.ConfigureAwait(false);

return HealthCheckState(isValid && queue is not null, name);
}

private async ValueTask<HealthCheckResult> ExecutePeekHealthCheckAsync(
string name,
ServiceBusQueueOptions options,
CancellationToken cancellationToken
)
{
var client = ClientCreation.GetClient(name, options, _serviceProvider);

var receiver = client.CreateReceiver(options.QueueName);

var (isValid, _) = await receiver
.ReceiveMessageAsync(cancellationToken: cancellationToken)
.WithTimeoutAsync(options.Timeout, cancellationToken)
.ConfigureAwait(false);

return HealthCheckState(isValid, name);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using Microsoft.Extensions.Options;

public class ServiceBusQueueOptions : ServiceBusOptionsBase

Check warning on line 5 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusQueueOptions.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / CodeQL / Run CodeQL

Missing XML comment for publicly visible type or member 'ServiceBusQueueOptions'

Check failure on line 5 in src/NetEvolve.HealthChecks.Azure.ServiceBus/ServiceBusQueueOptions.cs

View workflow job for this annotation

GitHub Actions / Build & Tests / Tests / Testing .NET solution

Missing XML comment for publicly visible type or member 'ServiceBusQueueOptions'
{
/// <summary>
/// Gets or sets a value indicating whether to enable peek mode, default is <c>false</c>.
/// </summary>
/// <remarks>
/// To enable the peek mode, the executing user requires Listen claim to work.
/// </remarks>
/// <seealso href="https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#azure-service-bus-data-sender"/>
public bool EnablePeekMode { get; set; }

/// <summary>
/// Gets or sets the queue name, which is checked if it exists.
/// </summary>
public string? QueueName { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace NetEvolve.HealthChecks.Azure.ServiceBus;

using Microsoft.Extensions.Options;

internal class ServiceBusQueueOptionsConfigure
: IConfigureNamedOptions<ServiceBusQueueOptions>,
IValidateOptions<ServiceBusQueueOptions>
{
public void Configure(string? name, ServiceBusQueueOptions options) { }

public void Configure(ServiceBusQueueOptions options) { }

public ValidateOptionsResult Validate(string? name, ServiceBusQueueOptions options) =>
ValidateOptionsResult.Success;
}
Loading
Loading