From 50ee43764b0bd43bdb6f32e13fc1e6808570bed4 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Tue, 14 Jan 2025 14:06:55 -0500 Subject: [PATCH 01/16] initial implementation --- .../EventHubs.AppHost/Program.cs | 2 +- .../EventHubsConsumer/Program.cs | 12 +---- .../AzureEventHubsExtensions.cs | 6 ++- .../AzureEventHubsResource.cs | 25 +++++++++- .../EventHub.cs | 6 +++ .../PublicAPI.Unshipped.txt | 2 + .../EventHubsComponent.cs | 3 ++ .../README.md | 2 + .../AzureEventHubsExtensionsTests.cs | 47 +++++++++++++++++++ 9 files changed, 90 insertions(+), 15 deletions(-) diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index 5d406a872e..dc51ae5aea 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -7,7 +7,7 @@ var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub"); + .WithHub("hub", configure => configure.IsDefaultEntity = true); builder.AddProject("consumer") .WithReference(eventHub).WaitFor(eventHub) diff --git a/playground/AspireEventHub/EventHubsConsumer/Program.cs b/playground/AspireEventHub/EventHubsConsumer/Program.cs index f4c166471b..e58b7432c5 100644 --- a/playground/AspireEventHub/EventHubsConsumer/Program.cs +++ b/playground/AspireEventHub/EventHubsConsumer/Program.cs @@ -10,11 +10,7 @@ if (useConsumer) { - builder.AddAzureEventHubConsumerClient("eventhubns", - settings => - { - settings.EventHubName = "hub"; - }); + builder.AddAzureEventHubConsumerClient("eventhubns"); builder.Services.AddHostedService(); Console.WriteLine("Starting EventHubConsumerClient..."); @@ -24,11 +20,7 @@ // required for checkpointing our position in the event stream builder.AddAzureBlobClient("checkpoints"); - builder.AddAzureEventProcessorClient("eventhubns", - settings => - { - settings.EventHubName = "hub"; - }); + builder.AddAzureEventProcessorClient("eventhubns"); builder.Services.AddHostedService(); Console.WriteLine("Starting EventProcessorClient..."); } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index f112ead917..275d1a3e39 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -200,7 +200,9 @@ public static IResourceBuilder RunAsEmulator(this IResou // an event hub namespace without an event hub? :) if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is { } hub) { - var healthCheckConnectionString = $"{connectionString};EntityPath={hub.Name};"; + var healthCheckConnectionString = connectionString.Contains(";EntityPath=") ? + connectionString : $"{connectionString};EntityPath={hub.Name};"; + client = new EventHubProducerClient(healthCheckConnectionString); } else @@ -365,7 +367,7 @@ public static IResourceBuilder WithHostPort(this /// Path to the file on the AppHost where the emulator configuration is located. /// A reference to the . public static IResourceBuilder WithConfigurationFile(this IResourceBuilder builder, string path) - { + { // Update the existing mount var configFileMount = builder.Resource.Annotations.OfType().LastOrDefault(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath); if (configFileMount != null) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 587c0d3125..1c2cb799a2 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -47,8 +47,29 @@ public class AzureEventHubsResource(string name, Action public ReferenceExpression ConnectionStringExpression => IsEmulator - ? ReferenceExpression.Create($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true;") - : ReferenceExpression.Create($"{EventHubsEndpoint}"); + ? ReferenceExpression.Create($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true{BuildEntityPath()}") + : ReferenceExpression.Create($"{EventHubsEndpoint}{BuildEntityPath()}"); + + private string BuildEntityPath() + { + if (!Hubs.Any(hub => hub.IsDefaultEntity)) + { + // Of zero or more hubs, none are flagged as default + return string.Empty; + } + + try + { + // Of one or more hubs, only one may be flagged as default + var defaultEntity = Hubs.Single(hub => hub.IsDefaultEntity); + + return $";EntityPath={defaultEntity.Name}"; + } + catch (InvalidOperationException ex) + { + throw new InvalidOperationException("Only one EventHub can be configured as the default entity.", ex); + } + } void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) { diff --git a/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs b/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs index 8464450305..862854ecc6 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs @@ -38,6 +38,12 @@ public EventHub(string name) /// public List ConsumerGroups { get; } = []; + /// + /// If set, this EventHub will be used as the EntityPath in the resource's connection string. + /// Only one EventHub can be set as the default entity. If more than one is specified, an Exception will be raised at runtime. + /// + public bool IsDefaultEntity { get; set; } + /// /// Converts the current instance to a provisioning entity. /// diff --git a/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt index 603ab3a4a5..65556c18a2 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt @@ -9,6 +9,8 @@ Aspire.Hosting.Azure.AzureEventHubsResource.IsEmulator.get -> bool Aspire.Hosting.Azure.EventHubs.EventHub Aspire.Hosting.Azure.EventHubs.EventHub.ConsumerGroups.get -> System.Collections.Generic.List! Aspire.Hosting.Azure.EventHubs.EventHub.EventHub(string! name) -> void +Aspire.Hosting.Azure.EventHubs.EventHub.IsDefaultEntity.get -> bool +Aspire.Hosting.Azure.EventHubs.EventHub.IsDefaultEntity.set -> void Aspire.Hosting.Azure.EventHubs.EventHub.Name.get -> string! Aspire.Hosting.Azure.EventHubs.EventHub.Name.set -> void Aspire.Hosting.Azure.EventHubs.EventHub.PartitionCount.get -> long? diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index 30cee2f2f8..498bce06ac 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -105,6 +105,9 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv $"A {typeof(TClient).Name} could not be configured. Ensure a valid EventHubName was provided in " + $"the '{configurationSectionName}' configuration section, or include an EntityPath in the ConnectionString."); } + + // this is used later to create the checkpoint blob container + settings.EventHubName = props.EventHubName; } } // If we have a namespace and no connection string, ensure there's an EventHubName diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/README.md b/src/Components/Aspire.Azure.Messaging.EventHubs/README.md index f60e1904aa..2aeef2eb91 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/README.md +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/README.md @@ -70,6 +70,8 @@ builder.AddAzureEventHubProducerClient("eventHubsConnectionName", }); ``` +NOTE: Earlier versions of Aspire (<9.1) required you to always set the EventHubName here because the Azure Event Hubs Hosting component did not provide a way to specify which Event Hub was to be included in the connection string. Beginning in 9.1, it is now possible to specify which Event Hub is to be used by way of the `configuration` callback in `WithHub(...)` using the `IsDefaultEntity` boolean property. Only one Event Hub can be the default and attempts to flag multiple will elicit an Exception at runtime. + And then the connection information will be retrieved from the `ConnectionStrings` configuration section. Two connection formats are supported: #### Fully Qualified Namespace diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index a2e5026d42..35d0d424c9 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -62,6 +62,53 @@ public async Task VerifyWaitForOnEventHubsEmulatorBlocksDependentResources() await app.StopAsync(); } + [Fact] + [RequiresDocker] + [ActiveIssue("https://github.com/dotnet/aspire/issues/7093")] + public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var eventHub = builder.AddAzureEventHubs("eventhubns") + .RunAsEmulator() + .WithHub("hub", configure => configure.IsDefaultEntity = true); + + using var app = builder.Build(); + await app.StartAsync(); + + string? connectionString = + await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + + // has entitypath? + Assert.Contains(";EntityPath=hub", connectionString); + + // well-formed connectionstring? + var props = EventHubsConnectionStringProperties.Parse(connectionString); + Assert.NotNull(props); + Assert.Equal("hub", props.EventHubName); + } + + [Fact] + [RequiresDocker] + [ActiveIssue("https://github.com/dotnet/aspire/issues/7093")] + public async Task VerifyMultipleDefaultEntityThrowsInvalidOperationException() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var eventHub = builder.AddAzureEventHubs("eventhubns") + .RunAsEmulator() + .WithHub("hub", configure => configure.IsDefaultEntity = true) + .WithHub("hub2", configure => configure.IsDefaultEntity = true); + + using var app = builder.Build(); + + await app.StartAsync(); + var hb = Host.CreateApplicationBuilder(); + using var host = hb.Build(); + await host.StartAsync(); + + await Assert.ThrowsAsync( + async () => await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + } + [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/aspire/issues/6751")] From d398cf3ef25e8d350f2eb0ce0098d05759d5674b Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 15 Jan 2025 09:34:36 -0500 Subject: [PATCH 02/16] Update src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs Co-authored-by: David Fowler --- src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 275d1a3e39..7db42a2a98 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -198,7 +198,7 @@ public static IResourceBuilder RunAsEmulator(this IResou // For the purposes of the health check we only need to know a hub name. If we don't have a hub // name we can't configure a valid producer client connection so we should throw. What good is // an event hub namespace without an event hub? :) - if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is { } hub) + if (builder.Resource.Hubs is [var hub]) { var healthCheckConnectionString = connectionString.Contains(";EntityPath=") ? connectionString : $"{connectionString};EntityPath={hub.Name};"; From e236685b93d9aae9284b067821b95cc6c95abd54 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 15 Jan 2025 09:35:48 -0500 Subject: [PATCH 03/16] Update playground/AspireEventHub/EventHubs.AppHost/Program.cs Co-authored-by: David Fowler --- playground/AspireEventHub/EventHubs.AppHost/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index dc51ae5aea..5397d653b2 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -7,7 +7,7 @@ var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", configure => configure.IsDefaultEntity = true); + .WithHub("hub", h => h.IsDefaultEntity = true); builder.AddProject("consumer") .WithReference(eventHub).WaitFor(eventHub) From dd3146fcc87ecc60b0597e5f52da411decda70cb Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 15 Jan 2025 11:36:09 -0500 Subject: [PATCH 04/16] address review points --- .../AzureEventHubsExtensions.cs | 7 +++-- .../AzureEventHubsResource.cs | 26 +++++++++++++------ .../AzureEventHubsExtensionsTests.cs | 8 +++--- 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 7db42a2a98..d18a040a8b 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -11,6 +11,7 @@ using AzureProvisioning = Azure.Provisioning.EventHubs; using Microsoft.Extensions.DependencyInjection; using System.Text.Json.Nodes; +using Azure.Messaging.EventHubs; namespace Aspire.Hosting; @@ -200,8 +201,10 @@ public static IResourceBuilder RunAsEmulator(this IResou // an event hub namespace without an event hub? :) if (builder.Resource.Hubs is [var hub]) { - var healthCheckConnectionString = connectionString.Contains(";EntityPath=") ? - connectionString : $"{connectionString};EntityPath={hub.Name};"; + var props = EventHubsConnectionStringProperties.Parse(connectionString); + + var healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) ? + $"{connectionString};EntityPath={hub.Name};" : connectionString; client = new EventHubProducerClient(healthCheckConnectionString); } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 1c2cb799a2..4a768a19da 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -45,17 +45,25 @@ public class AzureEventHubsResource(string name, Action /// Gets the connection string template for the manifest for the Azure Event Hubs endpoint. /// - public ReferenceExpression ConnectionStringExpression => - IsEmulator - ? ReferenceExpression.Create($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true{BuildEntityPath()}") - : ReferenceExpression.Create($"{EventHubsEndpoint}{BuildEntityPath()}"); + public ReferenceExpression ConnectionStringExpression => BuildConnectionString(); - private string BuildEntityPath() + private ReferenceExpression BuildConnectionString() { + var builder = new ReferenceExpressionBuilder(); + + if (IsEmulator) + { + builder.Append($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true"); + } + else + { + builder.Append($"{EventHubsEndpoint}"); + } + if (!Hubs.Any(hub => hub.IsDefaultEntity)) { // Of zero or more hubs, none are flagged as default - return string.Empty; + return builder.Build(); } try @@ -63,12 +71,14 @@ private string BuildEntityPath() // Of one or more hubs, only one may be flagged as default var defaultEntity = Hubs.Single(hub => hub.IsDefaultEntity); - return $";EntityPath={defaultEntity.Name}"; + builder.Append($";EntityPath={defaultEntity.Name}"); } catch (InvalidOperationException ex) { - throw new InvalidOperationException("Only one EventHub can be configured as the default entity.", ex); + throw new DistributedApplicationException("Only one EventHub can be configured as the default entity.", ex); } + + return builder.Build(); } void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary target, string connectionName) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 35d0d424c9..4dddd9b89a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -78,10 +78,10 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() string? connectionString = await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); - // has entitypath? + // has EntityPath? Assert.Contains(";EntityPath=hub", connectionString); - // well-formed connectionstring? + // well-formed connection string? var props = EventHubsConnectionStringProperties.Parse(connectionString); Assert.NotNull(props); Assert.Equal("hub", props.EventHubName); @@ -90,7 +90,7 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/aspire/issues/7093")] - public async Task VerifyMultipleDefaultEntityThrowsInvalidOperationException() + public async Task VerifyMultipleDefaultEntityThrowsException() { using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") @@ -105,7 +105,7 @@ public async Task VerifyMultipleDefaultEntityThrowsInvalidOperationException() using var host = hb.Build(); await host.StartAsync(); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( async () => await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); } From 1f84b3a2179fa1ff68fc6c2e024de76851528afe Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Wed, 15 Jan 2025 11:44:20 -0500 Subject: [PATCH 05/16] update naming --- .../AzureEventHubsExtensionsTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 4dddd9b89a..2b5cf23b53 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -70,7 +70,7 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", configure => configure.IsDefaultEntity = true); + .WithHub("hub", hub => hub.IsDefaultEntity = true); using var app = builder.Build(); await app.StartAsync(); @@ -95,8 +95,8 @@ public async Task VerifyMultipleDefaultEntityThrowsException() using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", configure => configure.IsDefaultEntity = true) - .WithHub("hub2", configure => configure.IsDefaultEntity = true); + .WithHub("hub", hub => hub.IsDefaultEntity = true) + .WithHub("hub2", hub => hub.IsDefaultEntity = true); using var app = builder.Build(); From 0484621d9ae3f1fb05d8ed4e4982adabb6bfd5ba Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 16 Jan 2025 17:18:26 -0500 Subject: [PATCH 06/16] update connectionstring to pass entitypath as a hint to clients; fix client logic to differentiate between Uri and FQNS (prior bug!) --- .../EventHubs.AppHost/Program.cs | 3 +- .../AzureEventHubsExtensions.cs | 33 +++++++++++++-- .../AzureEventHubsResource.cs | 12 +++++- .../EventHub.cs | 6 +-- .../PublicAPI.Unshipped.txt | 3 +- .../EventHubsComponent.cs | 40 +++++++++++++++---- .../AzureEventHubsExtensionsTests.cs | 9 +++-- 7 files changed, 84 insertions(+), 22 deletions(-) diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index 5397d653b2..ee3907ecb0 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -7,7 +7,8 @@ var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", h => h.IsDefaultEntity = true); + .WithHub("hub") + .WithDefaultEntity("hub"); builder.AddProject("consumer") .WithReference(eventHub).WaitFor(eventHub) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index d18a040a8b..9ae01ac04d 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -117,6 +117,19 @@ public static IResourceBuilder WithHub(this IResourceBui return builder; } + /// + /// Specifies that the named EventHub should be used as the EntityPath in the resource's connection string. + /// Only one EventHub can be set as the default entity. If more than one is configured as default, an Exception will be raised at runtime. + /// + /// The Azure Event Hubs resource builder. + /// The name of the Event Hub. + /// A reference to the . + public static IResourceBuilder WithDefaultEntity(this IResourceBuilder builder, [ResourceName] string name) + { + // WithHub is idempotent with respect to enrolling for creation of the hub, but configuration can be applied. + return WithHub(builder, name, hub => hub.IsDefaultEntity = true); + } + /// /// Configures an Azure Event Hubs resource to be emulated. This resource requires an to be added to the application model. /// @@ -201,10 +214,24 @@ public static IResourceBuilder RunAsEmulator(this IResou // an event hub namespace without an event hub? :) if (builder.Resource.Hubs is [var hub]) { - var props = EventHubsConnectionStringProperties.Parse(connectionString); + string healthCheckConnectionString; + if (Uri.IsWellFormedUriString(connectionString, UriKind.Absolute)) + { + // Uri format + var endpoint = new Uri(connectionString, UriKind.Absolute); + + healthCheckConnectionString = endpoint.AbsolutePath == "/" ? + $"{connectionString}{hub.Name}" : connectionString; + } + else + { + // Endpoint=... format + var props = EventHubsConnectionStringProperties.Parse(connectionString); - var healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) ? - $"{connectionString};EntityPath={hub.Name};" : connectionString; + healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) + ? $"{connectionString};EntityPath={hub.Name};" + : connectionString; + } client = new EventHubProducerClient(healthCheckConnectionString); } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 4a768a19da..2e4e658f9c 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -57,6 +57,7 @@ private ReferenceExpression BuildConnectionString() } else { + // Uri format, e.g. https://... builder.Append($"{EventHubsEndpoint}"); } @@ -71,7 +72,16 @@ private ReferenceExpression BuildConnectionString() // Of one or more hubs, only one may be flagged as default var defaultEntity = Hubs.Single(hub => hub.IsDefaultEntity); - builder.Append($";EntityPath={defaultEntity.Name}"); + if (IsEmulator) + { + // Endpoint=... + builder.Append($";EntityPath={defaultEntity.Name}"); + } + else + { + // Uri (https://.../?EntityPath=hub) + builder.Append($"?EntityPath={defaultEntity.Name}"); + } } catch (InvalidOperationException ex) { diff --git a/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs b/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs index 862854ecc6..71b97d6914 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs @@ -38,11 +38,7 @@ public EventHub(string name) /// public List ConsumerGroups { get; } = []; - /// - /// If set, this EventHub will be used as the EntityPath in the resource's connection string. - /// Only one EventHub can be set as the default entity. If more than one is specified, an Exception will be raised at runtime. - /// - public bool IsDefaultEntity { get; set; } + internal bool IsDefaultEntity { get; set; } /// /// Converts the current instance to a provisioning entity. diff --git a/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt b/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt index 65556c18a2..74adb420e3 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt @@ -9,8 +9,6 @@ Aspire.Hosting.Azure.AzureEventHubsResource.IsEmulator.get -> bool Aspire.Hosting.Azure.EventHubs.EventHub Aspire.Hosting.Azure.EventHubs.EventHub.ConsumerGroups.get -> System.Collections.Generic.List! Aspire.Hosting.Azure.EventHubs.EventHub.EventHub(string! name) -> void -Aspire.Hosting.Azure.EventHubs.EventHub.IsDefaultEntity.get -> bool -Aspire.Hosting.Azure.EventHubs.EventHub.IsDefaultEntity.set -> void Aspire.Hosting.Azure.EventHubs.EventHub.Name.get -> string! Aspire.Hosting.Azure.EventHubs.EventHub.Name.set -> void Aspire.Hosting.Azure.EventHubs.EventHub.PartitionCount.get -> long? @@ -26,6 +24,7 @@ static Aspire.Hosting.AzureEventHubsExtensions.RunAsEmulator(this Aspire.Hosting static Aspire.Hosting.AzureEventHubsExtensions.WithConfigurationFile(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! path) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureEventHubsExtensions.WithDataBindMount(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? path = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureEventHubsExtensions.WithDataVolume(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string? name = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! +static Aspire.Hosting.AzureEventHubsExtensions.WithDefaultEntity(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureEventHubsExtensions.WithGatewayPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureEventHubsExtensions.WithHostPort(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, int? port) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! static Aspire.Hosting.AzureEventHubsExtensions.WithHub(this Aspire.Hosting.ApplicationModel.IResourceBuilder! builder, string! name, System.Action? configure = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder! diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index 498bce06ac..503ac93cfe 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics; using System.Security.Cryptography; using Aspire.Azure.Common; using Aspire.Azure.Messaging.EventHubs; @@ -95,27 +96,52 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv // We have a connection string -- do we have an EventHubName? if (string.IsNullOrWhiteSpace(settings.EventHubName)) { - // look for EntityPath - var props = EventHubsConnectionStringProperties.Parse(connectionString); + // no EventHubName in callback -- try to extract it from the connection string + string? eventHubName = null; - // if EntityPath is missing, throw - if (string.IsNullOrWhiteSpace(props.EventHubName)) + // look for special EntityPath "hint" in the FQNS style connection string + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var fqns)) + { + var query = System.Web.HttpUtility.ParseQueryString(fqns.Query); + if (query.HasKeys() && query.AllKeys.Contains("EntityPath")) + { + eventHubName = query["EntityPath"]; + + // we control the query string, so this should never happen + Debug.Assert(!string.IsNullOrWhiteSpace(eventHubName)); + } + } + else + { + // look for EntityPath in the Endpoint style connection string + var props = EventHubsConnectionStringProperties.Parse(connectionString); + + // if EntityPath is found, capture it + if (!string.IsNullOrWhiteSpace(props.EventHubName)) + { + eventHubName = props.EventHubName; + } + } + + if (eventHubName == null) { throw new InvalidOperationException( $"A {typeof(TClient).Name} could not be configured. Ensure a valid EventHubName was provided in " + - $"the '{configurationSectionName}' configuration section, or include an EntityPath in the ConnectionString."); + $"the '{configurationSectionName}' configuration section, or assign one in the settings callback for this client."); } // this is used later to create the checkpoint blob container - settings.EventHubName = props.EventHubName; + settings.EventHubName = eventHubName; } } // If we have a namespace and no connection string, ensure there's an EventHubName else if (!string.IsNullOrWhiteSpace(settings.FullyQualifiedNamespace) && string.IsNullOrWhiteSpace(settings.EventHubName)) { + // NOTE: We don't handle the case where the EventHubName is in the connection string as a "hint" as this is an internal Aspire scenario. + // In the case where the user is setting the FQNS, they should also be providing the EventHubName. throw new InvalidOperationException( $"A {typeof(TClient).Name} could not be configured. Ensure a valid EventHubName was provided in " + - $"the '{configurationSectionName}' configuration section."); + $"the '{configurationSectionName}' configuration section, or assign one in the settings callback for this client."); } } } diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 2b5cf23b53..5f4573b8fd 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -70,7 +70,8 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", hub => hub.IsDefaultEntity = true); + .WithHub("hub") + .WithDefaultEntity("hub"); using var app = builder.Build(); await app.StartAsync(); @@ -95,8 +96,10 @@ public async Task VerifyMultipleDefaultEntityThrowsException() using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub", hub => hub.IsDefaultEntity = true) - .WithHub("hub2", hub => hub.IsDefaultEntity = true); + .WithHub("hub") + .WithHub("hub2") + .WithDefaultEntity("hub") + .WithDefaultEntity("hub2"); using var app = builder.Build(); From 6c0e11c5c25f5808397257fd007e8c94b971a220 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 16 Jan 2025 17:35:54 -0500 Subject: [PATCH 07/16] update logic for healthcheck url for RunAsEmulator --- .../AzureEventHubsExtensions.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 9ae01ac04d..f815df0814 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -215,13 +215,13 @@ public static IResourceBuilder RunAsEmulator(this IResou if (builder.Resource.Hubs is [var hub]) { string healthCheckConnectionString; - if (Uri.IsWellFormedUriString(connectionString, UriKind.Absolute)) + + // NOTE: the emulator doesn't currently support FQNS style connection strings, but I'm leaving this here for the future + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var endpoint)) { - // Uri format - var endpoint = new Uri(connectionString, UriKind.Absolute); - - healthCheckConnectionString = endpoint.AbsolutePath == "/" ? - $"{connectionString}{hub.Name}" : connectionString; + // Uri format (FQNS) + healthCheckConnectionString = endpoint.Query == string.Empty ? + $"{connectionString}?EntityPath={hub.Name}" : connectionString; } else { From c5a98fa07c3cddc44600c50dba23559a0a4056f9 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Thu, 16 Jan 2025 17:47:50 -0500 Subject: [PATCH 08/16] improve logic for WithDefaultEntity validation and throw early --- .../AzureEventHubsExtensions.cs | 17 +++++++++++-- .../AzureEventHubsResource.cs | 25 +++++++------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index f815df0814..9a602260ca 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -126,8 +126,21 @@ public static IResourceBuilder WithHub(this IResourceBui /// A reference to the . public static IResourceBuilder WithDefaultEntity(this IResourceBuilder builder, [ResourceName] string name) { - // WithHub is idempotent with respect to enrolling for creation of the hub, but configuration can be applied. - return WithHub(builder, name, hub => hub.IsDefaultEntity = true); + // Only one event hub can be the default entity + if (builder.Resource.Hubs.Any(h => h.IsDefaultEntity)) + { + throw new DistributedApplicationException("Only one EventHub can be configured as the default entity."); + } + + // We need to ensure that the hub exists before we can set it as the default entity. + if (builder.Resource.Hubs.Any(h => h.Name == name)) + { + // WithHub is idempotent with respect to enrolling for creation of the hub, but configuration can be applied. + return WithHub(builder, name, hub => hub.IsDefaultEntity = true); + } + + throw new DistributedApplicationException( + $"The specified EventHub does not exist in the Azure Event Hubs resource. Please ensure there is a call to WithHub(\"{name}\") before this call."); } /// diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 2e4e658f9c..55be13f9cb 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -67,25 +67,18 @@ private ReferenceExpression BuildConnectionString() return builder.Build(); } - try - { - // Of one or more hubs, only one may be flagged as default - var defaultEntity = Hubs.Single(hub => hub.IsDefaultEntity); + // Of one or more hubs, only one may be flagged as default + var defaultEntity = Hubs.Single(hub => hub.IsDefaultEntity); - if (IsEmulator) - { - // Endpoint=... - builder.Append($";EntityPath={defaultEntity.Name}"); - } - else - { - // Uri (https://.../?EntityPath=hub) - builder.Append($"?EntityPath={defaultEntity.Name}"); - } + if (IsEmulator) + { + // Endpoint=... + builder.Append($";EntityPath={defaultEntity.Name}"); } - catch (InvalidOperationException ex) + else { - throw new DistributedApplicationException("Only one EventHub can be configured as the default entity.", ex); + // Uri (https://.../?EntityPath=hub) + builder.Append($"?EntityPath={defaultEntity.Name}"); } return builder.Build(); From dd17cba48c1f95ffe4960583350ebebbea81df6a Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 17 Jan 2025 17:36:35 -0500 Subject: [PATCH 09/16] fix some validation logic; add FQNS hint parsing in client base component --- .../EventHubs.AppHost/Program.cs | 6 ++ .../AspireEventHub/EventHubsApi/Program.cs | 4 +- .../EventHubsConsumer/Program.cs | 11 +++- .../AzureEventHubsExtensions.cs | 2 +- .../AzureEventHubsResource.cs | 5 +- .../EventHubsComponent.cs | 60 +++++++------------ .../EventProcessorClientComponent.cs | 1 + .../AzureEventHubsExtensionsTests.cs | 21 +++---- 8 files changed, 55 insertions(+), 55 deletions(-) diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index ee3907ecb0..38da43f58c 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -1,12 +1,18 @@ +//#define EMULATOR + var builder = DistributedApplication.CreateBuilder(args); // required for the event processor client which will use the connectionName to get the connectionString. var blob = builder.AddAzureStorage("ehstorage") +#if EMULATOR .RunAsEmulator() +#endif .AddBlobs("checkpoints"); var eventHub = builder.AddAzureEventHubs("eventhubns") +#if EMULATOR .RunAsEmulator() +#endif .WithHub("hub") .WithDefaultEntity("hub"); diff --git a/playground/AspireEventHub/EventHubsApi/Program.cs b/playground/AspireEventHub/EventHubsApi/Program.cs index b9fb2a459f..c4f4397876 100644 --- a/playground/AspireEventHub/EventHubsApi/Program.cs +++ b/playground/AspireEventHub/EventHubsApi/Program.cs @@ -1,3 +1,4 @@ +using Azure.Identity; using Azure.Messaging.EventHubs; using Azure.Messaging.EventHubs.Producer; @@ -7,7 +8,8 @@ builder.AddAzureEventHubProducerClient("eventhubns", settings => { - settings.EventHubName = "hub"; + //settings.EventHubName = "hub"; + settings.Credential = new AzureCliCredential(); }); var app = builder.Build(); diff --git a/playground/AspireEventHub/EventHubsConsumer/Program.cs b/playground/AspireEventHub/EventHubsConsumer/Program.cs index e58b7432c5..fced41247a 100644 --- a/playground/AspireEventHub/EventHubsConsumer/Program.cs +++ b/playground/AspireEventHub/EventHubsConsumer/Program.cs @@ -1,3 +1,4 @@ +using Azure.Identity; using EventHubsConsumer; var builder = Host.CreateApplicationBuilder(args); @@ -10,7 +11,8 @@ if (useConsumer) { - builder.AddAzureEventHubConsumerClient("eventhubns"); + builder.AddAzureEventHubConsumerClient("eventhubns", + settings => settings.Credential = new AzureCliCredential()); builder.Services.AddHostedService(); Console.WriteLine("Starting EventHubConsumerClient..."); @@ -18,9 +20,12 @@ else { // required for checkpointing our position in the event stream - builder.AddAzureBlobClient("checkpoints"); + builder.AddAzureBlobClient("checkpoints", + settings => settings.Credential = new AzureCliCredential()); + + builder.AddAzureEventProcessorClient("eventhubns", + settings => settings.Credential = new AzureCliCredential()); - builder.AddAzureEventProcessorClient("eventhubns"); builder.Services.AddHostedService(); Console.WriteLine("Starting EventProcessorClient..."); } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 9a602260ca..157ad201d5 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -127,7 +127,7 @@ public static IResourceBuilder WithHub(this IResourceBui public static IResourceBuilder WithDefaultEntity(this IResourceBuilder builder, [ResourceName] string name) { // Only one event hub can be the default entity - if (builder.Resource.Hubs.Any(h => h.IsDefaultEntity)) + if (builder.Resource.Hubs.Any(h => h.IsDefaultEntity && h.Name != name)) { throw new DistributedApplicationException("Only one EventHub can be configured as the default entity."); } diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs index 55be13f9cb..342a2033f4 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -53,11 +53,12 @@ private ReferenceExpression BuildConnectionString() if (IsEmulator) { + // ConnectionString: Endpoint=... builder.Append($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true"); } else { - // Uri format, e.g. https://... + // FQNS: Uri format, e.g. https://... builder.Append($"{EventHubsEndpoint}"); } @@ -72,12 +73,10 @@ private ReferenceExpression BuildConnectionString() if (IsEmulator) { - // Endpoint=... builder.Append($";EntityPath={defaultEntity.Name}"); } else { - // Uri (https://.../?EntityPath=hub) builder.Append($"?EntityPath={defaultEntity.Name}"); } diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index 503ac93cfe..1579b0b81e 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -59,7 +59,7 @@ protected static string GetNamespaceFromSettings(AzureMessagingEventHubsSettings // This is likely to be similar to {yournamespace}.servicebus.windows.net or {yournamespace}.servicebus.chinacloudapi.cn if (ns.Contains(".servicebus", StringComparison.OrdinalIgnoreCase)) { - ns = ns[..ns.IndexOf(".servicebus")]; + ns = ns[..ns.IndexOf(".servicebus", StringComparison.OrdinalIgnoreCase)]; } else { @@ -90,55 +90,41 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv $"'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'FullyQualifiedNamespace' in the '{configurationSectionName}' configuration section."); } - // If we have a connection string, ensure there's an EntityPath if settings.EventHubName is missing + // Emulator: If we have a connection string, ensure there's an EntityPath if settings.EventHubName is missing if (!string.IsNullOrWhiteSpace(settings.ConnectionString)) { // We have a connection string -- do we have an EventHubName? if (string.IsNullOrWhiteSpace(settings.EventHubName)) - { - // no EventHubName in callback -- try to extract it from the connection string - string? eventHubName = null; + { + // look for EntityPath in the Endpoint style connection string + var props = EventHubsConnectionStringProperties.Parse(connectionString); - // look for special EntityPath "hint" in the FQNS style connection string - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var fqns)) + // if EntityPath is found, capture it + if (!string.IsNullOrWhiteSpace(props.EventHubName)) { - var query = System.Web.HttpUtility.ParseQueryString(fqns.Query); - if (query.HasKeys() && query.AllKeys.Contains("EntityPath")) - { - eventHubName = query["EntityPath"]; - - // we control the query string, so this should never happen - Debug.Assert(!string.IsNullOrWhiteSpace(eventHubName)); - } + // this is used later to create the checkpoint blob container + settings.EventHubName = props.EventHubName; } - else + } + } + // Live: If we have a namespace and no connection string, ensure there's an EventHubName (also look for hint in FQNS) + else if (!string.IsNullOrWhiteSpace(settings.FullyQualifiedNamespace) && string.IsNullOrWhiteSpace(settings.EventHubName)) + { + if (Uri.TryCreate(settings.FullyQualifiedNamespace, UriKind.Absolute, out var fqns)) + { + var query = System.Web.HttpUtility.ParseQueryString(fqns.Query); + if (query.HasKeys() && query.AllKeys.Contains("EntityPath")) { - // look for EntityPath in the Endpoint style connection string - var props = EventHubsConnectionStringProperties.Parse(connectionString); - - // if EntityPath is found, capture it - if (!string.IsNullOrWhiteSpace(props.EventHubName)) - { - eventHubName = props.EventHubName; - } - } + settings.EventHubName = query["EntityPath"]; - if (eventHubName == null) - { - throw new InvalidOperationException( - $"A {typeof(TClient).Name} could not be configured. Ensure a valid EventHubName was provided in " + - $"the '{configurationSectionName}' configuration section, or assign one in the settings callback for this client."); + // we control the query string, so this should never happen + Debug.Assert(!string.IsNullOrWhiteSpace(settings.EventHubName)); } - - // this is used later to create the checkpoint blob container - settings.EventHubName = eventHubName; } } - // If we have a namespace and no connection string, ensure there's an EventHubName - else if (!string.IsNullOrWhiteSpace(settings.FullyQualifiedNamespace) && string.IsNullOrWhiteSpace(settings.EventHubName)) + + if (string.IsNullOrWhiteSpace(settings.EventHubName)) { - // NOTE: We don't handle the case where the EventHubName is in the connection string as a "hint" as this is an internal Aspire scenario. - // In the case where the user is setting the FQNS, they should also be providing the EventHubName. throw new InvalidOperationException( $"A {typeof(TClient).Name} could not be configured. Ensure a valid EventHubName was provided in " + $"the '{configurationSectionName}' configuration section, or assign one in the settings callback for this client."); diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs index 6700975ff8..71a8ba051f 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs @@ -111,6 +111,7 @@ private static BlobContainerClient GetBlobContainerClient( } var containerClient = blobClient.GetBlobContainerClient(settings.BlobContainerName); + if (shouldTryCreateIfNotExists) { diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 5f4573b8fd..304b4465ff 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -76,6 +76,8 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() using var app = builder.Build(); await app.StartAsync(); + // since we're running in Docker, this only tests the ConnectionString with the Emulator + // when using the real service, we pass a hint in the FQNS to the client. We can't test that here. string? connectionString = await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); @@ -91,25 +93,24 @@ public async Task VerifyEntityPathInConnectionStringForIsDefaultEntity() [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/aspire/issues/7093")] - public async Task VerifyMultipleDefaultEntityThrowsException() + public Task VerifyMultipleDefaultEntityThrowsException() { using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() .WithHub("hub") .WithHub("hub2") - .WithDefaultEntity("hub") - .WithDefaultEntity("hub2"); + .WithDefaultEntity("hub"); - using var app = builder.Build(); + // should throw for a second hub with default entity + Assert.Throws(() => eventHub.WithDefaultEntity("hub2")); - await app.StartAsync(); - var hb = Host.CreateApplicationBuilder(); - using var host = hb.Build(); - await host.StartAsync(); + // should not throw for same hub again + eventHub.WithDefaultEntity("hub"); + + using var app = builder.Build(); - await Assert.ThrowsAsync( - async () => await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None)); + return Task.CompletedTask; } [Fact] From f438fd827a1eceec902ec3ad4cc8484a4a62c152 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 17 Jan 2025 18:40:37 -0500 Subject: [PATCH 10/16] clean up emulator healthcheck code; add defines to playground to ease testing --- .../EventHubs.AppHost/Program.cs | 3 ++- .../AspireEventHub/EventHubsApi/Program.cs | 13 +++++---- .../EventHubsConsumer/Program.cs | 27 +++++++++++++------ .../AzureEventHubsExtensions.cs | 24 +++++------------ 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index 38da43f58c..bf532d7aed 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -1,4 +1,4 @@ -//#define EMULATOR +#define EMULATOR var builder = DistributedApplication.CreateBuilder(args); @@ -14,6 +14,7 @@ .RunAsEmulator() #endif .WithHub("hub") + .WithHub("hub2") .WithDefaultEntity("hub"); builder.AddProject("consumer") diff --git a/playground/AspireEventHub/EventHubsApi/Program.cs b/playground/AspireEventHub/EventHubsApi/Program.cs index c4f4397876..32e9889660 100644 --- a/playground/AspireEventHub/EventHubsApi/Program.cs +++ b/playground/AspireEventHub/EventHubsApi/Program.cs @@ -1,4 +1,7 @@ +#define AZCLI +#if AZCLI using Azure.Identity; +#endif using Azure.Messaging.EventHubs; using Azure.Messaging.EventHubs.Producer; @@ -6,11 +9,11 @@ builder.AddServiceDefaults(); -builder.AddAzureEventHubProducerClient("eventhubns", settings => -{ - //settings.EventHubName = "hub"; - settings.Credential = new AzureCliCredential(); -}); +builder.AddAzureEventHubProducerClient("eventhubns" +#if AZCLI + , settings => settings.Credential = new AzureCliCredential() +#endif +); var app = builder.Build(); diff --git a/playground/AspireEventHub/EventHubsConsumer/Program.cs b/playground/AspireEventHub/EventHubsConsumer/Program.cs index fced41247a..5775cf27fd 100644 --- a/playground/AspireEventHub/EventHubsConsumer/Program.cs +++ b/playground/AspireEventHub/EventHubsConsumer/Program.cs @@ -1,4 +1,7 @@ +//#define AZCLI +#if AZCLI using Azure.Identity; +#endif using EventHubsConsumer; var builder = Host.CreateApplicationBuilder(args); @@ -11,20 +14,28 @@ if (useConsumer) { - builder.AddAzureEventHubConsumerClient("eventhubns", - settings => settings.Credential = new AzureCliCredential()); - + builder.AddAzureEventHubConsumerClient("eventhubns" +#if AZCLI + , settings => settings.Credential = new AzureCliCredential() +#endif + ); builder.Services.AddHostedService(); Console.WriteLine("Starting EventHubConsumerClient..."); } else { // required for checkpointing our position in the event stream - builder.AddAzureBlobClient("checkpoints", - settings => settings.Credential = new AzureCliCredential()); - - builder.AddAzureEventProcessorClient("eventhubns", - settings => settings.Credential = new AzureCliCredential()); + builder.AddAzureBlobClient("checkpoints" +#if AZCLI + , settings => settings.Credential = new AzureCliCredential() +#endif + ); + + builder.AddAzureEventProcessorClient("eventhubns" +#if AZCLI + , settings => settings.Credential = new AzureCliCredential() +#endif + ); builder.Services.AddHostedService(); Console.WriteLine("Starting EventProcessorClient..."); diff --git a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs index 157ad201d5..d12109214c 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -225,26 +225,14 @@ public static IResourceBuilder RunAsEmulator(this IResou // For the purposes of the health check we only need to know a hub name. If we don't have a hub // name we can't configure a valid producer client connection so we should throw. What good is // an event hub namespace without an event hub? :) - if (builder.Resource.Hubs is [var hub]) + if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is { } hub) { - string healthCheckConnectionString; + // Endpoint=... format + var props = EventHubsConnectionStringProperties.Parse(connectionString); - // NOTE: the emulator doesn't currently support FQNS style connection strings, but I'm leaving this here for the future - if (Uri.TryCreate(connectionString, UriKind.Absolute, out var endpoint)) - { - // Uri format (FQNS) - healthCheckConnectionString = endpoint.Query == string.Empty ? - $"{connectionString}?EntityPath={hub.Name}" : connectionString; - } - else - { - // Endpoint=... format - var props = EventHubsConnectionStringProperties.Parse(connectionString); - - healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) - ? $"{connectionString};EntityPath={hub.Name};" - : connectionString; - } + var healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) + ? $"{connectionString};EntityPath={hub.Name};" + : connectionString; client = new EventHubProducerClient(healthCheckConnectionString); } From 82c76228f20f55f5b96e088a9445181650ce5588 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 17 Jan 2025 18:41:24 -0500 Subject: [PATCH 11/16] missed a change --- playground/AspireEventHub/EventHubsApi/Program.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playground/AspireEventHub/EventHubsApi/Program.cs b/playground/AspireEventHub/EventHubsApi/Program.cs index 32e9889660..0ef18d09f3 100644 --- a/playground/AspireEventHub/EventHubsApi/Program.cs +++ b/playground/AspireEventHub/EventHubsApi/Program.cs @@ -1,4 +1,4 @@ -#define AZCLI +//#define AZCLI #if AZCLI using Azure.Identity; #endif From c7069f822700536f39cbf3170b67b1eae806000d Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Fri, 17 Jan 2025 19:11:55 -0500 Subject: [PATCH 12/16] Update README.md update note for README about WithDefaultEntity --- src/Components/Aspire.Azure.Messaging.EventHubs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/README.md b/src/Components/Aspire.Azure.Messaging.EventHubs/README.md index 2aeef2eb91..41b9818eae 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/README.md +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/README.md @@ -70,7 +70,7 @@ builder.AddAzureEventHubProducerClient("eventHubsConnectionName", }); ``` -NOTE: Earlier versions of Aspire (<9.1) required you to always set the EventHubName here because the Azure Event Hubs Hosting component did not provide a way to specify which Event Hub was to be included in the connection string. Beginning in 9.1, it is now possible to specify which Event Hub is to be used by way of the `configuration` callback in `WithHub(...)` using the `IsDefaultEntity` boolean property. Only one Event Hub can be the default and attempts to flag multiple will elicit an Exception at runtime. +NOTE: Earlier versions of Aspire (<9.1) required you to always set the EventHubName here because the Azure Event Hubs Hosting component did not provide a way to specify which Event Hub was to be included in the connection string. Beginning in 9.1, it is now possible to specify which Event Hub is to be used by way of calling `WithDefaultEntity(string)` with the name of a hub you have added via `WithHub(string)`. Only one Event Hub can be the default and attempts to flag multiple will elicit an Exception at runtime. And then the connection information will be retrieved from the `ConnectionStrings` configuration section. Two connection formats are supported: From 6ff7a82b3768f7c86de6cad8e4630709550e9f86 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Sat, 18 Jan 2025 10:08:06 -0500 Subject: [PATCH 13/16] replace systemweb cruft with lovely spans for davidfowl --- .../EventHubsComponent.cs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index 1579b0b81e..f060e4834c 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -112,12 +112,20 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv { if (Uri.TryCreate(settings.FullyQualifiedNamespace, UriKind.Absolute, out var fqns)) { - var query = System.Web.HttpUtility.ParseQueryString(fqns.Query); - if (query.HasKeys() && query.AllKeys.Contains("EntityPath")) + var query = fqns.Query.AsSpan().TrimStart('?'); + + var key = "EntityPath="; + int startIndex = query.IndexOf(key); + + if (startIndex != -1) { - settings.EventHubName = query["EntityPath"]; + var valueSpan = query.Slice(startIndex + key.Length); + int endIndex = valueSpan.IndexOf('&'); + var entityPath = endIndex == -1 ? valueSpan : + valueSpan.Slice(0, endIndex); + + settings.EventHubName = entityPath.ToString(); - // we control the query string, so this should never happen Debug.Assert(!string.IsNullOrWhiteSpace(settings.EventHubName)); } } From bdccf109358e4cb59276534aee15119e3bbaf58b Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Mon, 27 Jan 2025 20:34:55 -0500 Subject: [PATCH 14/16] Update src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs Co-authored-by: Eric Erhardt --- .../EventProcessorClientComponent.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs index 71a8ba051f..6700975ff8 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventProcessorClientComponent.cs @@ -111,7 +111,6 @@ private static BlobContainerClient GetBlobContainerClient( } var containerClient = blobClient.GetBlobContainerClient(settings.BlobContainerName); - if (shouldTryCreateIfNotExists) { From 4f08582eb4042ba4e8a6b6b0e45a9c9a8d4ef127 Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Mon, 27 Jan 2025 20:37:04 -0500 Subject: [PATCH 15/16] Update src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs Co-authored-by: Eric Erhardt --- .../Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index f060e4834c..6c6bf5f88c 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -90,7 +90,7 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv $"'ConnectionStrings:{connectionName}' or specify a 'ConnectionString' or 'FullyQualifiedNamespace' in the '{configurationSectionName}' configuration section."); } - // Emulator: If we have a connection string, ensure there's an EntityPath if settings.EventHubName is missing + // If we have a connection string, ensure there's an EntityPath if settings.EventHubName is missing if (!string.IsNullOrWhiteSpace(settings.ConnectionString)) { // We have a connection string -- do we have an EventHubName? From af0ac0c0143de8e1754f8abc2d9a2d97d6c3597a Mon Sep 17 00:00:00 2001 From: Oisin Grehan Date: Mon, 27 Jan 2025 20:37:14 -0500 Subject: [PATCH 16/16] Update src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs Co-authored-by: Eric Erhardt --- .../Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs index 6c6bf5f88c..a9651895e4 100644 --- a/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs +++ b/src/Components/Aspire.Azure.Messaging.EventHubs/EventHubsComponent.cs @@ -107,7 +107,7 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv } } } - // Live: If we have a namespace and no connection string, ensure there's an EventHubName (also look for hint in FQNS) + // If we have a namespace and no connection string, ensure there's an EventHubName (also look for hint in FQNS) else if (!string.IsNullOrWhiteSpace(settings.FullyQualifiedNamespace) && string.IsNullOrWhiteSpace(settings.EventHubName)) { if (Uri.TryCreate(settings.FullyQualifiedNamespace, UriKind.Absolute, out var fqns))