diff --git a/src/Mocha/src/Mocha.Transport.AzureServiceBus/Topology/AzureServiceBusRoutingStrategy.cs b/src/Mocha/src/Mocha.Transport.AzureServiceBus/Topology/AzureServiceBusRoutingStrategy.cs index a7eb6d7e296..f11309252ce 100644 --- a/src/Mocha/src/Mocha.Transport.AzureServiceBus/Topology/AzureServiceBusRoutingStrategy.cs +++ b/src/Mocha/src/Mocha.Transport.AzureServiceBus/Topology/AzureServiceBusRoutingStrategy.cs @@ -289,23 +289,34 @@ public override void DiscoverTopology( return; } - if (Transport.BindMode != MessagingBindMode.Implicit) - { - return; - } + // Under explicit binding, a target that is not declared is recorded in the topology + // but never provisioned. The sender does not own it. + var bindImplicitly = Transport.BindMode == MessagingBindMode.Implicit; if (azureConfiguration.TopicName is not null) { _topology.GetOrAddTopic( azureConfiguration.TopicName, - static _ => new AzureServiceBusTopicConfiguration()); + bindImplicitly + ? static _ => new AzureServiceBusTopicConfiguration() + : static _ => new AzureServiceBusTopicConfiguration + { + AutoProvision = false, + Origin = TopologyOrigin.Endpoint + }); } if (azureConfiguration.QueueName is not null) { _topology.GetOrAddQueue( azureConfiguration.QueueName, - static _ => new AzureServiceBusQueueConfiguration()); + bindImplicitly + ? static _ => new AzureServiceBusQueueConfiguration() + : static _ => new AzureServiceBusQueueConfiguration + { + AutoProvision = false, + Origin = TopologyOrigin.Endpoint + }); } } diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQDispatchEndpointDescriptor.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQDispatchEndpointDescriptor.cs index bd9de26e7d5..e8da9ba6c52 100644 --- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQDispatchEndpointDescriptor.cs +++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Descriptors/IRabbitMQDispatchEndpointDescriptor.cs @@ -8,7 +8,6 @@ public interface IRabbitMQDispatchEndpointDescriptor { /// /// Sets the endpoint to dispatch messages to the specified queue, clearing any exchange target. - /// A queue that is not declared in the transport topology is addressed by name and never provisioned. /// /// The target queue name. /// The descriptor for method chaining. @@ -16,7 +15,6 @@ public interface IRabbitMQDispatchEndpointDescriptor /// /// Sets the endpoint to dispatch messages to the specified exchange, clearing any queue target. - /// An exchange that is not declared in the transport topology is addressed by name and never provisioned. /// /// The target exchange name. /// The descriptor for method chaining. diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs index cb8f58a98dd..6918ff6f0c1 100644 --- a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs +++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQDispatchEndpoint.cs @@ -8,37 +8,17 @@ namespace Mocha.Transport.RabbitMQ; /// RabbitMQ dispatch endpoint that publishes outbound messages to a target queue or exchange /// using pooled channels from the transport's dispatcher. /// -/// -/// The target is addressed by name. A target that is part of the transport topology is provisioned -/// according to its auto-provision setting; a target outside the topology is published to by name -/// and is never declared, bound, or consumed. -/// /// The owning RabbitMQ transport instance. public sealed class RabbitMQDispatchEndpoint(RabbitMQMessagingTransport transport) : DispatchEndpoint(transport) { - private CachedString _exchangeName = CachedString.Empty; - private CachedString _routingKey = CachedString.Empty; - - /// - /// Gets the name of the target queue, or null if the endpoint targets an exchange. - /// - public string? QueueName { get; private set; } - - /// - /// Gets the name of the target exchange, or null if the endpoint targets a queue. - /// - public string? ExchangeName { get; private set; } - /// - /// Gets the topology queue this endpoint targets, or null if the endpoint targets an - /// exchange or a queue that is not part of the transport topology. + /// Gets the target queue for this endpoint, or null if the endpoint targets an exchange. /// public RabbitMQQueue? Queue { get; private set; } /// - /// Gets the topology exchange this endpoint targets, or null if the endpoint targets a - /// queue or an exchange that is not part of the transport topology. + /// Gets the target exchange for this endpoint, or null if the endpoint targets a queue. /// public RabbitMQExchange? Exchange { get; private set; } @@ -127,14 +107,19 @@ private async ValueTask DispatchAsync( } else { - exchangeName = _exchangeName; - routingKey = _routingKey; + if (Exchange is not null) + { + exchangeName = Exchange.CachedName; - if (ExchangeName is not null - && envelope.Headers is not null - && envelope.Headers.TryGet(RabbitMQMessageHeaders.RoutingKey, out var rk)) + if (envelope.Headers is not null + && envelope.Headers.TryGet(RabbitMQMessageHeaders.RoutingKey, out var rk)) + { + routingKey = new CachedString(rk); + } + } + else if (Queue is not null) { - routingKey = new CachedString(rk); + routingKey = Queue.CachedName; } } @@ -182,21 +167,22 @@ protected override void OnComplete( RabbitMQDispatchEndpointConfiguration configuration) { var topology = (RabbitMQMessagingTopology)Transport.Topology; - - if (configuration.ExchangeName is { } exchangeName) + if (configuration.ExchangeName is not null) { - ExchangeName = exchangeName; - Exchange = topology.Exchanges.FirstOrDefault(e => e.Name == exchangeName); - _exchangeName = Exchange?.CachedName ?? new CachedString(exchangeName); + Exchange = + topology.Exchanges.FirstOrDefault(e => e.Name == configuration.ExchangeName) + ?? throw new InvalidOperationException("Exchange not found"); } - else if (configuration.QueueName is { } queueName) + else if (configuration.QueueName is not null) { - QueueName = queueName; - Queue = topology.Queues.FirstOrDefault(q => q.Name == queueName); - _routingKey = Queue?.CachedName ?? new CachedString(queueName); + Queue = + topology.Queues.FirstOrDefault(q => q.Name == configuration.QueueName) + ?? throw new InvalidOperationException("Queue not found"); } - // The destination stays unset for a target outside the topology. - Destination = ((TopologyResource?)Exchange ?? Queue)!; + Destination = + Exchange as TopologyResource + ?? Queue as TopologyResource + ?? throw new InvalidOperationException("Destination is not set"); } } diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs index 279492437fe..8834858d668 100644 --- a/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs +++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/RabbitMQMessagingTransport.cs @@ -240,7 +240,12 @@ public override bool TryGetDispatchEndpoint(Uri address, [NotNullWhen(true)] out { foreach (var candidate in DispatchEndpoints) { - if (candidate.IsCompleted && candidate.Address == address) + if (!candidate.IsCompleted) + { + continue; + } + + if (candidate.Address == address) { endpoint = candidate; return true; @@ -248,35 +253,33 @@ public override bool TryGetDispatchEndpoint(Uri address, [NotNullWhen(true)] out } } - string? queueName = null; - string? exchangeName = null; - - if (address.TryParseTopologyAddress(Topology.Address, out var kind, out var name)) + if (Topology.Address.IsBaseOf(address)) { - if (kind is 'q') - { - queueName = name; - } - else if (kind is 'e') + foreach (var candidate in DispatchEndpoints) { - exchangeName = name; + if (!candidate.IsCompleted) + { + continue; + } + + if (candidate.Destination.Address == address) + { + endpoint = candidate; + return true; + } } } - else if (TryGetResourceName(address, "queue", out var resourceQueueName)) - { - queueName = resourceQueueName; - } - else if (TryGetResourceName(address, "exchange", out var resourceExchangeName)) - { - exchangeName = resourceExchangeName; - } - if (queueName is not null) + if (TryGetResourceName(address, "queue", out var queueName)) { foreach (var candidate in DispatchEndpoints) { - if (candidate is RabbitMQDispatchEndpoint { IsCompleted: true } rabbitCandidate - && rabbitCandidate.QueueName == queueName) + if (!candidate.IsCompleted) + { + continue; + } + + if (candidate.Destination is RabbitMQQueue queue && queue.Name == queueName) { endpoint = candidate; return true; @@ -284,12 +287,17 @@ public override bool TryGetDispatchEndpoint(Uri address, [NotNullWhen(true)] out } } - if (exchangeName is not null) + if (TryGetResourceName(address, "exchange", out var exchangeName)) { foreach (var candidate in DispatchEndpoints) { - if (candidate is RabbitMQDispatchEndpoint { IsCompleted: true } rabbitCandidate - && rabbitCandidate.ExchangeName == exchangeName) + if (!candidate.IsCompleted) + { + continue; + } + + if (candidate.Destination is RabbitMQExchange exchange + && exchange.Name == exchangeName) { endpoint = candidate; return true; diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs index 14cc293e1cd..f1c5f12f9fd 100644 --- a/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs +++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/Topology/RabbitMQRoutingStrategy.cs @@ -91,7 +91,7 @@ public sealed class RabbitMQRoutingStrategy : RoutingStrategy new RabbitMQExchangeConfiguration()); + bindImplicitly + ? static _ => new RabbitMQExchangeConfiguration() + : static _ => new RabbitMQExchangeConfiguration + { + AutoProvision = false, + Origin = TopologyOrigin.Endpoint + }); } - if (rabbitConfiguration.QueueName is not null && bindImplicitly) + if (rabbitConfiguration.QueueName is not null) { _topology.GetOrAddQueue( rabbitConfiguration.QueueName, - _ => new RabbitMQQueueConfiguration { AutoProvision = rabbitConfiguration.AutoProvision }); + bindImplicitly + ? _ => new RabbitMQQueueConfiguration { AutoProvision = rabbitConfiguration.AutoProvision } + : static _ => new RabbitMQQueueConfiguration + { + AutoProvision = false, + Origin = TopologyOrigin.Endpoint + }); } var schema = Transport.Schema; @@ -531,7 +540,7 @@ private bool TryGetQueueName( } } - if (address.TryParseTopologyAddress(Transport.Topology.Address, out var topologyKind, out var topologyName) + if (TryParseTopologyAddress(address, out var topologyKind, out var topologyName) && topologyKind is 'q') { queueName = topologyName; @@ -547,6 +556,75 @@ private bool TryGetQueueName( queueName = string.Empty; return false; } + + /// + /// Parses an address under the transport topology into its kind and resource name. + /// Everything after the kind segment is the name, because a queue or exchange name may + /// contain a slash. + /// + private bool TryParseTopologyAddress( + Uri address, + out char kind, + out string name) + { + kind = default; + name = string.Empty; + + // A valid resource path is not enough to identify this transport. For example, + // rabbitmq://other-host:5672/tenant-a/q/orders must not be claimed by this topology. + var topologyAddress = Transport.Topology.Address; + if (!address.Scheme.EqualsOrdinalIgnoreCase(topologyAddress.Scheme) + || !address.Host.EqualsOrdinalIgnoreCase(topologyAddress.Host) + || address.Port != topologyAddress.Port) + { + return false; + } + + // Normalize only the topology path: "/" becomes empty and "/tenant-a/" becomes + // "/tenant-a". Keep the address path unchanged while removing the topology prefix below. + var basePath = topologyAddress.AbsolutePath.AsSpan().TrimEnd('/'); + var path = address.AbsolutePath.AsSpan(); + ReadOnlySpan relativePath; + + if (basePath.IsEmpty) + { + // A root topology consumes exactly one structural slash: + // "/q/orders" becomes "q/orders". The shorthand "rabbitmq:q/orders" has no + // leading slash and is handled by the transport-specific branch above. + if (path.IsEmpty || path[0] is not '/') + { + return false; + } + + relativePath = path[1..]; + } + else + { + // Remove the exact virtual-host path and its separator: + // "/tenant-a/q/orders" becomes "q/orders". Requiring the separator prevents + // "/tenant-a" from matching "/tenant-a-other/q/orders". + if (path.Length <= basePath.Length + || !path.StartsWith(basePath, StringComparison.Ordinal) + || path[basePath.Length] is not '/') + { + return false; + } + + relativePath = path[(basePath.Length + 1)..]; + } + + // Require "{kind}/{name}", where kind is one character. Everything after the first + // slash is the name, so a name containing a slash, such as "q/nested/queue", stays intact. + if (relativePath.Length < 3 || relativePath[1] is not '/') + { + return false; + } + + kind = relativePath[0]; + // AbsolutePath is escaped, so "q/space%20name" must produce "space name". + name = Uri.UnescapeDataString(relativePath[2..].ToString()); + return true; + } } file static class Extensions diff --git a/src/Mocha/src/Mocha.Transport.RabbitMQ/UriHelpers.cs b/src/Mocha/src/Mocha.Transport.RabbitMQ/UriHelpers.cs index 5e354a34848..f7b27d87932 100644 --- a/src/Mocha/src/Mocha.Transport.RabbitMQ/UriHelpers.cs +++ b/src/Mocha/src/Mocha.Transport.RabbitMQ/UriHelpers.cs @@ -31,77 +31,4 @@ public static bool TryGetRoutingKey(this Uri uri, [NotNullWhen(true)] out string routingKey = null; return false; } - - /// - /// Attempts to parse an address under the given topology address into its resource kind and name. - /// Everything after the kind segment is the name, because a queue or exchange name may contain a slash. - /// - /// The address to parse. - /// The transport topology address the resource must belong to. - /// When this method returns true, contains the single-character resource kind, such as q or e. - /// When this method returns true, contains the unescaped resource name. - /// true if the address names a resource under the topology address; otherwise, false. - public static bool TryParseTopologyAddress( - this Uri address, - Uri topologyAddress, - out char kind, - out string name) - { - kind = default; - name = string.Empty; - - // A valid resource path is not enough to identify this transport. For example, - // rabbitmq://other-host:5672/tenant-a/q/orders must not be claimed by this topology. - if (!address.Scheme.EqualsOrdinalIgnoreCase(topologyAddress.Scheme) - || !address.Host.EqualsOrdinalIgnoreCase(topologyAddress.Host) - || address.Port != topologyAddress.Port) - { - return false; - } - - // Normalize only the topology path: "/" becomes empty and "/tenant-a/" becomes - // "/tenant-a". Keep the address path unchanged while removing the topology prefix below. - var basePath = topologyAddress.AbsolutePath.AsSpan().TrimEnd('/'); - var path = address.AbsolutePath.AsSpan(); - ReadOnlySpan relativePath; - - if (basePath.IsEmpty) - { - // A root topology consumes exactly one structural slash: - // "/q/orders" becomes "q/orders". The shorthand "rabbitmq:q/orders" has no - // leading slash and is handled by the transport-specific branch of the caller. - if (path.IsEmpty || path[0] is not '/') - { - return false; - } - - relativePath = path[1..]; - } - else - { - // Remove the exact virtual-host path and its separator: - // "/tenant-a/q/orders" becomes "q/orders". Requiring the separator prevents - // "/tenant-a" from matching "/tenant-a-other/q/orders". - if (path.Length <= basePath.Length - || !path.StartsWith(basePath, StringComparison.Ordinal) - || path[basePath.Length] is not '/') - { - return false; - } - - relativePath = path[(basePath.Length + 1)..]; - } - - // Require "{kind}/{name}", where kind is one character. Everything after the first - // slash is the name, so a name containing a slash, such as "q/nested/queue", stays intact. - if (relativePath.Length < 3 || relativePath[1] is not '/') - { - return false; - } - - kind = relativePath[0]; - // AbsolutePath is escaped, so "q/space%20name" must produce "space name". - name = Uri.UnescapeDataString(relativePath[2..].ToString()); - return true; - } } diff --git a/src/Mocha/src/Mocha/Endpoints/DispatchEndpoint.cs b/src/Mocha/src/Mocha/Endpoints/DispatchEndpoint.cs index 9b76620be6b..e9e9c729718 100644 --- a/src/Mocha/src/Mocha/Endpoints/DispatchEndpoint.cs +++ b/src/Mocha/src/Mocha/Endpoints/DispatchEndpoint.cs @@ -95,7 +95,6 @@ protected DispatchEndpoint(MessagingTransport transport) /// /// Gets the topology resource that represents the destination to which messages are dispatched. - /// A transport leaves this unset when the endpoint addresses a destination outside its topology. /// public TopologyResource Destination { get; protected set; } = null!; diff --git a/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Routing/AzureServiceBusReceiveTopologyTests.cs b/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Routing/AzureServiceBusReceiveTopologyTests.cs index 58760f1fd55..6f798dcdc21 100644 --- a/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Routing/AzureServiceBusReceiveTopologyTests.cs +++ b/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Routing/AzureServiceBusReceiveTopologyTests.cs @@ -95,23 +95,26 @@ public void DiscoverTopology_Should_SubscribeCustomDispatchTopic_When_PublishRou public void DiscoverTopology_Should_NotProvisionTopic_When_QueueBindsExplicitlyAndTopicIsUndeclared() { // arrange - // Receive topology discovery runs before dispatch topology discovery, so an unguarded - // EnsureTopic on the receive side would implicitly create "custom-orders" and mask the - // fact that it was never declared, letting the explicit-mode dispatch endpoint complete - // successfully against a topic the user never provisioned. - - // act - var exception = Record.Exception(() => CreateRuntime( + var runtime = CreateRuntime( b => b.AddConsumer(), t => { t.BindExplicitly(); t.Queue("orders").Consumer(); t.DispatchEndpoint("custom-orders-endpoint").ToTopic("custom-orders").Publish(); - })); + }); + var (topology, endpoint) = ResolveConsumerEndpoint(runtime); + + // act + var topic = topology.Topics.Single(t => t.Name == "custom-orders"); + var subscriptions = topology.Subscriptions + .Where(s => s.Source.Name == "custom-orders" && s.Destination.Name == endpoint.Queue.Name) + .ToList(); // assert - Assert.Equal("Topic not found", Assert.IsType(exception).Message); + Assert.False(topic.AutoProvision); + Assert.Equal(TopologyOrigin.Endpoint, topic.Origin); + Assert.Empty(subscriptions); } [Fact] diff --git a/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Topology/AzureServiceBusDispatchTargetTests.cs b/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Topology/AzureServiceBusDispatchTargetTests.cs new file mode 100644 index 00000000000..3a5b45419dd --- /dev/null +++ b/src/Mocha/test/Mocha.Transport.AzureServiceBus.Tests/Topology/AzureServiceBusDispatchTargetTests.cs @@ -0,0 +1,113 @@ +using CookieCrumble; +using Microsoft.Extensions.DependencyInjection; +using Mocha.Transport.AzureServiceBus.Tests.Helpers; + +namespace Mocha.Transport.AzureServiceBus.Tests.Topology; + +public class AzureServiceBusDispatchTargetTests +{ + private const string DummyConnectionString = + "Endpoint=sb://localhost/;SharedAccessKeyName=test;SharedAccessKey=test"; + + [Fact] + public void GetDispatchEndpoint_Should_AddUnprovisionedQueue_When_QueueNotDeclaredUnderExplicitBinding() + { + // arrange + var runtime = CreateRuntime(t => t.BindExplicitly()); + var transport = runtime.Transports.OfType().Single(); + + // act + var endpoint = runtime.GetDispatchEndpoint(new Uri("azuresb:q/reporting.events")); + + // assert + var azureEndpoint = Assert.IsType(endpoint); + Assert.NotNull(azureEndpoint.Queue); + Assert.False(azureEndpoint.Queue.AutoProvision); + Assert.Equal(TopologyOrigin.Endpoint, azureEndpoint.Queue.Origin); + Assert.Same(azureEndpoint.Queue, azureEndpoint.Destination); + AzureServiceBusDescribeSnapshot.Create(transport.Describe()).MatchInlineSnapshot( + """ + { + "Schema": "azuresb", + "TransportType": "AzureServiceBusMessagingTransport", + "Entities": [ + { + "Kind": "queue", + "Name": "reporting.events", + "AutoProvision": false, + "Origin": "endpoint" + } + ], + "Links": [] + } + """); + } + + [Fact] + public void GetDispatchEndpoint_Should_AddUnprovisionedTopic_When_TopicNotDeclaredUnderExplicitBinding() + { + // arrange + var runtime = CreateRuntime(t => t.BindExplicitly()); + var transport = runtime.Transports.OfType().Single(); + + // act + var endpoint = runtime.GetDispatchEndpoint(new Uri("azuresb:t/reporting-events")); + + // assert + var azureEndpoint = Assert.IsType(endpoint); + Assert.NotNull(azureEndpoint.Topic); + Assert.False(azureEndpoint.Topic.AutoProvision); + Assert.Equal(TopologyOrigin.Endpoint, azureEndpoint.Topic.Origin); + Assert.Same(azureEndpoint.Topic, azureEndpoint.Destination); + AzureServiceBusDescribeSnapshot.Create(transport.Describe()).MatchInlineSnapshot( + """ + { + "Schema": "azuresb", + "TransportType": "AzureServiceBusMessagingTransport", + "Entities": [ + { + "Kind": "topic", + "Name": "reporting-events", + "AutoProvision": false, + "Origin": "endpoint" + } + ], + "Links": [] + } + """); + } + + [Fact] + public void GetDispatchEndpoint_Should_BindDeclaredQueue_When_QueueDeclaredUnderExplicitBinding() + { + // arrange + var runtime = CreateRuntime(t => + { + t.BindExplicitly(); + t.DeclareQueue("reporting.events"); + }); + + // act + var endpoint = runtime.GetDispatchEndpoint(new Uri("azuresb:q/reporting.events")); + + // assert + var azureEndpoint = Assert.IsType(endpoint); + Assert.NotNull(azureEndpoint.Queue); + Assert.Equal(TopologyOrigin.Declared, azureEndpoint.Queue.Origin); + Assert.Same(azureEndpoint.Queue, azureEndpoint.Destination); + } + + private static MessagingRuntime CreateRuntime( + Action configureTransport) + { + var services = new ServiceCollection(); + var builder = services.AddMessageBus(); + return builder + .AddAzureServiceBus(t => + { + t.ConnectionString(DummyConnectionString); + configureTransport(t); + }) + .BuildRuntime(); + } +} diff --git a/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/RabbitMQDispatchEndpointTests.cs b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/RabbitMQDispatchEndpointTests.cs index d54e6d05ef8..35f08956a75 100644 --- a/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/RabbitMQDispatchEndpointTests.cs +++ b/src/Mocha/test/Mocha.Transport.RabbitMQ.Tests/RabbitMQDispatchEndpointTests.cs @@ -25,7 +25,6 @@ public void DispatchEndpoint_Should_TargetQueue_When_QueueConfigured() // assert Assert.NotNull(endpoint.Queue); Assert.Equal("my-q", endpoint.Queue.Name); - Assert.Equal("my-q", endpoint.QueueName); Assert.Null(endpoint.Exchange); Assert.IsType(endpoint.Destination); } @@ -49,7 +48,6 @@ public void DispatchEndpoint_Should_TargetExchange_When_ExchangeConfigured() // assert Assert.NotNull(endpoint.Exchange); Assert.Equal("my-ex", endpoint.Exchange.Name); - Assert.Equal("my-ex", endpoint.ExchangeName); Assert.Null(endpoint.Queue); Assert.IsType(endpoint.Destination); } @@ -201,11 +199,9 @@ public void DispatchEndpoint_Should_SetDestinationAddress_When_ReplyPathContains } [Fact] - public void GetDispatchEndpoint_Should_TargetQueueByName_When_QueueNotInTopology() + public void GetDispatchEndpoint_Should_AddUnprovisionedQueue_When_QueueNotDeclaredUnderExplicitBinding() { // arrange - // explicit binding adds nothing to the topology for an address-created endpoint, so the - // queue is addressed by name only var runtime = CreateRuntime(t => t.BindExplicitly()); var transport = runtime.Transports.OfType().Single(); @@ -214,22 +210,30 @@ public void GetDispatchEndpoint_Should_TargetQueueByName_When_QueueNotInTopology // assert var rabbitEndpoint = Assert.IsType(endpoint); - Assert.Equal("reporting.events", rabbitEndpoint.QueueName); - Assert.Null(rabbitEndpoint.Queue); - Assert.Null(rabbitEndpoint.Destination); + Assert.NotNull(rabbitEndpoint.Queue); + Assert.False(rabbitEndpoint.Queue.AutoProvision); + Assert.Equal(TopologyOrigin.Endpoint, rabbitEndpoint.Queue.Origin); + Assert.Same(rabbitEndpoint.Queue, rabbitEndpoint.Destination); RabbitMQDescribeSnapshot.Create(transport.Describe()).MatchInlineSnapshot( """ { "Schema": "rabbitmq", "TransportType": "RabbitMQMessagingTransport", - "Entities": [], + "Entities": [ + { + "Kind": "queue", + "Name": "reporting.events", + "AutoProvision": false, + "Origin": "endpoint" + } + ], "Links": [] } """); } [Fact] - public void GetDispatchEndpoint_Should_TargetExchangeByName_When_ExchangeNotInTopology() + public void GetDispatchEndpoint_Should_AddUnprovisionedExchange_When_ExchangeNotDeclaredUnderExplicitBinding() { // arrange var runtime = CreateRuntime(t => t.BindExplicitly()); @@ -240,15 +244,23 @@ public void GetDispatchEndpoint_Should_TargetExchangeByName_When_ExchangeNotInTo // assert var rabbitEndpoint = Assert.IsType(endpoint); - Assert.Equal("reporting-events", rabbitEndpoint.ExchangeName); - Assert.Null(rabbitEndpoint.Exchange); - Assert.Null(rabbitEndpoint.Destination); + Assert.NotNull(rabbitEndpoint.Exchange); + Assert.False(rabbitEndpoint.Exchange.AutoProvision); + Assert.Equal(TopologyOrigin.Endpoint, rabbitEndpoint.Exchange.Origin); + Assert.Same(rabbitEndpoint.Exchange, rabbitEndpoint.Destination); RabbitMQDescribeSnapshot.Create(transport.Describe()).MatchInlineSnapshot( """ { "Schema": "rabbitmq", "TransportType": "RabbitMQMessagingTransport", - "Entities": [], + "Entities": [ + { + "Kind": "exchange", + "Name": "reporting-events", + "AutoProvision": false, + "Origin": "endpoint" + } + ], "Links": [] } """); diff --git a/website/content/docs/mocha/routing-and-endpoints.md b/website/content/docs/mocha/routing-and-endpoints.md index d794c5bf19c..d197d8ef472 100644 --- a/website/content/docs/mocha/routing-and-endpoints.md +++ b/website/content/docs/mocha/routing-and-endpoints.md @@ -219,7 +219,7 @@ new SendOptions cancellationToken); ``` -The address does not have to be part of the transport topology. On RabbitMQ, `queue:` or the transport form `rabbitmq:q/` reaches a queue the host never declared, for example a queue another service owns. The host declares, binds, and consumes nothing for that queue. As with every RabbitMQ publish, a message the broker cannot route, for example because the queue does not exist, is dropped. +The queue does not have to be declared by the host. On RabbitMQ, `queue:` or the transport form `rabbitmq:q/` reaches a queue the host never declared, for example a queue another service owns. Under explicit binding, the host records that queue in its topology with auto-provisioning disabled and never declares, binds, or consumes it. A message the broker cannot route, for example because the queue does not exist, is dropped. # Customize queues and binding