Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow specifying an Event Hub to use as the default EntityPath for namespace connection string #7105

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion playground/AspireEventHub/EventHubs.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

var eventHub = builder.AddAzureEventHubs("eventhubns")
.RunAsEmulator()
.WithHub("hub");
.WithHub("hub", configure => configure.IsDefaultEntity = true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
.WithHub("hub", configure => configure.IsDefaultEntity = true);
.WithHub("hub", h => h.IsDefaultEntity = true);


builder.AddProject<Projects.EventHubsConsumer>("consumer")
.WithReference(eventHub).WaitFor(eventHub)
Expand Down
12 changes: 2 additions & 10 deletions playground/AspireEventHub/EventHubsConsumer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,7 @@

if (useConsumer)
{
builder.AddAzureEventHubConsumerClient("eventhubns",
settings =>
{
settings.EventHubName = "hub";
});
builder.AddAzureEventHubConsumerClient("eventhubns");

builder.Services.AddHostedService<Consumer>();
Console.WriteLine("Starting EventHubConsumerClient...");
Expand All @@ -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<Processor>();
Console.WriteLine("Starting EventProcessorClient...");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ public static IResourceBuilder<AzureEventHubsResource> RunAsEmulator(this IResou
// an event hub namespace without an event hub? :)
if (builder.Resource.Hubs is { Count: > 0 } && builder.Resource.Hubs[0] is { } hub)
Copy link
Member

@davidfowl davidfowl Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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=") ?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of Contains, we could parse it properly and build a new one.

connectionString : $"{connectionString};EntityPath={hub.Name};";

client = new EventHubProducerClient(healthCheckConnectionString);
}
else
Expand Down Expand Up @@ -365,7 +367,7 @@ public static IResourceBuilder<AzureEventHubsEmulatorResource> WithHostPort(this
/// <param name="path">Path to the file on the AppHost where the emulator configuration is located.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
public static IResourceBuilder<AzureEventHubsEmulatorResource> WithConfigurationFile(this IResourceBuilder<AzureEventHubsEmulatorResource> builder, string path)
{
{
// Update the existing mount
var configFileMount = builder.Resource.Annotations.OfType<ContainerMountAnnotation>().LastOrDefault(v => v.Target == AzureEventHubsEmulatorResource.EmulatorConfigJsonPath);
if (configFileMount != null)
Expand Down
25 changes: 23 additions & 2 deletions src/Aspire.Hosting.Azure.EventHubs/AzureEventHubsResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,29 @@ public class AzureEventHubsResource(string name, Action<AzureResourceInfrastruct
/// </summary>
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);
}
}
Comment on lines 48 to +72
Copy link
Member

@davidfowl davidfowl Jan 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Maybe a bit cleaner to use the ReferenceExpressionBuilder and a single method to build the connection string like
    internal ReferenceExpression BuildConnectionString(string? databaseName = null)
  2. Do we want this to throw here? Or somewhere else?


void IResourceWithAzureFunctionsConfig.ApplyAzureFunctionsConfiguration(IDictionary<string, object> target, string connectionName)
{
Expand Down
6 changes: 6 additions & 0 deletions src/Aspire.Hosting.Azure.EventHubs/EventHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ public EventHub(string name)
/// </summary>
public List<EventHubConsumerGroup> ConsumerGroups { get; } = [];

/// <summary>
/// If set, this EventHub will be used as the EntityPath in the resource's connection string.
/// <remarks>Only one EventHub can be set as the default entity. If more than one is specified, an Exception will be raised at runtime.</remarks>
/// </summary>
public bool IsDefaultEntity { get; set; }

/// <summary>
/// Converts the current instance to a provisioning entity.
/// </summary>
Expand Down
2 changes: 2 additions & 0 deletions src/Aspire.Hosting.Azure.EventHubs/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.EventHubConsumerGroup!>!
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?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Aspire.Azure.Messaging.EventHubs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ builder.AddAzureEventHubProducerClient("eventHubsConnectionName",
});
```

NOTE: Earlier versions of Aspire (&lt;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
Expand Down
47 changes: 47 additions & 0 deletions tests/Aspire.Hosting.Azure.Tests/AzureEventHubsExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(
async () => await eventHub.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None));
}

[Fact]
[RequiresDocker]
[ActiveIssue("https://github.com/dotnet/aspire/issues/6751")]
Expand Down
Loading