Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageVersion Include="MimeMapping" Version="4.0.0" />
<PackageVersion Include="PolySharp" Version="1.16.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
39 changes: 39 additions & 0 deletions Supabase.sln
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "red-test", "red-test", "{61
scripts\quality-gate\fixtures\red-test\overlay\Sample.Tests\WidgetTests.cs = scripts\quality-gate\fixtures\red-test\overlay\Sample.Tests\WidgetTests.cs
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{996F8526-0051-4327-A551-1175C455CC5F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DependencyInjection", "DependencyInjection", "{9FD9C523-BB83-D457-CA1C-9430D9580376}"
ProjectSection(SolutionItems) = preProject
packages\extensions\DependencyInjection\README.md = packages\extensions\DependencyInjection\README.md
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyInjection", "packages\extensions\DependencyInjection\DependencyInjection\DependencyInjection.csproj", "{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DependencyInjection.Tests", "packages\extensions\DependencyInjection\DependencyInjection.Tests\DependencyInjection.Tests.csproj", "{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -352,6 +363,30 @@ Global
{ACE6D9C0-1F55-4A95-8250-C074D5EBA913}.Release|x64.Build.0 = Release|Any CPU
{ACE6D9C0-1F55-4A95-8250-C074D5EBA913}.Release|x86.ActiveCfg = Release|Any CPU
{ACE6D9C0-1F55-4A95-8250-C074D5EBA913}.Release|x86.Build.0 = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|x64.ActiveCfg = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|x64.Build.0 = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|x86.ActiveCfg = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Debug|x86.Build.0 = Debug|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|Any CPU.Build.0 = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|x64.ActiveCfg = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|x64.Build.0 = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|x86.ActiveCfg = Release|Any CPU
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6}.Release|x86.Build.0 = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|Any CPU.Build.0 = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|x64.ActiveCfg = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|x64.Build.0 = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|x86.ActiveCfg = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Debug|x86.Build.0 = Debug|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|Any CPU.ActiveCfg = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|Any CPU.Build.0 = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x64.ActiveCfg = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x64.Build.0 = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.ActiveCfg = Release|Any CPU
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -389,6 +424,10 @@ Global
{1E2B584D-174B-4820-967B-FDB389FC03B4} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{097CDFF6-9B52-46C9-88BF-62ED8FE62957} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{612A3F59-5D51-4E02-80C1-4E687887B73D} = {3CC0DE6E-563A-4EA3-AF1A-0AF14FC57FE5}
{996F8526-0051-4327-A551-1175C455CC5F} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
{9FD9C523-BB83-D457-CA1C-9430D9580376} = {996F8526-0051-4327-A551-1175C455CC5F}
{481455AC-C8A3-4F61-A0C2-5D1CBF3D76F6} = {9FD9C523-BB83-D457-CA1C-9430D9580376}
{709EBF63-BDED-465F-9CEA-D1C7C9E2AB03} = {9FD9C523-BB83-D457-CA1C-9430D9580376}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {832DE89D-7252-4B03-9301-BB8D36B40992}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<AssemblyName>DependencyInjection.Tests</AssemblyName>
<RootNamespace>DependencyInjection.Tests</RootNamespace>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest</AnalysisLevel>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="MSTest.TestAdapter" />
<PackageReference Include="MSTest.TestFramework" />
<PackageReference Include="coverlet.collector">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="WireMock.Net" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\DependencyInjection\DependencyInjection.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Supabase;
using Supabase.Core.Http;
using Supabase.Extensions.DependencyInjection;

namespace DependencyInjection.Tests;

/// <summary>
/// <see cref="SupabaseServiceCollectionExtensions.AddSupabase"/> adds no new injection seams of its own —
/// every knob it touches (<c>ClientOptions.HttpClient</c> per package, Storage's three named clients,
/// <see cref="SupabaseOptions"/>' passthrough) already exists and is covered elsewhere. These tests instead
/// pin the two things that are actually new: that the wiring reaches every sub-client correctly, and — the
/// entire point of this package — that <see cref="IHttpClientFactory"/> genuinely pools the underlying
/// transport across resolutions instead of the SDK building a fresh one per <c>Client</c>.
/// </summary>
[TestClass]
[TestCategory("Unit")]
public class SupabaseServiceCollectionExtensionsTests
{
private const string SupabaseUrl = "http://localhost:54321";
private const string SupabaseKey = "test-key";

[TestMethod]
public void AddSupabase_ShouldResolveAClientAndEverySubClient()
{
var services = new ServiceCollection();
services.AddSupabase(SupabaseUrl, SupabaseKey);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var sp = scope.ServiceProvider;

sp.GetRequiredService<Client>().Should().NotBeNull();
sp.GetRequiredService<Client>().Auth.Should().NotBeNull();
sp.GetRequiredService<Client>().Postgrest.Should().NotBeNull();
sp.GetRequiredService<Client>().Storage.Should().NotBeNull();
sp.GetRequiredService<Client>().Functions.Should().NotBeNull();
sp.GetRequiredService<Client>().Realtime.Should().NotBeNull();
}

[TestMethod]
public void AddSupabase_ShouldWireFactoryCreatedHttpClients_IntoAuthPostgrestFunctionsAndStorage()
{
var services = new ServiceCollection();
services.AddSupabase(SupabaseUrl, SupabaseKey);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<Client>();

client.Auth.Options.HttpClient.Should().NotBeNull("Auth must send through the factory-pooled client, not build its own");
client.Postgrest.Options.HttpClient.Should().NotBeNull("Postgrest must send through the factory-pooled client, not build its own");
((Supabase.Functions.Client) client.Functions).Options.HttpClient.Should().NotBeNull("Functions must send through the factory-pooled client, not build its own");
client.Auth.Options.HttpClient.Should().BeSameAs(client.Postgrest.Options.HttpClient,
"Auth, Postgrest and Functions share one named client, not one each");

client.Storage.Options.HttpRequestClient.Should().NotBeNull();
client.Storage.Options.HttpUploadClient.Should().NotBeNull();
client.Storage.Options.HttpDownloadClient.Should().NotBeNull();
client.Storage.Options.HttpRequestClient.Should().NotBeSameAs(client.Storage.Options.HttpUploadClient,
"Storage's request/upload/download clients are independently named — a shared instance would collapse their independent timeout profiles");
}

[TestMethod]
public void AddSupabase_ShouldApplyConfigureOptions_ButOverwriteAnyHttpClientItSets()
{
var callerSuppliedClient = new HttpClient();
var retry = new RetryOptions { MaxRetries = 4 };
var services = new ServiceCollection();
services.AddSupabase(SupabaseUrl, SupabaseKey, options =>
{
options.PostgrestRetry = retry;
options.HttpClient = callerSuppliedClient;
});
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<Client>();

client.Postgrest.Options.Retry.Should().BeSameAs(retry, "plain configuration must reach the sub-clients unchanged");
client.Postgrest.Options.HttpClient.Should().NotBeSameAs(callerSuppliedClient,
"AddSupabase must supply the factory-pooled client even if configureOptions set one directly — that overwrite is documented on the method");
}

[TestMethod]
public void AddSupabase_ShouldReuseThePooledHandler_AcrossMultipleScopes()
{
CountingHandler.ConstructedCount = 0;
var services = new ServiceCollection();
services.AddSupabase(SupabaseUrl, SupabaseKey);
services.AddHttpClient("Supabase").AddHttpMessageHandler(() => new CountingHandler());

using var provider = services.BuildServiceProvider();

using (var scope1 = provider.CreateScope())
_ = scope1.ServiceProvider.GetRequiredService<Client>();

using (var scope2 = provider.CreateScope())
_ = scope2.ServiceProvider.GetRequiredService<Client>();

CountingHandler.ConstructedCount.Should().Be(1,
"the handler pipeline must be pooled across scope disposal, not rebuilt per Client resolution — " +
"that pooling is the actual socket-exhaustion fix this package exists for");
}

private sealed class CountingHandler : DelegatingHandler
{
public static int ConstructedCount;

public CountingHandler() => Interlocked.Increment(ref ConstructedCount);

protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<PackOnBuild>true</PackOnBuild>
<PackageId>Supabase.Extensions.DependencyInjection</PackageId>
<PackageProjectUrl>https://github.com/supabase-community/supabase-csharp/tree/master/packages/extensions/DependencyInjection</PackageProjectUrl>
<RootNamespace>Supabase.Extensions.DependencyInjection</RootNamespace>
<AssemblyName>Supabase.Extensions.DependencyInjection</AssemblyName>
<NeutralLanguage>en</NeutralLanguage>
<Owners>Joseph Schultz &lt;joseph@acupofjose.com&gt;</Owners>
<Summary>ASP.NET Core / Microsoft.Extensions.DependencyInjection integration for the Supabase C# SDK</Summary>
<Title>Supabase Dependency Injection</Title>
<Description>Registers a Supabase.Client (and each of its sub-clients) against IHttpClientFactory-managed, pooled HttpClients, for safe use under sustained ASP.NET Core load.</Description>
<PackageTags>supabase, dependency-injection, aspnetcore</PackageTags>
<!-- x-release-please-start-version -->
<PackageVersion>7.4.0</PackageVersion>
<ReleaseVersion>7.4.0</ReleaseVersion>
<!-- x-release-please-end -->
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<PackageReadmeFile>README.md</PackageReadmeFile>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
<WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>

<PropertyGroup Condition=" '$(Version)' == '' ">
<VersionPrefix Condition=" '$(VersionPrefix)' == '' ">7.4.0</VersionPrefix> <!-- x-release-please-version -->
<VersionSuffix Condition=" '$(VersionSuffix)' == '' "></VersionSuffix>
<Version Condition=" '$(VersionSuffix)' != '' ">$(VersionPrefix)-$(VersionSuffix)</Version>
<Version Condition=" '$(Version)' == '' ">$(VersionPrefix)</Version>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\Supabase\Supabase\Supabase.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#nullable enable
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#nullable enable
static Supabase.Extensions.DependencyInjection.SupabaseServiceCollectionExtensions.AddSupabase(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, string! supabaseUrl, string! supabaseKey, System.Action<Supabase.SupabaseOptions!>? configureOptions = null) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
Supabase.Extensions.DependencyInjection.SupabaseServiceCollectionExtensions
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;

namespace Supabase.Extensions.DependencyInjection;

/// <summary>
/// Registers a <see cref="Supabase.Client"/> — and each of its sub-clients individually — against
/// <see cref="IHttpClientFactory"/>-managed, pooled <see cref="HttpClient"/>s. This is the fix for the
/// socket/connection-pool exhaustion that an SDK building its own <see cref="HttpClient"/> per instance
/// causes under sustained ASP.NET Core load.
///
/// This package adds no new injection seams of its own: <see cref="SupabaseOptions.HttpClient"/>,
/// <see cref="SupabaseOptions.Proxy"/>, the per-package retry options, and Storage's three named
/// clients on <see cref="Storage.ClientOptions"/> already exist. <see cref="AddSupabase"/> only wires
/// <see cref="IHttpClientFactory"/>-created clients into those existing seams and calls the
/// already-public <see cref="Client(string, string?, SupabaseOptions?)"/> constructor.
/// </summary>
public static class SupabaseServiceCollectionExtensions
{
private const string HttpClientName = "Supabase";
private const string StorageRequestHttpClientName = "Supabase.Storage.Request";
private const string StorageUploadHttpClientName = "Supabase.Storage.Upload";
private const string StorageDownloadHttpClientName = "Supabase.Storage.Download";

/// <summary>
/// Registers a scoped <see cref="Supabase.Client"/>, plus each of its sub-clients individually
/// (<c>IGotrueClient&lt;User, Session&gt;</c>, <c>IPostgrestClient</c>, <c>IStorageClient&lt;Bucket, FileObject&gt;</c>,
/// <c>IFunctionsClient</c>, <c>IRealtimeClient&lt;RealtimeSocket, RealtimeChannel&gt;</c>) so a handler can inject
/// just the one it needs instead of the whole umbrella client.
/// </summary>
/// <param name="services"></param>
/// <param name="supabaseUrl">The project's Supabase URL, e.g. "https://xyz.supabase.co".</param>
/// <param name="supabaseKey">The project's anon or service-role API key.</param>
/// <param name="configureOptions">
/// Configures the <see cref="SupabaseOptions"/> passed to every sub-client. Any
/// <see cref="SupabaseOptions.HttpClient"/> or <see cref="Storage.ClientOptions"/> HttpXClient
/// properties set here are overwritten — <see cref="AddSupabase"/> supplies those from
/// <see cref="IHttpClientFactory"/> so they come from the pooled, DI-managed handlers instead.
/// </param>
public static IServiceCollection AddSupabase(
this IServiceCollection services,
string supabaseUrl,
string supabaseKey,
Action<SupabaseOptions>? configureOptions = null)
{
// Read once, purely for the static (non-per-request) proxy/timeout config below — never handed
// to a Client instance. The Client actually constructed per scope gets its own fresh
// SupabaseOptions further down, so concurrent scopes never race over shared mutable options.
var seed = new SupabaseOptions();
configureOptions?.Invoke(seed);

services.AddHttpClient(HttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => CreateHandler(seed.Proxy));

services.AddHttpClient(StorageRequestHttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => CreateHandler(seed.StorageClientOptions.Proxy))
.ConfigureHttpClient(c => c.Timeout = seed.StorageClientOptions.HttpRequestTimeout);

services.AddHttpClient(StorageUploadHttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => CreateHandler(seed.StorageClientOptions.Proxy))
.ConfigureHttpClient(c => c.Timeout = seed.StorageClientOptions.HttpUploadTimeout);

services.AddHttpClient(StorageDownloadHttpClientName)
.ConfigurePrimaryHttpMessageHandler(() => CreateHandler(seed.StorageClientOptions.Proxy))
.ConfigureHttpClient(c => c.Timeout = seed.StorageClientOptions.HttpDownloadTimeout);

services.AddScoped(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var options = new SupabaseOptions();
configureOptions?.Invoke(options);

options.HttpClient = factory.CreateClient(HttpClientName);
options.StorageClientOptions.HttpRequestClient = factory.CreateClient(StorageRequestHttpClientName);
options.StorageClientOptions.HttpUploadClient = factory.CreateClient(StorageUploadHttpClientName);
options.StorageClientOptions.HttpDownloadClient = factory.CreateClient(StorageDownloadHttpClientName);

return new Client(supabaseUrl, supabaseKey, options);
});

services.AddScoped(sp => sp.GetRequiredService<Client>().Auth);
services.AddScoped(sp => sp.GetRequiredService<Client>().Postgrest);
services.AddScoped(sp => sp.GetRequiredService<Client>().Storage);
services.AddScoped(sp => sp.GetRequiredService<Client>().Functions);
services.AddScoped(sp => sp.GetRequiredService<Client>().Realtime);

return services;
}

private static HttpClientHandler CreateHandler(IWebProxy? proxy) =>
new() { Proxy = proxy, UseProxy = proxy != null };
}
Loading
Loading