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[]