diff --git a/README.md b/README.md index a727d8c..92e1420 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,125 @@ services.AddAzureStorageQueueClient(x => }); ``` +### Add typed clients (IHttpClientFactory pattern) + +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 +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); + } +} +``` + +#### Multiple Clients +Register multiple client types with different configurations: + +```csharp +// Register different client types +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "orders"; +}); + +services.AddQueueClient(settings => +{ + settings.ConnectionString = "[your_connection_string]"; + settings.QueueName = "notifications"; +}); + +// Use in services +public class ECommerceService +{ + private readonly OrderClient _orderClient; + private readonly NotificationClient _notificationClient; + + public ECommerceService(OrderClient orderClient, NotificationClient notificationClient) + { + _orderClient = orderClient; + _notificationClient = notificationClient; + } +} +``` + ## Using the IQueueClientFactory ### Example 1: Get a default queue client @@ -146,10 +265,96 @@ 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: ```csharp @@ -171,10 +376,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 +434,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/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/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/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 ca481bb..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; @@ -15,12 +16,192 @@ public static IServiceCollection AddAzureStorageQueueClient(this IServiceCollect services.AddSingleton(Registry); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(new JsonQueueMessageConverter(serializerOptions)); 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. + /// + /// 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; + } + + /// + /// 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 + /// The service collection + public static IServiceCollection AddQueueClient(this IServiceCollection services) + where TClient : class + { + // 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/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/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 new file mode 100644 index 0000000..23e8ee2 --- /dev/null +++ b/tests/AzureStorage.QueueService.Tests/TypedQueueClientTests.cs @@ -0,0 +1,396 @@ +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"; + y.CreateIfNotExists = false; // Don't actually try to create in tests + })); + 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"; + y.CreateIfNotExists = false; // Don't actually try to create in tests + })); + 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"; + y.CreateIfNotExists = false; // Don't actually try to create in tests + })); + + // 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"; + 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 + }); + }); + + // 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(); + } + + [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(); + } + + [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 LegacyTestOrderClient(ITypedQueueClient queueClient) + { + _queueClient = queueClient; + } + + public async Task SendOrderAsync(TestObject order) + { + await _queueClient.SendMessageAsync(order); + } + + public ITypedQueueClient GetUnderlyingClient() => _queueClient; + } +} \ No newline at end of file