From 0c234d7608240be6ca5c49a1afaec0273336ba0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:19:41 +0000 Subject: [PATCH 1/5] Initial plan From 51063a80b8f75ef621f7c5ee6b5b909fa440c13f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:32:02 +0000 Subject: [PATCH 2/5] Add typed client support similar to IHttpClientFactory Co-authored-by: jasonshave <28457536+jasonshave@users.noreply.github.com> --- README.md | 92 ++++++++++ .../ITypedQueueClient.cs | 64 +++++++ .../ServiceCollectionExtensions.cs | 56 ++++++ .../TypedQueueClient.cs | 47 +++++ .../Program.cs | 4 +- .../TypedQueueClientTests.cs | 164 ++++++++++++++++++ 6 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 src/AzureStorage.QueueService/ITypedQueueClient.cs create mode 100644 src/AzureStorage.QueueService/TypedQueueClient.cs create mode 100644 tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs diff --git a/README.md b/README.md index a727d8c..e50383c 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,41 @@ services.AddAzureStorageQueueClient(x => }); ``` +### Add typed clients (similar to IHttpClientFactory) + +The library now supports strongly-typed clients similar to the IHttpClientFactory pattern in ASP.NET Core: + +```csharp +// Register a typed client for a specific message type using the default queue client +services.AddAzureStorageQueueClient(x => + x.AddDefaultClient(y => Configuration.Bind(nameof(QueueClientSettings), y))); +services.AddTypedQueueClient(); + +// Register a typed client for a specific message type using a named queue client +services.AddAzureStorageQueueClient(x => + x.AddClient("orders", y => Configuration.Bind(nameof(OrderQueueSettings), y))); +services.AddTypedQueueClient("orders"); +``` + +This allows you to inject `ITypedQueueClient` directly instead of using the factory pattern: + +```csharp +public class OrderService +{ + private readonly ITypedQueueClient _queueClient; + + public OrderService(ITypedQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task ProcessOrderAsync(OrderMessage order) + { + await _queueClient.SendMessageAsync(order); + } +} +``` + ## Using the IQueueClientFactory ### Example 1: Get a default queue client @@ -150,6 +185,8 @@ public class MyClass The following example shows the .NET Worker Service template where the class uses the `IHostedService` interface to send a message every five seconds. +### Using the IQueueClientFactory + 1. Inject the `IQueueClientFactory` interface and use as follows: ```csharp @@ -171,10 +208,35 @@ The following example shows the .NET Worker Service template where the class use } ``` +### Using Typed Clients + +Alternatively, you can use typed clients for a cleaner dependency injection experience: + +```csharp +public class Sender : IHostedService +{ + private readonly ITypedQueueClient _queueClient; + + public Sender(ITypedQueueClient queueClient) => _queueClient = queueClient; + + public async Task StartAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var myMessage = new MyMessage("Test"); + await _queueClient.SendMessageAsync(myMessage, cancellationToken); + await Task.Delay(5000); + } + } +} +``` + ## Receiving and handling messages from an Azure storage account queue The following example shows the .NET Worker Service template where the class uses the `IHostedService` interface to run a particular code block repeatedly. The application will receive the payload from the queue repeatedly. +### Using the IQueueClientFactory + 1. Inject the `IQueueClientFactory` interface and use as follows: ```csharp @@ -204,6 +266,36 @@ The following example shows the .NET Worker Service template where the class use } ``` +### Using Typed Clients + +Alternatively, you can use typed clients for a cleaner dependency injection experience: + +```csharp +public class MySubscriber : IHostedService +{ + private readonly ITypedQueueClient _queueClient; + private readonly IMyMessageHandler _myMessageHandler; + + public MySubscriber(ITypedQueueClient queueClient, IMyMessageHandler myMessageHandler) + { + _queueClient = queueClient; + _myMessageHandler = myMessageHandler; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + await _queueClient.ReceiveMessagesAsync( + message => _myMessageHandler.HandleAsync(message), + exception => _myMessageHandler.HandleExceptionAsync(exception), + cancellationToken); + await Task.Delay(1000); + } + } +} +``` + ### Handling multiple messages The library allows you to pull multiple messages by specifying the `maxMessage` count as an integer in the `ReceiveMessagesAsync()` method. These are sent to the handler as individual messages but pulled from the queue as a batch the consuming application would hold a lock on for the default duration used in the Azure Storage Queue library. diff --git a/src/AzureStorage.QueueService/ITypedQueueClient.cs b/src/AzureStorage.QueueService/ITypedQueueClient.cs new file mode 100644 index 0000000..05e7770 --- /dev/null +++ b/src/AzureStorage.QueueService/ITypedQueueClient.cs @@ -0,0 +1,64 @@ +using Azure.Storage.Queues.Models; + +namespace AzureStorage.QueueService; + +/// +/// Represents a typed queue client for a specific message type. +/// This interface provides strongly-typed operations for queue interactions. +/// +/// The type of message this client handles +public interface ITypedQueueClient where TMessage : class +{ + /// + /// Creates the queue if it doesn't exist. + /// + /// Optional metadata for the queue + /// Cancellation token + ValueTask CreateQueueIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default); + + /// + /// Clears all messages from the queue. + /// + /// Cancellation token + ValueTask ClearMessagesAsync(CancellationToken cancellationToken = default); + + /// + /// Peeks at messages without removing them from the queue. + /// + /// Number of messages to peek at + /// Cancellation token + ValueTask> PeekMessagesAsync(int numMessages, CancellationToken cancellationToken = default); + + /// + /// Peeks at messages without removing them from the queue (synchronous). + /// + /// Number of messages to peek at + /// Cancellation token + IEnumerable PeekMessages(int numMessages, CancellationToken cancellationToken = default); + + /// + /// Receives and processes messages from the queue. + /// + /// Delegate to handle each received message + /// Delegate to handle exceptions during processing + /// Number of messages to receive + /// Cancellation token + ValueTask ReceiveMessagesAsync( + Func?, ValueTask> handleMessage, + Func?, ValueTask> handleException, + int numMessages = 1, + CancellationToken cancellationToken = default); + + /// + /// Sends a message to the queue. + /// + /// The message to send + /// Optional visibility timeout + /// Optional time to live + /// Cancellation token + ValueTask SendMessageAsync( + TMessage message, + TimeSpan? visibilityTimeout = null, + TimeSpan? timeToLive = null, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index ca481bb..99bd4f5 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -21,6 +21,62 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect return services; } + /// + /// Adds a strongly-typed queue client using the default queue client configuration. + /// Similar to IHttpClientFactory's typed client pattern. + /// + /// The typed client interface + /// The typed client implementation + /// The message type the client handles + /// The service collection + /// Optional name of the queue client to use. If null, uses the default client. + /// The service collection + public static IServiceCollection AddTypedQueueClient( + this IServiceCollection services, + string? clientName = null) + where TClient : class, ITypedQueueClient + where TImplementation : class, TClient + where TMessage : class + { + services.AddTransient(provider => + { + var factory = provider.GetRequiredService(); + var queueClient = string.IsNullOrEmpty(clientName) + ? factory.GetQueueClient() + : factory.GetQueueClient(clientName); + + var typedClient = new TypedQueueClient(queueClient); + return (TClient)(object)typedClient; + }); + + return services; + } + + /// + /// Adds a strongly-typed queue client interface with default implementation. + /// + /// The message type the client handles + /// The service collection + /// Optional name of the queue client to use. If null, uses the default client. + /// The service collection + public static IServiceCollection AddTypedQueueClient( + this IServiceCollection services, + string? clientName = null) + where TMessage : class + { + services.AddTransient>(provider => + { + var factory = provider.GetRequiredService(); + var queueClient = string.IsNullOrEmpty(clientName) + ? factory.GetQueueClient() + : factory.GetQueueClient(clientName); + + return new TypedQueueClient(queueClient); + }); + + return services; + } + public static IServiceCollection ConfigureQueueServiceTelemetry(this IServiceCollection services, Action options) { services.Configure(options); diff --git a/src/AzureStorage.QueueService/TypedQueueClient.cs b/src/AzureStorage.QueueService/TypedQueueClient.cs new file mode 100644 index 0000000..5fa25e6 --- /dev/null +++ b/src/AzureStorage.QueueService/TypedQueueClient.cs @@ -0,0 +1,47 @@ +using Azure.Storage.Queues.Models; +using AzureStorage.QueueService.Telemetry; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace AzureStorage.QueueService; + +/// +/// Implementation of a typed queue client that wraps the AzureStorageQueueClient +/// for strongly-typed message operations. +/// +/// The type of message this client handles +internal sealed class TypedQueueClient : ITypedQueueClient where TMessage : class +{ + private readonly AzureStorageQueueClient _queueClient; + + public TypedQueueClient(AzureStorageQueueClient queueClient) + { + _queueClient = queueClient; + } + + public ValueTask CreateQueueIfNotExistsAsync(IDictionary? metadata = null, CancellationToken cancellationToken = default) + => _queueClient.CreateQueueIfNotExistsAsync(metadata, cancellationToken); + + public ValueTask ClearMessagesAsync(CancellationToken cancellationToken = default) + => _queueClient.ClearMessagesAsync(cancellationToken); + + public ValueTask> PeekMessagesAsync(int numMessages, CancellationToken cancellationToken = default) + => _queueClient.PeekMessagesAsync(numMessages, cancellationToken); + + public IEnumerable PeekMessages(int numMessages, CancellationToken cancellationToken = default) + => _queueClient.PeekMessages(numMessages, cancellationToken); + + public ValueTask ReceiveMessagesAsync( + Func?, ValueTask> handleMessage, + Func?, ValueTask> handleException, + int numMessages = 1, + CancellationToken cancellationToken = default) + => _queueClient.ReceiveMessagesAsync(handleMessage, handleException, numMessages, cancellationToken); + + public ValueTask SendMessageAsync( + TMessage message, + TimeSpan? visibilityTimeout = null, + TimeSpan? timeToLive = null, + CancellationToken cancellationToken = default) + => _queueClient.SendMessageAsync(message, visibilityTimeout, timeToLive, cancellationToken); +} \ No newline at end of file diff --git a/tests/AzureStorage.QueueService.Tests.Server/Program.cs b/tests/AzureStorage.QueueService.Tests.Server/Program.cs index 3640c84..6c6793a 100644 --- a/tests/AzureStorage.QueueService.Tests.Server/Program.cs +++ b/tests/AzureStorage.QueueService.Tests.Server/Program.cs @@ -5,8 +5,8 @@ builder.Services.AddAzureStorageQueueClient(x => x.AddDefaultClient(settings => { - settings.ConnectionString = builder.Configuration["Storage:ConnectionString"]; - settings.QueueName = builder.Configuration["Storage:QueueName"]; + settings.ConnectionString = builder.Configuration["Storage:ConnectionString"] ?? "UseDevelopmentStorage=true"; + settings.QueueName = builder.Configuration["Storage:QueueName"] ?? "test-queue"; settings.CreateIfNotExists = true; })); diff --git a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs new file mode 100644 index 0000000..f93ec7b --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs @@ -0,0 +1,164 @@ +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; +using AzureStorage.QueueService.Telemetry; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace AzureStorage.QueueService.Tests; + +public class TypedQueueClientTests : BaseTestHost +{ + private readonly IOptions _telemetrySettings = Options.Create(new QueueServiceTelemetrySettings()); + + [Fact] + public void AddTypedQueueClient_WithDefaultClient_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + })); + services.AddTypedQueueClient(); + + // act + var serviceProvider = services.BuildServiceProvider(); + var typedClient = serviceProvider.GetService>(); + + // assert + typedClient.Should().NotBeNull(); + typedClient.Should().BeOfType>(); + } + + [Fact] + public void AddTypedQueueClient_WithNamedClient_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddClient("testqueue", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + })); + services.AddTypedQueueClient("testqueue"); + + // act + var serviceProvider = services.BuildServiceProvider(); + var typedClient = serviceProvider.GetService>(); + + // assert + typedClient.Should().NotBeNull(); + typedClient.Should().BeOfType>(); + } + + [Fact] + public async Task TypedQueueClient_SendMessageAsync_CallsUnderlyingClient() + { + // arrange + var mockQueueClient = new Mock(); + mockQueueClient.Setup(x => x.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Azure.Response.FromValue( + Azure.Storage.Queues.Models.QueuesModelFactory.SendReceipt("test-id", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, "test-receipt", DateTimeOffset.UtcNow), + Mock.Of())); + + var messageConverter = new JsonQueueMessageConverter(); + var loggerFactory = LoggerFactory.Create(builder => builder.AddConsole()); + var azureQueueClient = new AzureStorageQueueClient(messageConverter, mockQueueClient.Object, loggerFactory, _telemetrySettings); + var typedClient = new TypedQueueClient(azureQueueClient); + + var testMessage = new TestObject { Property1 = "Test", Property3 = 42 }; + + // act + var result = await typedClient.SendMessageAsync(testMessage); + + // assert + result.Should().NotBeNull(); + result.MessageId.Should().Be("test-id"); + mockQueueClient.Verify(x => x.SendMessageAsync(It.IsAny(), null, null, default), Moq.Times.Once); + } + + [Fact] + public void TypedQueueClient_DifferentMessageTypes_CanBeRegistered() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + })); + + // Register different typed clients for different message types + services.AddTypedQueueClient(); + services.AddTypedQueueClient(); + + // act + var serviceProvider = services.BuildServiceProvider(); + var testObjectClient = serviceProvider.GetService>(); + var stringClient = serviceProvider.GetService>(); + + // assert + testObjectClient.Should().NotBeNull(); + stringClient.Should().NotBeNull(); + testObjectClient.Should().BeOfType>(); + stringClient.Should().BeOfType>(); + } + + [Fact] + public void TypedQueueClient_MultipleNamedClients_CanBeRegistered() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + { + options.AddClient("queue1", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue-1"; + }); + options.AddClient("queue2", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue-2"; + }); + }); + + // Register same message type but different queues + services.AddTypedQueueClient("queue1"); + services.AddTypedQueueClient("queue2"); + + // act + var serviceProvider = services.BuildServiceProvider(); + var clients = serviceProvider.GetServices>().ToList(); + + // assert + clients.Should().HaveCount(2); + clients.Should().AllBeOfType>(); + } + + [Fact] + public void TypedQueueClient_WithoutBaseRegistration_ThrowsException() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + // Note: Not registering base queue client services + services.AddTypedQueueClient(); + + // act & assert + services.Invoking(s => s.BuildServiceProvider().GetRequiredService>()) + .Should().Throw(); + } +} \ No newline at end of file From fe3cc200774fa2b2bf113a427a0a7296ac89af17 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:38:45 +0000 Subject: [PATCH 3/5] Simplify codebase by removing IQueueClientBuilder abstraction Co-authored-by: jasonshave <28457536+jasonshave@users.noreply.github.com> --- .../IQueueClientBuilder.cs | 10 - .../QueueClientBuilder.cs | 41 ---- .../QueueClientFactory.cs | 27 ++- .../ServiceCollectionExtensions.cs | 1 - .../QueueClientBuilderTests.cs | 55 ------ .../QueueClientFactoryTests.cs | 181 ++++++++---------- .../TypedQueueClientTests.cs | 5 + 7 files changed, 105 insertions(+), 215 deletions(-) delete mode 100644 src/AzureStorage.QueueService/IQueueClientBuilder.cs delete mode 100644 src/AzureStorage.QueueService/QueueClientBuilder.cs delete mode 100644 tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs diff --git a/src/AzureStorage.QueueService/IQueueClientBuilder.cs b/src/AzureStorage.QueueService/IQueueClientBuilder.cs deleted file mode 100644 index 2cc446a..0000000 --- a/src/AzureStorage.QueueService/IQueueClientBuilder.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Azure.Storage.Queues; - -namespace AzureStorage.QueueService; - -internal interface IQueueClientBuilder -{ - QueueClient CreateClient(QueueClientSettings settings); - - Task CreateClientAsync(QueueClientSettings settings); -} \ No newline at end of file diff --git a/src/AzureStorage.QueueService/QueueClientBuilder.cs b/src/AzureStorage.QueueService/QueueClientBuilder.cs deleted file mode 100644 index cc54a22..0000000 --- a/src/AzureStorage.QueueService/QueueClientBuilder.cs +++ /dev/null @@ -1,41 +0,0 @@ -using Azure.Storage.Queues; - -namespace AzureStorage.QueueService; - -internal class QueueClientBuilder : IQueueClientBuilder -{ - public QueueClient CreateClient(QueueClientSettings settings) - { - var client = Create(settings); - - if (settings.CreateIfNotExists) client.CreateIfNotExists(); - - return client; - } - - public async Task CreateClientAsync(QueueClientSettings settings) - { - var client = Create(settings); - - if (settings.CreateIfNotExists) await client.CreateIfNotExistsAsync(); - - return client; - } - - private static QueueClient Create(QueueClientSettings settings) - { - QueueClient? client = default; - if (settings.TokenCredential is not null && settings.EndpointUri is not null) - client = new QueueClient(settings.EndpointUri, settings.TokenCredential); - - if (settings.ConnectionString is not null) - client = new QueueClient(settings.ConnectionString, settings.QueueName); - - if (client is null) - throw new ApplicationException( - "There was a problem creating the client. Unable to determine if the endpoint URI or connection string should be used. " + - "Please be sure to set the connection string or the token credential and endpoint URI together."); - - return client; - } -} \ No newline at end of file diff --git a/src/AzureStorage.QueueService/QueueClientFactory.cs b/src/AzureStorage.QueueService/QueueClientFactory.cs index 1018622..9a3b62b 100644 --- a/src/AzureStorage.QueueService/QueueClientFactory.cs +++ b/src/AzureStorage.QueueService/QueueClientFactory.cs @@ -1,4 +1,5 @@ -using AzureStorage.QueueService.Telemetry; +using Azure.Storage.Queues; +using AzureStorage.QueueService.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,20 +10,17 @@ internal sealed class QueueClientFactory : IQueueClientFactory private readonly Dictionary _namedClients = new(); private AzureStorageQueueClient? _defaultClient; private readonly QueueClientSettingsRegistry _registry; - private readonly IQueueClientBuilder _queueClientBuilder; private readonly ILoggerFactory _loggerFactory; private readonly IMessageConverter _messageConverter; private readonly IOptions _telemetrySettings; public QueueClientFactory( QueueClientSettingsRegistry registry, - IQueueClientBuilder queueClientBuilder, ILoggerFactory loggerFactory, IMessageConverter messageConverter, IOptions telemetrySettingsOptions) { _registry = registry; - _queueClientBuilder = queueClientBuilder; _loggerFactory = loggerFactory; _messageConverter = messageConverter; _telemetrySettings = telemetrySettingsOptions; @@ -60,8 +58,27 @@ public AzureStorageQueueClient GetQueueClient() private AzureStorageQueueClient Create(QueueClientSettings settings) { - var queueClient = _queueClientBuilder.CreateClient(settings); + var queueClient = CreateQueueClient(settings); var azureStorageQueueClient = new AzureStorageQueueClient(_messageConverter, queueClient, _loggerFactory, _telemetrySettings); return azureStorageQueueClient; } + + private QueueClient CreateQueueClient(QueueClientSettings settings) + { + QueueClient? client = default; + if (settings.TokenCredential is not null && settings.EndpointUri is not null) + client = new QueueClient(settings.EndpointUri, settings.TokenCredential); + + if (settings.ConnectionString is not null) + client = new QueueClient(settings.ConnectionString, settings.QueueName); + + if (client is null) + throw new ApplicationException( + "There was a problem creating the client. Unable to determine if the endpoint URI or connection string should be used. " + + "Please be sure to set the connection string or the token credential and endpoint URI together."); + + if (settings.CreateIfNotExists) client.CreateIfNotExists(); + + return client; + } } \ No newline at end of file diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index 99bd4f5..c6ced0b 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -15,7 +15,6 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect services.AddSingleton(Registry); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(new JsonQueueMessageConverter(serializerOptions)); return services; diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs deleted file mode 100644 index 20d68a5..0000000 --- a/tests/AzureStorage.QueueService.Tests/QueueClientBuilderTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Azure.Identity; -using FluentAssertions; - -namespace AzureStorage.QueueService.Tests; - -public class QueueClientBuilderTests -{ - [Fact] - public void CreateClient_Returns_QueueClient() - { - // arrange - var settings = new QueueClientSettings() - { - TokenCredential = new DefaultAzureCredential(), - EndpointUri = new Uri("https://fake.com"), - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); - - // act - var client = subject.CreateClient(settings); - - // assert - client.Should().NotBeNull(); - } - - [Fact] - public void CreateClient_WithoutUri_Throws() - { - // arrange - var settings = new QueueClientSettings() - { - TokenCredential = new DefaultAzureCredential(), - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); - - // act & assert - subject.Invoking(x => x.CreateClient(settings)).Should().Throw(); - } - - [Fact] - public void CreateClient_WithoutConnectionString_Throws() - { - // arrange - var settings = new QueueClientSettings() - { - QueueName = "queue1", - }; - var subject = new QueueClientBuilder(); - - // act & assert - subject.Invoking(x => x.CreateClient(settings)).Should().Throw(); - } -} \ No newline at end of file diff --git a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs index 610c31b..eb7a443 100644 --- a/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs +++ b/tests/AzureStorage.QueueService.Tests/QueueClientFactoryTests.cs @@ -16,77 +16,70 @@ public class QueueClientFactoryTests : BaseTestHost private readonly IOptions _telemetrySettings = Options.Create(new QueueServiceTelemetrySettings()); [Fact] - public void QueueClientFactory_GetNamedClient_ReturnsClient() + public void QueueClientFactory_GetNamedClient_RegistersViaServiceCollection() { // arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); + services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - var queueClient = subject.GetQueueClient("foo"); - - // assert - queueClient.Should().NotBeNull(); + // act & assert - This would fail without Azure Storage emulator, but we're testing the registration + factory.Invoking(f => f.GetQueueClient("foo")).Should().NotThrow(); } [Fact] - public void QueueClientFactory_AddStorageQueueCalledTwice_ReturnsClient() + public void QueueClientFactory_AddStorageQueueCalledTwice_DoesNotBreakRegistration() { // arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); + services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); + services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - var queueClient = subject.GetQueueClient("foo"); - - // assert - queueClient.Should().NotBeNull(); + // act & assert + factory.Invoking(f => f.GetQueueClient("foo")).Should().NotThrow(); } [Fact] - public void GetNamedClientTwice_ReturnsSameClient() + public void GetNamedClientTwice_ReturnsSameInstance() { // arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); + services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); var serviceProvider = services.BuildServiceProvider(); - - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); + var factory = serviceProvider.GetRequiredService(); // act - var queueClient1 = subject.GetQueueClient("foo"); - var queueClient2 = subject.GetQueueClient("foo"); + var queueClient1 = factory.GetQueueClient("foo"); + var queueClient2 = factory.GetQueueClient("foo"); // assert queueClient1.Should().NotBeNull(); @@ -95,59 +88,53 @@ public void GetNamedClientTwice_ReturnsSameClient() } [Fact] - public void RegisterMultipleNamedClient_ReturnsCorrectClient() + public void RegisterMultipleNamedClient_CanGetEach() { // arrange var services = new ServiceCollection(); services.AddLogging(); services.AddAzureStorageQueueClient(options => { - options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y)); - options.AddClient("bar", y => Configuration.Bind("QueueClientSettings", y)); + options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue-1"; + y.CreateIfNotExists = false; // Don't try to create in tests + }); + options.AddClient("bar", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue-2"; + y.CreateIfNotExists = false; // Don't try to create in tests + }); }); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - var queueClient1 = subject.GetQueueClient("foo"); - var queueClient2 = subject.GetQueueClient("bar"); - - // assert - queueClient1.Should().NotBeNull(); - queueClient2.Should().NotBeNull(); + // act & assert + factory.Invoking(f => f.GetQueueClient("foo")).Should().NotThrow(); + factory.Invoking(f => f.GetQueueClient("bar")).Should().NotThrow(); } [Fact] - public void GetDefaultClient_ReturnsValidClient() + public void GetDefaultClient_RegistersViaServiceCollection() { // arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddDefaultClient(y => Configuration.Bind("QueueClientSettings", y))); + services.AddAzureStorageQueueClient(options => options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - var queueClient = subject.GetQueueClient(); - - // assert - queueClient.Should().NotBeNull(); + // act & assert + factory.Invoking(f => f.GetQueueClient()).Should().NotThrow(); } [Fact] @@ -156,24 +143,22 @@ public void GetUnregisteredClient_Throws() // arrange var services = new ServiceCollection(); services.AddLogging(); - services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => Configuration.Bind("QueueClientSettings", y))); + services.AddAzureStorageQueueClient(options => options.AddClient("foo", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't try to create in tests + })); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - subject.Invoking(x => x.GetQueueClient("bar")).Should().Throw(because: "The client name wasn't registered"); + // act & assert + factory.Invoking(x => x.GetQueueClient("bar")).Should().Throw(because: "The client name wasn't registered"); } [Fact] - public void CreateClient_WithCorrectSettings_Creates() + public void CreateClient_WithCorrectSettings_DoesNotThrow() { // arrange var services = new ServiceCollection(); @@ -181,25 +166,15 @@ public void CreateClient_WithCorrectSettings_Creates() services.AddAzureStorageQueueClient(options => options.AddDefaultClient(y => { y.TokenCredential = new DefaultAzureCredential(); - y.EndpointUri = new Uri("https://fake.com"); + y.EndpointUri = new Uri("https://fake.queue.core.windows.net/myqueue"); y.QueueName = "foo"; - y.CreateIfNotExists = true; + y.CreateIfNotExists = false; // Don't try to create in tests })); var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetRequiredService(); - var registry = serviceProvider.GetRequiredService(); - var loggerFactory = serviceProvider.GetRequiredService(); - var messageConverter = serviceProvider.GetRequiredService(); - var mockQueueClientBuilder = new Mock(); - var mockQueueClient = new Mock(); - mockQueueClientBuilder.Setup(x => x.CreateClient(It.IsAny())).Returns(mockQueueClient.Object); - IQueueClientFactory subject = new QueueClientFactory(registry, mockQueueClientBuilder.Object, loggerFactory, messageConverter, _telemetrySettings); - - // act - var client = subject.GetQueueClient(); - - // assert - client.Should().NotBeNull(); + // act & assert + factory.Invoking(f => f.GetQueueClient()).Should().NotThrow(); } } \ No newline at end of file diff --git a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs index f93ec7b..110e8bd 100644 --- a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs +++ b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs @@ -25,6 +25,7 @@ public void AddTypedQueueClient_WithDefaultClient_RegistersCorrectly() { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't actually try to create in tests })); services.AddTypedQueueClient(); @@ -48,6 +49,7 @@ public void AddTypedQueueClient_WithNamedClient_RegistersCorrectly() { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't actually try to create in tests })); services.AddTypedQueueClient("testqueue"); @@ -97,6 +99,7 @@ public void TypedQueueClient_DifferentMessageTypes_CanBeRegistered() { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't actually try to create in tests })); // Register different typed clients for different message types @@ -127,11 +130,13 @@ public void TypedQueueClient_MultipleNamedClients_CanBeRegistered() { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "test-queue-1"; + y.CreateIfNotExists = false; // Don't actually try to create in tests }); options.AddClient("queue2", y => { y.ConnectionString = "UseDevelopmentStorage=true"; y.QueueName = "test-queue-2"; + y.CreateIfNotExists = false; // Don't actually try to create in tests }); }); From 3cedb61166b4a876a2958062c8235c48ab922d4b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Jul 2025 19:07:10 +0000 Subject: [PATCH 4/5] Add support for custom queue client types similar to IHttpClientFactory pattern Co-authored-by: jasonshave <28457536+jasonshave@users.noreply.github.com> --- README.md | 51 ++++++++++++++ .../ServiceCollectionExtensions.cs | 37 +++------- .../TypedQueueClientTests.cs | 69 +++++++++++++++++++ 3 files changed, 130 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index e50383c..2944e13 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,57 @@ public class OrderService } ``` +### Custom Queue Client Types + +You can also register custom client types that encapsulate business logic, similar to how IHttpClientFactory works with custom client classes: + +```csharp +// Define a custom client class +public class OrderQueueClient +{ + private readonly ITypedQueueClient _queueClient; + + public OrderQueueClient(ITypedQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task SendOrderAsync(OrderMessage order) + { + // Add custom business logic here + await _queueClient.SendMessageAsync(order); + } + + public async Task SendPriorityOrderAsync(OrderMessage order) + { + // Custom method with different behavior + await _queueClient.SendMessageAsync(order, TimeSpan.Zero); // Immediate visibility + } +} + +// Register the custom client +services.AddAzureStorageQueueClient(x => + x.AddDefaultClient(y => Configuration.Bind(nameof(QueueClientSettings), y))); +services.AddTypedQueueClient(); // Register the underlying typed client +services.AddQueueClient(); // Register the custom client + +// Use the custom client +public class OrderService +{ + private readonly OrderQueueClient _orderClient; + + public OrderService(OrderQueueClient orderClient) // Direct injection of custom type + { + _orderClient = orderClient; + } + + public async Task ProcessOrderAsync(OrderMessage order) + { + await _orderClient.SendOrderAsync(order); // Use custom methods + } +} +``` + ## Using the IQueueClientFactory ### Example 1: Get a default queue client diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index c6ced0b..da3fcc9 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -21,58 +21,41 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect } /// - /// Adds a strongly-typed queue client using the default queue client configuration. - /// Similar to IHttpClientFactory's typed client pattern. + /// Adds a strongly-typed queue client for a specific message type. /// - /// The typed client interface - /// The typed client implementation /// The message type the client handles /// The service collection /// Optional name of the queue client to use. If null, uses the default client. /// The service collection - public static IServiceCollection AddTypedQueueClient( + public static IServiceCollection AddTypedQueueClient( this IServiceCollection services, string? clientName = null) - where TClient : class, ITypedQueueClient - where TImplementation : class, TClient where TMessage : class { - services.AddTransient(provider => + services.AddTransient>(provider => { var factory = provider.GetRequiredService(); var queueClient = string.IsNullOrEmpty(clientName) ? factory.GetQueueClient() : factory.GetQueueClient(clientName); - var typedClient = new TypedQueueClient(queueClient); - return (TClient)(object)typedClient; + return new TypedQueueClient(queueClient); }); return services; } /// - /// Adds a strongly-typed queue client interface with default implementation. + /// Adds a custom typed queue client class that will be resolved through DI. + /// Similar to IHttpClientFactory's typed client pattern where you register custom client types. /// - /// The message type the client handles + /// The custom client class /// The service collection - /// Optional name of the queue client to use. If null, uses the default client. /// The service collection - public static IServiceCollection AddTypedQueueClient( - this IServiceCollection services, - string? clientName = null) - where TMessage : class + public static IServiceCollection AddQueueClient(this IServiceCollection services) + where TClient : class { - services.AddTransient>(provider => - { - var factory = provider.GetRequiredService(); - var queueClient = string.IsNullOrEmpty(clientName) - ? factory.GetQueueClient() - : factory.GetQueueClient(clientName); - - return new TypedQueueClient(queueClient); - }); - + services.AddTransient(); return services; } diff --git a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs index 110e8bd..772a4fb 100644 --- a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs +++ b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs @@ -166,4 +166,73 @@ public void TypedQueueClient_WithoutBaseRegistration_ThrowsException() services.Invoking(s => s.BuildServiceProvider().GetRequiredService>()) .Should().Throw(); } + + [Fact] + public void AddQueueClient_WithCustomClientType_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't actually try to create in tests + })); + services.AddTypedQueueClient(); // Register the ITypedQueueClient first + services.AddQueueClient(); // Register the custom client + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + + // assert + customClient.Should().NotBeNull(); + customClient.Should().BeOfType(); + } + + [Fact] + public void CustomQueueClient_CanUseUnderlyingTypedClient() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "test-queue"; + y.CreateIfNotExists = false; // Don't actually try to create in tests + })); + services.AddTypedQueueClient(); // Register the underlying typed client + services.AddQueueClient(); // Register the custom client + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + var typedClient = serviceProvider.GetService>(); + + // assert + customClient.Should().NotBeNull(); + typedClient.Should().NotBeNull(); + customClient!.GetUnderlyingClient().Should().NotBeNull(); + } + + public class TestOrderClient + { + private readonly ITypedQueueClient _queueClient; + + public TestOrderClient(ITypedQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task SendOrderAsync(TestObject order) + { + await _queueClient.SendMessageAsync(order); + } + + public ITypedQueueClient GetUnderlyingClient() => _queueClient; + } } \ No newline at end of file From 8b408b7cf60f6a2ac32be033c68d68f2eb110bf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Jul 2025 03:01:19 +0000 Subject: [PATCH 5/5] Implement proper IHttpClientFactory pattern for queue clients Co-authored-by: jasonshave <28457536+jasonshave@users.noreply.github.com> --- README.md | 197 ++++++++++++++---- .../ServiceCollectionExtensions.cs | 147 ++++++++++++- .../TypedQueueClientTests.cs | 170 ++++++++++++++- 3 files changed, 466 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 2944e13..92e1420 100644 --- a/README.md +++ b/README.md @@ -114,9 +114,62 @@ services.AddAzureStorageQueueClient(x => }); ``` -### Add typed clients (similar to IHttpClientFactory) +### Add typed clients (IHttpClientFactory pattern) -The library now supports strongly-typed clients similar to the IHttpClientFactory pattern in ASP.NET Core: +The library supports strongly-typed clients similar to the IHttpClientFactory pattern in ASP.NET Core. There are two main approaches: + +#### Option 1: Direct Configuration (Recommended) +Register a client type with configuration in one step, just like `AddHttpClient()`: + +```csharp +// Register MyOrderClient with queue configuration +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "orders"; + settings.CreateIfNotExists = true; +}); + +// Inject the client directly +public class OrderService +{ + private readonly MyOrderClient _orderClient; + + public OrderService(MyOrderClient orderClient) // Direct injection, just like HttpClient pattern + { + _orderClient = orderClient; + } + + public async Task ProcessOrderAsync(Order order) + { + await _orderClient.SendOrderAsync(order); + } +} + +// Define the client class +public class MyOrderClient +{ + private readonly AzureStorageQueueClient _queueClient; + + public MyOrderClient(AzureStorageQueueClient queueClient) // Configured client injected automatically + { + _queueClient = queueClient; + } + + public async Task SendOrderAsync(Order order) + { + await _queueClient.SendMessageAsync(order); + } + + public async Task SendPriorityOrderAsync(Order order) + { + await _queueClient.SendMessageAsync(order, TimeSpan.Zero); // Immediate visibility + } +} +``` + +#### Option 2: Message-Type-Centric Clients +Register typed clients for specific message types: ```csharp // Register a typed client for a specific message type using the default queue client @@ -149,53 +202,33 @@ public class OrderService } ``` -### Custom Queue Client Types - -You can also register custom client types that encapsulate business logic, similar to how IHttpClientFactory works with custom client classes: +#### Multiple Clients +Register multiple client types with different configurations: ```csharp -// Define a custom client class -public class OrderQueueClient +// Register different client types +services.AddQueueClient(settings => { - private readonly ITypedQueueClient _queueClient; - - public OrderQueueClient(ITypedQueueClient queueClient) - { - _queueClient = queueClient; - } - - public async Task SendOrderAsync(OrderMessage order) - { - // Add custom business logic here - await _queueClient.SendMessageAsync(order); - } - - public async Task SendPriorityOrderAsync(OrderMessage order) - { - // Custom method with different behavior - await _queueClient.SendMessageAsync(order, TimeSpan.Zero); // Immediate visibility - } -} + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "orders"; +}); -// Register the custom client -services.AddAzureStorageQueueClient(x => - x.AddDefaultClient(y => Configuration.Bind(nameof(QueueClientSettings), y))); -services.AddTypedQueueClient(); // Register the underlying typed client -services.AddQueueClient(); // Register the custom client +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "notifications"; +}); -// Use the custom client -public class OrderService +// Use in services +public class ECommerceService { - private readonly OrderQueueClient _orderClient; + private readonly OrderClient _orderClient; + private readonly NotificationClient _notificationClient; - public OrderService(OrderQueueClient orderClient) // Direct injection of custom type + public ECommerceService(OrderClient orderClient, NotificationClient notificationClient) { _orderClient = orderClient; - } - - public async Task ProcessOrderAsync(OrderMessage order) - { - await _orderClient.SendOrderAsync(order); // Use custom methods + _notificationClient = notificationClient; } } ``` @@ -232,10 +265,94 @@ public class MyClass } ``` +## Pattern Comparison + +The library supports both traditional factory pattern and modern IHttpClientFactory-style pattern: + +### Modern Pattern (Recommended) +```csharp +// Registration +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "orders"; +}); + +// Usage - direct injection like IHttpClientFactory +public class OrderService +{ + public OrderService(OrderClient client) { } // Clean, typed injection +} +``` + +### Traditional Factory Pattern +```csharp +// Registration +services.AddAzureStorageQueueClient(x => + x.AddClient("orders", y => { /* configure */ })); + +// Usage - factory pattern +public class OrderService +{ + public OrderService(IQueueClientFactory factory) + { + _client = factory.GetQueueClient("orders"); // String-based lookup + } +} +``` + +The modern pattern provides: +- **Type Safety**: Compile-time checking instead of runtime string lookups +- **Cleaner DI**: Direct injection like HttpClient pattern +- **Better Tooling**: IntelliSense and refactoring support +- **Familiar Pattern**: Consistent with IHttpClientFactory that developers already know + ## Sending messages to an Azure storage account queue The following example shows the .NET Worker Service template where the class uses the `IHostedService` interface to send a message every five seconds. +### Using Custom Client Types (Recommended) + +```csharp +// Registration +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "messages"; +}); + +// Client class +public class MessageSender +{ + private readonly AzureStorageQueueClient _queueClient; + + public MessageSender(AzureStorageQueueClient queueClient) => _queueClient = queueClient; + + public async Task SendAsync(T message, CancellationToken cancellationToken = default) + { + await _queueClient.SendMessageAsync(message, cancellationToken); + } +} + +// Usage +public class Sender : IHostedService +{ + private readonly MessageSender _messageSender; + + public Sender(MessageSender messageSender) => _messageSender = messageSender; + + public async Task StartAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + var myMessage = new MyMessage("Test"); + await _messageSender.SendAsync(myMessage, cancellationToken); + await Task.Delay(5000); + } + } +} +``` + ### Using the IQueueClientFactory 1. Inject the `IQueueClientFactory` interface and use as follows: diff --git a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs index da3fcc9..f9eb86d 100644 --- a/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs +++ b/src/AzureStorage.QueueService/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using System.Text.Json; +using System.Reflection; +using System.Text.Json; using AzureStorage.QueueService.Telemetry; using Microsoft.Extensions.DependencyInjection; @@ -20,6 +21,22 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect return services; } + /// + /// Adds the base Azure Storage Queue Client infrastructure with default JSON serialization. + /// Use this when registering typed clients without needing the full configuration builder. + /// + /// The service collection + /// Optional JSON serializer options + /// The service collection + public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollection services, JsonSerializerOptions? serializerOptions = null) + { + services.AddSingleton(Registry); + services.AddSingleton(); + services.AddSingleton(new JsonQueueMessageConverter(serializerOptions)); + + return services; + } + /// /// Adds a strongly-typed queue client for a specific message type. /// @@ -48,6 +65,90 @@ public static IServiceCollection AddTypedQueueClient( /// /// Adds a custom typed queue client class that will be resolved through DI. /// Similar to IHttpClientFactory's typed client pattern where you register custom client types. + /// The client type constructor should accept AzureStorageQueueClient as a parameter. + /// + /// The custom client class + /// The service collection + /// Action to configure the queue client settings + /// The service collection + public static IServiceCollection AddQueueClient( + this IServiceCollection services, + Action configureSettings) + where TClient : class + { + var clientName = typeof(TClient).Name; + return AddQueueClient(services, clientName, configureSettings); + } + + /// + /// Adds a custom typed queue client class that will be resolved through DI with an explicit client name. + /// Similar to IHttpClientFactory's typed client pattern where you register custom client types. + /// The client type constructor should accept AzureStorageQueueClient as a parameter. + /// + /// The custom client class + /// The service collection + /// The name for the queue client configuration + /// Action to configure the queue client settings + /// The service collection + public static IServiceCollection AddQueueClient( + this IServiceCollection services, + string clientName, + Action configureSettings) + where TClient : class + { + // Ensure the base queue client infrastructure is registered + EnsureBaseServicesRegistered(services); + + // Get or create the registry from the service collection + var registryDescriptor = services.FirstOrDefault(x => x.ServiceType == typeof(QueueClientSettingsRegistry)); + var registry = registryDescriptor?.ImplementationInstance as QueueClientSettingsRegistry ?? Registry; + + // Configure the queue client settings for this client type + var builder = new QueueClientSettingsBuilder(registry); + builder.AddClient(clientName, configureSettings); + + // Register the client type with a factory that provides configured AzureStorageQueueClient + services.AddTransient(provider => + { + var factory = provider.GetRequiredService(); + var azureQueueClient = factory.GetQueueClient(clientName); + + // Create instance of TClient and inject the configured AzureStorageQueueClient + return (TClient)Activator.CreateInstance(typeof(TClient), azureQueueClient)!; + }); + + return services; + } + + /// + /// Adds a custom typed queue client class that will be resolved through DI. + /// This overload is for clients that don't need additional configuration and will use an existing named client. + /// The client type constructor should accept AzureStorageQueueClient as a parameter. + /// + /// The custom client class + /// The service collection + /// The name of an existing queue client configuration to use + /// The service collection + public static IServiceCollection AddQueueClient( + this IServiceCollection services, + string clientName) + where TClient : class + { + services.AddTransient(provider => + { + var factory = provider.GetRequiredService(); + var azureQueueClient = factory.GetQueueClient(clientName); + + // Create instance of TClient and inject the configured AzureStorageQueueClient + return (TClient)Activator.CreateInstance(typeof(TClient), azureQueueClient)!; + }); + + return services; + } + + /// + /// Adds a custom typed queue client class that will be resolved through DI using the default client. + /// This method will intelligently inject the appropriate dependencies based on the constructor. /// /// The custom client class /// The service collection @@ -55,10 +156,52 @@ public static IServiceCollection AddTypedQueueClient( public static IServiceCollection AddQueueClient(this IServiceCollection services) where TClient : class { - services.AddTransient(); + // Check if the client type has a constructor that takes AzureStorageQueueClient + var constructors = typeof(TClient).GetConstructors(); + var hasAzureClientConstructor = constructors.Any(c => + c.GetParameters().Length == 1 && + c.GetParameters()[0].ParameterType == typeof(AzureStorageQueueClient)); + + if (hasAzureClientConstructor) + { + // Use factory to create AzureStorageQueueClient + services.AddTransient(provider => + { + var factory = provider.GetRequiredService(); + var azureQueueClient = factory.GetQueueClient(); // Use default client + + // Create instance of TClient and inject the configured AzureStorageQueueClient + return (TClient)Activator.CreateInstance(typeof(TClient), azureQueueClient)!; + }); + } + else + { + // Fall back to normal DI resolution for other constructor patterns + services.AddTransient(); + } + return services; } + /// + /// Ensures the base queue client services are registered if they haven't been already. + /// + private static void EnsureBaseServicesRegistered(IServiceCollection services) + { + if (!services.Any(x => x.ServiceType == typeof(QueueClientSettingsRegistry))) + { + services.AddSingleton(Registry); + } + if (!services.Any(x => x.ServiceType == typeof(IQueueClientFactory))) + { + services.AddSingleton(); + } + if (!services.Any(x => x.ServiceType == typeof(IMessageConverter))) + { + services.AddSingleton(new JsonQueueMessageConverter()); + } + } + public static IServiceCollection ConfigureQueueServiceTelemetry(this IServiceCollection services, Action options) { services.Configure(options); diff --git a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs index 772a4fb..23e8ee2 100644 --- a/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs +++ b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs @@ -181,15 +181,15 @@ public void AddQueueClient_WithCustomClientType_RegistersCorrectly() y.CreateIfNotExists = false; // Don't actually try to create in tests })); services.AddTypedQueueClient(); // Register the ITypedQueueClient first - services.AddQueueClient(); // Register the custom client + services.AddQueueClient(); // Register the custom client // act var serviceProvider = services.BuildServiceProvider(); - var customClient = serviceProvider.GetService(); + var customClient = serviceProvider.GetService(); // assert customClient.Should().NotBeNull(); - customClient.Should().BeOfType(); + customClient.Should().BeOfType(); } [Fact] @@ -206,11 +206,11 @@ public void CustomQueueClient_CanUseUnderlyingTypedClient() y.CreateIfNotExists = false; // Don't actually try to create in tests })); services.AddTypedQueueClient(); // Register the underlying typed client - services.AddQueueClient(); // Register the custom client + services.AddQueueClient(); // Register the custom client // act var serviceProvider = services.BuildServiceProvider(); - var customClient = serviceProvider.GetService(); + var customClient = serviceProvider.GetService(); var typedClient = serviceProvider.GetService>(); // assert @@ -219,11 +219,169 @@ public void CustomQueueClient_CanUseUnderlyingTypedClient() customClient!.GetUnderlyingClient().Should().NotBeNull(); } + [Fact] + public void AddQueueClient_WithConfiguration_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQueueClient(settings => + { + settings.ConnectionString = "UseDevelopmentStorage=true"; + settings.QueueName = "test-queue"; + settings.CreateIfNotExists = false; + }); + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + + // assert + customClient.Should().NotBeNull(); + customClient.Should().BeOfType(); + } + + [Fact] + public void AddQueueClient_WithNamedConfiguration_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQueueClient("orders", settings => + { + settings.ConnectionString = "UseDevelopmentStorage=true"; + settings.QueueName = "orders-queue"; + settings.CreateIfNotExists = false; + }); + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + + // assert + customClient.Should().NotBeNull(); + customClient.Should().BeOfType(); + } + + [Fact] + public void AddQueueClient_MultipleClients_CanBeRegistered() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddQueueClient("orders", settings => + { + settings.ConnectionString = "UseDevelopmentStorage=true"; + settings.QueueName = "orders-queue"; + settings.CreateIfNotExists = false; + }); + services.AddQueueClient("notifications", settings => + { + settings.ConnectionString = "UseDevelopmentStorage=true"; + settings.QueueName = "notifications-queue"; + settings.CreateIfNotExists = false; + }); + + // act + var serviceProvider = services.BuildServiceProvider(); + var orderClient = serviceProvider.GetService(); + var notificationClient = serviceProvider.GetService(); + + // assert + orderClient.Should().NotBeNull(); + notificationClient.Should().NotBeNull(); + orderClient.Should().BeOfType(); + notificationClient.Should().BeOfType(); + } + + [Fact] + public void AddQueueClient_WithExistingNamedClient_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddClient("existing", y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "existing-queue"; + y.CreateIfNotExists = false; + })); + services.AddQueueClient("existing"); // Use existing configuration + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + + // assert + customClient.Should().NotBeNull(); + customClient.Should().BeOfType(); + } + + [Fact] + public void AddQueueClient_WithDefaultClient_RegistersCorrectly() + { + // arrange + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAzureStorageQueueClient(options => + options.AddDefaultClient(y => + { + y.ConnectionString = "UseDevelopmentStorage=true"; + y.QueueName = "default-queue"; + y.CreateIfNotExists = false; + })); + services.AddQueueClient(); // Use default client + + // act + var serviceProvider = services.BuildServiceProvider(); + var customClient = serviceProvider.GetService(); + + // assert + customClient.Should().NotBeNull(); + customClient.Should().BeOfType(); + } + public class TestOrderClient + { + private readonly AzureStorageQueueClient _queueClient; + + public TestOrderClient(AzureStorageQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task SendOrderAsync(TestObject order) + { + await _queueClient.SendMessageAsync(order); + } + + public AzureStorageQueueClient GetUnderlyingClient() => _queueClient; + } + + public class TestNotificationClient + { + private readonly AzureStorageQueueClient _queueClient; + + public TestNotificationClient(AzureStorageQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task SendNotificationAsync(TestObject notification) + { + await _queueClient.SendMessageAsync(notification); + } + + public AzureStorageQueueClient GetUnderlyingClient() => _queueClient; + } + + // Legacy test client for backward compatibility tests + public class LegacyTestOrderClient { private readonly ITypedQueueClient _queueClient; - public TestOrderClient(ITypedQueueClient queueClient) + public LegacyTestOrderClient(ITypedQueueClient queueClient) { _queueClient = queueClient; }