From c71a2b4a9bac136d5295242348bae62b727b8a0a Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Mon, 13 Jul 2026 17:16:53 -0500 Subject: [PATCH] Promote a real Azure Service Bus emulator API into the shipping package (GH-3366) The docs told readers to call UseAzureServiceBusTesting(), but that extension method only existed in the test suite and depended on the test-only Servers type. Add a shipping UseAzureServiceBusEmulator() with defaulted and explicit connection-string overloads, an opt-in DeleteAllExistingObjectsOnStartup() for the destructive namespace wipe, and a standalone cleanup helper. Refactor the test helper onto the new API and rewrite the emulator docs around compiled snippets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../azureservicebus/conventional-routing.md | 6 +- .../azureservicebus/deadletterqueues.md | 8 +- .../transports/azureservicebus/emulator.md | 165 ++++++++++-------- .../transports/azureservicebus/index.md | 43 +++-- .../transports/azureservicebus/listening.md | 6 +- .../azureservicebus/object-management.md | 2 +- .../transports/azureservicebus/publishing.md | 4 +- .../azureservicebus/session-identifiers.md | 20 +-- .../transports/azureservicebus/topics.md | 2 +- .../AzureServiceBusTesting.cs | 35 ++-- .../end_to_end_with_conventional_routing.cs | 3 - .../DocumentationSamples.cs | 157 +++++++++++++++++ .../emulator_configuration.cs | 51 ++++++ .../end_to_end.cs | 6 - .../AzureServiceBusConfiguration.cs | 14 ++ .../AzureServiceBusEmulatorExtensions.cs | 90 ++++++++++ .../AzureServiceBusTransport.cs | 22 ++- 17 files changed, 493 insertions(+), 141 deletions(-) create mode 100644 src/Transports/Azure/Wolverine.AzureServiceBus.Tests/emulator_configuration.cs create mode 100644 src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusEmulatorExtensions.cs diff --git a/docs/guide/messaging/transports/azureservicebus/conventional-routing.md b/docs/guide/messaging/transports/azureservicebus/conventional-routing.md index 71a271405..00b39307f 100644 --- a/docs/guide/messaging/transports/azureservicebus/conventional-routing.md +++ b/docs/guide/messaging/transports/azureservicebus/conventional-routing.md @@ -54,7 +54,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Route to Topics and Subscriptions @@ -65,7 +65,7 @@ message type names like this: ```cs -opts.UseAzureServiceBusTesting() +opts.UseAzureServiceBusEmulator() .UseTopicAndSubscriptionConventionalRouting(convention => { // Optionally control every aspect of the convention and @@ -82,7 +82,7 @@ opts.UseAzureServiceBusTesting() .AutoProvision() .AutoPurgeOnStartup(); ``` -snippet source | anchor +snippet source | anchor ## Handler Type Naming diff --git a/docs/guide/messaging/transports/azureservicebus/deadletterqueues.md b/docs/guide/messaging/transports/azureservicebus/deadletterqueues.md index c634fec4f..c8afb1400 100644 --- a/docs/guide/messaging/transports/azureservicebus/deadletterqueues.md +++ b/docs/guide/messaging/transports/azureservicebus/deadletterqueues.md @@ -30,7 +30,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ### Buffered Endpoints @@ -62,7 +62,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ### Durable Endpoints @@ -93,7 +93,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Disabling Dead Letter Queues @@ -121,7 +121,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Recovering Native Dead Letters to Durable Storage diff --git a/docs/guide/messaging/transports/azureservicebus/emulator.md b/docs/guide/messaging/transports/azureservicebus/emulator.md index de600fc11..1e4f49af8 100644 --- a/docs/guide/messaging/transports/azureservicebus/emulator.md +++ b/docs/guide/messaging/transports/azureservicebus/emulator.md @@ -1,6 +1,8 @@ # Using the Azure Service Bus Emulator -The [Azure Service Bus Emulator](https://learn.microsoft.com/en-us/azure/service-bus-messaging/overview-emulator) allows you to run integration tests against a local emulator instance instead of a real Azure Service Bus namespace. This is exactly what Wolverine uses internally for its own test suite. +The [Azure Service Bus Emulator](https://learn.microsoft.com/en-us/azure/service-bus-messaging/overview-emulator) lets you run +Wolverine against a local emulator instance instead of a real Azure Service Bus namespace, either for local development or for +integration testing. This is exactly what Wolverine uses internally for its own test suite. ## Docker Compose Setup @@ -24,7 +26,7 @@ services: volumes: - ./docker/asb/Config.json:/ServiceBus_Emulator/ConfigFiles/Config.json ports: - - "5673:5672" # AMQP messaging + - "5672:5672" # AMQP messaging - "5300:5300" # HTTP management environment: SQL_SERVER: asb-sql @@ -38,7 +40,9 @@ services: ``` ::: tip -The emulator exposes two ports: the AMQP port (5672) for sending and receiving messages, and an HTTP management port (5300) for queue/topic administration. These must be mapped to different host ports. +The emulator exposes two ports: the AMQP port (5672) for sending and receiving messages, and an HTTP management port (5300) for +queue and topic administration. Wolverine's own `docker-compose.yml` maps the AMQP port to host port **5673** to avoid colliding +with RabbitMQ, which is why Wolverine's own tests pass explicit connection strings as shown below. ::: ## Emulator Configuration File @@ -101,101 +105,119 @@ You can also pre-configure queues and topics in this file if needed: } ``` -## Connection Strings +## Connecting Wolverine to the Emulator -The emulator uses standard Azure Service Bus connection strings with `UseDevelopmentEmulator=true`: - -```cs -// AMQP connection for sending/receiving messages -var messagingConnectionString = - "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; - -// HTTP connection for management operations (creating queues, topics, etc.) -var managementConnectionString = - "Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; -``` - -::: warning -The emulator uses separate ports for messaging (AMQP) and management (HTTP) operations. In production Azure Service Bus, a single connection string handles both, but the emulator requires you to configure these separately. -::: - -## Configuring Wolverine with the Emulator - -The key to using the emulator with Wolverine is setting both the primary connection string (for AMQP messaging) and the `ManagementConnectionString` (for HTTP administration) on the transport: +The emulator uses one connection string for messaging (AMQP) and a *separate* one for management (HTTP), where real Azure Service +Bus uses a single connection string for both. `UseAzureServiceBusEmulator()` hides that split. With the standard emulator ports it +takes no arguments at all: + + ```cs var builder = Host.CreateApplicationBuilder(); builder.UseWolverine(opts => { - opts.UseAzureServiceBus(messagingConnectionString) + // Connect to a locally running Azure Service Bus emulator using the + // standard emulator ports (AMQP on 5672, management on 5300) + opts.UseAzureServiceBusEmulator() + + // The emulator starts out empty, so let Wolverine build + // any queues, topics, or subscriptions it needs .AutoProvision() .AutoPurgeOnStartup(); - // Required for the emulator: set the management connection string - // to the HTTP port since it differs from the AMQP port - var transport = opts.Transports.GetOrCreate(); - transport.ManagementConnectionString = managementConnectionString; - - // Configure your queues, topics, etc. as normal opts.ListenToAzureServiceBusQueue("my-queue"); opts.PublishAllMessages().ToAzureServiceBusQueue("my-queue"); }); + +using var host = builder.Build(); +await host.StartAsync(); ``` +snippet source | anchor + + +The parameterless version uses the defaults below, which are also exposed as the constants +`AzureServiceBusEmulatorExtensions.DefaultEmulatorConnectionString` and +`AzureServiceBusEmulatorExtensions.DefaultEmulatorManagementConnectionString`: -## Creating a Test Helper +| Purpose | Default connection string | +| --- | --- | +| Messaging (AMQP) | `Endpoint=sb://localhost:5672;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` | +| Management (HTTP) | `Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;` | -Wolverine's own test suite uses a static helper extension method to standardize emulator configuration across all tests. Here's the pattern: +If you have mapped the emulator to different host ports, pass both connection strings explicitly: + + ```cs -public static class AzureServiceBusTesting +var builder = Host.CreateApplicationBuilder(); +builder.UseWolverine(opts => { - // Connection strings pointing at the emulator - public static readonly string MessagingConnectionString = - "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + // If you've mapped the emulator to non-standard ports, pass both the + // messaging (AMQP) and management (HTTP) connection strings explicitly + opts.UseAzureServiceBusEmulator( + "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;") + .AutoProvision() + .AutoPurgeOnStartup(); +}); - public static readonly string ManagementConnectionString = - "Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; +using var host = builder.Build(); +await host.StartAsync(); +``` +snippet source | anchor + - private static bool _cleaned; +`UseAzureServiceBusEmulator()` returns the same `AzureServiceBusConfiguration` as `UseAzureServiceBus()`, so everything else -- +`AutoProvision()`, `AutoPurgeOnStartup()`, conventional routing, queue and topic configuration -- chains off of it exactly as it +would against a real namespace. - public static AzureServiceBusConfiguration UseAzureServiceBusTesting( - this WolverineOptions options) - { - // Delete all queues and topics on first usage to start clean - if (!_cleaned) - { - _cleaned = true; - DeleteAllEmulatorObjectsAsync().GetAwaiter().GetResult(); - } +## Deleting All Existing Objects at Startup - var config = options.UseAzureServiceBus(MessagingConnectionString); +The emulator is a long lived, shared resource, and leftover queues or topics from earlier runs can leak into later ones. Wolverine +can wipe the namespace clean at application startup, but this behavior is strictly **opt in**: - var transport = options.Transports.GetOrCreate(); - transport.ManagementConnectionString = ManagementConnectionString; + + +```cs +var builder = Host.CreateApplicationBuilder(); +builder.UseWolverine(opts => +{ + opts.UseAzureServiceBusEmulator() - return config.AutoProvision(); - } + // CAUTION! This deletes *every* queue and topic in the connected + // namespace at startup. It is opt in, and is only meant for the + // emulator or a throwaway namespace. Never turn this on against + // a real Azure Service Bus namespace you care about + .DeleteAllExistingObjectsOnStartup() - public static async Task DeleteAllEmulatorObjectsAsync() - { - var client = new ServiceBusAdministrationClient(ManagementConnectionString); + .AutoProvision(); +}); + +using var host = builder.Build(); +await host.StartAsync(); +``` +snippet source | anchor + + +::: danger +`DeleteAllExistingObjectsOnStartup()` deletes **every** queue and topic -- and therefore every message and subscription -- in the +connected namespace each time the application starts. It is irreversible, and it is never turned on for you. It exists for the +emulator and for throwaway namespaces. Never enable it against a real Azure Service Bus namespace that holds anything you care +about. If you only want to drain messages out of the endpoints Wolverine itself knows about, use `AutoPurgeOnStartup()` instead. +::: - await foreach (var topic in client.GetTopicsAsync()) - { - await client.DeleteTopicAsync(topic.Name); - } +If you would rather clean up out of band -- say, once per test run rather than once per host -- call the standalone helper. It +takes the *management* connection string, and defaults to the standard emulator one: - await foreach (var queue in client.GetQueuesAsync()) - { - await client.DeleteQueueAsync(queue.Name); - } - } -} +```cs +// Same destructive semantics: this drops every queue and topic in the namespace +await AzureServiceBusEmulatorExtensions.DeleteAllAzureServiceBusObjectsAsync(); ``` ## Writing Integration Tests -With the helper in place, integration tests become straightforward: +Integration tests against the emulator are just normal Wolverine tests: ```cs public class when_sending_messages : IAsyncLifetime @@ -207,7 +229,8 @@ public class when_sending_messages : IAsyncLifetime _host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { - opts.UseAzureServiceBusTesting() + opts.UseAzureServiceBusEmulator() + .AutoProvision() .AutoPurgeOnStartup(); opts.ListenToAzureServiceBusQueue("send_and_receive"); @@ -238,12 +261,14 @@ public class when_sending_messages : IAsyncLifetime ``` ::: tip -Use `.IncludeExternalTransports()` on the tracked session so Wolverine waits for messages that travel through Azure Service Bus rather than only tracking in-memory activity. +Use `.IncludeExternalTransports()` on the tracked session so Wolverine waits for messages that travel through Azure Service Bus +rather than only tracking in-memory activity. ::: ## Disabling Parallel Test Execution -Because the emulator is a shared resource, tests that create and tear down queues or topics can interfere with each other when run in parallel. Wolverine's own test suite disables parallel execution for its Azure Service Bus tests: +Because the emulator is a shared resource, tests that create and tear down queues or topics can interfere with each other when run +in parallel. Wolverine's own test suite disables parallel execution for its Azure Service Bus tests: ```cs // Add to a file like NoParallelization.cs in your test project diff --git a/docs/guide/messaging/transports/azureservicebus/index.md b/docs/guide/messaging/transports/azureservicebus/index.md index ddf933371..8327cb853 100644 --- a/docs/guide/messaging/transports/azureservicebus/index.md +++ b/docs/guide/messaging/transports/azureservicebus/index.md @@ -90,14 +90,37 @@ builder.UseWolverine(opts => }); ``` -When using the Azure Service Bus emulator for local development, use `UseAzureServiceBusTesting()` instead: +When using the [Azure Service Bus emulator](/guide/messaging/transports/azureservicebus/emulator) for local development or testing, +use `UseAzureServiceBusEmulator()` instead. It connects to the emulator's messaging (AMQP) port *and* its separate management (HTTP) +port for you: -```csharp -// For local development with the Azure Service Bus emulator -opts.UseAzureServiceBusTesting() - .AutoProvision() - .AutoPurgeOnStartup(); + + +```cs +var builder = Host.CreateApplicationBuilder(); +builder.UseWolverine(opts => +{ + // Connect to a locally running Azure Service Bus emulator using the + // standard emulator ports (AMQP on 5672, management on 5300) + opts.UseAzureServiceBusEmulator() + + // The emulator starts out empty, so let Wolverine build + // any queues, topics, or subscriptions it needs + .AutoProvision() + .AutoPurgeOnStartup(); + + opts.ListenToAzureServiceBusQueue("my-queue"); + opts.PublishAllMessages().ToAzureServiceBusQueue("my-queue"); +}); + +using var host = builder.Build(); +await host.StartAsync(); ``` +snippet source | anchor + + +See [Using the Azure Service Bus Emulator](/guide/messaging/transports/azureservicebus/emulator) for the Docker Compose setup, the +overload that takes explicit connection strings, and the opt in namespace cleanup. ## Request/Reply @@ -138,7 +161,7 @@ builder.UseWolverine(opts => }); ``` -snippet source | anchor +snippet source | anchor ## Disabling System Queues @@ -149,10 +172,10 @@ to disable system queues to avoid having some annoying error messages popping up ```cs -var host = await Host.CreateDefaultBuilder() +using var host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { - opts.UseAzureServiceBusTesting() + opts.UseAzureServiceBus("some connection string") .AutoProvision().AutoPurgeOnStartup() .SystemQueuesAreEnabled(false); @@ -161,7 +184,7 @@ var host = await Host.CreateDefaultBuilder() opts.PublishAllMessages().ToAzureServiceBusQueue("send_and_receive"); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Connecting To Multiple Namespaces diff --git a/docs/guide/messaging/transports/azureservicebus/listening.md b/docs/guide/messaging/transports/azureservicebus/listening.md index 63c2d3456..08b3e0515 100644 --- a/docs/guide/messaging/transports/azureservicebus/listening.md +++ b/docs/guide/messaging/transports/azureservicebus/listening.md @@ -42,7 +42,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ## Configuring the ServiceBusProcessor @@ -90,7 +90,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor ::: warning @@ -129,7 +129,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor Note that any of these settings would be overridden by specific configuration to diff --git a/docs/guide/messaging/transports/azureservicebus/object-management.md b/docs/guide/messaging/transports/azureservicebus/object-management.md index 5e0f41b82..e7e2713d6 100644 --- a/docs/guide/messaging/transports/azureservicebus/object-management.md +++ b/docs/guide/messaging/transports/azureservicebus/object-management.md @@ -131,6 +131,6 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor diff --git a/docs/guide/messaging/transports/azureservicebus/publishing.md b/docs/guide/messaging/transports/azureservicebus/publishing.md index c583bc664..99a3d8334 100644 --- a/docs/guide/messaging/transports/azureservicebus/publishing.md +++ b/docs/guide/messaging/transports/azureservicebus/publishing.md @@ -28,7 +28,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor @@ -60,7 +60,7 @@ builder.UseWolverine(opts => using var host = builder.Build(); await host.StartAsync(); ``` -snippet source | anchor +snippet source | anchor Note that any of these settings would be overridden by specific configuration to diff --git a/docs/guide/messaging/transports/azureservicebus/session-identifiers.md b/docs/guide/messaging/transports/azureservicebus/session-identifiers.md index b4e62bff9..93be78790 100644 --- a/docs/guide/messaging/transports/azureservicebus/session-identifiers.md +++ b/docs/guide/messaging/transports/azureservicebus/session-identifiers.md @@ -10,15 +10,16 @@ Wolverine when sessions are required on any listening endpoint so that it can op ::: You can now take advantage of [sessions and first-in, first out queues in Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/message-sessions) with Wolverine. -To tell Wolverine that an Azure Service Bus queue or subscription should require sessions, you have this syntax shown in an internal test: +To tell Wolverine that an Azure Service Bus queue or subscription should require sessions, use this syntax (shown here against the +[Azure Service Bus emulator](/guide/messaging/transports/azureservicebus/emulator), but identical against a real namespace): ```cs -_host = await Host.CreateDefaultBuilder() +using var host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { - opts.UseAzureServiceBusTesting() + opts.UseAzureServiceBusEmulator() .AutoProvision().AutoPurgeOnStartup(); opts.ListenToAzureServiceBusQueue("send_and_receive"); @@ -43,19 +44,10 @@ _host = await Host.CreateDefaultBuilder() // Require sessions on this subscription .RequireSessions(1) - .ProcessInline(); - - opts.PublishMessage().ToAzureServiceBusTopic("asb4").BufferedInMemory(); - opts.ListenToAzureServiceBusSubscription("asb4") - .FromTopic("asb4") - - // Require sessions on this subscription - .RequireSessions(1) - .ProcessInline(); }).StartAsync(); ``` -snippet source | anchor +snippet source | anchor To publish messages to Azure Service Bus with a session id, you will need to of course supply the session id: @@ -68,7 +60,7 @@ await bus.SendAsync(new AsbMessage3("Red"), new DeliveryOptions { GroupId = "2" await bus.SendAsync(new AsbMessage3("Green"), new DeliveryOptions { GroupId = "2" }); await bus.SendAsync(new AsbMessage3("Refactor"), new DeliveryOptions { GroupId = "2" }); ``` -snippet source | anchor +snippet source | anchor ::: info diff --git a/docs/guide/messaging/transports/azureservicebus/topics.md b/docs/guide/messaging/transports/azureservicebus/topics.md index f17932e3c..cbb81aab4 100644 --- a/docs/guide/messaging/transports/azureservicebus/topics.md +++ b/docs/guide/messaging/transports/azureservicebus/topics.md @@ -62,7 +62,7 @@ opts.ListenToAzureServiceBusSubscription( }) .FromTopic("topic1"); ``` -snippet source | anchor +snippet source | anchor The default filter if not customized is a simple `1=1` (always true) filter. diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/AzureServiceBusTesting.cs b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/AzureServiceBusTesting.cs index 3c4e2b2b5..bb3379d36 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/AzureServiceBusTesting.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/AzureServiceBusTesting.cs @@ -1,4 +1,3 @@ -using Azure.Messaging.ServiceBus.Administration; using IntegrationTests; namespace Wolverine.AzureServiceBus.Tests; @@ -7,6 +6,11 @@ public static class AzureServiceBusTesting { private static bool _cleaned; + /// + /// Connect to the Azure Service Bus emulator from Wolverine's own docker-compose setup. This delegates + /// to the shipping UseAzureServiceBusEmulator() API, but adds a one time cleanup of any objects left + /// behind by previous test runs. + /// public static AzureServiceBusConfiguration UseAzureServiceBusTesting(this WolverineOptions options) { if (!_cleaned) @@ -17,32 +21,21 @@ public static AzureServiceBusConfiguration UseAzureServiceBusTesting(this Wolver #pragma warning restore VSTHRD002 // Avoid problematic synchronous waits } - var config = options.UseAzureServiceBus(Servers.AzureServiceBusConnectionString); - - var transport = options.Transports.GetOrCreate(); - transport.ManagementConnectionString = Servers.AzureServiceBusManagementConnectionString; - - return config.AutoProvision(); + return options + .UseAzureServiceBusEmulator(Servers.AzureServiceBusConnectionString, + Servers.AzureServiceBusManagementConnectionString) + .AutoProvision(); } - public static async Task DeleteAllEmulatorObjectsAsync() - => await DeleteAllEmulatorObjectsAsync(Servers.AzureServiceBusManagementConnectionString); + public static Task DeleteAllEmulatorObjectsAsync() + { + return DeleteAllEmulatorObjectsAsync(Servers.AzureServiceBusManagementConnectionString); + } public static async Task DeleteAllEmulatorObjectsAsync(string connectionString) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var ct = cts.Token; - - var client = new ServiceBusAdministrationClient(connectionString); - await foreach (var topic in client.GetTopicsAsync().WithCancellation(ct)) - { - await client.DeleteTopicAsync(topic.Name, ct); - } - - await foreach (var queue in client.GetQueuesAsync().WithCancellation(ct)) - { - await client.DeleteQueueAsync(queue.Name, ct); - } + await AzureServiceBusEmulatorExtensions.DeleteAllAzureServiceBusObjectsAsync(connectionString, cts.Token); } } diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/ConventionalRouting/Broadcasting/end_to_end_with_conventional_routing.cs b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/ConventionalRouting/Broadcasting/end_to_end_with_conventional_routing.cs index 904149107..5857a5a64 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/ConventionalRouting/Broadcasting/end_to_end_with_conventional_routing.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/ConventionalRouting/Broadcasting/end_to_end_with_conventional_routing.cs @@ -30,7 +30,6 @@ public async Task InitializeAsync() _receiver = await WolverineHost.ForAsync(opts => { - #region sample_using_topic_and_subscription_conventional_routing_with_azure_service_bus opts.UseAzureServiceBusTesting() .UseTopicAndSubscriptionConventionalRouting(convention => { @@ -48,8 +47,6 @@ public async Task InitializeAsync() .AutoProvision() .AutoPurgeOnStartup(); - #endregion - opts.ServiceName = "Receiver"; }); } diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/DocumentationSamples.cs b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/DocumentationSamples.cs index b5e013580..f766ff356 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/DocumentationSamples.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/DocumentationSamples.cs @@ -43,6 +43,163 @@ public async Task bootstrapping() #endregion } + public async Task bootstrapping_with_the_emulator() + { + #region sample_using_azure_service_bus_emulator + + var builder = Host.CreateApplicationBuilder(); + builder.UseWolverine(opts => + { + // Connect to a locally running Azure Service Bus emulator using the + // standard emulator ports (AMQP on 5672, management on 5300) + opts.UseAzureServiceBusEmulator() + + // The emulator starts out empty, so let Wolverine build + // any queues, topics, or subscriptions it needs + .AutoProvision() + .AutoPurgeOnStartup(); + + opts.ListenToAzureServiceBusQueue("my-queue"); + opts.PublishAllMessages().ToAzureServiceBusQueue("my-queue"); + }); + + using var host = builder.Build(); + await host.StartAsync(); + + #endregion + } + + public async Task bootstrapping_with_the_emulator_and_explicit_connection_strings() + { + #region sample_using_azure_service_bus_emulator_with_connection_strings + + var builder = Host.CreateApplicationBuilder(); + builder.UseWolverine(opts => + { + // If you've mapped the emulator to non-standard ports, pass both the + // messaging (AMQP) and management (HTTP) connection strings explicitly + opts.UseAzureServiceBusEmulator( + "Endpoint=sb://localhost:5673;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;", + "Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;") + .AutoProvision() + .AutoPurgeOnStartup(); + }); + + using var host = builder.Build(); + await host.StartAsync(); + + #endregion + } + + public async Task bootstrapping_with_the_emulator_and_cleanup() + { + #region sample_using_azure_service_bus_emulator_with_cleanup + + var builder = Host.CreateApplicationBuilder(); + builder.UseWolverine(opts => + { + opts.UseAzureServiceBusEmulator() + + // CAUTION! This deletes *every* queue and topic in the connected + // namespace at startup. It is opt in, and is only meant for the + // emulator or a throwaway namespace. Never turn this on against + // a real Azure Service Bus namespace you care about + .DeleteAllExistingObjectsOnStartup() + + .AutoProvision(); + }); + + using var host = builder.Build(); + await host.StartAsync(); + + #endregion + } + + public async Task azure_service_bus_session_identifiers() + { + #region sample_using_azure_service_bus_session_identifiers + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseAzureServiceBusEmulator() + .AutoProvision().AutoPurgeOnStartup(); + + opts.ListenToAzureServiceBusQueue("send_and_receive"); + opts.PublishMessage().ToAzureServiceBusQueue("send_and_receive"); + + opts.ListenToAzureServiceBusQueue("fifo1") + + // Require session identifiers with this queue + .RequireSessions() + + // This controls the Wolverine handling to force it to process + // messages sequentially + .Sequential(); + + opts.PublishMessage() + .ToAzureServiceBusQueue("fifo1"); + + opts.PublishMessage().ToAzureServiceBusTopic("asb3").SendInline(); + opts.ListenToAzureServiceBusSubscription("asb3") + .FromTopic("asb3") + + // Require sessions on this subscription + .RequireSessions(1) + + .ProcessInline(); + }).StartAsync(); + + #endregion + } + + public async Task disable_system_queues() + { + #region sample_disable_system_queues_in_azure_service_bus + + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.UseAzureServiceBus("some connection string") + .AutoProvision().AutoPurgeOnStartup() + .SystemQueuesAreEnabled(false); + + opts.ListenToAzureServiceBusQueue("send_and_receive"); + + opts.PublishAllMessages().ToAzureServiceBusQueue("send_and_receive"); + }).StartAsync(); + + #endregion + } + + public async Task topic_and_subscription_conventional_routing() + { + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + #region sample_using_topic_and_subscription_conventional_routing_with_azure_service_bus + + opts.UseAzureServiceBusEmulator() + .UseTopicAndSubscriptionConventionalRouting(convention => + { + // Optionally control every aspect of the convention and + // its applicability to types + // as well as overriding any listener, sender, topic, or subscription + // options + + // Can't use the full name because of limitations on name length + convention.SubscriptionNameForListener(t => t.Name.ToLowerInvariant()); + convention.TopicNameForListener(t => t.Name.ToLowerInvariant()); + convention.TopicNameForSender(t => t.Name.ToLowerInvariant()); + }) + + .AutoProvision() + .AutoPurgeOnStartup(); + + #endregion + }).StartAsync(); + } + public async Task configuring_queues() { #region sample_configuring_azure_service_bus_queues diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/emulator_configuration.cs b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/emulator_configuration.cs new file mode 100644 index 000000000..a349c6dc5 --- /dev/null +++ b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/emulator_configuration.cs @@ -0,0 +1,51 @@ +using Shouldly; +using Xunit; + +namespace Wolverine.AzureServiceBus.Tests; + +public class emulator_configuration +{ + [Fact] + public void use_the_default_emulator_connection_strings() + { + var options = new WolverineOptions(); + options.UseAzureServiceBusEmulator(); + + var transport = options.AzureServiceBusTransport(); + + transport.ConnectionString.ShouldBe(AzureServiceBusEmulatorExtensions.DefaultEmulatorConnectionString); + transport.ManagementConnectionString + .ShouldBe(AzureServiceBusEmulatorExtensions.DefaultEmulatorManagementConnectionString); + } + + [Fact] + public void use_explicit_emulator_connection_strings() + { + var options = new WolverineOptions(); + options.UseAzureServiceBusEmulator("Endpoint=sb://localhost:5673;UseDevelopmentEmulator=true;", + "Endpoint=sb://localhost:5301;UseDevelopmentEmulator=true;"); + + var transport = options.AzureServiceBusTransport(); + + transport.ConnectionString.ShouldBe("Endpoint=sb://localhost:5673;UseDevelopmentEmulator=true;"); + transport.ManagementConnectionString.ShouldBe("Endpoint=sb://localhost:5301;UseDevelopmentEmulator=true;"); + } + + [Fact] + public void the_destructive_cleanup_is_not_enabled_by_default() + { + var options = new WolverineOptions(); + options.UseAzureServiceBusEmulator(); + + options.AzureServiceBusTransport().DeleteAllExistingObjectsOnStartup.ShouldBeFalse(); + } + + [Fact] + public void opt_into_the_destructive_cleanup() + { + var options = new WolverineOptions(); + options.UseAzureServiceBusEmulator().DeleteAllExistingObjectsOnStartup(); + + options.AzureServiceBusTransport().DeleteAllExistingObjectsOnStartup.ShouldBeTrue(); + } +} diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/end_to_end.cs b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/end_to_end.cs index 481559efc..beb44fcb3 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/end_to_end.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus.Tests/end_to_end.cs @@ -15,7 +15,6 @@ public class end_to_end : IAsyncLifetime public async Task InitializeAsync() { - #region sample_using_azure_service_bus_session_identifiers _host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { @@ -55,8 +54,6 @@ public async Task InitializeAsync() .ProcessInline(); }).StartAsync(); - - #endregion } public async Task DisposeAsync() @@ -82,7 +79,6 @@ public void builds_response_and_retry_queue_by_default() [Fact] public async Task disable_system_queues() { - #region sample_disable_system_queues_in_azure_service_bus using var host = await Host.CreateDefaultBuilder() .UseWolverine(opts => { @@ -95,8 +91,6 @@ public async Task disable_system_queues() opts.PublishAllMessages().ToAzureServiceBusQueue("send_and_receive"); }).StartAsync(); - #endregion - var transport = host.GetRuntime().Options.Transports.GetOrCreate(); var endpoints = transport diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusConfiguration.cs b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusConfiguration.cs index 263f875ec..6e2df301f 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusConfiguration.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusConfiguration.cs @@ -29,6 +29,20 @@ protected override AzureServiceBusQueueSubscriberConfiguration createSubscriberE return new AzureServiceBusQueueSubscriberConfiguration(subscriberEndpoint); } + /// + /// CAUTION!!! This directs Wolverine to delete *every* queue and topic in the connected Azure Service + /// Bus namespace at application start up, before any objects are provisioned. This is destructive and + /// irreversible, and is only meant for local development or automated testing against the Azure Service + /// Bus emulator. Never enable this against a real Azure Service Bus namespace that holds anything you + /// care about. + /// + /// + public AzureServiceBusConfiguration DeleteAllExistingObjectsOnStartup() + { + Transport.DeleteAllExistingObjectsOnStartup = true; + return this; + } + /// /// Override the sending logic behavior for unknown or missing tenant ids when /// using multi-tenanted namespaces diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusEmulatorExtensions.cs b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusEmulatorExtensions.cs new file mode 100644 index 000000000..1406d15dd --- /dev/null +++ b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusEmulatorExtensions.cs @@ -0,0 +1,90 @@ +using Azure.Messaging.ServiceBus; +using Azure.Messaging.ServiceBus.Administration; + +namespace Wolverine.AzureServiceBus; + +/// +/// Helpers for connecting Wolverine to a locally running +/// Azure Service Bus emulator. +/// The emulator exposes messaging (AMQP) and management (HTTP) on two different ports, so it requires +/// both a messaging connection string and a separate management connection string. +/// +public static class AzureServiceBusEmulatorExtensions +{ + /// + /// The default messaging (AMQP) connection string for a locally hosted Azure Service Bus emulator + /// listening on the standard port 5672 + /// + public const string DefaultEmulatorConnectionString = + "Endpoint=sb://localhost:5672;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + + /// + /// The default management (HTTP) connection string for a locally hosted Azure Service Bus emulator + /// listening on the standard port 5300 + /// + public const string DefaultEmulatorManagementConnectionString = + "Endpoint=sb://localhost:5300;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;"; + + /// + /// Connect to a locally running Azure Service Bus emulator using the standard emulator connection + /// strings (AMQP on localhost:5672, management on localhost:5300). Use the overload with explicit + /// connection strings if you have mapped the emulator to different ports. + /// + /// + /// Optionally configure the underlying + /// + public static AzureServiceBusConfiguration UseAzureServiceBusEmulator(this WolverineOptions endpoints, + Action? configure = null) + { + return endpoints.UseAzureServiceBusEmulator(DefaultEmulatorConnectionString, + DefaultEmulatorManagementConnectionString, configure); + } + + /// + /// Connect to a locally running Azure Service Bus emulator with explicit messaging (AMQP) and + /// management (HTTP) connection strings + /// + /// + /// The messaging (AMQP) connection string + /// The management (HTTP) connection string used for administration + /// Optionally configure the underlying + /// + /// + public static AzureServiceBusConfiguration UseAzureServiceBusEmulator(this WolverineOptions endpoints, + string connectionString, string managementConnectionString, Action? configure = null) + { + if (managementConnectionString == null) + { + throw new ArgumentNullException(nameof(managementConnectionString)); + } + + var configuration = endpoints.UseAzureServiceBus(connectionString, configure); + + endpoints.AzureServiceBusTransport().ManagementConnectionString = managementConnectionString; + + return configuration; + } + + /// + /// CAUTION!!! This deletes every queue and topic in the targeted Azure Service Bus namespace. This is + /// meant strictly for local development and testing against the Azure Service Bus emulator. + /// + /// The management (HTTP) connection string of the emulator + /// + public static async Task DeleteAllAzureServiceBusObjectsAsync( + string managementConnectionString = DefaultEmulatorManagementConnectionString, + CancellationToken token = default) + { + var client = new ServiceBusAdministrationClient(managementConnectionString); + + await foreach (var topic in client.GetTopicsAsync(token).WithCancellation(token)) + { + await client.DeleteTopicAsync(topic.Name, token); + } + + await foreach (var queue in client.GetQueuesAsync(token).WithCancellation(token)) + { + await client.DeleteQueueAsync(queue.Name, token); + } + } +} diff --git a/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusTransport.cs b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusTransport.cs index 20c35d5c2..302c98f20 100644 --- a/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusTransport.cs +++ b/src/Transports/Azure/Wolverine.AzureServiceBus/AzureServiceBusTransport.cs @@ -42,6 +42,14 @@ public AzureServiceBusTransport(string protocolName) : base(protocolName, "Azure IdentifierDelimiter = "."; } + /// + /// CAUTION!!! If set to true, Wolverine will delete *every* queue and topic in the connected + /// Azure Service Bus namespace at application start up before provisioning any objects. This is + /// opt in, and is only intended for local development or testing against the Azure Service Bus + /// emulator. See + /// + public bool DeleteAllExistingObjectsOnStartup { get; set; } + public async Task DeleteAllObjectsAsync() { var topics = _managementClient.Value.GetTopicsAsync(); @@ -324,10 +332,18 @@ protected override AzureServiceBusEndpoint findEndpointByUri(Uri uri) throw new ArgumentOutOfRangeException(nameof(uri)); } - public override ValueTask ConnectAsync(IWolverineRuntime runtime) + public override async ValueTask ConnectAsync(IWolverineRuntime runtime) { - // we're going to use a client per endpoint - return ValueTask.CompletedTask; + if (DeleteAllExistingObjectsOnStartup) + { + runtime.Logger.LogWarning( + "Deleting all existing queues and topics in the Azure Service Bus namespace at {Broker} because DeleteAllExistingObjectsOnStartup() was enabled", + DescribeEndpoint()); + + await DeleteAllObjectsAsync(); + } + + // otherwise, we're going to use a client per endpoint } public WolverineTransportHealthCheck BuildHealthCheck(IWolverineRuntime runtime)