From ac8c560a2d4421e44187ed6ae14f7a1298abc0e2 Mon Sep 17 00:00:00 2001 From: Eric Erhardt <eric.erhardt@microsoft.com> Date: Thu, 2 Nov 2023 16:17:51 -0500 Subject: [PATCH] Azure cosmosdb support in aspire (#359) (#669) * Initial addition of CosmosDB support, based on SqlServer * Remove Healthchecks support from CosmosDB EF Component * Cleanup connection string handling in Cosmos EF * Cleanup connection string handling in Cosmos component * Update CosmosDB package to get OTel support * Use the parent name for the connection * Udpate manifest strings * Add CosmosDB components to Progress and Telemetry * Rename CosmosDB components to Aspire.Azure.Data.Cosmos[.EntityFrameworkCore] * Rename options -> settings * Rename Cosmos Components to follow naming guidelines * Update to CosmosDB preview package and pin to get OpenTelemetry support * Update comments and add Keyed DI to Aspire.Microsoft.Azure.Cosmos * Add log categories to Cosmos Component schemas * Add basic support for CosmosClientOptions (no IConfiguration binding yet) * Remove healthchecks support from CosmosDB Component * Add README for Aspire.Microsoft.Azure.Cosmos * Add README for Aspire.Microsoft.EntityFrameworkCore.Cosmos, and rename a couple of things * Update config schema to be nested for Aspire.Microsoft.EntityFramework.Cosmos and Aspire.Microsoft.Azure.Cosmos * Rename AzureDataCosmosSettings -> AzureCosmosDBSettings * Update Aspire_Components_Progress.md * Add PackageTags, Descriptions, and Icons * Add AccountEndpoint to ConfigurationScheama.json * Fix DB context builder config * Add xml doc comments for CosmosDB hosting methods and types * Move Cosmos DB hosting to Aspire.Hosting.Azure * Update manifest type names * Respond to PR feedback Co-authored-by: Kevin Pilch <me@pilchie.com> --- Aspire.sln | 28 ++++ Directory.Packages.props | 4 +- ...smosDBCloudApplicationBuilderExtensions.cs | 60 ++++++++ .../AzureCosmosDBConnectionResource.cs | 21 +++ .../AzureCosmosDatabaseResource.cs | 29 ++++ .../Aspire.Microsoft.Azure.Cosmos.csproj | 20 +++ .../AspireAzureCosmosDBExtensions.cs | 126 ++++++++++++++++ .../AzureCosmosDBSettings.cs | 39 +++++ .../ConfigurationSchema.json | 46 ++++++ .../Aspire.Microsoft.Azure.Cosmos/README.md | 135 ++++++++++++++++++ ...icrosoft.EntityFrameworkCore.Cosmos.csproj | 24 ++++ .../AspireAzureEFCoreCosmosDBExtensions.cs | 132 +++++++++++++++++ .../ConfigurationSchema.json | 80 +++++++++++ .../EntityFrameworkCoreCosmosDBSettings.cs | 54 +++++++ .../README.md | 113 +++++++++++++++ src/Components/Aspire_Components_Progress.md | 4 +- src/Components/Telemetry.md | 30 ++++ src/Shared/AzureCosmosDB_256x.png | Bin 0 -> 20762 bytes ...Aspire.Microsoft.Azure.Cosmos.Tests.csproj | 12 ++ .../ConfigurationTests.cs | 17 +++ .../ConformanceTests.cs | 77 ++++++++++ ...ft.EntityFrameworkCore.Cosmos.Tests.csproj | 17 +++ ...spireAzureEfCoreCosmosDBExtensionsTests.cs | 51 +++++++ .../ConformanceTests_NoPooling.cs | 22 +++ .../ConformanceTests_Pooling.cs | 115 +++++++++++++++ 25 files changed, 1254 insertions(+), 2 deletions(-) create mode 100644 src/Aspire.Hosting.Azure/AzureCosmosDBCloudApplicationBuilderExtensions.cs create mode 100644 src/Aspire.Hosting.Azure/AzureCosmosDBConnectionResource.cs create mode 100644 src/Aspire.Hosting.Azure/AzureCosmosDatabaseResource.cs create mode 100644 src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj create mode 100644 src/Components/Aspire.Microsoft.Azure.Cosmos/AspireAzureCosmosDBExtensions.cs create mode 100644 src/Components/Aspire.Microsoft.Azure.Cosmos/AzureCosmosDBSettings.cs create mode 100644 src/Components/Aspire.Microsoft.Azure.Cosmos/ConfigurationSchema.json create mode 100644 src/Components/Aspire.Microsoft.Azure.Cosmos/README.md create mode 100644 src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj create mode 100644 src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/AspireAzureEFCoreCosmosDBExtensions.cs create mode 100644 src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/ConfigurationSchema.json create mode 100644 src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/EntityFrameworkCoreCosmosDBSettings.cs create mode 100644 src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/README.md create mode 100644 src/Shared/AzureCosmosDB_256x.png create mode 100644 tests/Aspire.Microsoft.Azure.Cosmos.Tests/Aspire.Microsoft.Azure.Cosmos.Tests.csproj create mode 100644 tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConfigurationTests.cs create mode 100644 tests/Aspire.Microsoft.Azure.Cosmos.Tests/ConformanceTests.cs create mode 100644 tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj create mode 100644 tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/AspireAzureEfCoreCosmosDBExtensionsTests.cs create mode 100644 tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_NoPooling.cs create mode 100644 tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/ConformanceTests_Pooling.cs diff --git a/Aspire.sln b/Aspire.sln index 5647600b1a..f48ad76f5a 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 59dad36f96..a5b99920fb 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ <PackageVersion Include="Azure.Security.KeyVault.Secrets" Version="4.5.0" /> <PackageVersion Include="Azure.Storage.Blobs" Version="12.18.0" /> <PackageVersion Include="Azure.Storage.Queues" Version="12.16.0" /> + <PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.36.0-preview" /> <PackageVersion Include="Microsoft.Extensions.Azure" Version="1.7.1" /> <!-- Azure Management SDK for .NET dependencies --> <PackageVersion Include="Azure.ResourceManager.KeyVault" Version="1.2.0-beta.2" /> @@ -44,6 +45,7 @@ <!-- sql client dependencies --> <PackageVersion Include="Microsoft.Data.SqlClient" Version="5.1.1" /> <!-- efcore dependencies --> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Cosmos" Version="$(EfCoreVersion)" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(EfCoreVersion)" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(EfCoreVersion)" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="$(EfCoreVersion)" /> @@ -100,4 +102,4 @@ <PackageVersion Include="Microsoft.Signed.Wix" Version="1.0.0-v3.14.0.5722" /> <PackageVersion Include="Microsoft.DotNet.Build.Tasks.Installers" Version="8.0.0-beta.23371.1" /> </ItemGroup> -</Project> +</Project> \ 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 0000000000..3d307a2ff7 --- /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; + +/// <summary> +/// Provides extension methods for adding Azure Cosmos DB resources to an <see cref="IDistributedApplicationBuilder"/>. +/// </summary> +public static class AzureCosmosDBCloudApplicationBuilderExtensions +{ + /// <summary> + /// Adds an Azure Cosmos DB connection to the application model. + /// </summary> + /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param> + /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param> + /// <param name="connectionString">The connection string.</param> + /// <returns>A reference to the <see cref="IResourceBuilder{AzureCosmosDatabaseResource}"/>.</returns> + public static IResourceBuilder<AzureCosmosDBConnectionResource> 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); + } + + /// <summary> + /// Adds an Azure Cosmos DB database to a <see cref="IResourceBuilder{AzureCosmosDatabaseResource}"/>. + /// </summary> + /// <param name="builder">The <see cref="IDistributedApplicationBuilder"/>.</param> + /// <param name="name">The name of the resource. This name will be used as the connection string name when referenced in a dependency.</param> + /// <returns>A reference to the <see cref="IResourceBuilder{AzureCosmosDatabaseResource}"/>.</returns> + public static IResourceBuilder<AzureCosmosDatabaseResource> AddDatabase(this IResourceBuilder<AzureCosmosDBConnectionResource> 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 0000000000..53b1f2381e --- /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; + +/// <summary> +/// Represents a connection to an Azure Cosmos DB account. +/// </summary> +/// <param name="name">The resource name.</param> +/// <param name="connectionString">The connection string to use to connect.</param> +public class AzureCosmosDBConnectionResource(string name, string? connectionString) + : Resource(name), IResourceWithConnectionString +{ + /// <summary> + /// Gets the connection string to use for this database. + /// </summary> + /// <returns>The connection string to use for this database.</returns> + 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 0000000000..96cc0feb4b --- /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; + +/// <summary> +/// Represents an Azure Cosmos DB database. +/// </summary> +/// <param name="name">The database name.</param> +/// <param name="parent">The parent <see cref="AzureCosmosDBConnectionResource"/>.</param> +public class AzureCosmosDatabaseResource(string name, AzureCosmosDBConnectionResource parent) + : Resource(name), IResourceWithParent<AzureCosmosDBConnectionResource>, IResourceWithConnectionString +{ + /// <summary> + /// Gets the parent <see cref="AzureCosmosDBConnectionResource"/>. + /// </summary> + public AzureCosmosDBConnectionResource Parent { get; } = parent; + + /// <summary> + /// Gets the connection string to use for this database. + /// </summary> + /// <returns>The connection string to use for this database.</returns> + 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 0000000000..e41dd94948 --- /dev/null +++ b/src/Components/Aspire.Microsoft.Azure.Cosmos/Aspire.Microsoft.Azure.Cosmos.csproj @@ -0,0 +1,20 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>$(NetCurrent)</TargetFramework> + <IsPackable>true</IsPackable> + <EnableConfigurationBindingGenerator>false</EnableConfigurationBindingGenerator> + <IsAotCompatible>false</IsAotCompatible> + <PackageTags>$(ComponentAzurePackageTags) cosmos cosmosdb data database db</PackageTags> + <Description>A client for Azure Cosmos DB that integrates with Aspire, including logging and telemetry.</Description> + <PackageIconFullPath>$(SharedDir)AzureCosmosDB_256x.png</PackageIconFullPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Azure.Identity" /> + <PackageReference Include="Microsoft.Azure.Cosmos" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> + <PackageReference Include="OpenTelemetry.Extensions.Hosting" /> + </ItemGroup> + +</Project> 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 0000000000..cfe43cbc4e --- /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; + +/// <summary> +/// Azure CosmosDB extension +/// </summary> +public static class AspireAzureCosmosDBExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Microsoft:Azure:Cosmos"; + + /// <summary> + /// Registers <see cref="CosmosClient" /> as a singleton in the services provided by the <paramref name="builder"/>. + /// Configures logging and telemetry for the <see cref="CosmosClient" />. + /// </summary> + /// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param> + /// <param name="connectionName">The connection name to use to find a connection string.</param> + /// <param name="configureSettings">An optional method that can be used for customizing the <see cref="AzureCosmosDBSettings"/>. It's invoked after the settings are read from the configuration.</param> + /// <param name="configureClientOptions">An optional method that can be used for customizing the <see cref="CosmosClientOptions"/>.</param> + /// <remarks>Reads the configuration from "Aspire:Microsoft:Azure:Cosmos" section.</remarks> + /// <exception cref="InvalidOperationException">If required ConnectionString is not provided in configuration section</exception> + public static void AddAzureCosmosDB( + this IHostApplicationBuilder builder, + string connectionName, + Action<AzureCosmosDBSettings>? configureSettings = null, + Action<CosmosClientOptions>? configureClientOptions = null) + { + AddAzureCosmosDB(builder, DefaultConfigSectionName, configureSettings, configureClientOptions, connectionName, serviceKey: null); + } + + /// <summary> + /// Registers <see cref="CosmosClient" /> as a singleton for given <paramref name="name" /> in the services provided by the <paramref name="builder"/>. + /// Configures logging and telemetry for the <see cref="CosmosClient" />. + /// </summary> + /// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param> + /// <param name="name">The name of the component, which is used as the <see cref="ServiceDescriptor.ServiceKey"/> of the service and also to retrieve the connection string from the ConnectionStrings configuration section.</param> + /// <param name="configureSettings">An optional method that can be used for customizing the <see cref="AzureCosmosDBSettings"/>. It's invoked after the settings are read from the configuration.</param> + /// <param name="configureClientOptions">An optional method that can be used for customizing the <see cref="CosmosClientOptions"/>.</param> + /// <remarks>Reads the configuration from "Aspire:Microsoft:Azure:Cosmos:{name}" section.</remarks> + /// <exception cref="InvalidOperationException">If required ConnectionString is not provided in configuration section</exception> + public static void AddKeyedAzureCosmosDB( + this IHostApplicationBuilder builder, + string name, + Action<AzureCosmosDBSettings>? configureSettings = null, + Action<CosmosClientOptions>? configureClientOptions = null) + { + AddAzureCosmosDB(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, configureClientOptions, connectionName: name, serviceKey: name); + } + + private static void AddAzureCosmosDB( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action<AzureCosmosDBSettings>? configureSettings, + Action<CosmosClientOptions>? 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 0000000000..98b9aaae60 --- /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; + +/// <summary> +/// The settings relevant to accessing Azure Cosmos DB. +/// </summary> +public sealed class AzureCosmosDBSettings +{ + /// <summary> + /// Gets or sets the connection string of the Azure Cosmos database to connect to. + /// </summary> + public string? ConnectionString { get; set; } + + /// <summary> + /// <para>Gets or sets a boolean value that indicates whether the OpenTelemetry tracing is enabled or not.</para> + /// <para>Enabled by default.</para> + /// </summary> + public bool Tracing { get; set; } = true; + + /// <summary> + /// A <see cref="Uri"/> referencing the Azure Cosmos DB Endpoint. + /// This is likely to be similar to "https://{account_name}.queue.core.windows.net". + /// </summary> + /// <remarks> + /// Must not contain shared access signature. + /// Used along with <see cref="Credential"/> to establish the connection. + /// </remarks> + public Uri? AccountEndpoint { get; set; } + + /// <summary> + /// Gets or sets the credential used to authenticate to the Azure Cosmos DB endpoint. + /// </summary> + 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 0000000000..9ba4f921b4 --- /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 0000000000..2aeefdbafc --- /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<AzureCosmosDBSettings> 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<CosmosClientOptions> 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<Projects.MyService>() + .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 0000000000..913d7e6027 --- /dev/null +++ b/src/Components/Aspire.Microsoft.EntityFrameworkCore.Cosmos/Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj @@ -0,0 +1,24 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>$(NetCurrent)</TargetFramework> + <IsPackable>true</IsPackable> + <EnableConfigurationBindingGenerator>false</EnableConfigurationBindingGenerator> + <IsAotCompatible>false</IsAotCompatible> + <PackageTags>$(ComponentEfCorePackageTags) azure cosmos cosmosdb </PackageTags> + <Description>A Microsoft Azure Cosmos DB provider for Entity Framework Core that integrates with Aspire, including connection pooling, logging, and telemetry.</Description> + <PackageIconFullPath>$(SharedDir)AzureCosmosDB_256x.png</PackageIconFullPath> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Azure.Identity" /> + <PackageReference Include="Microsoft.Azure.Cosmos" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Cosmos" /> + <PackageReference Include="Microsoft.Extensions.Configuration.Binder" /> + <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" /> + <PackageReference Include="OpenTelemetry.Extensions.Hosting" /> + <PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" /> + <PackageReference Include="OpenTelemetry.Instrumentation.EventCounters" /> + </ItemGroup> + +</Project> 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 0000000000..fa518a0a41 --- /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; + +/// <summary> +/// Extension methods for configuring EntityFrameworkCore DbContext to Azure Cosmos DB +/// </summary> +public static class AspireAzureEFCoreCosmosDBExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Microsoft:EntityFrameworkCore:Cosmos"; + private const DynamicallyAccessedMemberTypes RequiredByEF = DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; + + /// <summary> + /// Registers the given <see cref="DbContext" /> as a service in the services provided by the <paramref name="builder"/>. + /// Configures the connection pooling, logging and telemetry for the <see cref="DbContext" />. + /// </summary> + /// <typeparam name="TContext">The <see cref="DbContext" /> that needs to be registered.</typeparam> + /// <param name="builder">The <see cref="IHostApplicationBuilder" /> to read config from and add services to.</param> + /// <param name="connectionName">A name used to retrieve the connection string from the ConnectionStrings configuration section.</param> + /// <param name="databaseName">The name of the database to use within the Azure Cosmos DB account.</param> + /// <param name="configureSettings">An optional delegate that can be used for customizing settings. It's invoked after the settings are read from the configuration.</param> + /// <param name="configureDbContextOptions">An optional delegate to configure the <see cref="DbContextOptions"/> for the context.</param> + /// <exception cref="ArgumentNullException">Thrown if mandatory <paramref name="builder"/> is null.</exception> + /// <exception cref="InvalidOperationException">Thrown when mandatory <see cref="EntityFrameworkCoreCosmosDBSettings.ConnectionString"/> is not provided.</exception> + public static void AddCosmosDbContext<[DynamicallyAccessedMembers(RequiredByEF)] TContext>( + this IHostApplicationBuilder builder, + string connectionName, + string databaseName, + Action<EntityFrameworkCoreCosmosDBSettings>? configureSettings = null, + Action<DbContextOptionsBuilder>? 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<TContext>(ConfigureDbContext); + } + else + { + builder.Services.AddDbContext<TContext>(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 0000000000..40dc2ae5b2 --- /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 0000000000..320f595ccf --- /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; + +/// <summary> +/// The settings relevant to accessing Azure Cosmos DB database using EntityFrameworkCore. +/// </summary> +public sealed class EntityFrameworkCoreCosmosDBSettings +{ + /// <summary> + /// The connection string of the Azure Cosmos DB server database to connect to. + /// </summary> + public string? ConnectionString { get; set; } + + /// <summary> + /// A <see cref="Uri"/> referencing the Azure Cosmos DB Endpoint. + /// This is likely to be similar to "https://{account_name}.queue.core.windows.net". + /// </summary> + /// <remarks> + /// Must not contain shared access signature. + /// Used along with <see cref="Credential"/> to establish the connection. + /// </remarks> + public Uri? AccountEndpoint { get; set; } + + /// <summary> + /// Gets or sets the credential used to authenticate to the Azure Cosmos DB endpoint. + /// </summary> + public TokenCredential? Credential { get; set; } + + /// <summary> + /// Gets or sets a boolean value that indicates whether the db context will be pooled or explicitly created every time it's requested. + /// </summary> + public bool DbContextPooling { get; set; } = true; + + /// <summary> + /// <para>Gets or sets a boolean value that indicates whether the Open Telemetry tracing is enabled or not.</para> + /// <para>Enabled by default.</para> + /// </summary> + public bool Tracing { get; set; } = true; + + /// <summary> + /// <para>Gets or sets a boolean value that indicates whether the Open Telemetry metrics are enabled or not.</para> + /// <para>Enabled by default.</para> + /// </summary> + public bool Metrics { get; set; } = true; + + /// <summary> + /// Gets or sets a string value that indicates what Azure region this client will run in. + /// </summary> + 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 0000000000..f1d90dbaad --- /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<MyDbContext>("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<MyDbContext>("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<EntityFrameworkCoreCosmosDBSettings> configureSettings` delegate to set up some or all the options inline, for example to disable tracing from code: + +```csharp + builder.AddCosmosDbContext<MyDbContext>("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<Projects.MyService>() + .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<MyDbContext>("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 20dbc4a81b..38c7fc20aa 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 d3bd8a8219..715f8d8a9f 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 0000000000000000000000000000000000000000..ad2850532c66c8eba547f3509ecf14607df2b593 GIT binary patch literal 20762 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 LdZJvTWEuHC>9Ku0 literal 0 HcmV?d00001 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 0000000000..07a7d23049 --- /dev/null +++ b/tests/Aspire.Microsoft.Azure.Cosmos.Tests/Aspire.Microsoft.Azure.Cosmos.Tests.csproj @@ -0,0 +1,12 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>$(NetCurrent)</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\Components\Aspire.Microsoft.Azure.Cosmos\Aspire.Microsoft.Azure.Cosmos.csproj" /> + <ProjectReference Include="..\Aspire.Components.Common.Tests\Aspire.Components.Common.Tests.csproj" /> + </ItemGroup> + +</Project> 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 0000000000..df3284ebf7 --- /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 0000000000..4ff5fa82f3 --- /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<CosmosClient, AzureCosmosDBSettings> +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + protected override string ActivitySourceName => "Azure.Cosmos.Operation"; + + protected override string[] RequiredLogCategories => Array.Empty<string>(); + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair<string, string?>[1] + { + new KeyValuePair<string, string?>(CreateConfigKey("Aspire:Microsoft:Azure:Cosmos", key, "ConnectionString"), + "AccountEndpoint=https://example.documents.azure.com:443/;AccountKey=fake;") + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action<AzureCosmosDBSettings>? 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 0000000000..6ef388b18b --- /dev/null +++ b/tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests/Aspire.Microsoft.EntityFrameworkCore.Cosmos.Tests.csproj @@ -0,0 +1,17 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFramework>$(NetCurrent)</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <!-- this file is included as a link, as both components rely on different versions of EF (for now) --> + <Compile Include="..\Aspire.Npgsql.EntityFrameworkCore.PostgreSQL.Tests\TestDbContext.cs" Link="TestDbContext.cs" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\Components\Aspire.Microsoft.EntityFrameworkCore.Cosmos\Aspire.Microsoft.EntityFrameworkCore.Cosmos.csproj" /> + <ProjectReference Include="..\Aspire.Components.Common.Tests\Aspire.Components.Common.Tests.csproj" /> + </ItemGroup> + +</Project> 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 0000000000..d462195c43 --- /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=<fake-key>;"; + + [Fact] + public void CanConfigureDbContextOptions() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair<string, string?>("ConnectionStrings:cosmosConnection", ConnectionString), + new KeyValuePair<string, string?>("Aspire:Microsoft:EntityFrameworkCore:Cosmos:Region", "westus"), + ]); + + builder.AddCosmosDbContext<TestDbContext>("cosmosConnection", "databaseName", configureDbContextOptions: optionsBuilder => + { + optionsBuilder.UseCosmos(ConnectionString, "databaseName", cosmosBuilder => + { + cosmosBuilder.RequestTimeout(TimeSpan.FromSeconds(608)); + }); + }); + + var host = builder.Build(); + var context = host.Services.GetRequiredService<TestDbContext>(); + +#pragma warning disable EF1001 // Internal EF Core API usage. + + var extension = context.Options.FindExtension<CosmosOptionsExtension>(); + 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 0000000000..374c8117a2 --- /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<EntityFrameworkCoreCosmosDBSettings>? configure = null, string? key = null) + { + builder.AddCosmosDbContext<TestDbContext>("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 0000000000..1ac9540ad6 --- /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<TestDbContext, EntityFrameworkCoreCosmosDBSettings> +{ + 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<string, string?>[] + { + new KeyValuePair<string, string?>("Aspire:Microsoft:EntityFrameworkCore:Cosmos:ConnectionString", + "Host=fake;Database=catalog"), + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action<EntityFrameworkCoreCosmosDBSettings>? configure = null, string? key = null) + => builder.AddCosmosDbContext<TestDbContext>("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<TestDbContext>? pool = host.Services.GetService<IDbContextPool<TestDbContext>>(); + + 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<TestDbContext>(); + + Assert.NotNull(dbContext); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + } +}