diff --git a/Storage/Extensions/PurgeCacheOptionsExtension.cs b/Storage/Extensions/PurgeCacheOptionsExtension.cs
index 403d565..34e0c75 100644
--- a/Storage/Extensions/PurgeCacheOptionsExtension.cs
+++ b/Storage/Extensions/PurgeCacheOptionsExtension.cs
@@ -1,27 +1,41 @@
-using System;
using System.Collections.Specialized;
using System.Web;
-namespace Supabase.Storage.Extensions;
-
-public static class PurgeCacheOptionsExtension
+namespace Supabase.Storage.Extensions
{
///
- /// Transforms options into a NameValueCollection to be used with a
+ /// Translates into the query string the CDN purge endpoint expects.
+ ///
+ public static class PurgeCacheOptionsExtension
+ {
+ ///
+ /// Transforms the options into a to be appended to a purge URL.
+ /// The transformations flag is only emitted when explicitly requested, mirroring the
+ /// storage-js client, so that the default purges every cached version.
///
- ///
- ///
+ /// The purge options to translate.
+ /// A query collection carrying the options, empty when none apply.
public static NameValueCollection ToQueryCollection(this PurgeCacheOptions options)
{
var query = HttpUtility.ParseQueryString(string.Empty);
- if (options.Transformations == null)
- {
- return query;
- }
-
- query.Add("transformations", options.Transformations.ToString().ToLower());
+ if (options.Transformations == true)
+ query.Add("transformations", "true");
return query;
}
-}
\ No newline at end of file
+
+ ///
+ /// Appends the options as a query string to , adding the ?
+ /// separator only when at least one option applies.
+ ///
+ /// The purge options, or null for none.
+ /// The purge endpoint URL without a query string.
+ /// The URL with the options appended.
+ public static string ToPurgeUrl(this PurgeCacheOptions? options, string baseUrl)
+ {
+ var query = options?.ToQueryCollection().ToString();
+ return string.IsNullOrEmpty(query) ? baseUrl : $"{baseUrl}?{query}";
+ }
+ }
+}
diff --git a/Storage/FetchCache.cs b/Storage/FetchCache.cs
deleted file mode 100644
index 3abd4fd..0000000
--- a/Storage/FetchCache.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Runtime.Serialization;
-using Supabase.Core.Attributes;
-
-namespace Supabase.Storage;
-
-public enum FetchCache
-{
- [MapTo("cache"), EnumMember(Value = "cache")]
- Cache,
- [MapTo("default"), EnumMember(Value = "default")]
- Default,
- [MapTo("no-store"), EnumMember(Value = "no-store")]
- NoStore,
- [MapTo("reload"), EnumMember(Value = "reload")]
- Reload,
- [MapTo("no-cache"), EnumMember(Value = "no-cache")]
- NoCache,
- [MapTo("force-cache"), EnumMember(Value = "force-cache")]
- ForceCache,
- [MapTo("only-if-cached"), EnumMember(Value = "only-if-cached")]
- OnlyIfCached,
-}
\ No newline at end of file
diff --git a/Storage/FetchParameter.cs b/Storage/FetchParameter.cs
deleted file mode 100644
index 1e22877..0000000
--- a/Storage/FetchParameter.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using Newtonsoft.Json;
-
-namespace Supabase.Storage;
-
-public class FetchParameter
-{
- [JsonProperty("cache")]
- public FetchCache? Cache { get; set; }
-}
\ No newline at end of file
diff --git a/Storage/Interfaces/IStorageBucketApi.cs b/Storage/Interfaces/IStorageBucketApi.cs
index 73ce509..e87e532 100644
--- a/Storage/Interfaces/IStorageBucketApi.cs
+++ b/Storage/Interfaces/IStorageBucketApi.cs
@@ -17,6 +17,22 @@ public interface IStorageBucketApi : IGettableHeaders
Task GetBucket(string id);
Task?> ListBuckets();
Task UpdateBucket(string id, BucketUpsertOptions? options = null);
- Task PurgeBucketCache(string id, PurgeCacheOptions? options = null, FetchParameter? fetchParameter = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Purges the CDN cache for every object in a bucket. Requires a service-role key.
+ ///
+ /// The bucket whose cached objects should be purged.
+ ///
+ /// When is true, only the transformed
+ /// variants are purged; otherwise every cached version is purged.
+ ///
+ /// Token used to cancel the request.
+ /// The service acknowledgement of the purge.
+ ///
+ ///
+ /// await storage.PurgeBucketCache("avatars", new PurgeCacheOptions { Transformations = true });
+ ///
+ ///
+ Task PurgeBucketCache(string id, PurgeCacheOptions? options = null, CancellationToken cancellationToken = default);
}
}
\ No newline at end of file
diff --git a/Storage/Interfaces/IStorageFileApi.cs b/Storage/Interfaces/IStorageFileApi.cs
index b338e70..8c7df70 100644
--- a/Storage/Interfaces/IStorageFileApi.cs
+++ b/Storage/Interfaces/IStorageFileApi.cs
@@ -121,8 +121,23 @@ Task UploadToSignedUrl(
bool inferContentType = true
);
Task CreateUploadSignedUrl(string supabasePath);
-
- Task PurgeCache(string path, PurgeCacheOptions? options = null, FetchParameter? fetchParameter = null, CancellationToken cancellationToken = default);
+
+ ///
+ /// Purges the CDN cache for a single object. Requires a service-role key.
+ ///
+ /// The object path within the bucket to purge.
+ ///
+ /// When is true, only the transformed
+ /// variants are purged; otherwise every cached version is purged.
+ ///
+ /// Token used to cancel the request.
+ /// The service acknowledgement of the purge.
+ ///
+ ///
+ /// await storage.From("avatars").PurgeCache("folder/avatar.png");
+ ///
+ ///
+ Task PurgeCache(string path, PurgeCacheOptions? options = null, CancellationToken cancellationToken = default);
}
}
diff --git a/Storage/PurgeCacheOptions.cs b/Storage/PurgeCacheOptions.cs
index 540886d..2eb084e 100644
--- a/Storage/PurgeCacheOptions.cs
+++ b/Storage/PurgeCacheOptions.cs
@@ -1,13 +1,16 @@
-using Newtonsoft.Json;
-
-namespace Supabase.Storage;
-
-public class PurgeCacheOptions
+namespace Supabase.Storage
{
///
- /// If true, purges only the transformations (resized/formatted variants) for the object or bucket,
- /// leaving the original cached file intact. If omitted, purges all cached versions
+ /// Options for and
+ /// .
///
- [JsonProperty("transformations")]
- public bool? Transformations { get; set; } = true;
-}
\ No newline at end of file
+ public class PurgeCacheOptions
+ {
+ ///
+ /// If true, purges only the transformations (resized/formatted variants) for the object
+ /// or bucket, leaving the original cached file intact. If left null, all cached versions
+ /// are purged.
+ ///
+ public bool? Transformations { get; set; }
+ }
+}
diff --git a/Storage/StorageBucketApi.cs b/Storage/StorageBucketApi.cs
index b5f28ae..438069e 100644
--- a/Storage/StorageBucketApi.cs
+++ b/Storage/StorageBucketApi.cs
@@ -3,7 +3,6 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
-using System.Web;
using Supabase.Core;
using Supabase.Core.Extensions;
using Supabase.Storage.Exceptions;
@@ -154,19 +153,15 @@ public async Task CreateBucket(string id, BucketUpsertOptions? options =
public Task DeleteBucket(string id) =>
Helpers.MakeRequest(HttpMethod.Delete, $"{Url}/bucket/{id}", null, Headers);
+ ///
public Task PurgeBucketCache(
- string id,
+ string id,
PurgeCacheOptions? options = null,
- FetchParameter? fetchParameter = null,
CancellationToken cancellationToken = default
)
{
- var queryParams = HttpUtility.ParseQueryString(string.Empty);
- if (options != null)
- queryParams.Add(options.ToQueryCollection());
-
- var url = $"{Url}/cdn/{id}?{queryParams}";
- return Helpers.MakeRequest(HttpMethod.Delete, url, fetchParameter, Headers, cancellationToken);
- }
+ var url = options.ToPurgeUrl($"{Url}/cdn/{id}");
+ return Helpers.MakeRequest(HttpMethod.Delete, url, null, Headers, cancellationToken);
+ }
}
}
diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs
index 4af21a2..9076ac9 100644
--- a/Storage/StorageFileApi.cs
+++ b/Storage/StorageFileApi.cs
@@ -676,20 +676,15 @@ public async Task CreateUploadSignedUrl(string supabasePath)
return new UploadSignedUrl(generatedUri, token, supabasePath);
}
- public async Task PurgeCache(
- string path,
+ ///
+ public Task PurgeCache(
+ string path,
PurgeCacheOptions? options = null,
- FetchParameter? fetchParameter = null,
CancellationToken cancellationToken = default
- )
+ )
{
- var finalPath = this.GetFinalPath(path);
- var queryParams = HttpUtility.ParseQueryString(string.Empty);
- if (options != null)
- queryParams.Add(options.ToQueryCollection());
-
- var url = $"{Url}/cdn/{finalPath}?{queryParams}";
- return await Helpers.MakeRequest(HttpMethod.Delete, url, fetchParameter, Headers, cancellationToken);
+ var url = options.ToPurgeUrl($"{Url}/cdn/{GetFinalPath(path)}");
+ return Helpers.MakeRequest(HttpMethod.Delete, url, null, Headers, cancellationToken);
}
private async Task UploadOrUpdate(
diff --git a/StorageTests/Buckets/StorageBucketApiContractTests.cs b/StorageTests/Buckets/StorageBucketApiContractTests.cs
index 9de9dea..fbe12b5 100644
--- a/StorageTests/Buckets/StorageBucketApiContractTests.cs
+++ b/StorageTests/Buckets/StorageBucketApiContractTests.cs
@@ -133,6 +133,38 @@ public async Task DeleteBucket_ShouldDeleteTheBucketId()
}
}
+ [TestMethod]
+ public async Task PurgeBucketCache_ShouldDeleteTheCdnPathWithNoBodyAndReturnTheMessage()
+ {
+ this.Respond("/storage/v1/cdn/photos", "DELETE", 200, "{\"message\":\"success\"}");
+ var response = await this.client.PurgeBucketCache("photos", new PurgeCacheOptions { Transformations = true });
+ using (new AssertionScope())
+ {
+ response!.Message.Should().Be("success");
+ var request = this.SingleRequest();
+ request.Method.Should().Be("DELETE");
+ request.Path.Should().Be("/storage/v1/cdn/photos");
+ request.Body.Should().BeNullOrEmpty("options travel in the query string, so the purge carries no payload");
+ }
+ }
+
+ [TestMethod]
+ public async Task PurgeBucketCache_ShouldPurgeEveryVersion_GivenDefaultOptions()
+ {
+ this.Respond("/storage/v1/cdn/photos", "DELETE", 200, "{\"message\":\"success\"}");
+ await this.client.PurgeBucketCache("photos", new PurgeCacheOptions());
+ this.SingleRequest().Query.Should().BeNullOrEmpty(
+ "unset options must purge every cached version, not only the transformations");
+ }
+
+ [TestMethod]
+ public async Task PurgeBucketCache_ShouldRequestTransformationsOnly_GivenTheTransformationsOption()
+ {
+ this.Respond("/storage/v1/cdn/photos", "DELETE", 200, "{\"message\":\"success\"}");
+ await this.client.PurgeBucketCache("photos", new PurgeCacheOptions { Transformations = true });
+ this.SingleRequest().Query.Should().Contain(pair => pair.Key == "transformations" && pair.Value.Contains("true"));
+ }
+
private void Respond(string path, string method, int statusCode, string body) =>
this.server.Given(Request.Create().WithPath(path).UsingMethod(method))
.RespondWith(Response.Create().WithStatusCode(statusCode)
diff --git a/StorageTests/Buckets/StorageBucketTests.cs b/StorageTests/Buckets/StorageBucketTests.cs
index 2f4bc29..6144956 100644
--- a/StorageTests/Buckets/StorageBucketTests.cs
+++ b/StorageTests/Buckets/StorageBucketTests.cs
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using FluentAssertions.Execution;
@@ -134,32 +133,4 @@ public async Task DeleteBucket_ShouldRemoveTheBucket_GivenEmptied()
await this.Storage.DeleteBucket(id);
(await this.Storage.GetBucket(id)).Should().BeNull();
}
-
- [TestMethod]
- public async Task PurgeCacheBucket_ShouldCancelPurgeBucketCache_GivenBucket()
- {
- using var cts = new CancellationTokenSource();
- await cts.CancelAsync();
- var id = Guid.NewGuid().ToString();
- var fetch = new FetchParameter
- {
- Cache = FetchCache.Cache,
- };
- var act = () => this.Storage.PurgeBucketCache(id, new PurgeCacheOptions(), fetch, cts.Token);
- await act.Should().ThrowAsync();
- }
-
- [TestMethod]
- public async Task PurgeCacheBucket_ShouldThrowUnknown_GivenCdnEnvEmptyBucket()
- {
- var id = Guid.NewGuid().ToString();
- await this.Storage.CreateBucket(id);
- var fetch = new FetchParameter
- {
- Cache = FetchCache.Cache,
- };
- var act = () => this.Storage.PurgeBucketCache(id, new PurgeCacheOptions(), fetch);
- await act.Should().ThrowAsync("Missing Required Parameter CDN_PURGE_ENDPOINT_URL is not set");
- await this.Storage.DeleteBucket(id);
- }
}
diff --git a/StorageTests/Files/PurgeCacheCancellationTests.cs b/StorageTests/Files/PurgeCacheCancellationTests.cs
new file mode 100644
index 0000000..8cc7fb7
--- /dev/null
+++ b/StorageTests/Files/PurgeCacheCancellationTests.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Supabase.Storage;
+
+namespace StorageTests.Files;
+
+///
+/// Contract tests pinning that the CDN purge calls thread the caller's
+/// onto the outgoing HTTP request. A stub stands in for the request
+/// client and observes the token at the point under test, so the assertion distinguishes a forwarded
+/// token from the default one a dropped argument would leave behind.
+///
+[TestClass]
+[TestCategory("Contract")]
+public class PurgeCacheCancellationTests
+{
+ private const string Bucket = "bucket";
+
+ private Client client = null!;
+ private HttpClient? requestClient;
+
+ [TestInitialize]
+ public void TestInitialize() =>
+ this.client = new Client("http://localhost/storage/v1", new Dictionary
+ {
+ { "Authorization", "Bearer test-key" }
+ });
+
+ [TestCleanup]
+ public void TestCleanup() => this.requestClient?.Dispose();
+
+ [TestMethod]
+ public async Task PurgeCache_ShouldForwardTheTokenToTheRequest()
+ {
+ using var cts = new CancellationTokenSource();
+ var act = () => this.client.From(Bucket).PurgeCache("a.png", options: null, cts.Token);
+ (await this.TokenSeenBy(act, cts)).Should().BeTrue(
+ "the object purge must carry the caller's token so an in-flight request can be cancelled");
+ }
+
+ [TestMethod]
+ public async Task PurgeBucketCache_ShouldForwardTheTokenToTheRequest()
+ {
+ using var cts = new CancellationTokenSource();
+ var act = () => this.client.PurgeBucketCache("photos", options: null, cts.Token);
+ (await this.TokenSeenBy(act, cts)).Should().BeTrue(
+ "the bucket purge must carry the caller's token so an in-flight request can be cancelled");
+ }
+
+ private async Task TokenSeenBy(Func act, CancellationTokenSource cts)
+ {
+ var requestSawLiveToken = false;
+ this.UseHandler(new StubHandler((_, token) =>
+ {
+ cts.Cancel();
+ requestSawLiveToken = token.IsCancellationRequested;
+ token.ThrowIfCancellationRequested();
+ return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(Array.Empty()) };
+ }));
+ await act.Should().ThrowAsync();
+ return requestSawLiveToken;
+ }
+
+ private void UseHandler(HttpMessageHandler handler) =>
+ Supabase.Storage.Helpers.HttpRequestClient = this.requestClient = new HttpClient(handler);
+
+ private sealed class StubHandler(Func respond)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken) =>
+ Task.FromResult(respond(request, cancellationToken));
+ }
+}
diff --git a/StorageTests/Files/StorageFileApiContractTests.cs b/StorageTests/Files/StorageFileApiContractTests.cs
index 2350279..f1fd3fb 100644
--- a/StorageTests/Files/StorageFileApiContractTests.cs
+++ b/StorageTests/Files/StorageFileApiContractTests.cs
@@ -250,6 +250,21 @@ public async Task Download_ShouldGetBytesFromTheObjectPath()
}
}
+ [TestMethod]
+ public async Task PurgeCache_ShouldDeleteTheCdnObjectPathWithNoBodyAndReturnTheMessage()
+ {
+ this.Respond($"/storage/v1/cdn/{Bucket}/a.png", "DELETE", 200, "{\"message\":\"success\"}");
+ var response = await this.client.From(Bucket).PurgeCache("a.png", new PurgeCacheOptions { Transformations = true });
+ using (new AssertionScope())
+ {
+ response!.Message.Should().Be("success");
+ var request = this.SingleRequest();
+ request.Method.Should().Be("DELETE");
+ request.Path.Should().Be($"/storage/v1/cdn/{Bucket}/a.png");
+ request.Body.Should().BeNullOrEmpty("options travel in the query string, so the purge carries no payload");
+ }
+ }
+
[TestMethod]
public async Task List_ShouldSurfaceStorageException_GivenNonJsonError()
{
diff --git a/StorageTests/Files/StorageFileTests.cs b/StorageTests/Files/StorageFileTests.cs
index b41766c..44ea248 100644
--- a/StorageTests/Files/StorageFileTests.cs
+++ b/StorageTests/Files/StorageFileTests.cs
@@ -9,7 +9,6 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using StorageTests;
using Supabase.Storage;
-using Supabase.Storage.Exceptions;
using Supabase.Storage.Interfaces;
using FileOptions = Supabase.Storage.FileOptions;
@@ -381,28 +380,6 @@ public async Task List_ShouldReturnFilesDescending_GivenCreatedAtColumnAndOrderD
list!.Select(item => item.Name).Should().Equal(names[2], names[1], names[0]);
}
- [TestMethod]
- public async Task PurgeCacheFile_ShouldCancelPurgeCache_GivenFile()
- {
- using var cts = new CancellationTokenSource();
- await cts.CancelAsync();
- var imagePath = Path.Combine(BasePath(), "Assets", "supabase-csharp.png");
-
- var act = () => this.bucket.PurgeCache(imagePath, new PurgeCacheOptions(), null, cts.Token);
- await act.Should().ThrowAsync();
- }
-
- [TestMethod]
- public async Task PurgeCacheFile_ShouldThrowUnknown_GivenCdnEnvEmpty()
- {
- using var cts = new CancellationTokenSource();
- await cts.CancelAsync();
- var imagePath = Path.Combine(BasePath(), "Assets", "supabase-csharp.png");
-
- var act = () => this.bucket.PurgeCache(imagePath, new PurgeCacheOptions(), null, cts.Token);
- await act.Should().ThrowAsync("Missing Required Parameter CDN_PURGE_ENDPOINT_URL is not set");
- }
-
private async Task UploadThreeNumbered()
{
var names = new[]