From c06118a1001e30b8700495294f255b9d470e7e37 Mon Sep 17 00:00:00 2001
From: alexmaie <alexmaie@gmail.com>
Date: Sat, 12 Apr 2025 18:54:21 +0300
Subject: [PATCH] Azure Integration (#1)

* created a new assembly in order to isolate the requirements for Azure Tokens: Hangfire.PostgreSql.Azure
* implemented a factory so that users can easily plugin
* introduced Hangfire.PostgreSql.Azure.Tests for the new assembly tests
* updated readme
---
 Hangfire.PostgreSql.sln                       | 14 +++++
 README.md                                     | 27 ++++++++-
 .../Factories/AzureNpgsqlConnectionFactory.cs | 58 +++++++++++++++++++
 .../Hangfire.PostgreSql.Azure.csproj          | 15 +++++
 ...eSqlBootstrapperConfigurationExtensions.cs | 25 ++++++++
 .../Hangfire.PostgreSql.Azure.Tests.csproj    | 20 +++++++
 .../PostgreUseAzurePostgreSqlStorageFacts.cs  | 38 ++++++++++++
 7 files changed, 196 insertions(+), 1 deletion(-)
 create mode 100644 src/Hangfire.PostgreSql.Azure/Factories/AzureNpgsqlConnectionFactory.cs
 create mode 100644 src/Hangfire.PostgreSql.Azure/Hangfire.PostgreSql.Azure.csproj
 create mode 100644 src/Hangfire.PostgreSql.Azure/PostgreSqlBootstrapperConfigurationExtensions.cs
 create mode 100644 tests/Hangfire.PostgreSql.Azure.Tests/Hangfire.PostgreSql.Azure.Tests.csproj
 create mode 100644 tests/Hangfire.PostgreSql.Azure.Tests/PostgreUseAzurePostgreSqlStorageFacts.cs

diff --git a/Hangfire.PostgreSql.sln b/Hangfire.PostgreSql.sln
index 34e7fcf1..f19e9232 100644
--- a/Hangfire.PostgreSql.sln
+++ b/Hangfire.PostgreSql.sln
@@ -19,6 +19,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
 		.github\workflows\pack.yml = .github\workflows\pack.yml
 	EndProjectSection
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.PostgreSql.Azure", "src\Hangfire.PostgreSql.Azure\Hangfire.PostgreSql.Azure.csproj", "{AF77F244-4B8B-4166-88D7-316A92CBABF5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.PostgreSql.Azure.Tests", "tests\Hangfire.PostgreSql.Azure.Tests\Hangfire.PostgreSql.Azure.Tests.csproj", "{61C844BE-C5D4-446D-BC5F-4561433BBE85}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -33,6 +37,14 @@ Global
 		{6044A48D-730B-4D1F-B03A-EB2B458DAF53}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{6044A48D-730B-4D1F-B03A-EB2B458DAF53}.Release|Any CPU.ActiveCfg = Release|Any CPU
 		{6044A48D-730B-4D1F-B03A-EB2B458DAF53}.Release|Any CPU.Build.0 = Release|Any CPU
+		{AF77F244-4B8B-4166-88D7-316A92CBABF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{AF77F244-4B8B-4166-88D7-316A92CBABF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{AF77F244-4B8B-4166-88D7-316A92CBABF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{AF77F244-4B8B-4166-88D7-316A92CBABF5}.Release|Any CPU.Build.0 = Release|Any CPU
+		{61C844BE-C5D4-446D-BC5F-4561433BBE85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{61C844BE-C5D4-446D-BC5F-4561433BBE85}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{61C844BE-C5D4-446D-BC5F-4561433BBE85}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{61C844BE-C5D4-446D-BC5F-4561433BBE85}.Release|Any CPU.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -41,6 +53,8 @@ Global
 		{3E4DBC41-F38E-4D1C-A6A7-206A132A29D6} = {0D30A51B-814F-474E-93B8-44E9C155255C}
 		{6044A48D-730B-4D1F-B03A-EB2B458DAF53} = {766BE831-F758-46BC-AFD3-BBEEFE0F686F}
 		{AAA78654-9846-4870-A13C-D9DBAF0792C4} = {5CA38188-92EE-453C-A04E-A506DF15A787}
+		{AF77F244-4B8B-4166-88D7-316A92CBABF5} = {0D30A51B-814F-474E-93B8-44E9C155255C}
+		{61C844BE-C5D4-446D-BC5F-4561433BBE85} = {766BE831-F758-46BC-AFD3-BBEEFE0F686F}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {F7E32105-7F61-4127-8517-5E4275B9CABE}
diff --git a/README.md b/README.md
index 58a3d26d..fb46ef7a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Hangfire.PostgreSql
+# Hangfire.PostgreSql
 
 [![Build status](https://github.com/hangfire-postgres/Hangfire.PostgreSql/actions/workflows/pack.yml/badge.svg)](https://github.com/hangfire-postgres/Hangfire.PostgreSql/actions/workflows/pack.yml) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/hangfire-postgres/Hangfire.PostgreSql?label=Release)](https://github.com/hangfire-postgres/Hangfire.PostgreSql/releases/latest) [![Nuget](https://img.shields.io/nuget/v/Hangfire.PostgreSql?label=NuGet)](https://www.nuget.org/packages/Hangfire.PostgreSql)
 
@@ -50,6 +50,20 @@ And... That's it. You are ready to go.
 
 If you encounter any issues/bugs or have idea of a feature regarding Hangfire.Postgresql, [create us an issue](https://github.com/hangfire-postgres/Hangfire.PostgreSql/issues/new). Thanks!
 
+### Connecting to Azure Postgres Flexible Servers
+
+To connect to Azure PostgreSQL Flexible Servers, use need to use
+
+```csharp
+services.AddHangfire(config =>
+    config.UseAzurePostgreSqlStorage(c => Configuration.GetConnectionString("HangfireConnection"))
+    );
+```
+
+This factory generates a data source builder which behind the scenes configured a periodic password provider. 
+This provider will use DefaultAzureCredential to fetch a token depending on the environment.
+If you need to customize the behavior, use the dataSourceBuilderSetup override. That one is called after the internal configuration.  
+
 ### Enabling SSL support
 
 SSL support can be enabled for Hangfire.PostgreSql library using the following mechanism:
@@ -82,6 +96,17 @@ app.UseHangfireServer(options);
 
 this provider would first process jobs in `a-long-running-queue`, then `general-queue` and lastly `very-fast-queue`.
 
+### Running Unit Tests
+
+In order to run unit tests you need to setup a postgresql database. Simples way to do that is to use docker;
+
+Environment configurations:
+          POSTGRES_PASSWORD: postgres
+          POSTGRES_HOST_AUTH_METHOD: trust
+ports:
+          - 5432:5432
+
+
 ### License
 
 Copyright © 2014-2024 Frank Hommers https://github.com/hangfire-postgres/Hangfire.PostgreSql.
diff --git a/src/Hangfire.PostgreSql.Azure/Factories/AzureNpgsqlConnectionFactory.cs b/src/Hangfire.PostgreSql.Azure/Factories/AzureNpgsqlConnectionFactory.cs
new file mode 100644
index 00000000..e150a65d
--- /dev/null
+++ b/src/Hangfire.PostgreSql.Azure/Factories/AzureNpgsqlConnectionFactory.cs
@@ -0,0 +1,58 @@
+using Azure.Core;
+using Azure.Identity;
+using Hangfire.PostgreSql.Factories;
+using Hangfire.PostgreSql.Properties;
+using Npgsql;
+
+namespace Hangfire.PostgreSql.Azure.Factories
+{
+  public class AzureNpgsqlConnectionFactory : NpgsqlInstanceConnectionFactoryBase
+  {
+    private readonly NpgsqlDataSource _dataSource;
+
+    /// <summary>
+    /// Instatiates a factory already configured to fetch tokens from azure
+    /// Token is refreshed every 4 hours
+    /// </summary>
+    /// <param name="connectionString"></param>
+    /// <param name="options"></param>
+    /// <param name="dataSourceBuilderSetup">You have here the opportunity to override the datasource builder, including the password provider</param>
+    public AzureNpgsqlConnectionFactory(string connectionString, PostgreSqlStorageOptions options, [CanBeNull] Action<NpgsqlDataSourceBuilder>? dataSourceBuilderSetup = null) : base(options)
+    {
+      NpgsqlDataSourceBuilder dataSourceBuilder = new(connectionString);
+
+      ConfigurePeriodicPasswordProvider(dataSourceBuilder);
+
+      dataSourceBuilderSetup?.Invoke(dataSourceBuilder);
+      _dataSource = dataSourceBuilder.Build()!;
+    }
+
+
+    public override NpgsqlConnection GetOrCreateConnection()
+    {
+      return _dataSource.CreateConnection();
+    }
+
+    private static void ConfigurePeriodicPasswordProvider(NpgsqlDataSourceBuilder dataSourceBuilder)
+    {
+      //Kudos https://mattparker.dev/blog/azure-managed-identity-postgres-aspnetcore
+      dataSourceBuilder.UsePeriodicPasswordProvider(
+          async (connectionStringBuilder, cancellationToken) => {
+            try
+            {
+              DefaultAzureCredentialOptions options = new();
+              DefaultAzureCredential credentials = new(options);
+              AccessToken token = await credentials.GetTokenAsync(new TokenRequestContext(["https://ossrdbms-aad.database.windows.net/.default"]), cancellationToken);
+              return token.Token;
+            }
+            catch
+            {
+              throw;
+            }
+          },
+          TimeSpan.FromHours(4), // successRefreshInterval
+          TimeSpan.FromSeconds(10) // failureRefreshInterval
+      );
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/Hangfire.PostgreSql.Azure/Hangfire.PostgreSql.Azure.csproj b/src/Hangfire.PostgreSql.Azure/Hangfire.PostgreSql.Azure.csproj
new file mode 100644
index 00000000..5f2d4f82
--- /dev/null
+++ b/src/Hangfire.PostgreSql.Azure/Hangfire.PostgreSql.Azure.csproj
@@ -0,0 +1,15 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>net9.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Azure.Identity" Version="1.13.2" />
+    <PackageReference Include="Hangfire.PostgreSql" Version="1.20.12" />
+    <PackageReference Include="Npgsql" Version="9.0.3" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Hangfire.PostgreSql.Azure/PostgreSqlBootstrapperConfigurationExtensions.cs b/src/Hangfire.PostgreSql.Azure/PostgreSqlBootstrapperConfigurationExtensions.cs
new file mode 100644
index 00000000..1d6e72bb
--- /dev/null
+++ b/src/Hangfire.PostgreSql.Azure/PostgreSqlBootstrapperConfigurationExtensions.cs
@@ -0,0 +1,25 @@
+using Hangfire.Annotations;
+using Hangfire.PostgreSql.Azure.Factories;
+using Npgsql;
+
+namespace Hangfire.PostgreSql.Azure
+{
+  public static class PostgreSqlBootstrapperConfigurationExtensions
+  {
+    /// <summary>
+    ///   Tells the bootstrapper to use PostgreSQL as a job storage  with the given options
+    /// </summary>
+    /// <param name="configuration">Configuration</param>
+    /// <param name="connectionString">Connection string</param>
+    /// <param name="dataSourceBuilderSetup">You have here the opportunity to override the datasource builder, including the password provider</param>
+    public static IGlobalConfiguration<PostgreSqlStorage> UseAzurePostgreSqlStorage(
+      this IGlobalConfiguration configuration,
+      string connectionString,
+      PostgreSqlStorageOptions options,
+      [CanBeNull] Action<NpgsqlDataSourceBuilder>? dataSourceBuilderSetup = null)
+    {
+      return configuration.UsePostgreSqlStorage(c => c.UseConnectionFactory(new AzureNpgsqlConnectionFactory(connectionString, options, dataSourceBuilderSetup)), options);
+    }
+  }
+
+}
diff --git a/tests/Hangfire.PostgreSql.Azure.Tests/Hangfire.PostgreSql.Azure.Tests.csproj b/tests/Hangfire.PostgreSql.Azure.Tests/Hangfire.PostgreSql.Azure.Tests.csproj
new file mode 100644
index 00000000..6f455d7f
--- /dev/null
+++ b/tests/Hangfire.PostgreSql.Azure.Tests/Hangfire.PostgreSql.Azure.Tests.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <AssemblyTitle>Hangfire.PostgreSql.Azure.Tests</AssemblyTitle>
+    <TargetFramework>net9.0</TargetFramework>
+    <AssemblyName>Hangfire.PostgreSql.Azure.Tests</AssemblyName>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <PackageId>Hangfire.PostgreSql.Azure.Tests</PackageId>
+    <GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
+    <LangVersion>default</LangVersion>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
+    <ProjectReference Include="..\..\src\Hangfire.PostgreSql.Azure\Hangfire.PostgreSql.Azure.csproj" />
+    <ProjectReference Include="..\Hangfire.PostgreSql.Tests\Hangfire.PostgreSql.Tests.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/tests/Hangfire.PostgreSql.Azure.Tests/PostgreUseAzurePostgreSqlStorageFacts.cs b/tests/Hangfire.PostgreSql.Azure.Tests/PostgreUseAzurePostgreSqlStorageFacts.cs
new file mode 100644
index 00000000..06ccf7ee
--- /dev/null
+++ b/tests/Hangfire.PostgreSql.Azure.Tests/PostgreUseAzurePostgreSqlStorageFacts.cs
@@ -0,0 +1,38 @@
+using Hangfire.PostgreSql.Azure.Factories;
+using Hangfire.PostgreSql.Tests.Utils;
+using Npgsql;
+using Xunit;
+
+namespace Hangfire.PostgreSql.Azure.Tests
+{
+  public class PostgreUseAzurePostgreSqlStorageFacts : IClassFixture<PostgreSqlStorageFixture>
+  {
+    private readonly PostgreSqlStorageOptions _options;
+
+    public PostgreUseAzurePostgreSqlStorageFacts()
+    {
+      _options = new();
+    }
+
+    [Fact]
+    public async Task AzureNpgsqlConnectionFactory_Can_Generate_Connection()
+    {
+      AzureNpgsqlConnectionFactory factory = new(ConnectionUtils.GetConnectionString().Replace("Password=password", ""), _options, dsb => {
+        dsb.UsePeriodicPasswordProvider((_, _) => ValueTask.FromResult("password"), TimeSpan.FromHours(1), TimeSpan.FromSeconds(1));
+      });
+
+      using NpgsqlConnection connection = factory.GetOrCreateConnection();
+
+      await connection.OpenAsync();
+
+      await using NpgsqlCommand command = new("SELECT '8'", connection);
+      await using NpgsqlDataReader reader = await command.ExecuteReaderAsync();
+      await reader.ReadAsync();
+
+      Assert.Equal("8", reader.GetValue(0));
+
+      await connection.CloseAsync();
+    }
+
+  }
+}