diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index 5d406a872e..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"); + .WithHub("hub", h => h.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..7db42a2a98 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsExtensions.cs @@ -198,9 +198,11 @@ 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};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")]