Skip to content

Commit 82d22d9

Browse files
authored
Add IResourceBuilder<T> extension method for event subscriptions (#10097)
* Add IResourceBuilder<T> extension method for event subscriptions Add a number of extension method on `IResourceBuilder<T>` to easily subscribe to `IDistributedApplicationResourceEvent` on a resource Refactored a few subscription calls over to use the new methods - at least one for each event * Correct `OnResourceReady` type restriction * Migrate a few more cases to the new API. * Add `resource` argument to resource subscription extension method callback. * PR Feedback - Fix formatting in AzureCosmosDBExtensions - Fix rogue `"/>` in `On*` method xml docs
1 parent c05c187 commit 82d22d9

File tree

8 files changed

+200
-134
lines changed

8 files changed

+200
-134
lines changed

playground/CustomResources/CustomResources.AppHost/TalkingClockResource.cs

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,35 @@ public static IResourceBuilder<TalkingClockResource> AddTalkingClock(
2929
var tockHandResource = new ClockHandResource(name + "-tock-hand");
3030
var clockResource = new TalkingClockResource(name, tickHandResource, tockHandResource);
3131

32-
builder.Eventing.Subscribe<InitializeResourceEvent>(clockResource, static async (@event, token) =>
32+
// Add the resource instance to the Aspire application builder and configure it using fluent APIs.
33+
var clockBuilder = builder.AddResource(clockResource)
34+
// Use Aspire's ExcludeFromManifest to prevent this resource from being included in deployment manifests.
35+
.ExcludeFromManifest()
36+
// Set a URL for the resource, which will be displayed in the Aspire dashboard.
37+
.WithUrl("https://www.speaking-clock.com/", "Speaking Clock")
38+
// Use Aspire's WithInitialState to set an initial state snapshot for the resource.
39+
// This provides initial metadata visible in the Aspire dashboard.
40+
.WithInitialState(new CustomResourceSnapshot // Aspire type for custom resource state.
41+
{
42+
ResourceType = "TalkingClock", // A string identifying the type of resource for Aspire, this shows in the dashboard.
43+
CreationTimeStamp = DateTime.UtcNow,
44+
State = KnownResourceStates.NotStarted, // Use an Aspire well-known state.
45+
// Add custom properties displayed in the Aspire dashboard's resource details.
46+
Properties =
47+
[
48+
// Use Aspire's known property key for source information.
49+
new(CustomResourceKnownProperties.Source, "Talking Clock")
50+
]
51+
});
52+
53+
clockBuilder.OnInitializeResource(static async (resource, @event, token) =>
3354
{
3455
// This event is published when the resource is initialized.
3556
// You add custom logic here to establish the lifecycle for your custom resource.
3657

3758
var log = @event.Logger; // Get the logger for this resource instance.
3859
var eventing = @event.Eventing; // Get the eventing service for publishing events.
3960
var notification = @event.Notifications; // Get the notification service for state updates.
40-
var resource = (TalkingClockResource)@event.Resource; // Get the resource instance.
4161
var services = @event.Services; // Get the service provider for dependency injection.
4262

4363
// Publish an Aspire event indicating that this resource is about to start.
@@ -97,27 +117,6 @@ await notification.PublishUpdateAsync(resource.TockHand,
97117
}
98118
});
99119

100-
// Add the resource instance to the Aspire application builder and configure it using fluent APIs.
101-
var clockBuilder = builder.AddResource(clockResource)
102-
// Use Aspire's ExcludeFromManifest to prevent this resource from being included in deployment manifests.
103-
.ExcludeFromManifest()
104-
// Set a URL for the resource, which will be displayed in the Aspire dashboard.
105-
.WithUrl("https://www.speaking-clock.com/", "Speaking Clock")
106-
// Use Aspire's WithInitialState to set an initial state snapshot for the resource.
107-
// This provides initial metadata visible in the Aspire dashboard.
108-
.WithInitialState(new CustomResourceSnapshot // Aspire type for custom resource state.
109-
{
110-
ResourceType = "TalkingClock", // A string identifying the type of resource for Aspire, this shows in the dashboard.
111-
CreationTimeStamp = DateTime.UtcNow,
112-
State = KnownResourceStates.NotStarted, // Use an Aspire well-known state.
113-
// Add custom properties displayed in the Aspire dashboard's resource details.
114-
Properties =
115-
[
116-
// Use Aspire's known property key for source information.
117-
new(CustomResourceKnownProperties.Source, "Talking Clock")
118-
]
119-
});
120-
121120
AddHandResource(tickHandResource);
122121
AddHandResource(tockHandResource);
123122

playground/mongo/Mongo.AppHost/Program.cs

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,27 +9,26 @@
99

1010
var db = builder.AddMongoDB("mongo")
1111
.WithMongoExpress(c => c.WithHostPort(3022))
12-
.AddDatabase("db");
13-
14-
builder.Eventing.Subscribe<ResourceReadyEvent>(db.Resource, async (@event, ct) =>
15-
{
16-
// Artificial delay to demonstrate the waiting
17-
await Task.Delay(TimeSpan.FromSeconds(10), ct);
18-
19-
// Seed the database with some data
20-
var cs = await db.Resource.ConnectionStringExpression.GetValueAsync(ct);
21-
using var client = new MongoClient(cs);
22-
23-
const string collectionName = "entries";
24-
25-
var myDb = client.GetDatabase("db");
26-
await myDb.CreateCollectionAsync(collectionName, cancellationToken: ct);
27-
28-
for (int i = 0; i < 10; i++)
29-
{
30-
await myDb.GetCollection<Entry>(collectionName).InsertOneAsync(new Entry(), cancellationToken: ct);
31-
}
32-
});
12+
.AddDatabase("db")
13+
.OnResourceReady(async (db, @event, ct) =>{
14+
// Artificial delay to demonstrate the waiting
15+
await Task.Delay(TimeSpan.FromSeconds(10), ct);
16+
17+
// Seed the database with some data
18+
//var cs = await db.Resource.ConnectionStringExpression.GetValueAsync(ct);
19+
var cs = await db.ConnectionStringExpression.GetValueAsync(ct);
20+
using var client = new MongoClient(cs);
21+
22+
const string collectionName = "entries";
23+
24+
var myDb = client.GetDatabase("db");
25+
await myDb.CreateCollectionAsync(collectionName, cancellationToken: ct);
26+
27+
for (int i = 0; i < 10; i++)
28+
{
29+
await myDb.GetCollection<Entry>(collectionName).InsertOneAsync(new Entry(), cancellationToken: ct);
30+
}
31+
});
3332

3433
builder.AddProject<Projects.Mongo_ApiService>("api")
3534
.WithExternalHttpEndpoints()

src/Aspire.Hosting.Azure.AIFoundry/AzureAIFoundryExtensions.cs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,10 +132,9 @@ public static IResourceBuilder<AzureAIFoundryResource> RunAsFoundryLocal(this IR
132132

133133
private static IResourceBuilder<AzureAIFoundryResource> WithInitializer(this IResourceBuilder<AzureAIFoundryResource> builder)
134134
{
135-
builder.ApplicationBuilder.Eventing.Subscribe<InitializeResourceEvent>(builder.Resource, (@event, ct)
135+
return builder.OnInitializeResource((resource, @event, ct)
136136
=> Task.Run(async () =>
137137
{
138-
var resource = (AzureAIFoundryResource)@event.Resource;
139138
var rns = @event.Services.GetRequiredService<ResourceNotificationService>();
140139
var manager = @event.Services.GetRequiredService<FoundryLocalManager>();
141140
var logger = @event.Services.GetRequiredService<ResourceLoggerService>().GetLogger(resource);
@@ -176,8 +175,6 @@ await rns.PublishUpdateAsync(resource, state => state with
176175
}
177176

178177
}, ct));
179-
180-
return builder;
181178
}
182179

183180
/// <summary>
@@ -187,9 +184,7 @@ internal static IResourceBuilder<AzureAIFoundryDeploymentResource> AsLocalDeploy
187184
{
188185
ArgumentNullException.ThrowIfNull(deployment, nameof(deployment));
189186

190-
var foundryResource = builder.Resource.Parent;
191-
192-
builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(foundryResource, (@event, ct) =>
187+
builder.OnResourceReady((foundryResource, @event, ct) =>
193188
{
194189
var rns = @event.Services.GetRequiredService<ResourceNotificationService>();
195190
var loggerService = @event.Services.GetRequiredService<ResourceLoggerService>();

src/Aspire.Hosting.Azure.CosmosDB/AzureCosmosDBExtensions.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,20 +88,18 @@ private static IResourceBuilder<AzureCosmosDBResource> RunAsEmulator(this IResou
8888
});
8989

9090
CosmosClient? cosmosClient = null;
91-
92-
builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(builder.Resource, async (@event, ct) =>
91+
builder.OnConnectionStringAvailable(async (cosmosDb, @event, ct) =>
9392
{
94-
var connectionString = await builder.Resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
93+
var connectionString = await cosmosDb.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
9594

9695
if (connectionString == null)
9796
{
9897
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{builder.Resource.Name}' resource but the connection string was null.");
9998
}
10099

101100
cosmosClient = CreateCosmosClient(connectionString);
102-
});
103-
104-
builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
101+
})
102+
.OnResourceReady(async (cosmosDb, @event, ct) =>
105103
{
106104
if (cosmosClient is null)
107105
{
@@ -110,7 +108,7 @@ private static IResourceBuilder<AzureCosmosDBResource> RunAsEmulator(this IResou
110108

111109
await cosmosClient.ReadAccountAsync().WaitAsync(ct).ConfigureAwait(false);
112110

113-
foreach (var database in builder.Resource.Databases)
111+
foreach (var database in cosmosDb.Databases)
114112
{
115113
var db = (await cosmosClient.CreateDatabaseIfNotExistsAsync(database.DatabaseName, cancellationToken: ct).ConfigureAwait(false)).Database;
116114

src/Aspire.Hosting.Azure.Storage/AzureStorageExtensions.cs

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -132,32 +132,32 @@ public static IResourceBuilder<AzureStorageResource> RunAsEmulator(this IResourc
132132
});
133133

134134
BlobServiceClient? blobServiceClient = null;
135-
builder.ApplicationBuilder.Eventing.Subscribe<BeforeResourceStartedEvent>(builder.Resource, async (@event, ct) =>
136-
{
137-
// The BlobServiceClient is created before the health check is run.
138-
// We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
139-
// we use BeforeResourceStartedEvent
140-
141-
var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
142-
blobServiceClient = CreateBlobServiceClient(connectionString);
143-
});
144-
145-
builder.ApplicationBuilder.Eventing.Subscribe<ResourceReadyEvent>(builder.Resource, async (@event, ct) =>
146-
{
147-
// The ResourceReadyEvent of a resource is triggered after its health check is healthy.
148-
// This means we can safely use this event to create the blob containers.
149-
150-
if (blobServiceClient is null)
135+
builder
136+
.OnBeforeResourceStarted(async (storage, @event, ct) =>
151137
{
152-
throw new InvalidOperationException("BlobServiceClient is not initialized.");
153-
}
154-
155-
foreach (var container in builder.Resource.BlobContainers)
138+
// The BlobServiceClient is created before the health check is run.
139+
// We can't use ConnectionStringAvailableEvent here because the resource doesn't have a connection string, so
140+
// we use BeforeResourceStartedEvent
141+
142+
var connectionString = await builder.Resource.GetBlobConnectionString().GetValueAsync(ct).ConfigureAwait(false) ?? throw new DistributedApplicationException($"{nameof(ConnectionStringAvailableEvent)} was published for the '{builder.Resource.Name}' resource but the connection string was null.");
143+
blobServiceClient = CreateBlobServiceClient(connectionString);
144+
})
145+
.OnResourceReady(async (storage, @event, ct) =>
156146
{
157-
var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName);
158-
await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
159-
}
160-
});
147+
// The ResourceReadyEvent of a resource is triggered after its health check is healthy.
148+
// This means we can safely use this event to create the blob containers.
149+
150+
if (blobServiceClient is null)
151+
{
152+
throw new InvalidOperationException("BlobServiceClient is not initialized.");
153+
}
154+
155+
foreach (var container in builder.Resource.BlobContainers)
156+
{
157+
var blobContainerClient = blobServiceClient.GetBlobContainerClient(container.BlobContainerName);
158+
await blobContainerClient.CreateIfNotExistsAsync(cancellationToken: ct).ConfigureAwait(false);
159+
}
160+
});
161161

162162
var healthCheckKey = $"{builder.Resource.Name}_check";
163163

@@ -295,10 +295,6 @@ public static IResourceBuilder<AzureBlobStorageResource> AddBlobs(this IResource
295295
var resource = new AzureBlobStorageResource(name, builder.Resource);
296296

297297
string? connectionString = null;
298-
builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
299-
{
300-
connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
301-
});
302298

303299
var healthCheckKey = $"{resource.Name}_check";
304300

@@ -308,7 +304,13 @@ public static IResourceBuilder<AzureBlobStorageResource> AddBlobs(this IResource
308304
return blobServiceClient ??= CreateBlobServiceClient(connectionString ?? throw new InvalidOperationException("Connection string is not initialized."));
309305
}, name: healthCheckKey);
310306

311-
return builder.ApplicationBuilder.AddResource(resource).WithHealthCheck(healthCheckKey);
307+
return builder.ApplicationBuilder
308+
.AddResource(resource)
309+
.WithHealthCheck(healthCheckKey)
310+
.OnConnectionStringAvailable(async (blobs, @event, ct) =>
311+
{
312+
connectionString = await resource.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
313+
});
312314
}
313315

314316
/// <summary>
@@ -329,9 +331,9 @@ public static IResourceBuilder<AzureBlobStorageContainerResource> AddBlobContain
329331
builder.Resource.Parent.BlobContainers.Add(resource);
330332

331333
string? connectionString = null;
332-
builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(resource, async (@event, ct) =>
334+
builder.OnConnectionStringAvailable(async (blobStorage, @event, ct) =>
333335
{
334-
connectionString = await resource.Parent.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
336+
connectionString = await blobStorage.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
335337
});
336338

337339
var healthCheckKey = $"{resource.Name}_check";

src/Aspire.Hosting.SqlServer/SqlServerBuilderExtensions.cs

Lines changed: 40 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -45,37 +45,6 @@ public static IResourceBuilder<SqlServerServerResource> AddSqlServer(this IDistr
4545

4646
string? connectionString = null;
4747

48-
builder.Eventing.Subscribe<ConnectionStringAvailableEvent>(sqlServer, async (@event, ct) =>
49-
{
50-
connectionString = await sqlServer.GetConnectionStringAsync(ct).ConfigureAwait(false);
51-
52-
if (connectionString == null)
53-
{
54-
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{sqlServer.Name}' resource but the connection string was null.");
55-
}
56-
});
57-
58-
builder.Eventing.Subscribe<ResourceReadyEvent>(sqlServer, async (@event, ct) =>
59-
{
60-
if (connectionString is null)
61-
{
62-
throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{sqlServer.Name}' resource but the connection string was null.");
63-
}
64-
65-
using var sqlConnection = new SqlConnection(connectionString);
66-
await sqlConnection.OpenAsync(ct).ConfigureAwait(false);
67-
68-
if (sqlConnection.State != System.Data.ConnectionState.Open)
69-
{
70-
throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'");
71-
}
72-
73-
foreach (var sqlDatabase in sqlServer.DatabaseResources)
74-
{
75-
await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false);
76-
}
77-
});
78-
7948
var healthCheckKey = $"{name}_check";
8049
builder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey);
8150

@@ -88,7 +57,36 @@ public static IResourceBuilder<SqlServerServerResource> AddSqlServer(this IDistr
8857
{
8958
context.EnvironmentVariables["MSSQL_SA_PASSWORD"] = sqlServer.PasswordParameter;
9059
})
91-
.WithHealthCheck(healthCheckKey);
60+
.WithHealthCheck(healthCheckKey)
61+
.OnConnectionStringAvailable(async (sqlServer, @event, ct) =>
62+
{
63+
connectionString = await sqlServer.GetConnectionStringAsync(ct).ConfigureAwait(false);
64+
65+
if (connectionString == null)
66+
{
67+
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{sqlServer.Name}' resource but the connection string was null.");
68+
}
69+
})
70+
.OnResourceReady(async (sqlServer, @event, ct) =>
71+
{
72+
if (connectionString is null)
73+
{
74+
throw new DistributedApplicationException($"ResourceReadyEvent was published for the '{sqlServer.Name}' resource but the connection string was null.");
75+
}
76+
77+
using var sqlConnection = new SqlConnection(connectionString);
78+
await sqlConnection.OpenAsync(ct).ConfigureAwait(false);
79+
80+
if (sqlConnection.State != System.Data.ConnectionState.Open)
81+
{
82+
throw new InvalidOperationException($"Could not open connection to '{sqlServer.Name}'");
83+
}
84+
85+
foreach (var sqlDatabase in sqlServer.DatabaseResources)
86+
{
87+
await CreateDatabaseAsync(sqlConnection, sqlDatabase, @event.Services, ct).ConfigureAwait(false);
88+
}
89+
});
9290
}
9391

9492
/// <summary>
@@ -112,22 +110,21 @@ public static IResourceBuilder<SqlServerDatabaseResource> AddDatabase(this IReso
112110

113111
string? connectionString = null;
114112

115-
builder.ApplicationBuilder.Eventing.Subscribe<ConnectionStringAvailableEvent>(sqlServerDatabase, async (@event, ct) =>
116-
{
117-
connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
118-
119-
if (connectionString == null)
120-
{
121-
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null.");
122-
}
123-
});
124-
125113
var healthCheckKey = $"{name}_check";
126114
builder.ApplicationBuilder.Services.AddHealthChecks().AddSqlServer(sp => connectionString ?? throw new InvalidOperationException("Connection string is unavailable"), name: healthCheckKey);
127115

128116
return builder.ApplicationBuilder
129117
.AddResource(sqlServerDatabase)
130-
.WithHealthCheck(healthCheckKey);
118+
.WithHealthCheck(healthCheckKey)
119+
.OnConnectionStringAvailable(async (sqlServerDatabase, @event, ct) =>
120+
{
121+
connectionString = await sqlServerDatabase.ConnectionStringExpression.GetValueAsync(ct).ConfigureAwait(false);
122+
123+
if (connectionString == null)
124+
{
125+
throw new DistributedApplicationException($"ConnectionStringAvailableEvent was published for the '{name}' resource but the connection string was null.");
126+
}
127+
});
131128
}
132129

133130
/// <summary>

0 commit comments

Comments
 (0)