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
42 changes: 28 additions & 14 deletions Storage/Extensions/PurgeCacheOptionsExtension.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Transforms options into a NameValueCollection to be used with a <see cref="UriBuilder"/>
/// Translates <see cref="PurgeCacheOptions"/> into the query string the CDN purge endpoint expects.
/// </summary>
public static class PurgeCacheOptionsExtension
{
/// <summary>
/// Transforms the options into a <see cref="NameValueCollection"/> to be appended to a purge URL.
/// The <c>transformations</c> flag is only emitted when explicitly requested, mirroring the
/// storage-js client, so that the default purges every cached version.
/// </summary>
/// <param name="options"></param>
/// <returns></returns>
/// <param name="options">The purge options to translate.</param>
/// <returns>A query collection carrying the options, empty when none apply.</returns>
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;
}
}

/// <summary>
/// Appends the options as a query string to <paramref name="baseUrl"/>, adding the <c>?</c>
/// separator only when at least one option applies.
/// </summary>
/// <param name="options">The purge options, or <c>null</c> for none.</param>
/// <param name="baseUrl">The purge endpoint URL without a query string.</param>
/// <returns>The URL with the options appended.</returns>
public static string ToPurgeUrl(this PurgeCacheOptions? options, string baseUrl)
{
var query = options?.ToQueryCollection().ToString();
return string.IsNullOrEmpty(query) ? baseUrl : $"{baseUrl}?{query}";
}
}
}
22 changes: 0 additions & 22 deletions Storage/FetchCache.cs

This file was deleted.

9 changes: 0 additions & 9 deletions Storage/FetchParameter.cs

This file was deleted.

18 changes: 17 additions & 1 deletion Storage/Interfaces/IStorageBucketApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ public interface IStorageBucketApi<TBucket> : IGettableHeaders
Task<TBucket?> GetBucket(string id);
Task<List<TBucket>?> ListBuckets();
Task<TBucket?> UpdateBucket(string id, BucketUpsertOptions? options = null);
Task<GenericResponse?> PurgeBucketCache(string id, PurgeCacheOptions? options = null, FetchParameter? fetchParameter = null, CancellationToken cancellationToken = default);

/// <summary>
/// Purges the CDN cache for every object in a bucket. Requires a service-role key.
/// </summary>
/// <param name="id">The bucket whose cached objects should be purged.</param>
/// <param name="options">
/// When <see cref="PurgeCacheOptions.Transformations"/> is <c>true</c>, only the transformed
/// variants are purged; otherwise every cached version is purged.
/// </param>
/// <param name="cancellationToken">Token used to cancel the request.</param>
/// <returns>The service acknowledgement of the purge.</returns>
/// <example>
/// <code>
/// await storage.PurgeBucketCache("avatars", new PurgeCacheOptions { Transformations = true });
/// </code>
/// </example>
Task<GenericResponse?> PurgeBucketCache(string id, PurgeCacheOptions? options = null, CancellationToken cancellationToken = default);
}
}
19 changes: 17 additions & 2 deletions Storage/Interfaces/IStorageFileApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,23 @@ Task<string> UploadToSignedUrl(
bool inferContentType = true
);
Task<UploadSignedUrl> CreateUploadSignedUrl(string supabasePath);

Task<GenericResponse?> PurgeCache(string path, PurgeCacheOptions? options = null, FetchParameter? fetchParameter = null, CancellationToken cancellationToken = default);

/// <summary>
/// Purges the CDN cache for a single object. Requires a service-role key.
/// </summary>
/// <param name="path">The object path within the bucket to purge.</param>
/// <param name="options">
/// When <see cref="PurgeCacheOptions.Transformations"/> is <c>true</c>, only the transformed
/// variants are purged; otherwise every cached version is purged.
/// </param>
/// <param name="cancellationToken">Token used to cancel the request.</param>
/// <returns>The service acknowledgement of the purge.</returns>
/// <example>
/// <code>
/// await storage.From("avatars").PurgeCache("folder/avatar.png");
/// </code>
/// </example>
Task<GenericResponse?> PurgeCache(string path, PurgeCacheOptions? options = null, CancellationToken cancellationToken = default);
}
}

23 changes: 13 additions & 10 deletions Storage/PurgeCacheOptions.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
using Newtonsoft.Json;

namespace Supabase.Storage;

public class PurgeCacheOptions
namespace Supabase.Storage
{
/// <summary>
/// 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 <see cref="Interfaces.IStorageFileApi{TFileObject}.PurgeCache"/> and
/// <see cref="Interfaces.IStorageBucketApi{TBucket}.PurgeBucketCache"/>.
/// </summary>
[JsonProperty("transformations")]
public bool? Transformations { get; set; } = true;
}
public class PurgeCacheOptions
{
/// <summary>
/// If <c>true</c>, purges only the transformations (resized/formatted variants) for the object
/// or bucket, leaving the original cached file intact. If left <c>null</c>, all cached versions
/// are purged.
/// </summary>
public bool? Transformations { get; set; }
}
}
15 changes: 5 additions & 10 deletions Storage/StorageBucketApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -154,19 +153,15 @@ public async Task<string> CreateBucket(string id, BucketUpsertOptions? options =
public Task<GenericResponse?> DeleteBucket(string id) =>
Helpers.MakeRequest<GenericResponse>(HttpMethod.Delete, $"{Url}/bucket/{id}", null, Headers);

/// <inheritdoc />
public Task<GenericResponse?> 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<GenericResponse>(HttpMethod.Delete, url, fetchParameter, Headers, cancellationToken);
}
var url = options.ToPurgeUrl($"{Url}/cdn/{id}");
return Helpers.MakeRequest<GenericResponse>(HttpMethod.Delete, url, null, Headers, cancellationToken);
}
}
}
17 changes: 6 additions & 11 deletions Storage/StorageFileApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -676,20 +676,15 @@ public async Task<UploadSignedUrl> CreateUploadSignedUrl(string supabasePath)
return new UploadSignedUrl(generatedUri, token, supabasePath);
}

public async Task<GenericResponse?> PurgeCache(
string path,
/// <inheritdoc />
public Task<GenericResponse?> 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<GenericResponse>(HttpMethod.Delete, url, fetchParameter, Headers, cancellationToken);
var url = options.ToPurgeUrl($"{Url}/cdn/{GetFinalPath(path)}");
return Helpers.MakeRequest<GenericResponse>(HttpMethod.Delete, url, null, Headers, cancellationToken);
}

private async Task<string> UploadOrUpdate(
Expand Down
32 changes: 32 additions & 0 deletions StorageTests/Buckets/StorageBucketApiContractTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 0 additions & 29 deletions StorageTests/Buckets/StorageBucketTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<OperationCanceledException>();
}

[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<SupabaseStorageException>("Missing Required Parameter CDN_PURGE_ENDPOINT_URL is not set");
await this.Storage.DeleteBucket(id);
}
}
80 changes: 80 additions & 0 deletions StorageTests/Files/PurgeCacheCancellationTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Contract tests pinning that the CDN purge calls thread the caller's <see cref="CancellationToken"/>
/// onto the outgoing HTTP request. A stub <see cref="HttpMessageHandler"/> 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.
/// </summary>
[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<string, string>
{
{ "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<bool> TokenSeenBy(Func<Task> 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<byte>()) };
}));
await act.Should().ThrowAsync<OperationCanceledException>();
return requestSawLiveToken;
}

private void UseHandler(HttpMessageHandler handler) =>
Supabase.Storage.Helpers.HttpRequestClient = this.requestClient = new HttpClient(handler);

private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, HttpResponseMessage> respond)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(respond(request, cancellationToken));
}
}
Loading