diff --git a/playground/AspireEventHub/EventHubs.AppHost/Program.cs b/playground/AspireEventHub/EventHubs.AppHost/Program.cs index 5d406a872ee..765b089117a 100644 --- a/playground/AspireEventHub/EventHubs.AppHost/Program.cs +++ b/playground/AspireEventHub/EventHubs.AppHost/Program.cs @@ -7,7 +7,9 @@ var eventHub = builder.AddAzureEventHubs("eventhubns") .RunAsEmulator() - .WithHub("hub"); + .WithHub("hub") + .WithHub("hub2") + .WithDefaultEntity("hub"); builder.AddProject("consumer") .WithReference(eventHub).WaitFor(eventHub) diff --git a/playground/AspireEventHub/EventHubsApi/Program.cs b/playground/AspireEventHub/EventHubsApi/Program.cs index b9fb2a459f1..921e9a05cdc 100644 --- a/playground/AspireEventHub/EventHubsApi/Program.cs +++ b/playground/AspireEventHub/EventHubsApi/Program.cs @@ -5,10 +5,7 @@ builder.AddServiceDefaults(); -builder.AddAzureEventHubProducerClient("eventhubns", settings => -{ - settings.EventHubName = "hub"; -}); +builder.AddAzureEventHubProducerClient("eventhubns"); var app = builder.Build(); diff --git a/playground/AspireEventHub/EventHubsConsumer/Program.cs b/playground/AspireEventHub/EventHubsConsumer/Program.cs index f4c166471b0..b7697dc0e96 100644 --- a/playground/AspireEventHub/EventHubsConsumer/Program.cs +++ b/playground/AspireEventHub/EventHubsConsumer/Program.cs @@ -10,12 +10,7 @@ if (useConsumer) { - builder.AddAzureEventHubConsumerClient("eventhubns", - settings => - { - settings.EventHubName = "hub"; - }); - + builder.AddAzureEventHubConsumerClient("eventhubns"); builder.Services.AddHostedService(); Console.WriteLine("Starting EventHubConsumerClient..."); } @@ -24,11 +19,8 @@ // 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 37eeae1f879..80db4586b76 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; @@ -116,6 +117,32 @@ 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) + { + // Only one event hub can be the default entity + if (builder.Resource.Hubs.Any(h => h.IsDefaultEntity && h.Name != name)) + { + 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."); + } + /// /// Configures an Azure Event Hubs resource to be emulated. This resource requires an to be added to the application model. /// @@ -201,7 +228,13 @@ 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};"; + // Endpoint=... format + var props = EventHubsConnectionStringProperties.Parse(connectionString); + + var healthCheckConnectionString = string.IsNullOrEmpty(props.EventHubName) + ? $"{connectionString};EntityPath={hub.Name};" + : connectionString; + client = new EventHubProducerClient(healthCheckConnectionString); } else @@ -366,7 +399,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 587c0d3125d..4dba137234e 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs @@ -45,10 +45,34 @@ 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;") - : ReferenceExpression.Create($"{EventHubsEndpoint}"); + public ReferenceExpression ConnectionStringExpression => BuildConnectionString(); + + private ReferenceExpression BuildConnectionString() + { + var builder = new ReferenceExpressionBuilder(); + + string entityPathSeparator; + + if (IsEmulator) + { + builder.Append($"Endpoint=sb://{EmulatorEndpoint.Property(EndpointProperty.Host)}:{EmulatorEndpoint.Property(EndpointProperty.Port)};SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=SAS_KEY_VALUE;UseDevelopmentEmulator=true"); + entityPathSeparator = ";"; + } + else + { + builder.Append($"{EventHubsEndpoint}"); + entityPathSeparator = "?"; + } + + // WithDefaultEntity guards against multiple default entities. + var defaultEntity = Hubs.SingleOrDefault(hub => hub.IsDefaultEntity); + if (defaultEntity is not null) + { + builder.Append($"{entityPathSeparator}EntityPath={defaultEntity.Name}"); + } + + return builder.Build(); + } 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 8464450305d..71b97d69146 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs +++ b/src/Aspire.Hosting.Azure.EventHubs/EventHub.cs @@ -38,6 +38,8 @@ public EventHub(string name) /// public List ConsumerGroups { get; } = []; + 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 603ab3a4a52..74adb420e37 100644 --- a/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt +++ b/src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt @@ -24,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 30cee2f2f8a..a9651895e4e 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; @@ -58,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 { @@ -94,25 +95,47 @@ protected static void EnsureConnectionStringOrNamespaceProvided(AzureMessagingEv { // We have a connection string -- do we have an EventHubName? if (string.IsNullOrWhiteSpace(settings.EventHubName)) - { - // look for EntityPath + { + // look for EntityPath in the Endpoint style connection string var props = EventHubsConnectionStringProperties.Parse(connectionString); - // if EntityPath is missing, throw - if (string.IsNullOrWhiteSpace(props.EventHubName)) + // if EntityPath is found, capture it + if (!string.IsNullOrWhiteSpace(props.EventHubName)) { - 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."); + // 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 + // 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 = fqns.Query.AsSpan().TrimStart('?'); + + var key = "EntityPath="; + int startIndex = query.IndexOf(key); + + if (startIndex != -1) + { + var valueSpan = query.Slice(startIndex + key.Length); + int endIndex = valueSpan.IndexOf('&'); + var entityPath = endIndex == -1 ? valueSpan : + valueSpan.Slice(0, endIndex); + + settings.EventHubName = entityPath.ToString(); + + Debug.Assert(!string.IsNullOrWhiteSpace(settings.EventHubName)); + } + } + } + + if (string.IsNullOrWhiteSpace(settings.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/src/Components/Aspire.Azure.Messaging.EventHubs/README.md b/src/Components/Aspire.Azure.Messaging.EventHubs/README.md index f60e1904aa8..41b9818eae0 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 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: #### Fully Qualified Namespace diff --git a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs index 41aa74bac28..4e8a2bc9f9a 100644 --- a/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs +++ b/tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs @@ -63,6 +63,57 @@ 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") + .WithDefaultEntity("hub"); + + 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); + + // has EntityPath? + Assert.Contains(";EntityPath=hub", connectionString); + + // well-formed connection string? + var props = EventHubsConnectionStringProperties.Parse(connectionString); + Assert.NotNull(props); + Assert.Equal("hub", props.EventHubName); + } + + [Fact] + [RequiresDocker] + [ActiveIssue("https://github.com/dotnet/aspire/issues/7093")] + public Task VerifyMultipleDefaultEntityThrowsException() + { + using var builder = TestDistributedApplicationBuilder.Create(testOutputHelper); + var eventHub = builder.AddAzureEventHubs("eventhubns") + .RunAsEmulator() + .WithHub("hub") + .WithHub("hub2") + .WithDefaultEntity("hub"); + + // should throw for a second hub with default entity + Assert.Throws(() => eventHub.WithDefaultEntity("hub2")); + + // should not throw for same hub again + eventHub.WithDefaultEntity("hub"); + + using var app = builder.Build(); + + return Task.CompletedTask; + } + [Fact] [RequiresDocker] [ActiveIssue("https://github.com/dotnet/aspire/issues/6751")]