diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureCosmosResource.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureCosmosResource.cs new file mode 100644 index 000000000..5eb6be378 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociAzureCosmosResource.cs @@ -0,0 +1,51 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents the Cosmos DB API exposed by a Floci Azure emulator resource. +/// +/// The name of the resource. +/// The Cosmos DB account name. +/// The parent Floci Azure emulator resource. +[AspireExport(ExposeProperties = true)] +public class FlociAzureCosmosResource( + string name, + string accountName, + FlociAzureContainerResource parent) : Resource(name), + IResourceWithParent, + IResourceWithConnectionString +{ + internal const string DefaultName = "cosmos"; + + // Well-known Cosmos DB emulator account key that floci-az accepts by default (no auth enforced). + internal const string DefaultAccountKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + /// + /// Gets the parent Floci Azure emulator resource. + /// + public FlociAzureContainerResource Parent { get; } = parent ?? throw new ArgumentNullException(nameof(parent)); + + /// + /// Gets the Cosmos DB account name. + /// + public string AccountName { get; } = string.IsNullOrWhiteSpace(accountName) + ? throw new ArgumentException("The account name cannot be empty or whitespace.", nameof(accountName)) + : accountName; + + /// + /// Gets the Cosmos DB account endpoint. + /// + public ReferenceExpression AccountEndpoint => + ReferenceExpression.Create($"{Parent.ConnectionStringExpression}/{AccountName}-cosmos/"); + + /// + /// Gets the Cosmos DB connection string expression. + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"AccountEndpoint={AccountEndpoint};AccountKey={DefaultAccountKey};"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() => + Parent.CombineProperties([ + new("AccountEndpoint", AccountEndpoint), + new("AccountName", ReferenceExpression.Create($"{AccountName}")) + ]); +} diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs index bd6100db2..90ec94c08 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/FlociHostingExtension.Azure.cs @@ -73,6 +73,38 @@ public static IResourceBuilder WithReference( }); + /// + /// Adds a child resource representing the Cosmos DB API exposed by the Floci Azure emulator. + /// + /// + /// Reference the returned resource with Aspire's standard WithReference API to inject its + /// Cosmos DB connection string. floci-az serves the Cosmos SQL/NoSQL API from the parent + /// resource's endpoint under the {account}-cosmos path. + /// + /// Adds a Cosmos DB child resource to the Floci Azure emulator + /// The Floci Azure resource builder. + /// The name of the Cosmos DB resource (default: cosmos). + /// The Cosmos account name segment (default: devstoreaccount1). + /// A reference to the for further configuration. + [AspireExport] + public static IResourceBuilder WithCosmos( + this IResourceBuilder builder, + [ResourceName] string name = FlociAzureCosmosResource.DefaultName, + string? accountName = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var cosmosResource = new FlociAzureCosmosResource( + name, + accountName ?? FlociAzureContainerResource.DefaultAccountName, + builder.Resource); + + return builder.ApplicationBuilder + .AddResource(cosmosResource) + .WithParentRelationship(builder); + } + /// /// Mounts the Docker socket into the Floci Azure container so that Azure Functions and other /// container-backed services can launch sibling containers. diff --git a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md index 7cbd9b811..7c35d914e 100644 --- a/src/CommunityToolkit.Aspire.Hosting.Floci/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.Floci/README.md @@ -73,6 +73,41 @@ await builder.addProject('api', '../MyApi/MyApi.csproj') | `ConnectionStrings__floci-az` | `http://localhost:{port}` (standard Aspire connection string) | | `AZURE_STORAGE_CONNECTION_STRING` | Development storage connection string pointed at the Floci Azure endpoint, carrying `BlobEndpoint`, `QueueEndpoint` and `TableEndpoint` and the well-known `devstoreaccount1` dev credentials | +For **Cosmos DB**, use `WithCosmos()` / `withCosmos()` to model the Cosmos API as a child resource, then reference it through Aspire's standard connection-string flow: + +```csharp +var azure = builder.AddFlociAzure("floci-az"); +var cosmos = azure.WithCosmos(); + +builder.AddProject("api") + .WithReference(azure) // storage variables (optional) + .WithReference(cosmos) // ConnectionStrings__cosmos + .WaitFor(azure); +``` + +```typescript +const azure = await builder.addFlociAzure('floci-az'); +const cosmos = await azure.withCosmos(); + +await builder.addProject('api', '../MyApi/MyApi.csproj') + .withFlociAzureReference(azure) + .withReference(cosmos) + .waitFor(azure); +``` + +App side, this is the standard Aspire flow: + +```csharp +builder.AddAzureCosmosClient("cosmos"); +``` + +| Variable | Value | +|---|---| +| `ConnectionStrings__{resourceName}` (default `cosmos`) | `AccountEndpoint={scheme}://{host}:{port}/{account}-cosmos/;AccountKey=…` — the well-known Cosmos DB emulator key. Resource name and account name (default `devstoreaccount1`) are configurable. | + +The Cosmos child resource is additive, so combine `WithReference(cosmos)` with `WithReference(azure)` when you also want the base endpoint / storage variables. (Talking to the floci Cosmos emulator over HTTP from the .NET SDK still needs the usual client-side settings — Gateway mode, and HTTP/1.1 — which are the app's concern, as with any local Cosmos emulator.) + + **GCP** ```csharp diff --git a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs index 608ae3462..d3fcb6cd4 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.Floci.Tests/WithReferenceTests.cs @@ -103,6 +103,91 @@ public async Task WithReferenceGcpSetsEmulatorHostEnvironmentVariables() Assert.StartsWith("{floci-gcp.bindings.gcp.scheme}://", storageHost); } + [Fact] + public async Task WithCosmosCreatesChildResourceUsedByStandardWithReference() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var floci = builder.AddFlociAzure("floci-az"); + var cosmos = floci.WithCosmos(); + var worker = builder.AddExecutable("worker", "dotnet", ".").WithReference(cosmos); + + var envVars = await ResolveEnvironmentAsync(builder, worker); + + Assert.Equal("cosmos", cosmos.Resource.Name); + Assert.Same(floci.Resource, cosmos.Resource.Parent); + Assert.Contains( + cosmos.Resource.Annotations.OfType(), + annotation => annotation.Type == "Parent" && ReferenceEquals(annotation.Resource, floci.Resource)); + Assert.Contains("ConnectionStrings__cosmos", envVars.Keys); + + var connectionStringReference = Assert.IsType(envVars["ConnectionStrings__cosmos"]); + Assert.Same(cosmos.Resource, connectionStringReference.Resource); + var connectionString = cosmos.Resource.ConnectionStringExpression.ValueExpression; + Assert.Contains("AccountEndpoint=", connectionString); + Assert.Contains($"/{FlociAzureContainerResource.DefaultAccountName}-cosmos/;", connectionString); + Assert.Contains("AccountKey=", connectionString); + // The endpoint is an unresolved expression so it tracks Aspire's (possibly randomized) port + // assignment, and the scheme flips to https if a certificate is configured. + Assert.Contains("{floci-az.bindings.azure.scheme}://", connectionString); + } + + [Fact] + public async Task WithCosmosComposesWithFlociAzureReference() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var floci = builder.AddFlociAzure("floci-az"); + var cosmos = floci.WithCosmos(); + var worker = builder.AddExecutable("worker", "dotnet", ".") + .WithReference(floci) + .WithReference(cosmos); + + var envVars = await ResolveEnvironmentAsync(builder, worker); + + // The base storage reference and the Cosmos connection string coexist. + Assert.Contains("ConnectionStrings__floci-az", envVars.Keys); + Assert.Contains("AZURE_STORAGE_CONNECTION_STRING", envVars.Keys); + Assert.Contains("ConnectionStrings__cosmos", envVars.Keys); + } + + [Fact] + public async Task WithCosmosHonorsCustomResourceAndAccountName() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + + var floci = builder.AddFlociAzure("floci-az"); + var cosmos = floci.WithCosmos(name: "notifications", accountName: "acct2"); + var worker = builder.AddExecutable("worker", "dotnet", ".").WithReference(cosmos); + + var envVars = await ResolveEnvironmentAsync(builder, worker); + + Assert.Contains("ConnectionStrings__notifications", envVars.Keys); + var connectionStringReference = Assert.IsType(envVars["ConnectionStrings__notifications"]); + Assert.Same(cosmos.Resource, connectionStringReference.Resource); + var connectionString = cosmos.Resource.ConnectionStringExpression.ValueExpression; + Assert.Contains("/acct2-cosmos/;", connectionString); + } + + [Fact] + public void WithCosmosBuilderShouldNotBeNull() + { + IResourceBuilder builder = null!; + + Assert.Throws(() => builder.WithCosmos()); + } + + [Fact] + public void WithCosmosResourceNameShouldNotBeEmpty() + { + IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(); + var floci = builder.AddFlociAzure("floci-az"); + + Assert.Throws(() => floci.WithCosmos(GetInvalidResourceName())); + } + + private static string GetInvalidResourceName() => string.Empty; + private static async Task> ResolveEnvironmentAsync( IDistributedApplicationBuilder builder, IResourceBuilder dependent)