diff --git a/Aspire.sln b/Aspire.sln index 5647600b1a6..f48ad76f5ab 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -133,6 +133,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Hosting.Azure.Provis EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eShopLite", "eShopLite", "{A68BA1A5-1604-433D-9778-DC0199831C2A}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Microsoft.Azure.Cosmos", "src\Components\Aspire.Microsoft.Azure.Cosmos\Aspire.Microsoft.Azure.Cosmos.csproj", "{23298562-C1D4-41CD-83FE-426C94FEE35F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Microsoft.EntityFrameworkCore.Cosmos", "src\Components\Aspire.Microsoft.EntityFrameworkCore.Cosmos\Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj", "{00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Microsoft.Azure.Cosmos.Tests", "tests\Aspire.Microsoft.Azure.Cosmos.Tests\Aspire.Microsoft.Azure.Cosmos.Tests.csproj", "{A5836BC1-6A45-4BB6-9D22-A7F750890AB8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests", "tests\Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests\Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj", "{FDA02617-9C49-4DA8-A43A-A34DBA9B8596}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CatalogDb", "samples\eShopLite\CatalogDb\CatalogDb.csproj", "{A84C4EE3-2601-4804-BCDC-E9948E164A22}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{991DB378-6CB5-4441-BFC3-657400690FC3}" @@ -370,6 +378,22 @@ Global {D4BD974F-6505-43FC-A94E-2019F0DB5D5D}.Debug|Any CPU.Build.0 = Debug|Any CPU {D4BD974F-6505-43FC-A94E-2019F0DB5D5D}.Release|Any CPU.ActiveCfg = Release|Any CPU {D4BD974F-6505-43FC-A94E-2019F0DB5D5D}.Release|Any CPU.Build.0 = Release|Any CPU + {23298562-C1D4-41CD-83FE-426C94FEE35F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23298562-C1D4-41CD-83FE-426C94FEE35F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23298562-C1D4-41CD-83FE-426C94FEE35F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23298562-C1D4-41CD-83FE-426C94FEE35F}.Release|Any CPU.Build.0 = Release|Any CPU + {00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63}.Release|Any CPU.Build.0 = Release|Any CPU + {A5836BC1-6A45-4BB6-9D22-A7F750890AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A5836BC1-6A45-4BB6-9D22-A7F750890AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A5836BC1-6A45-4BB6-9D22-A7F750890AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A5836BC1-6A45-4BB6-9D22-A7F750890AB8}.Release|Any CPU.Build.0 = Release|Any CPU + {FDA02617-9C49-4DA8-A43A-A34DBA9B8596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDA02617-9C49-4DA8-A43A-A34DBA9B8596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDA02617-9C49-4DA8-A43A-A34DBA9B8596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDA02617-9C49-4DA8-A43A-A34DBA9B8596}.Release|Any CPU.Build.0 = Release|Any CPU {A84C4EE3-2601-4804-BCDC-E9948E164A22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A84C4EE3-2601-4804-BCDC-E9948E164A22}.Debug|Any CPU.Build.0 = Debug|Any CPU {A84C4EE3-2601-4804-BCDC-E9948E164A22}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -446,6 +470,10 @@ Global {E2EC79D0-80F7-4471-9613-D7C8C3D52F95} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {D4BD974F-6505-43FC-A94E-2019F0DB5D5D} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47} {A68BA1A5-1604-433D-9778-DC0199831C2A} = {D173887B-AF42-4576-B9C1-96B9E9B3D9C0} + {23298562-C1D4-41CD-83FE-426C94FEE35F} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {00C9BA50-2AFB-4D9C-A2D6-8154BCCD0A63} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {A5836BC1-6A45-4BB6-9D22-A7F750890AB8} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {FDA02617-9C49-4DA8-A43A-A34DBA9B8596} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {A84C4EE3-2601-4804-BCDC-E9948E164A22} = {A68BA1A5-1604-433D-9778-DC0199831C2A} {4D8A92AB-4E77-4965-AD8E-8E206DCE66A4} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {165411FE-755E-4869-A756-F87F455860AC} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} diff --git a/Directory.Packages.props b/Directory.Packages.props index 58ed2336686..1429decffc2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + @@ -45,6 +46,7 @@ + @@ -101,4 +103,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting.Azure/AzureCosmosDBCloudApplicationBuilderExtensions.cs b/src/Aspire.Hosting.Azure/AzureCosmosDBCloudApplicationBuilderExtensions.cs new file mode 100644 index 00000000000..3d307a2ff78 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureCosmosDBCloudApplicationBuilderExtensions.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure.Data.Cosmos; +using System.Text.Json; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Azure Cosmos DB resources to an . +/// +public static class AzureCosmosDBCloudApplicationBuilderExtensions +{ + /// + /// Adds an Azure Cosmos DB connection to the application model. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The connection string. + /// A reference to the . + public static IResourceBuilder AddAzureCosmosDB( + this IDistributedApplicationBuilder builder, + string name, + string? connectionString = null) + { + var connection = new AzureCosmosDBConnectionResource(name, connectionString); + return builder.AddResource(connection) + .WithAnnotation(new ManifestPublishingCallbackAnnotation(jsonWriter => WriteCosmosDBConnectionToManifest(jsonWriter, connection))); + } + + private static void WriteCosmosDBConnectionToManifest(Utf8JsonWriter jsonWriter, AzureCosmosDBConnectionResource cosmosDbConnection) + { + jsonWriter.WriteString("type", "azure.cosmosdb.connection.v0"); + jsonWriter.WriteString("connectionString", cosmosDbConnection.GetConnectionString()); + } + + private static void WriteCosmosDBDatabaseToManifest(Utf8JsonWriter jsonWriter, AzureCosmosDatabaseResource cosmosDatabase) + { + jsonWriter.WriteString("type", "azure.cosmosdb.database.v0"); + jsonWriter.WriteString("parent", cosmosDatabase.Parent.Name); + jsonWriter.WriteString("databaseName", cosmosDatabase.Name); + } + + /// + /// Adds an Azure Cosmos DB database to a . + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// A reference to the . + public static IResourceBuilder AddDatabase(this IResourceBuilder builder, string name) + { + var cosmosDatabase = new AzureCosmosDatabaseResource(name, builder.Resource); + return builder + .ApplicationBuilder + .AddResource(cosmosDatabase) + .WithAnnotation(new ManifestPublishingCallbackAnnotation( + (json) => WriteCosmosDBDatabaseToManifest(json, cosmosDatabase))); + } +} diff --git a/src/Aspire.Hosting.Azure/AzureCosmosDBConnectionResource.cs b/src/Aspire.Hosting.Azure/AzureCosmosDBConnectionResource.cs new file mode 100644 index 00000000000..53b1f2381e6 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureCosmosDBConnectionResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.Data.Cosmos; + +/// +/// Represents a connection to an Azure Cosmos DB account. +/// +/// The resource name. +/// The connection string to use to connect. +public class AzureCosmosDBConnectionResource(string name, string? connectionString) + : Resource(name), IResourceWithConnectionString +{ + /// + /// Gets the connection string to use for this database. + /// + /// The connection string to use for this database. + public string? GetConnectionString() => connectionString; +} diff --git a/src/Aspire.Hosting.Azure/AzureCosmosDatabaseResource.cs b/src/Aspire.Hosting.Azure/AzureCosmosDatabaseResource.cs new file mode 100644 index 00000000000..96cc0feb4b2 --- /dev/null +++ b/src/Aspire.Hosting.Azure/AzureCosmosDatabaseResource.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Azure.Data.Cosmos; + +/// +/// Represents an Azure Cosmos DB database. +/// +/// The database name. +/// The parent . +public class AzureCosmosDatabaseResource(string name, AzureCosmosDBConnectionResource parent) + : Resource(name), IResourceWithParent, IResourceWithConnectionString +{ + /// + /// Gets the parent . + /// + public AzureCosmosDBConnectionResource Parent { get; } = parent; + + /// + /// Gets the connection string to use for this database. + /// + /// The connection string to use for this database. + public string? GetConnectionString() + { + return Parent.GetConnectionString(); + } +} diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj new file mode 100644 index 00000000000..e41dd949483 --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj @@ -0,0 +1,20 @@ + + + + $(NetCurrent) + true + false + false + $(ComponentAzurePackageTags) cosmos cosmosdb data database db + A client for Azure Cosmos DB that integrates with Aspire, including logging and telemetry. + $(SharedDir)AzureCosmosDB_256x.png + + + + + + + + + + diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireAzureCosmosDBExtensions.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireAzureCosmosDBExtensions.cs new file mode 100644 index 00000000000..cfe43cbc4e0 --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AspireAzureCosmosDBExtensions.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Microsoft.Azure.Cosmos; +using Azure.Identity; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Azure CosmosDB extension +/// +public static class AspireAzureCosmosDBExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Microsoft:Azure:Cosmos"; + + /// + /// Registers as a singleton in the services provided by the . + /// Configures logging and telemetry for the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Microsoft:Azure:Cosmos" section. + /// If required ConnectionString is not provided in configuration section + public static void AddAzureCosmosDB( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action? configureClientOptions = null) + { + AddAzureCosmosDB(builder, DefaultConfigSectionName, configureSettings, configureClientOptions, connectionName, serviceKey: null); + } + + /// + /// Registers as a singleton for given in the services provided by the . + /// Configures logging and telemetry for the . + /// + /// The to read config from and add services to. + /// The name of the component, which is used as the of the service and also to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// An optional method that can be used for customizing the . + /// Reads the configuration from "Aspire:Microsoft:Azure:Cosmos:{name}" section. + /// If required ConnectionString is not provided in configuration section + public static void AddKeyedAzureCosmosDB( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null, + Action? configureClientOptions = null) + { + AddAzureCosmosDB(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, configureClientOptions, connectionName: name, serviceKey: name); + } + + private static void AddAzureCosmosDB( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + Action? configureClientOptions, + string connectionName, + string? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new AzureCosmosDBSettings(); + builder.Configuration.GetSection(configurationSectionName).Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + settings.AccountEndpoint = uri; + } + else + { + settings.ConnectionString = connectionString; + } + } + + configureSettings?.Invoke(settings); + + var clientOptions = new CosmosClientOptions(); + // Needs to be enabled for either logging or tracing to work. + clientOptions.CosmosClientTelemetryOptions.DisableDistributedTracing = false; + if (settings.Tracing) + { + builder.Services.AddOpenTelemetry().WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder.AddSource("Azure.Cosmos.Operation"); + }); + } + + configureClientOptions?.Invoke(clientOptions); + + if (serviceKey is null) + { + builder.Services.AddSingleton(_ => ConfigureDb()); + } + else + { + builder.Services.AddKeyedSingleton(serviceKey, (sp, key) => ConfigureDb()); + } + + CosmosClient ConfigureDb() + { + if (!string.IsNullOrEmpty(settings.ConnectionString)) + { + return new CosmosClient(settings.ConnectionString, clientOptions); + } + else if (settings.AccountEndpoint is not null) + { + var credential = settings.Credential ?? new DefaultAzureCredential(); + return new CosmosClient(settings.AccountEndpoint.OriginalString, credential, clientOptions); + } + else + { + throw new InvalidOperationException( + $"A CosmosClient could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.ConnectionString)} or {nameof(settings.AccountEndpoint)} must be provided " + + $"in the '{configurationSectionName}' configuration section."); + } + } + } +} diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDBSettings.cs b/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDBSettings.cs new file mode 100644 index 00000000000..98b9aaae607 --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDBSettings.cs @@ -0,0 +1,39 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; + +namespace Aspire.Microsoft.Azure.Cosmos; + +/// +/// The settings relevant to accessing Azure Cosmos DB. +/// +public sealed class AzureCosmosDBSettings +{ + /// + /// Gets or sets the connection string of the Azure Cosmos database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not. + /// Enabled by default. + /// + public bool Tracing { get; set; } = true; + + /// + /// A referencing the Azure Cosmos DB Endpoint. + /// This is likely to be similar to "https://{account_name}.queue.core.windows.net". + /// + /// + /// Must not contain shared access signature. + /// Used along with to establish the connection. + /// + public Uri? AccountEndpoint { get; set; } + + /// + /// Gets or sets the credential used to authenticate to the Azure Cosmos DB endpoint. + /// + public TokenCredential? Credential { get; set; } +} + diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json b/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json new file mode 100644 index 00000000000..9ba4f921b4a --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json @@ -0,0 +1,46 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Azure-Cosmos-Operation-Request-Diagnostics": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Microsoft": { + "type": "object", + "properties": { + "Azure": { + "type": "object", + "properties": { + "Cosmos": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string of the Azure Cosmos DB to connect to. If both are provided, 'ConnectionString' takes precedence over 'AccountEndpoint'." + }, + "AccountEndpoint": { + "type": "string", + "format": "uri", + "description": "Gets or sets the account endpoint of the Azure Cosmos DB to connect to. If both are provided, 'ConnectionString' takes precedence over 'AccountEndpoint'." + }, + "Tracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not." + } + } + } + } + } + } + } + } + } + } +} diff --git a/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md b/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md new file mode 100644 index 00000000000..2aeefdbafce --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/README.md @@ -0,0 +1,135 @@ +# Aspire.Microsoft.Azure.Cosmos library + +Registers [CosmosClient](https://learn.microsoft.com/dotnet/api/microsoft.azure.cosmos.cosmosclient) as a singleton in the DI container for connecting to Azure Cosmos DB. Enables corresponding logging and telemetry. + +## Getting started + +### Prerequisites + +- Azure subscription - [create one for free](https://azure.microsoft.com/free/) +- Azure Cosmos DB account - [create a Cosmos DB account](https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-create-account) + +### Install the package + +Install the Aspire Microsft Azure Cosmos DB library with [NuGet][nuget]: + +```dotnetcli +dotnet add package Aspire.Microsoft.Azure.Cosmos +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddAzureCosmosDB` extension method to register a `CosmosClient` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddAzureCosmosDB("cosmosConnectionName"); +``` + +You can then retrieve the `CosmosClient` instance using dependency injection. For example, to retrieve the client from a Web API controller: + +```csharp +private readonly CosmosClient _client; + +public ProductsController(CosmosClient client) +{ + _client = client; +} +``` + +See the [Azure Cosmos DB documentation](https://learn.microsoft.com/dotnet/api/microsoft.azure.cosmos.cosmosclient) for examples on using the `CosmosClient`. + +## Configuration + +The Aspire Azure Cosmos DB library provides multiple options to configure the Azure Cosmos DB connection based on the requirements and conventions of your project. Note that either an `AccountEndpoint` or a `ConnectionString` is a required to be supplied. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddAzureCosmosDB()`: + +```csharp +builder.AddAzureCosmosDB("cosmosConnectionName"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section. Two connection formats are supported: + +#### Account Endpoint + +The recommended approach is to use an AccountEndpoint, which works with the `AzureCosmosDBSettings.Credential` property to establish a connection. If no credential is configured, the [DefaultAzureCredential](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential) is used. + +```json +{ + "ConnectionStrings": { + "cosmosConnectionName": "https://{account_name}.documents.azure.com:443/" + } +} +``` + +#### Connection string + +Alternatively, an [Azure Cosmos DB connection string](https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-dotnet-get-started#connect-with-a-connection-string) can be used. + +```json +{ + "ConnectionStrings": { + "cosmosConnectionName": "AccountEndpoint=https://{account_name}.documents.azure.com:443/;AccountKey={account_key};" + } +} +``` + +### Use configuration providers + +The Aspire Microsoft Azure Cosmos DB library supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `AzureCosmosDBSettings` and `QueueClientOptions` from configuration by using the `Aspire:Microsoft:Azure:Cosmos` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Microsoft": { + "Azure": { + "Cosmos": { + "Tracing": true, + } + } + } + } +} +``` + +### Use inline delegates + +You can also pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable tracing from code: + +```csharp + builder.AddAzureCosmosDB("cosmosConnectionName", settings => settings.Tracing = false); +``` + +You can also setup the [CosmosClientOptions](https://learn.microsoft.com/dotnet/api/microsoft.azure.cosmos.cosmosclientoptions) using the optional `Action configureClientOptions` parameter of the `AddAzureCosmosDB` method. For example, to set the `ApplicationName` "User-Agent" header suffix for all requests issues by this client: + +```csharp + builder.AddAzureCosmosDB("cosmosConnectionName", configureClientOptions: clientOptions => clientOptions.ApplicationName = "myapp"); +``` + +## AppHost extensions + +In your AppHost project, add a Cosmos DB connection and consume the connection using the following methods: + +```csharp +var cosmosdb = builder.AddAzureCosmosDB("cdb").AddDatabase("cosmosdb"); + +var myService = builder.AddProject() + .WithReference(cosmosdb); +``` + +The `AddAzureCosmosDB` method will read connection information from the AppHost's configuration (for example, from "user secrets") under the `ConnectionStrings:cosmosdb` config key. The `WithReference` method passes that connection information into a connection string named `cosmosdb` in the `MyService` project. In the _Program.cs_ file of `MyService`, the connection can be consumed using: + +```csharp +builder.AddAzureCosmosDB("cosmosdb"); +``` + +## Additional documentation + +* https://learn.microsoft.com/azure/cosmos-db/nosql/sdk-dotnet-v3 +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj new file mode 100644 index 00000000000..913d7e60273 --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj @@ -0,0 +1,24 @@ + + + + $(NetCurrent) + true + false + false + $(ComponentEfCorePackageTags) azure cosmos cosmosdb + A Microsoft Azure Cosmos DB provider for Entity Framework Core that integrates with Aspire, including connection pooling, logging, and telemetry. + $(SharedDir)AzureCosmosDB_256x.png + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosDBExtensions.cs b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosDBExtensions.cs new file mode 100644 index 00000000000..fa518a0a41f --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosDBExtensions.cs @@ -0,0 +1,132 @@ +// 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.CodeAnalysis; +using Aspire.Microsoft.EntityFrameworkCore.Cosmos; +using Azure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for configuring EntityFrameworkCore DbContext to Azure Cosmos DB +/// +public static class AspireAzureEFCoreCosmosDBExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Microsoft:EntityFrameworkCore:Cosmos"; + private const DynamicallyAccessedMemberTypes RequiredByEF = DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; + + /// + /// Registers the given as a service in the services provided by the . + /// Configures the connection pooling, logging and telemetry for the . + /// + /// The that needs to be registered. + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// The name of the database to use within the Azure Cosmos DB account. + /// An optional delegate that can be used for customizing settings. It's invoked after the settings are read from the configuration. + /// An optional delegate to configure the for the context. + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided. + public static void AddCosmosDbContext<[DynamicallyAccessedMembers(RequiredByEF)] TContext>( + this IHostApplicationBuilder builder, + string connectionName, + string databaseName, + Action? configureSettings = null, + Action? configureDbContextOptions = null) where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(builder); + + var settings = new EntityFrameworkCoreCosmosDBSettings(); + var typeSpecificSectionName = $"{DefaultConfigSectionName}:{typeof(TContext).Name}"; + var typeSpecificConfigurationSection = builder.Configuration.GetSection(typeSpecificSectionName); + if (typeSpecificConfigurationSection.Exists()) // https://github.com/dotnet/runtime/issues/91380 + { + typeSpecificConfigurationSection.Bind(settings); + } + else + { + builder.Configuration.GetSection(DefaultConfigSectionName).Bind(settings); + } + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + if (Uri.TryCreate(connectionString, UriKind.Absolute, out var uri)) + { + settings.AccountEndpoint = uri; + } + else + { + settings.ConnectionString = connectionString; + } + } + configureSettings?.Invoke(settings); + + if (settings.DbContextPooling) + { + builder.Services.AddDbContextPool(ConfigureDbContext); + } + else + { + builder.Services.AddDbContext(ConfigureDbContext); + } + + if (settings.Tracing) + { + builder.Services.AddOpenTelemetry().WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder.AddEntityFrameworkCoreInstrumentation(); + tracerProviderBuilder.AddSource("Azure.Cosmos.Operation"); + }); + } + + if (settings.Metrics) + { + builder.Services.AddOpenTelemetry().WithMetrics(meterProviderBuilder => + { + meterProviderBuilder.AddEventCountersInstrumentation(eventCountersInstrumentationOptions => + { + // https://github.com/dotnet/efcore/blob/main/src/EFCore/Infrastructure/EntityFrameworkEventSource.cs#L45 + eventCountersInstrumentationOptions.AddEventSources("Microsoft.EntityFrameworkCore"); + }); + }); + } + + void ConfigureDbContext(DbContextOptionsBuilder dbContextOptionsBuilder) + { + if (!string.IsNullOrEmpty(settings.ConnectionString)) + { + dbContextOptionsBuilder.UseCosmos(settings.ConnectionString, databaseName, UseCosmosBody); + } + else if (settings.AccountEndpoint is not null) + { + var credential = settings.Credential ?? new DefaultAzureCredential(); + dbContextOptionsBuilder.UseCosmos(settings.AccountEndpoint.OriginalString, credential, databaseName, UseCosmosBody); + } + else + { + throw new InvalidOperationException( + $"A DbContext could not be configured. Ensure valid connection information was provided in 'ConnectionStrings:{connectionName}' or either " + + $"{nameof(settings.ConnectionString)} or {nameof(settings.AccountEndpoint)} must be provided " + + $"in the '{DefaultConfigSectionName}' or '{typeSpecificSectionName}' configuration section."); + } + + configureDbContextOptions?.Invoke(dbContextOptionsBuilder); + } + + void UseCosmosBody(CosmosDbContextOptionsBuilder builder) + { + // We don't register logger factory, because there is no need to: + // https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontextoptionsbuilder.useloggerfactory?view=efcore-7.0#remarks + if (settings.Region is not null) + { + builder.Region(settings.Region); + } + } + } +} diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/ConfigurationSchema.json b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/ConfigurationSchema.json new file mode 100644 index 00000000000..40dc2ae5b25 --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/ConfigurationSchema.json @@ -0,0 +1,80 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Azure-Cosmos-Operation-Request-Diagnostics": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.ChangeTracking": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Command": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Infrastructure": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Query": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Microsoft": { + "type": "object", + "properties": { + "EntityFrameworkCore": { + "type": "object", + "properties": { + "Cosmos": { + "type": "object", + "properties": { + "AccountEndpoint": { + "type": "string", + "format": "uri", + "description": "Gets or sets the account endpoint of the Azure Cosmos DB account to connect to. Used along with \"Credential\" to establish the connection." + }, + "ConnectionString": { + "type": "string", + "description": "Gets or sets the connection string of the Azure Cosmos DB account to connect to." + }, + "DbContextPooling": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the DbContext will be pooled or explicitly created every time it's requested.", + "default": true + }, + "Tracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.", + "default": true + }, + "Metrics": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the OpenTelemetry metrics are enabled or not.", + "default": true + }, + "Region": { + "type": "string", + "description": "Gets or sets a string value that indicates what Azure region this client will run in." + } + } + } + } + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/EntityFrameworkCoreCosmosDBSettings.cs b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/EntityFrameworkCoreCosmosDBSettings.cs new file mode 100644 index 00000000000..320f595ccfb --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/EntityFrameworkCoreCosmosDBSettings.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Azure.Core; + +namespace Aspire.Microsoft.EntityFrameworkCore.Cosmos; + +/// +/// The settings relevant to accessing Azure Cosmos DB database using EntityFrameworkCore. +/// +public sealed class EntityFrameworkCoreCosmosDBSettings +{ + /// + /// The connection string of the Azure Cosmos DB server database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// A referencing the Azure Cosmos DB Endpoint. + /// This is likely to be similar to "https://{account_name}.queue.core.windows.net". + /// + /// + /// Must not contain shared access signature. + /// Used along with to establish the connection. + /// + public Uri? AccountEndpoint { get; set; } + + /// + /// Gets or sets the credential used to authenticate to the Azure Cosmos DB endpoint. + /// + public TokenCredential? Credential { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the db context will be pooled or explicitly created every time it's requested. + /// + public bool DbContextPooling { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the Open Telemetry tracing is enabled or not. + /// Enabled by default. + /// + public bool Tracing { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the Open Telemetry metrics are enabled or not. + /// Enabled by default. + /// + public bool Metrics { get; set; } = true; + + /// + /// Gets or sets a string value that indicates what Azure region this client will run in. + /// + public string? Region { get; set; } +} diff --git a/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/README.md b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/README.md new file mode 100644 index 00000000000..f1d90dbaad0 --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/README.md @@ -0,0 +1,113 @@ +# Aspire.Microsoft.EntityFrameworkCore.Cosmos library + +Registers [EntityFrameworkCore](https://learn.microsoft.com/en-us/ef/core/) [DbContext](https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontext) in the DI container for connecting to Azure Cosmos DB. Enables connection pooling, logging and telemetry. + +## Getting started + +### Prerequisites + +- CosmosDB database and connection string for accessing the database. + +### Install the package + +Install the Aspire Microsoft EntityFrameworkCore Cosmos library with [NuGet][nuget]: + +```dotnetcli +dotnet add package Aspire.Microsoft.EntityFrameworkCore.Cosmos +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddCosmosDbContext` extension method to register a `DbContext` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddCosmosDbContext("cosmosdb"); +``` + +You can then retrieve the `MyDbContext` instance using dependency injection. For example, to retrieve the context from a Web API controller: + +```csharp +private readonly MyDbContext _context; + +public ProductsController(MyDbContext context) +{ + _context = context; +} +``` + +## Configuration + +The Aspire Microsoft EntityFrameworkCore Cosmos component provides multiple options to configure the database connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddCosmosDbContext()`: + +```csharp +builder.AddCosmosDbContext("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "AccountEndpoint=https://{account_name}.documents.azure.com:443/;AccountKey={account_key};" + } +} +``` + +See the [ConnectionString documentation](https://learn.microsoft.com/azure/cosmos-db/nosql/how-to-dotnet-get-started#connect-with-a-connection-string) for more information. + +### Use configuration providers + +The Aspire Microsoft EntityFrameworkCore Cosmos component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `EntityFrameworkCoreCosmosDBSettings` from configuration by using the `Aspire:Microsaoft:EntityFrameworkCore:Cosmos` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Microsoft": { + "EntityFrameworkCore": { + "Cosmos": { + "DbContextPooling": true, + "Tracing": false + } + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable tracing from code: + +```csharp + builder.AddCosmosDbContext("cosmosdb", settings => settings.Tracing = false); +``` + +## AppHost extensions + +In your AppHost project, add a Cosmos DB connection and consume the connection using the following methods:: + +```csharp +var cosmosdb = builder.AddAzureCosmosDB("cdb").AddDatabase("cosmosdb"); + +var myService = builder.AddProject() + .WithReference(cosmosdb); +``` + +The `WithReference` method configures a connection in the `MyService` project named `cosmosdb`. In the _Program.cs_ file of `MyService`, the database connection can be consumed using: + +```csharp +builder.AddCosmosDbContext("cosmosdb"); +``` + +## Additional documentation + +* https://learn.microsoft.com/ef/core/ +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire \ No newline at end of file diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 20dbc4a81b0..38c7fc20aaa 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -6,9 +6,11 @@ As part of the Aspire November preview, we want to include a set of Aspire Compo | --------------------------------------- | :---------------------------------: | :-----------------------: | :----------------------------------------------------: | :-------------------------: | :-----------------: | :-----------------: | :-----------------: | :-----------------------------: | | Npgsql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Npgsql.EntityFrameworkCore.PostgreSQL | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Microsoft.Azure.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | Microsoft.Data.SqlClient | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Microsoft.EntityFramework.Cosmos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | | Microsoft.EntityFrameworkCore.SqlServer | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Aspire.Azure.Data.Tables | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Azure.Data.Tables | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Messaging.ServiceBus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Security.KeyVault | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | | Azure.Storage.Blobs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index d3bd8a82194..715f8d8a9f4 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -47,6 +47,13 @@ Aspire.Azure.Storage.Queues: - Metric names: - none (currently not supported by the Azure SDK) +Aspire.Microsoft.Azure.Cosmos: +- Log categories: + - "Azure-Cosmos-Operation-Request-Diagnostics" +- Activity source names: + - "Azure.Cosmos.Operation" +- Metric names: + Aspire.Microsoft.Data.SqlClient: - Log categories: - none (the client does not provide an easy way to integrate it with logger factory) @@ -71,6 +78,29 @@ Aspire.Microsoft.Data.SqlClient: - "number-of-stasis-connections" - "number-of-reclaimed-connections" +Aspire.Microsoft.EntityFrameworkCore.Cosmos: +- Log categories: + - "Azure-Cosmos-Operation-Request-Diagnostics" + - "Microsoft.EntityFrameworkCore.ChangeTracking", + - "Microsoft.EntityFrameworkCore.Database.Command", + - "Microsoft.EntityFrameworkCore.Infrastructure", + - "Microsoft.EntityFrameworkCore.Query", +- Activity source names: + - "Azure.Cosmos.Operation" + - "OpenTelemetry.Instrumentation.EntityFrameworkCore" +- Metric names: + - "Microsoft.EntityFrameworkCore": + - "ec_Microsoft_EntityFrameworkCore_active_db_contexts" + - "ec_Microsoft_EntityFrameworkCore_total_queries" + - "ec_Microsoft_EntityFrameworkCore_queries_per_second" + - "ec_Microsoft_EntityFrameworkCore_total_save_changes" + - "ec_Microsoft_EntityFrameworkCore_save_changes_per_second" + - "ec_Microsoft_EntityFrameworkCore_compiled_query_cache_hit_rate" + - "ec_Microsoft_Entity_total_execution_strategy_operation_failures" + - "ec_Microsoft_E_execution_strategy_operation_failures_per_second" + - "ec_Microsoft_EntityFramew_total_optimistic_concurrency_failures" + - "ec_Microsoft_EntityF_optimistic_concurrency_failures_per_second" + Aspire.Microsoft.EntityFrameworkCore.SqlServer: - Log categories: - "Microsoft.EntityFrameworkCore.ChangeTracking" diff --git a/src/Shared/AzureCosmosDB_256x.png b/src/Shared/AzureCosmosDB_256x.png new file mode 100644 index 00000000000..ad2850532c6 Binary files /dev/null and b/src/Shared/AzureCosmosDB_256x.png differ diff --git a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/Aspire.Microsoft.Azure.Cosmos.Tests.csproj b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/Aspire.Microsoft.Azure.Cosmos.Tests.csproj new file mode 100644 index 00000000000..07a7d23049b --- /dev/null +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/Aspire.Microsoft.Azure.Cosmos.Tests.csproj @@ -0,0 +1,12 @@ + + + + $(NetCurrent) + + + + + + + + diff --git a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConfigurationTests.cs b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConfigurationTests.cs new file mode 100644 index 00000000000..df3284ebf7f --- /dev/null +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConfigurationTests.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Xunit; + +namespace Aspire.Microsoft.Azure.Cosmos.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringIsNullByDefault() + => Assert.Null(new AzureCosmosDBSettings().ConnectionString); + + [Fact] + public void TracingIsEnabledByDefault() + => Assert.True(new AzureCosmosDBSettings().Tracing); +} diff --git a/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs new file mode 100644 index 00000000000..4ff5fa82f30 --- /dev/null +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.ConformanceTests; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Microsoft.Azure.Cosmos.Tests; + +public class ConformanceTests : ConformanceTests +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.Cosmos.Operation"; + + protected override string[] RequiredLogCategories => Array.Empty(); + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new KeyValuePair(CreateConfigKey("Aspire:Microsoft:Azure:Cosmos", key, "ConnectionString"), + "AccountEndpoint=https://example.documents.azure.com:443/;AccountKey=fake;") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddAzureCosmosDB("cosmosdb", configure); + } + else + { + builder.AddKeyedAzureCosmosDB(key, configure); + } + } + + protected override void SetHealthCheck(AzureCosmosDBSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetTracing(AzureCosmosDBSettings options, bool enabled) + => options.Tracing = enabled; + + protected override void SetMetrics(AzureCosmosDBSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override string JsonSchemaPath + => "src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Microsoft": { + "Azure": { + "Cosmos": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "Tracing": true + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Microsoft":{ "Azure": { "Cosmos": { "AccountEndpoint": 3 }}}}}""", "Value is \"integer\" but should be \"string\""), + ("""{"Aspire": { "Microsoft":{ "Azure": { "Cosmos": { "AccountEndpoint": "hello" }}}}}""", "Value does not match format \"uri\"") + }; + + protected override void TriggerActivity(CosmosClient service) + { + // TODO: Get rid of GetAwaiter().GetResult() + service.ReadAccountAsync().GetAwaiter().GetResult(); + } +} diff --git a/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj new file mode 100644 index 00000000000..6ef388b18b7 --- /dev/null +++ b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(NetCurrent) + + + + + + + + + + + + + diff --git a/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/AspireAzureEfCoreCosmosDBExtensionsTests.cs b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/AspireAzureEfCoreCosmosDBExtensionsTests.cs new file mode 100644 index 00000000000..d462195c43f --- /dev/null +++ b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/AspireAzureEfCoreCosmosDBExtensionsTests.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Cosmos.Infrastructure.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests; + +public class AspireAzureEfCoreCosmosDBExtensionsTests +{ + private const string ConnectionString = "AccountEndpoint=https://fake-account.documents.azure.com:443/;AccountKey=;"; + + [Fact] + public void CanConfigureDbContextOptions() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:cosmosConnection", ConnectionString), + new KeyValuePair("Aspire:Microsoft:EntityFrameworkCore:Cosmos:Region", "westus"), + ]); + + builder.AddCosmosDbContext("cosmosConnection", "databaseName", configureDbContextOptions: optionsBuilder => + { + optionsBuilder.UseCosmos(ConnectionString, "databaseName", cosmosBuilder => + { + cosmosBuilder.RequestTimeout(TimeSpan.FromSeconds(608)); + }); + }); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + +#pragma warning disable EF1001 // Internal EF Core API usage. + + var extension = context.Options.FindExtension(); + Assert.NotNull(extension); + + // Ensure the RequestTimeout from config size was respected + Assert.Equal(TimeSpan.FromSeconds(608), extension.RequestTimeout); + + // Ensure the Region from the lambda was respected + Assert.Equal("westus", extension.Region); + +#pragma warning restore EF1001 // Internal EF Core API usage. + } +} diff --git a/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_NoPooling.cs b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_NoPooling.cs new file mode 100644 index 00000000000..374c8117a20 --- /dev/null +++ b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_NoPooling.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests; + +public class ConformanceTests_NoPooling : ConformanceTests_Pooling +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Scoped; + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + builder.AddCosmosDbContext("cosmosdb", "TestDatabase", settings => + { + settings.DbContextPooling = false; + configure?.Invoke(settings); + }); + } +} diff --git a/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_Pooling.cs b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_Pooling.cs new file mode 100644 index 00000000000..1ac9540ad66 --- /dev/null +++ b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_Pooling.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests; + +public class ConformanceTests_Pooling : ConformanceTests +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/cb5b2193ef9cacc0b9ef699e085022577551bf85/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs#L38 + protected override string ActivitySourceName => "OpenTelemetry.Instrumentation.EntityFrameworkCore"; + + protected override string[] RequiredLogCategories => new string[] + { + "Microsoft.EntityFrameworkCore.ChangeTracking", + "Microsoft.EntityFrameworkCore.Database.Command", + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Query", + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[] + { + new KeyValuePair("Aspire:Microsoft:EntityFrameworkCore:Cosmos:ConnectionString", + "Host=fake;Database=catalog"), + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + => builder.AddCosmosDbContext("cosmosdb", "TestDatabase", configure); + + protected override void SetHealthCheck(EntityFrameworkCoreCosmosDBSettings options, bool enabled) + => throw new NotImplementedException(); + + protected override void SetTracing(EntityFrameworkCoreCosmosDBSettings options, bool enabled) + => options.Tracing = enabled; + + protected override void SetMetrics(EntityFrameworkCoreCosmosDBSettings options, bool enabled) + => options.Metrics = enabled; + + protected override string JsonSchemaPath + => "src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/ConfigurationSchema.json"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Microsoft": { + "EntityFrameworkCore": { + "Cosmos": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "Tracing": true, + "Metrics": true + } + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Microsoft":{ "EntityFrameworkCore": { "Cosmos": { "AccountEndpoint": 3 }}}}}""", "Value is \"integer\" but should be \"string\""), + ("""{"Aspire": { "Microsoft":{ "EntityFrameworkCore": { "Cosmos": { "AccountEndpoint": "hello" }}}}}""", "Value does not match format \"uri\""), + ("""{"Aspire": { "Microsoft":{ "EntityFrameworkCore": { "Cosmos": { "Region": 3 }}}}}""", "Value is \"integer\" but should be \"string\""), + }; + + protected override void TriggerActivity(TestDbContext service) + { + if (service.Database.CanConnect()) + { + service.Database.EnsureCreated(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "EF1001:Internal EF Core API usage.", Justification = "Required to verify pooling without touching DB")] + public void DbContextPoolingRegistersIDbContextPool(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + + IDbContextPool? pool = host.Services.GetService>(); + + Assert.Equal(enabled, pool is not null); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DbContextCanBeAlwaysResolved(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + + TestDbContext? dbContext = host.Services.GetService(); + + Assert.NotNull(dbContext); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + } +}