diff --git a/Aspire.sln b/Aspire.sln
index e0bfd2cf28a..c815a3b698b 100644
--- a/Aspire.sln
+++ b/Aspire.sln
@@ -665,6 +665,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Docker.Tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Hosting.Kubernetes.Tests", "tests\Aspire.Hosting.Kubernetes.Tests\Aspire.Hosting.Kubernetes.Tests.csproj", "{582B06FD-CC56-4C58-8138-D92F8FE62BBD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql", "src\Components\Aspire.Azure.Npgsql\Aspire.Azure.Npgsql.csproj", "{417C3703-058A-210D-7E9A-28198EFDB25F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.Azure.Npgsql.Tests", "tests\Aspire.Azure.Npgsql.Tests\Aspire.Azure.Npgsql.Tests.csproj", "{201765B1-37A1-9F5B-DD7F-730347046BE1}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -3891,6 +3895,30 @@ Global
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x64.Build.0 = Release|Any CPU
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.ActiveCfg = Release|Any CPU
{582B06FD-CC56-4C58-8138-D92F8FE62BBD}.Release|x86.Build.0 = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x64.Build.0 = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Debug|x86.Build.0 = Debug|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.ActiveCfg = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x64.Build.0 = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.ActiveCfg = Release|Any CPU
+ {417C3703-058A-210D-7E9A-28198EFDB25F}.Release|x86.Build.0 = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x64.Build.0 = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Debug|x86.Build.0 = Debug|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.ActiveCfg = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x64.Build.0 = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.ActiveCfg = Release|Any CPU
+ {201765B1-37A1-9F5B-DD7F-730347046BE1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -4210,6 +4238,8 @@ Global
{3AE2ED5B-4EC7-4E6D-A61D-C68B837E5FA7} = {B80354C7-BE58-43F6-8928-9F3A74AB7F47}
{43B560D6-F158-4A4C-8E43-981056EB9038} = {830A89EC-4029-4753-B25A-068BAE37DEC7}
{582B06FD-CC56-4C58-8138-D92F8FE62BBD} = {830A89EC-4029-4753-B25A-068BAE37DEC7}
+ {417C3703-058A-210D-7E9A-28198EFDB25F} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2}
+ {201765B1-37A1-9F5B-DD7F-730347046BE1} = {C424395C-1235-41A4-BF55-07880A04368C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {47DCFECF-5631-4BDE-A1EC-BE41E90F60C4}
diff --git a/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj b/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj
new file mode 100644
index 00000000000..880c56ec2c3
--- /dev/null
+++ b/src/Components/Aspire.Azure.Npgsql/Aspire.Azure.Npgsql.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(DefaultTargetFramework)
+ true
+ $(ComponentAzurePackageTags) postgresql postgres npgsql sql
+ A client for Azure Database for PostgreSQL® that integrates with Aspire, including health checks, logging and telemetry.
+ $(SharedDir)AzurePostgreSQL_256x.png
+ false
+
+
+
+
+
+
+
+
diff --git a/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs b/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs
new file mode 100644
index 00000000000..49c292e3cd7
--- /dev/null
+++ b/src/Components/Aspire.Azure.Npgsql/AspireAzureNpgsqlExtensions.cs
@@ -0,0 +1,223 @@
+// 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;
+using System.Text.Json;
+using Aspire.Azure.Npgsql;
+using Aspire.Npgsql;
+using Azure.Core;
+using Azure.Identity;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+
+namespace Microsoft.Extensions.Hosting;
+
+///
+/// Extension methods for connecting to an Azure Database for PostgreSQL with Npgsql client
+///
+public static class AspireAzureNpgsqlExtensions
+{
+ private const string AzureDatabaseForPostgresSqlScope = "https://ossrdbms-aad.database.windows.net/.default";
+ private const string AzureManagementScope = "https://management.azure.com/.default";
+
+ private static readonly TokenRequestContext s_databaseForPostgresSqlTokenRequestContext = new([AzureDatabaseForPostgresSqlScope]);
+ private static readonly TokenRequestContext s_managementTokenRequestContext = new([AzureManagementScope]);
+
+ ///
+ /// Registers service for connecting PostgreSQL database with Npgsql client.
+ /// Configures health check, logging and telemetry for the Npgsql client.
+ ///
+ /// The to read config from and add services to.
+ /// A name used to retrieve the connection string from the ConnectionStrings configuration section.
+ /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration.
+ /// An optional delegate that can be used for customizing the .
+ /// Reads the configuration from "Aspire:Npgsql" section.
+ /// Thrown if mandatory is null.
+ /// Thrown when mandatory is not provided or the is invalid.
+ public static void AddAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string connectionName, Action? configureSettings = null, Action? configureDataSourceBuilder = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(connectionName);
+
+ AzureNpgsqlSettings? azureSettings = null;
+
+ builder.AddNpgsqlDataSource(connectionName, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder =>
+ {
+ Debug.Assert(azureSettings != null);
+
+ ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder);
+
+ configureDataSourceBuilder?.Invoke(dataSourceBuilder);
+ });
+ }
+
+ ///
+ /// Registers as a keyed service for given for connecting PostgreSQL database with Npgsql client.
+ /// Configures health check, logging and telemetry for the Npgsql client.
+ ///
+ /// 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 options. It's invoked after the settings are read from the configuration.
+ /// An optional delegate that can be used for customizing the .
+ /// Reads the configuration from "Aspire:Npgsql:{name}" section.
+ /// Thrown when or is null.
+ /// Thrown if mandatory is empty.
+ /// Thrown when mandatory is not provided or the is invalid.
+ public static void AddKeyedAzureNpgsqlDataSource(this IHostApplicationBuilder builder, string name, Action? configureSettings = null, Action? configureDataSourceBuilder = null)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentException.ThrowIfNullOrEmpty(name);
+
+ AzureNpgsqlSettings? azureSettings = null;
+
+ builder.AddKeyedNpgsqlDataSource(name, settings => azureSettings = ConfigureSettings(configureSettings, settings), dataSourceBuilder =>
+ {
+ Debug.Assert(azureSettings != null);
+
+ ConfigureDataSourceBuilder(azureSettings, dataSourceBuilder);
+
+ configureDataSourceBuilder?.Invoke(dataSourceBuilder);
+ });
+ }
+
+ private static AzureNpgsqlSettings ConfigureSettings(Action? userConfigureSettings, NpgsqlSettings settings)
+ {
+ var azureSettings = new AzureNpgsqlSettings();
+
+ // Copy the values updated by Npgsql integration.
+ CopySettings(settings, azureSettings);
+
+ // Invoke the Aspire configuration.
+ userConfigureSettings?.Invoke(azureSettings);
+
+ // Copy to the Npgsql integration settings as it needs to get any values set in userConfigureSettings.
+ CopySettings(azureSettings, settings);
+
+ return azureSettings;
+ }
+
+ private static void CopySettings(NpgsqlSettings source, NpgsqlSettings destination)
+ {
+ destination.ConnectionString = source.ConnectionString;
+ destination.DisableHealthChecks = source.DisableHealthChecks;
+ destination.DisableMetrics = source.DisableMetrics;
+ destination.DisableTracing = source.DisableTracing;
+ }
+
+ private static void ConfigureDataSourceBuilder(AzureNpgsqlSettings settings, NpgsqlDataSourceBuilder dataSourceBuilder)
+ {
+ // The connection string required the username to be provided. Since it will depend on the Managed Identity that is used
+ // we attempt to get the username from the access token.
+
+ var credential = settings.Credential ?? new DefaultAzureCredential();
+
+ if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Username))
+ {
+ // Ensure to use the management scope, so the token contains user names for all managed identity types - e.g. user and service principal
+ var token = credential.GetToken(s_managementTokenRequestContext, default);
+
+ if (TryGetUsernameFromToken(token.Token, out var username))
+ {
+ dataSourceBuilder.ConnectionStringBuilder.Username = username;
+ }
+ else
+ {
+ // Otherwise check using the PostgresSql scope
+ token = credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default);
+
+ if (TryGetUsernameFromToken(token.Token, out username))
+ {
+ dataSourceBuilder.ConnectionStringBuilder.Username = username;
+ }
+ }
+
+ // If we still don't have a username, we let Npgsql handle the error when trying to connect.
+ // The user will be hinted to provide a username by using the configureDataSourceBuilder callback.
+ }
+
+ if (string.IsNullOrEmpty(dataSourceBuilder.ConnectionStringBuilder.Password))
+ {
+ // The token is not cached since it is refreshed for each new physical connection, or when it has expired.
+
+ dataSourceBuilder.UsePasswordProvider(
+ passwordProvider: _ => credential.GetToken(s_databaseForPostgresSqlTokenRequestContext, default).Token,
+ passwordProviderAsync: async (_, ct) => (await credential.GetTokenAsync(s_databaseForPostgresSqlTokenRequestContext, default).ConfigureAwait(false)).Token
+ );
+ }
+ }
+
+ private static bool TryGetUsernameFromToken(string jwtToken, out string? username)
+ {
+ username = null;
+
+ // Split the token into its parts (Header, Payload, Signature)
+ var tokenParts = jwtToken.Split('.');
+ if (tokenParts.Length != 3)
+ {
+ return false;
+ }
+
+ // The payload is the second part, Base64Url encoded
+ var payload = tokenParts[1];
+
+ // Add padding if necessary
+ payload = AddBase64Padding(payload);
+
+ // Decode the payload from Base64Url
+ var decodedBytes = Convert.FromBase64String(payload);
+
+ // Parse the decoded payload as JSON
+ var reader = new Utf8JsonReader(decodedBytes);
+ var payloadJson = JsonElement.ParseValue(ref reader);
+
+ // Try to get the username from 'xms_mirid', 'upn', 'preferred_username', or 'unique_name' claims
+ if (payloadJson.TryGetProperty("xms_mirid", out var xms_mirid) &&
+ xms_mirid.GetString() is string xms_miridString &&
+ ParsePrincipalName(xms_miridString) is string principalName)
+ {
+ username = principalName;
+ }
+ else if (payloadJson.TryGetProperty("upn", out var upn))
+ {
+ username = upn.GetString();
+ }
+ else if (payloadJson.TryGetProperty("preferred_username", out var preferredUsername))
+ {
+ username = preferredUsername.GetString();
+ }
+ else if (payloadJson.TryGetProperty("unique_name", out var uniqueName))
+ {
+ username = uniqueName.GetString();
+ }
+
+ return username != null;
+ }
+
+ // parse the xms_mirid claim which look like
+ // /subscriptions/{subId}/resourcegroups/{resourceGroup}/providers/Microsoft.ManagedIdentity/userAssignedIdentities/{principalName}
+ private static string? ParsePrincipalName(string xms_mirid)
+ {
+ var lastSlashIndex = xms_mirid.LastIndexOf('/');
+ if (lastSlashIndex == -1)
+ {
+ return null;
+ }
+
+ var beginning = xms_mirid.AsSpan(0, lastSlashIndex);
+ var principalName = xms_mirid.AsSpan(lastSlashIndex + 1);
+
+ if (principalName.IsEmpty || !beginning.EndsWith("providers/Microsoft.ManagedIdentity/userAssignedIdentities", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return principalName.ToString();
+ }
+
+ private static string AddBase64Padding(string base64) => (base64.Length % 4) switch
+ {
+ 2 => base64 + "==",
+ 3 => base64 + "=",
+ _ => base64,
+ };
+}
diff --git a/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs b/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs
new file mode 100644
index 00000000000..689bf0eb377
--- /dev/null
+++ b/src/Components/Aspire.Azure.Npgsql/AzureNpgsqlSettings.cs
@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Npgsql;
+using Azure.Core;
+
+namespace Aspire.Azure.Npgsql;
+
+///
+/// Provides the client configuration settings for connecting to an Azure Database for PostgreSQL using Npgsql.
+///
+public sealed class AzureNpgsqlSettings : NpgsqlSettings
+{
+ ///
+ /// Gets or sets the credential used to authenticate to the Azure Database for PostgreSQL.
+ ///
+ public TokenCredential? Credential { get; set; }
+}
diff --git a/src/Components/Aspire.Azure.Npgsql/README.md b/src/Components/Aspire.Azure.Npgsql/README.md
new file mode 100644
index 00000000000..ec18c88147a
--- /dev/null
+++ b/src/Components/Aspire.Azure.Npgsql/README.md
@@ -0,0 +1,146 @@
+# Aspire.Azure.Npgsql library
+
+Registers [NpgsqlDataSource](https://www.npgsql.org/doc/api/Npgsql.NpgsqlDataSource.html) in the DI container for connecting to PostgreSQL and Azure Database for PostgreSQL. Enables corresponding health check, metrics, logging and telemetry.
+
+## Getting started
+
+### Prerequisites
+
+- PostgreSQL database and connection string for accessing the database.
+- or an Azure Database for PostgreSQL instance, learn more about how to [Create an Azure Database for PostgreSQL resource](https://learn.microsoft.com/azure/postgresql/flexible-server/quickstart-create-server?tabs=portal-create-flexible%2Cportal-get-connection%2Cportal-delete-resources).
+
+### Differences with Aspire.Npgsql
+
+The Aspire.Azure.Npgsql library is a wrapper around the Aspire.Npgsql library that provides additional features for connecting to Azure Database for PostgreSQL. If you don't need these features, you can use the Aspire.Npgsql library instead.
+At runtime the client integration will detect whether the connection string has a Username and Password, and if not, it will use Entra Id to authenticate with Azure Database for PostgreSQL.
+
+### Install the package
+
+Install the .NET Aspire Azure Npgsql library with [NuGet](https://www.nuget.org):
+
+```dotnetcli
+dotnet add package Aspire.Azure.Npgsql
+```
+
+## Usage example
+
+In the _Program.cs_ file of your project, call the `AddAzureNpgsqlDataSource` extension method to register a `NpgsqlDataSource` for use via the dependency injection container. The method takes a connection name parameter.
+
+```csharp
+builder.AddAzureNpgsqlDataSource("postgresdb");
+```
+
+You can then retrieve the `NpgsqlDataSource` instance using dependency injection. For example, to retrieve the data source from a Web API controller:
+
+```csharp
+private readonly NpgsqlDataSource _dataSource;
+
+public ProductsController(NpgsqlDataSource dataSource)
+{
+ _dataSource = dataSource;
+}
+```
+
+## Configuration
+
+The .NET Aspire Azure Npgsql 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.AddAzureNpgsqlDataSource()`:
+
+```csharp
+builder.AddAzureNpgsqlDataSource("myConnection");
+```
+
+And then the connection string will be retrieved from the `ConnectionStrings` configuration section:
+
+```json
+{
+ "ConnectionStrings": {
+ "myConnection": "Host=myserver;Database=test"
+ }
+}
+```
+
+See the [ConnectionString documentation](https://www.npgsql.org/doc/connection-string-parameters.html) for more information on how to format this connection string.
+
+Note that the username and password will be automatically inferred from the credential provided in the settings.
+
+### Use configuration providers
+
+The .NET Aspire Azure Npgsql component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `AzureNpgsqlSettings` from configuration by using the `Aspire:Azure:Npgsql` key. Example `appsettings.json` that configures some of the options:
+
+```json
+{
+ "Aspire": {
+ "Azure": {
+ "Npgsql": {
+ "DisableHealthChecks": true,
+ "DisableTracing": true
+ }
+ }
+ }
+}
+```
+
+### Use inline delegates
+
+Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code:
+
+```csharp
+ builder.AddAzureNpgsqlDataSource("postgresdb", settings => settings.DisableHealthChecks = true);
+```
+
+Use the `AzureNpgsqlSettings.Credential` property to establish a connection. If no credential is configured, the [DefaultAzureCredential](https://learn.microsoft.com/dotnet/api/azure.identity.defaultazurecredential) is used.
+
+If the connection string contains a username and a password then the credential will be ignored.
+
+## AppHost extensions
+
+In your AppHost project, install the `Aspire.Hosting.Azure.PostgreSQL` library with [NuGet](https://www.nuget.org):
+
+```dotnetcli
+dotnet add package Aspire.Hosting.Azure.PostgreSQL
+```
+
+Then, in the _Program.cs_ file of `AppHost`, register a Azure Database for PostgreSQL instance and consume the connection using the following methods:
+
+```csharp
+var postgresdb = builder.AddAzurePostgresFlexibleServer("pg").AddDatabase("postgresdb");
+
+var myService = builder.AddProject()
+ .WithReference(postgresdb);
+```
+
+The `WithReference` method configures a connection in the `MyService` project named `postgresdb`. In the _Program.cs_ file of `MyService`, the database connection can be consumed using:
+
+```csharp
+builder.AddAzureNpgsqlDataSource("postgresdb");
+```
+
+This will also require your Azure environment to be configure by following [these instructions](https://learn.microsoft.com/dotnet/aspire/azure/local-provisioning#configuration).
+
+## Troubleshooting
+
+In the rare case that the Username property is not provided and the integration can't detect it using the application's Managed Identity, Npgsql will throw an exception like the following:
+
+> Npgsql.PostgresException (0x80004005): 28P01: password authentication failed for user ...
+
+In that case you can configure the Username property in the connection string by using the `configureDataSourceBuilder` callback like so:
+
+```csharp
+builder.AddAzureNpgsqlDataSource("db", configureDataSourceBuilder:
+ dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = "");
+```
+
+## Additional documentation
+
+* https://www.npgsql.org/doc/basic-usage.html
+* https://github.com/dotnet/aspire/tree/main/src/Components/README.md
+
+## Feedback & contributing
+
+https://github.com/dotnet/aspire
+
+_*Postgres, PostgreSQL and the Slonik Logo are trademarks or registered trademarks of the PostgreSQL Community Association of Canada, and used with their permission._
diff --git a/src/Components/Aspire.Npgsql/NpgsqlSettings.cs b/src/Components/Aspire.Npgsql/NpgsqlSettings.cs
index 9f8eca81c39..800c4ba5b85 100644
--- a/src/Components/Aspire.Npgsql/NpgsqlSettings.cs
+++ b/src/Components/Aspire.Npgsql/NpgsqlSettings.cs
@@ -6,7 +6,7 @@ namespace Aspire.Npgsql;
///
/// Provides the client configuration settings for connecting to a PostgreSQL database using Npgsql.
///
-public sealed class NpgsqlSettings
+public class NpgsqlSettings
{
///
/// The connection string of the PostgreSQL database to connect to.
diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md
index 246caf788e1..32fa704c7b3 100644
--- a/src/Components/Aspire_Components_Progress.md
+++ b/src/Components/Aspire_Components_Progress.md
@@ -16,6 +16,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As
| Azure.Messaging.EventHubs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | |
| Azure.Messaging.WebPubSub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.Messaging.ServiceBus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
+| Azure.Npgsql | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Azure.Search.Documents | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.Security.KeyVault | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
| Azure.Storage.Blobs | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ |
diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md
index 2ce7170e07d..46fbc46c4ec 100644
--- a/src/Components/Telemetry.md
+++ b/src/Components/Telemetry.md
@@ -48,6 +48,9 @@ Aspire.Azure.Messaging.WebPubSub:
- Metric names:
- none (currently not supported by the Azure SDK)
+Aspire.Azure.Npgsql:
+- Everything from `Aspire.Npgsql`
+
Aspire.Azure.Search.Documents:
- Log categories:
- "Azure.Core"
diff --git a/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj b/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj
new file mode 100644
index 00000000000..563c5a32a8a
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/Aspire.Azure.Npgsql.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+
+ $(DefaultTargetFramework)
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs b/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs
new file mode 100644
index 00000000000..91f7ed8da77
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/AspireAzurePostgreSqlNpgsqlExtensionsTests.cs
@@ -0,0 +1,340 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Npgsql;
+using Azure.Core;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using Xunit;
+
+namespace Aspire.Azure.Npgsql.Tests;
+
+public class AspireAzurePostgreSqlNpgsqlExtensionsTests
+{
+ private const string ConnectionString = "Host=localhost;Database=test_aspire_npgsql";
+ private const string ConnectionStringWithUsername = "Host=localhost;Database=test_aspire_npgsql;Username=admin";
+ private const string ConnectionStringWithUsernameAndPassword = "Host=localhost;Database=test_aspire_npgsql;Username=admin;Password=p@ssw0rd1";
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ReadsUsernameFromToken(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionString)
+ ]);
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.Contains(ConnectionString, dataSource.ConnectionString);
+ Assert.Contains("Username=mikey@mouse.com", dataSource.ConnectionString);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ReadsUsernameFromManagedIdentityToken(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionString)
+ ]);
+
+ var fakeCred = new FakeTokenCredential(useManagedIdentity: true);
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings => settings.Credential = fakeCred);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings => settings.Credential = fakeCred);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.Contains(ConnectionString, dataSource.ConnectionString);
+ Assert.Contains("Username=mi-123", dataSource.ConnectionString);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void TokenCredentialIsIgnoredWhenUsernameAndPasswordAreSet(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionStringWithUsernameAndPassword)
+ ]);
+
+ FakeTokenCredential? tokenCredential = null;
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ });
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ });
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.NotNull(tokenCredential);
+ Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString);
+ Assert.False(tokenCredential.IsGetTokenInvoked);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ConnectionStringCanBeSetInCode(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", "unused")
+ ]);
+
+ static void SetConnectionString(NpgsqlSettings settings) => settings.ConnectionString = ConnectionStringWithUsernameAndPassword;
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", SetConnectionString);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", SetConnectionString);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString);
+ // the connection string from config should not be used since code set it explicitly
+ Assert.DoesNotContain("unused", dataSource.ConnectionString);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void DoesNotThrowWhenTokenCredentialHasNoUsername(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionString)
+ ]);
+
+ FakeTokenCredential? tokenCredential = null;
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureAnonumousTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ });
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureAnonumousTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ });
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.NotNull(tokenCredential);
+ Assert.Equal(ConnectionString, dataSource.ConnectionString);
+ Assert.True(tokenCredential.IsGetTokenInvoked);
+ Assert.Contains("https://ossrdbms-aad.database.windows.net/.default", tokenCredential.RequestedScopes);
+ Assert.Contains("https://management.azure.com/.default", tokenCredential.RequestedScopes);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void UsernameCanBeConfiguredWhenTokenCredentialHasNoUsername(bool useKeyed)
+ {
+ const string username = "admin";
+
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionString)
+ ]);
+
+ FakeTokenCredential? tokenCredential = null;
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureAnonumousTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ }, configureDataSourceBuilder: dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = username);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: settings =>
+ {
+ ConfigureAnonumousTokenCredentials(settings);
+ tokenCredential = settings.Credential as FakeTokenCredential;
+ }, configureDataSourceBuilder: dataSourceBuilder => dataSourceBuilder.ConnectionStringBuilder.Username = username);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.NotNull(tokenCredential);
+ Assert.Contains(ConnectionString, dataSource.ConnectionString);
+ Assert.Contains("Username=admin", dataSource.ConnectionString);
+ Assert.True(tokenCredential.IsGetTokenInvoked);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void ConnectionNameWinsOverConfigSection(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+
+ var key = useKeyed ? "npgsql" : null;
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair(ConformanceTests.CreateConfigKey("Aspire:Npgsql", key, "ConnectionString"), "unused"),
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionStringWithUsername)
+ ]);
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.Equal(ConnectionStringWithUsername, dataSource.ConnectionString);
+ // the connection string from config should not be used since it was found in ConnectionStrings
+ Assert.DoesNotContain("unused", dataSource.ConnectionString);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void CustomDataSourceBuilderIsExecuted(bool useKeyed)
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql", ConnectionString)
+ ]);
+
+ var wasCalled = false;
+ void configureDataSourceBuilder(NpgsqlDataSourceBuilder b) => wasCalled = true;
+
+ if (useKeyed)
+ {
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials, configureDataSourceBuilder: configureDataSourceBuilder);
+ }
+ else
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", configureSettings: ConfigureTokenCredentials, configureDataSourceBuilder: configureDataSourceBuilder);
+ }
+
+ using var host = builder.Build();
+ var dataSource = useKeyed ?
+ host.Services.GetRequiredKeyedService("npgsql") :
+ host.Services.GetRequiredService();
+
+ Assert.True(wasCalled);
+ }
+
+ [Fact]
+ public void CanAddMultipleKeyedServices()
+ {
+ var builder = Host.CreateEmptyApplicationBuilder(null);
+ builder.Configuration.AddInMemoryCollection([
+ new KeyValuePair("ConnectionStrings:npgsql1", "Host=localhost1;Database=test_aspire_npgsql"),
+ new KeyValuePair("ConnectionStrings:npgsql2", "Host=localhost2;Database=test_aspire_npgsql"),
+ new KeyValuePair("ConnectionStrings:npgsql3", "Host=localhost3;Database=test_aspire_npgsql"),
+ ]);
+
+ builder.AddAzureNpgsqlDataSource("npgsql1", configureSettings: ConfigureTokenCredentials);
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql2", configureSettings: ConfigureTokenCredentials);
+ builder.AddKeyedAzureNpgsqlDataSource("npgsql3", configureSettings: ConfigureTokenCredentials);
+
+ using var host = builder.Build();
+
+ var connection1 = host.Services.GetRequiredService();
+ var connection2 = host.Services.GetRequiredKeyedService("npgsql2");
+ var connection3 = host.Services.GetRequiredKeyedService("npgsql3");
+
+ Assert.NotSame(connection1, connection2);
+ Assert.NotSame(connection1, connection3);
+ Assert.NotSame(connection2, connection3);
+
+ Assert.Contains("localhost1", connection1.ConnectionString);
+ Assert.Contains("localhost2", connection2.ConnectionString);
+ Assert.Contains("localhost3", connection3.ConnectionString);
+ }
+
+ private void ConfigureTokenCredentials(AzureNpgsqlSettings settings)
+ {
+ settings.Credential = new FakeTokenCredential();
+ }
+
+ private static void ConfigureAnonumousTokenCredentials(AzureNpgsqlSettings settings)
+ {
+ const string token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwiUm9sZSI6IkFkbWluIn0.nT9VhsXfI0v78C5J57ehy3NERNNN0e6NvVZwq_XOr-A";
+ var accesstoken = new AccessToken(token, DateTimeOffset.Now.AddHours(1));
+
+ // {
+ // "Issuer": "Issuer",
+ // "Issued At": "2025-03-21T01:37:00.198Z",
+ // "Expiration": "2025-03-21T01:37:00.198Z",
+ // "Role": "Admin"
+ // }
+
+ settings.Credential = new FakeTokenCredential(accesstoken);
+ }
+}
diff --git a/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs b/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs
new file mode 100644
index 00000000000..dd5be09895e
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/ConfigurationTests.cs
@@ -0,0 +1,25 @@
+// 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.Azure.Npgsql.Tests;
+
+public class ConfigurationTests
+{
+ [Fact]
+ public void ConnectionStringIsNullByDefault()
+ => Assert.Null(new AzureNpgsqlSettings().ConnectionString);
+
+ [Fact]
+ public void HealthCheckIsEnabledByDefault()
+ => Assert.False(new AzureNpgsqlSettings().DisableHealthChecks);
+
+ [Fact]
+ public void TracingIsEnabledByDefault()
+ => Assert.False(new AzureNpgsqlSettings().DisableTracing);
+
+ [Fact]
+ public void MetricsAreEnabledByDefault()
+ => Assert.False(new AzureNpgsqlSettings().DisableMetrics);
+}
diff --git a/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs b/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs
new file mode 100644
index 00000000000..020fa6bed5d
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/ConformanceTests.cs
@@ -0,0 +1,144 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Data.Common;
+using Aspire.Components.Common.Tests;
+using Aspire.Components.ConformanceTests;
+using Microsoft.DotNet.RemoteExecutor;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Npgsql;
+using Xunit;
+
+namespace Aspire.Azure.Npgsql.Tests;
+
+public class ConformanceTests : ConformanceTests, IClassFixture
+{
+ private readonly PostgreSQLContainerFixture? _containerFixture;
+ protected string ConnectionString { get; private set; }
+ protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton;
+
+ // https://github.com/npgsql/npgsql/blob/ef9db1ffe9e432c1562d855b46dfac3514726b1b/src/Npgsql.OpenTelemetry/TracerProviderBuilderExtensions.cs#L18
+ protected override string ActivitySourceName => "Npgsql";
+
+ protected override string[] RequiredLogCategories => new string[]
+ {
+ "Npgsql.Connection",
+ "Npgsql.Command",
+ "Npgsql.Transaction",
+ "Npgsql.Copy",
+ "Npgsql.Replication",
+ "Npgsql.Exception"
+ };
+
+ protected override bool SupportsKeyedRegistrations => true;
+
+ protected override bool CanConnectToServer => RequiresDockerAttribute.IsSupported;
+
+ protected override string? ConfigurationSectionName => "Aspire:Npgsql";
+
+ protected override string ValidJsonConfig => """
+ {
+ "Aspire": {
+ "Npgsql": {
+ "ConnectionString": "YOUR_CONNECTION_STRING",
+ "DisableHealthChecks": true,
+ "DisableTracing": false,
+ "DisableMetrics": false
+ }
+ }
+ }
+ """;
+
+ protected override (string json, string error)[] InvalidJsonToErrorMessage => [];
+
+ public ConformanceTests(PostgreSQLContainerFixture? containerFixture)
+ {
+ _containerFixture = containerFixture;
+ ConnectionString = (_containerFixture is not null && RequiresDockerAttribute.IsSupported)
+ ? _containerFixture.GetConnectionString()
+ : "Server=localhost;User ID=root;Password=password;Database=test_aspire_mysql";
+ }
+
+ protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null)
+ => configuration.AddInMemoryCollection(new KeyValuePair[1]
+ {
+ new KeyValuePair(CreateConfigKey("Aspire:Npgsql", key, "ConnectionString"), ConnectionString)
+ });
+
+ protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null)
+ {
+ void Configure(AzureNpgsqlSettings settings)
+ {
+ configure?.Invoke(settings);
+ settings.Credential = new FakeTokenCredential();
+ };
+
+ if (key is null)
+ {
+ builder.AddAzureNpgsqlDataSource("npgsql", Configure);
+ }
+ else
+ {
+ builder.AddKeyedAzureNpgsqlDataSource(key, Configure);
+ }
+ }
+
+ protected override void SetHealthCheck(AzureNpgsqlSettings options, bool enabled)
+ => options.DisableHealthChecks = !enabled;
+
+ protected override void SetTracing(AzureNpgsqlSettings options, bool enabled)
+ => options.DisableTracing = !enabled;
+
+ protected override void SetMetrics(AzureNpgsqlSettings options, bool enabled)
+ => options.DisableMetrics = !enabled;
+
+ protected override void TriggerActivity(NpgsqlDataSource service)
+ {
+ using NpgsqlConnection connection = service.CreateConnection();
+ connection.Open();
+ using NpgsqlCommand command = connection.CreateCommand();
+ command.CommandText = "Select 1;";
+ command.ExecuteScalar();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("key")]
+ public void BothDataSourceAndConnectionCanBeResolved(string? key)
+ {
+ using IHost host = CreateHostWithComponent(key: key);
+
+ NpgsqlDataSource? npgsqlDataSource = Resolve();
+ DbDataSource? dbDataSource = Resolve();
+ NpgsqlConnection? npgsqlConnection = Resolve();
+ DbConnection? dbConnection = Resolve();
+
+ Assert.NotNull(npgsqlDataSource);
+ Assert.Same(npgsqlDataSource, dbDataSource);
+
+ Assert.NotNull(npgsqlConnection);
+ Assert.NotNull(dbConnection);
+
+ Assert.Equal(dbConnection.ConnectionString, npgsqlConnection.ConnectionString);
+ Assert.Equal(npgsqlDataSource.ConnectionString, npgsqlConnection.ConnectionString);
+
+ T? Resolve() => key is null ? host.Services.GetService() : host.Services.GetKeyedService(key);
+ }
+
+ [Fact]
+ [RequiresDocker]
+ public void TracingEnablesTheRightActivitySource()
+ => RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: null)),
+ ConnectionString).Dispose();
+
+ [Fact]
+ [RequiresDocker]
+ public void TracingEnablesTheRightActivitySource_Keyed()
+ => RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: "key")),
+ ConnectionString).Dispose();
+
+ private static void RunWithConnectionString(string connectionString, Action test)
+ => test(new ConformanceTests(null) { ConnectionString = connectionString });
+}
diff --git a/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs b/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs
new file mode 100644
index 00000000000..1370aa569a4
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/FakeTokenCredential.cs
@@ -0,0 +1,57 @@
+// 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.Azure.Npgsql.Tests;
+
+internal sealed class FakeTokenCredential : TokenCredential
+{
+ private const string Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibWlrZXlAbW91c2UuY29tIiwiUm9sZSI6IkFkbWluIn0.WdKzHL5CBeMOIlpzqaotvNbo0mmvaMtcW0zpMlB3lUE";
+ // {
+ // "Issuer": "Issuer",
+ // "Issued At": "2025-03-21T01:37:00.198Z",
+ // "Expiration": "2025-03-21T01:37:00.198Z",
+ // "preferred_username": "mikey@mouse.com",
+ // "Role": "Admin"
+ // }
+
+ private const string ManagedIdentityToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJJc3N1ZXIiOiJJc3N1ZXIiLCJJc3N1ZWQgQXQiOiIyMDI1LTAzLTIxVDAxOjM3OjAwLjE5OFoiLCJFeHBpcmF0aW9uIjoiMjAyNS0wMy0yMVQwMTozNzowMC4xOThaIiwieG1zX21pcmlkIjoiL3N1YnNjcmlwdGlvbnMvMTIzL3Jlc291cmNlZ3JvdXBzL3JnLTEyMy9wcm92aWRlcnMvTWljcm9zb2Z0Lk1hbmFnZWRJZGVudGl0eS91c2VyQXNzaWduZWRJZGVudGl0aWVzL21pLTEyMyIsIlJvbGUiOiJBZG1pbiJ9.luuw0374yNSOWKfswHURCm620UoY9qrZriqLG0668Tw";
+ // {
+ // "Issuer": "Issuer",
+ // "Issued At": "2025-03-21T01:37:00.198Z",
+ // "Expiration": "2025-03-21T01:37:00.198Z",
+ // "xms_mirid": "/subscriptions/123/resourcegroups/rg-123/providers/Microsoft.ManagedIdentity/userAssignedIdentities/mi-123",
+ // "Role": "Admin"
+ //}
+
+ private readonly AccessToken _token;
+
+ public FakeTokenCredential(bool useManagedIdentity = false) :
+ this(new AccessToken(useManagedIdentity ? ManagedIdentityToken : Token, DateTime.UtcNow))
+ {
+ }
+
+ public FakeTokenCredential(AccessToken token)
+ {
+ _token = token;
+ }
+
+ public bool IsGetTokenInvoked { get; private set; }
+
+ public List RequestedScopes { get; } = [];
+
+ public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ RequestedScopes.AddRange(requestContext.Scopes);
+ IsGetTokenInvoked = true;
+ return _token;
+ }
+
+ public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
+ {
+ RequestedScopes.AddRange(requestContext.Scopes);
+ IsGetTokenInvoked = true;
+ return new ValueTask(_token);
+ }
+}
diff --git a/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs b/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs
new file mode 100644
index 00000000000..d3ad2749d19
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/NpgsqlPublicApiTests.cs
@@ -0,0 +1,71 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Aspire.Azure.Npgsql.Tests;
+
+public class NpgsqlPublicApiTests
+{
+ [Fact]
+ public void AddNpgsqlDataSourceShouldThrowWhenBuilderIsNull()
+ {
+ IHostApplicationBuilder builder = null!;
+ const string connectionName = "npgsql";
+
+ var action = () => builder.AddAzureNpgsqlDataSource(connectionName, configureSettings: ConfigureTokenCredentials);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void AddNpgsqlDataSourceShouldThrowWhenConnectionNameIsNullOrEmpty(bool isNull)
+ {
+ IHostApplicationBuilder builder = new HostApplicationBuilder();
+ var connectionName = isNull ? null! : string.Empty;
+
+ var action = () => builder.AddAzureNpgsqlDataSource(connectionName, configureSettings: ConfigureTokenCredentials);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(connectionName), exception.ParamName);
+ }
+
+ [Fact]
+ public void AddKeyedNpgsqlDataSourceShouldThrowWhenBuilderIsNull()
+ {
+ IHostApplicationBuilder builder = null!;
+ const string name = "npgsql";
+
+ var action = () => builder.AddKeyedAzureNpgsqlDataSource(name, configureSettings: ConfigureTokenCredentials);
+
+ var exception = Assert.Throws(action);
+ Assert.Equal(nameof(builder), exception.ParamName);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void AddKeyedNpgsqlDataSourceShouldThrowWhenNameIsNullOrEmpty(bool isNull)
+ {
+ IHostApplicationBuilder builder = new HostApplicationBuilder();
+ var name = isNull ? null! : string.Empty;
+
+ var action = () => builder.AddKeyedAzureNpgsqlDataSource(name, configureSettings: ConfigureTokenCredentials);
+
+ var exception = isNull
+ ? Assert.Throws(action)
+ : Assert.Throws(action);
+ Assert.Equal(nameof(name), exception.ParamName);
+ }
+
+ private void ConfigureTokenCredentials(AzureNpgsqlSettings settings)
+ {
+ settings.Credential = new FakeTokenCredential();
+ }
+}
diff --git a/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs b/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs
new file mode 100644
index 00000000000..52d3e828c5f
--- /dev/null
+++ b/tests/Aspire.Azure.Npgsql.Tests/PostgreSQLContainerFixture.cs
@@ -0,0 +1,36 @@
+// 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.Hosting.Postgres;
+using Testcontainers.PostgreSql;
+using Xunit;
+
+namespace Aspire.Azure.Npgsql.Tests;
+
+public sealed class PostgreSQLContainerFixture : IAsyncLifetime
+{
+ public PostgreSqlContainer? Container { get; private set; }
+
+ public string GetConnectionString() => Container?.GetConnectionString() ??
+ throw new InvalidOperationException("The test container was not initialized.");
+
+ public async Task InitializeAsync()
+ {
+ if (RequiresDockerAttribute.IsSupported)
+ {
+ Container = new PostgreSqlBuilder()
+ .WithImage($"{ComponentTestConstants.AspireTestContainerRegistry}/{PostgresContainerImageTags.Image}:{PostgresContainerImageTags.Tag}")
+ .Build();
+ await Container.StartAsync();
+ }
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (Container is not null)
+ {
+ await Container.DisposeAsync();
+ }
+ }
+}
diff --git a/tests/Aspire.Components.Common.Tests/ConformanceTests.cs b/tests/Aspire.Components.Common.Tests/ConformanceTests.cs
index 87f4eb6025d..f4143745977 100644
--- a/tests/Aspire.Components.Common.Tests/ConformanceTests.cs
+++ b/tests/Aspire.Components.Common.Tests/ConformanceTests.cs
@@ -54,6 +54,8 @@ protected virtual void DisableRetries(TOptions options) { }
protected bool TracingIsSupported => CheckIfImplemented(SetTracing);
+ protected virtual bool CheckOptionClassSealed => true;
+
///
/// Calls the actual Component
///
@@ -93,6 +95,11 @@ public void OptionsTypeIsSealed()
throw new SkipTestException("Not implemented yet");
}
+ if (!CheckOptionClassSealed)
+ {
+ throw new SkipTestException("Opt-out of test");
+ }
+
Assert.True(typeof(TOptions).IsSealed);
}
diff --git a/tests/Aspire.Npgsql.Tests/ConformanceTests.cs b/tests/Aspire.Npgsql.Tests/ConformanceTests.cs
index 2b57c17faa2..a245df9f34f 100644
--- a/tests/Aspire.Npgsql.Tests/ConformanceTests.cs
+++ b/tests/Aspire.Npgsql.Tests/ConformanceTests.cs
@@ -137,6 +137,8 @@ public void TracingEnablesTheRightActivitySource_Keyed()
=> RemoteExecutor.Invoke(static connectionStringToUse => RunWithConnectionString(connectionStringToUse, obj => obj.ActivitySourceTest(key: "key")),
ConnectionString).Dispose();
+ protected override bool CheckOptionClassSealed => false; // AzureNpgsqlSettings needs to inherit from NpgsqlSettings
+
private static void RunWithConnectionString(string connectionString, Action test)
=> test(new ConformanceTests(null) { ConnectionString = connectionString });
}