diff --git a/Storage/Extensions/HttpClientProgress.cs b/Storage/Extensions/HttpClientProgress.cs index 6410bbc..15d0061 100644 --- a/Storage/Extensions/HttpClientProgress.cs +++ b/Storage/Extensions/HttpClientProgress.cs @@ -4,139 +4,312 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using BirdMessenger; +using BirdMessenger.Collections; using Newtonsoft.Json; using Supabase.Storage.Exceptions; namespace Supabase.Storage.Extensions { - /// - /// Adapted from: https://gist.github.com/dalexsoto/9fd3c5bdbe9f61a717d47c5843384d11 - /// - internal static class HttpClientProgress - { - public static async Task DownloadDataAsync(this HttpClient client, Uri uri, Dictionary? headers = null, IProgress? progress = null, CancellationToken cancellationToken = default(CancellationToken)) - { - var destination = new MemoryStream(); - var message = new HttpRequestMessage(HttpMethod.Get, uri); - - if (headers != null) - { - foreach (var header in headers) - { - message.Headers.Add(header.Key, header.Value); - } - } - - using (var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead)) - { - if (!response.IsSuccessStatusCode) - { - var content = await response.Content.ReadAsStringAsync(); - var errorResponse = JsonConvert.DeserializeObject(content); - var e = new SupabaseStorageException(errorResponse?.Message ?? content) - { - Content = content, - Response = response, - StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode - }; - - e.AddReason(); - throw e; - } - - var contentLength = response.Content.Headers.ContentLength; - using (var download = await response.Content.ReadAsStreamAsync()) - { - // no progress... no contentLength... very sad - if (progress is null || !contentLength.HasValue) - { - await download.CopyToAsync(destination); - return destination; - } - - // Such progress and contentLength much reporting Wow! - var progressWrapper = new Progress(totalBytes => progress.Report(GetProgressPercentage(totalBytes, contentLength.Value))); - await download.CopyToAsync(destination, 81920, progressWrapper, cancellationToken); - } - } - - float GetProgressPercentage(float totalBytes, float currentBytes) => (totalBytes / currentBytes) * 100f; - - return destination; - } - - static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress? progress = null, CancellationToken cancellationToken = default(CancellationToken)) - { - if (bufferSize < 0) - throw new ArgumentOutOfRangeException(nameof(bufferSize)); - if (source is null) - throw new ArgumentNullException(nameof(source)); - if (!source.CanRead) - throw new InvalidOperationException($"'{nameof(source)}' is not readable."); - if (destination == null) - throw new ArgumentNullException(nameof(destination)); - if (!destination.CanWrite) - throw new InvalidOperationException($"'{nameof(destination)}' is not writable."); - - var buffer = new byte[bufferSize]; - long totalBytesRead = 0; - int bytesRead; - - while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false)) != 0) - { - await destination.WriteAsync(buffer, 0, bytesRead, cancellationToken).ConfigureAwait(false); - totalBytesRead += bytesRead; - progress?.Report(totalBytesRead); - } - } - - public static Task UploadFileAsync(this HttpClient client, Uri uri, string filePath, Dictionary? headers = null, Progress? progress = null) - { - var fileStream = new FileStream(filePath, mode: FileMode.Open, FileAccess.Read); - return UploadAsync(client, uri, fileStream, headers, progress); - } - - public static Task UploadBytesAsync(this HttpClient client, Uri uri, byte[] data, Dictionary? headers = null, Progress? progress = null) - { - var stream = new MemoryStream(data); - return UploadAsync(client, uri, stream, headers, progress); - } - - public static async Task UploadAsync(this HttpClient client, Uri uri, Stream stream, Dictionary? headers = null, Progress? progress = null) - { - var content = new ProgressableStreamContent(stream, 4096, progress); - - if (headers != null) - { - client.DefaultRequestHeaders.Clear(); - - foreach (var header in headers) - { - if (header.Key.Contains("content")) - content.Headers.Add(header.Key, header.Value); - else - client.DefaultRequestHeaders.Add(header.Key, header.Value); - } - } - - var response = await client.PostAsync(uri, content); - - if (!response.IsSuccessStatusCode) - { - var httpContent = await response.Content.ReadAsStringAsync(); - var errorResponse = JsonConvert.DeserializeObject(httpContent); - var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent) - { - Content = httpContent, - Response = response, - StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode - }; - - e.AddReason(); - throw e; - } - - return response; - } - } + /// + /// Adapted from: https://gist.github.com/dalexsoto/9fd3c5bdbe9f61a717d47c5843384d11 + /// + internal static class HttpClientProgress + { + public static async Task DownloadDataAsync( + this HttpClient client, + Uri uri, + Dictionary? headers = null, + IProgress? progress = null, + CancellationToken cancellationToken = default(CancellationToken) + ) + { + var destination = new MemoryStream(); + var message = new HttpRequestMessage(HttpMethod.Get, uri); + + if (headers != null) + { + foreach (var header in headers) + { + message.Headers.Add(header.Key, header.Value); + } + } + + using ( + var response = await client.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead + ) + ) + { + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonConvert.DeserializeObject(content); + var e = new SupabaseStorageException(errorResponse?.Message ?? content) + { + Content = content, + Response = response, + StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode, + }; + + e.AddReason(); + throw e; + } + + var contentLength = response.Content.Headers.ContentLength; + using (var download = await response.Content.ReadAsStreamAsync()) + { + // no progress... no contentLength... very sad + if (progress is null || !contentLength.HasValue) + { + await download.CopyToAsync(destination); + return destination; + } + + // Such progress and contentLength much reporting Wow! + var progressWrapper = new Progress(totalBytes => + progress.Report(GetProgressPercentage(totalBytes, contentLength.Value)) + ); + await download.CopyToAsync( + destination, + 81920, + progressWrapper, + cancellationToken + ); + } + } + + float GetProgressPercentage(float totalBytes, float currentBytes) => + (totalBytes / currentBytes) * 100f; + + return destination; + } + + static async Task CopyToAsync( + this Stream source, + Stream destination, + int bufferSize, + IProgress? progress = null, + CancellationToken cancellationToken = default(CancellationToken) + ) + { + if (bufferSize < 0) + throw new ArgumentOutOfRangeException(nameof(bufferSize)); + if (source is null) + throw new ArgumentNullException(nameof(source)); + if (!source.CanRead) + throw new InvalidOperationException($"'{nameof(source)}' is not readable."); + if (destination == null) + throw new ArgumentNullException(nameof(destination)); + if (!destination.CanWrite) + throw new InvalidOperationException($"'{nameof(destination)}' is not writable."); + + var buffer = new byte[bufferSize]; + long totalBytesRead = 0; + int bytesRead; + + while ( + ( + bytesRead = await source + .ReadAsync(buffer, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false) + ) != 0 + ) + { + await destination + .WriteAsync(buffer, 0, bytesRead, cancellationToken) + .ConfigureAwait(false); + totalBytesRead += bytesRead; + progress?.Report(totalBytesRead); + } + } + + public static Task UploadFileAsync( + this HttpClient client, + Uri uri, + string filePath, + Dictionary? headers = null, + Progress? progress = null + ) + { + var fileStream = new FileStream(filePath, mode: FileMode.Open, FileAccess.Read); + return UploadAsync(client, uri, fileStream, headers, progress); + } + + public static Task UploadBytesAsync( + this HttpClient client, + Uri uri, + byte[] data, + Dictionary? headers = null, + Progress? progress = null + ) + { + var stream = new MemoryStream(data); + return UploadAsync(client, uri, stream, headers, progress); + } + + public static async Task UploadAsync( + this HttpClient client, + Uri uri, + Stream stream, + Dictionary? headers = null, + Progress? progress = null + ) + { + var content = new ProgressableStreamContent(stream, 4096, progress); + + if (headers != null) + { + client.DefaultRequestHeaders.Clear(); + + foreach (var header in headers) + { + if (header.Key.Contains("content")) + content.Headers.Add(header.Key, header.Value); + else + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + var response = await client.PostAsync(uri, content); + + if (!response.IsSuccessStatusCode) + { + var httpContent = await response.Content.ReadAsStringAsync(); + var errorResponse = JsonConvert.DeserializeObject(httpContent); + var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent) + { + Content = httpContent, + Response = response, + StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode, + }; + + e.AddReason(); + throw e; + } + + return response; + } + + public static Task UploadOrContinueFileAsync( + this HttpClient client, + Uri uri, + string filePath, + Dictionary? headers = null, + MetadataCollection? metadata = null, + Progress? progress = null, + CancellationToken cancellationToken = default + ) + { + var fileStream = new FileStream(filePath, mode: FileMode.Open, FileAccess.Read); + return ResumableUploadAsync( + client, + uri, + fileStream, + headers, + metadata, + progress, + cancellationToken + ); + } + + public static Task UploadOrContinueByteAsync( + this HttpClient client, + Uri uri, + byte[] data, + Dictionary? headers = null, + MetadataCollection? metadata = null, + Progress? progress = null, + CancellationToken cancellationToken = default + ) + { + var stream = new MemoryStream(data); + return ResumableUploadAsync( + client, + uri, + stream, + headers, + metadata, + progress, + cancellationToken + ); + } + + private static async Task ResumableUploadAsync( + this HttpClient client, + Uri uri, + Stream fileStream, + Dictionary? headers = null, + MetadataCollection? metadata = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + if (fileStream == null) + throw new ArgumentNullException(nameof(fileStream)); + + if (fileStream.Position != 0 && fileStream.CanSeek) + { + fileStream.Seek(0, SeekOrigin.Begin); + } + + if (headers != null) + { + client.DefaultRequestHeaders.Clear(); + foreach (var header in headers) + { + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + var createOption = new TusCreateRequestOption() + { + Endpoint = uri, + Metadata = metadata, + UploadLength = fileStream.Length, + }; + + var responseCreate = await client.TusCreateAsync(createOption, cancellationToken); + + var patchOption = new TusPatchRequestOption + { + FileLocation = responseCreate.FileLocation, + Stream = fileStream, + UploadBufferSize = 6 * 1024 * 1024, + UploadType = UploadType.Chunk, + OnProgressAsync = x => + { + if (progress == null) + return Task.CompletedTask; + + var uploadedProgress = (float)x.UploadedSize / x.TotalSize * 100f; + progress.Report(uploadedProgress); + + return Task.CompletedTask; + }, + OnCompletedAsync = _ => Task.CompletedTask, + OnFailedAsync = _ => Task.CompletedTask, + }; + + var responsePatch = await client.TusPatchAsync(patchOption, cancellationToken); + + if (responsePatch.OriginResponseMessage.IsSuccessStatusCode) + return responsePatch.OriginResponseMessage; + + var httpContent = await responsePatch.OriginResponseMessage.Content.ReadAsStringAsync(); + var errorResponse = JsonConvert.DeserializeObject(httpContent); + var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent) + { + Content = httpContent, + Response = responsePatch.OriginResponseMessage, + StatusCode = + errorResponse?.StatusCode + ?? (int)responsePatch.OriginResponseMessage.StatusCode, + }; + + e.AddReason(); + throw e; + } + } } diff --git a/Storage/Interfaces/IStorageFileApi.cs b/Storage/Interfaces/IStorageFileApi.cs index 4af9dcc..c0a5d2a 100644 --- a/Storage/Interfaces/IStorageFileApi.cs +++ b/Storage/Interfaces/IStorageFileApi.cs @@ -1,34 +1,119 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; namespace Supabase.Storage.Interfaces { - public interface IStorageFileApi - where TFileObject : FileObject - { - ClientOptions Options { get; } - Task CreateSignedUrl(string path, int expiresIn, TransformOptions? transformOptions = null, DownloadOptions? options = null); - Task?> CreateSignedUrls(List paths, int expiresIn, DownloadOptions? options = null); - Task Download(string supabasePath, EventHandler? onProgress = null); - Task Download(string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null); - Task Download(string supabasePath, string localPath, EventHandler? onProgress = null); - Task Download(string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null); - Task DownloadPublicFile(string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null); - Task DownloadPublicFile(string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null); - string GetPublicUrl(string path, TransformOptions? transformOptions = null, DownloadOptions? options = null); - Task?> List(string path = "", SearchOptions? options = null); - Task Info(string path); - Task Move(string fromPath, string toPath, DestinationOptions? options = null); - Task Copy(string fromPath, string toPath, DestinationOptions? options = null); + public interface IStorageFileApi + where TFileObject : FileObject + { + ClientOptions Options { get; } + Task CreateSignedUrl( + string path, + int expiresIn, + TransformOptions? transformOptions = null, + DownloadOptions? options = null + ); + Task?> CreateSignedUrls( + List paths, + int expiresIn, + DownloadOptions? options = null + ); + Task Download(string supabasePath, EventHandler? onProgress = null); + Task Download( + string supabasePath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ); + Task Download( + string supabasePath, + string localPath, + EventHandler? onProgress = null + ); + Task Download( + string supabasePath, + string localPath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ); + Task DownloadPublicFile( + string supabasePath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ); + Task DownloadPublicFile( + string supabasePath, + string localPath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ); + string GetPublicUrl( + string path, + TransformOptions? transformOptions = null, + DownloadOptions? options = null + ); + Task?> List(string path = "", SearchOptions? options = null); + Task Info(string path); + Task Move(string fromPath, string toPath, DestinationOptions? options = null); + Task Copy(string fromPath, string toPath, DestinationOptions? options = null); Task Remove(string path); - Task?> Remove(List paths); - Task Update(byte[] data, string supabasePath, FileOptions? options = null, EventHandler? onProgress = null); - Task Update(string localFilePath, string supabasePath, FileOptions? options = null, EventHandler? onProgress = null); - Task Upload(byte[] data, string supabasePath, FileOptions? options = null, EventHandler? onProgress = null, bool inferContentType = true); - Task Upload(string localFilePath, string supabasePath, FileOptions? options = null, EventHandler? onProgress = null, bool inferContentType = true); - Task UploadToSignedUrl(byte[] data, UploadSignedUrl url, FileOptions? options = null, EventHandler? onProgress = null, bool inferContentType = true); - Task UploadToSignedUrl(string localFilePath, UploadSignedUrl url, FileOptions? options = null, EventHandler? onProgress = null, bool inferContentType = true); - Task CreateUploadSignedUrl(string supabasePath); - } -} \ No newline at end of file + Task?> Remove(List paths); + Task Update( + byte[] data, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null + ); + Task Update( + string localFilePath, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null + ); + Task Upload( + byte[] data, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ); + Task Upload( + string localFilePath, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ); + Task UploadOrResume( + string localPath, + string fileName, + FileOptions options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ); + Task UploadOrResume( + byte[] data, + string fileName, + FileOptions options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ); + Task UploadToSignedUrl( + byte[] data, + UploadSignedUrl url, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ); + Task UploadToSignedUrl( + string localFilePath, + UploadSignedUrl url, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ); + Task CreateUploadSignedUrl(string supabasePath); + } +} + diff --git a/Storage/Storage.csproj b/Storage/Storage.csproj index ae6d100..712d3c3 100644 --- a/Storage/Storage.csproj +++ b/Storage/Storage.csproj @@ -40,6 +40,7 @@ + diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index 2ab8091..758c46b 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -4,8 +4,10 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using System.Web; +using BirdMessenger.Collections; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Supabase.Storage.Exceptions; @@ -22,13 +24,22 @@ public class StorageFileApi : IStorageFileApi protected Dictionary Headers { get; set; } protected string? BucketId { get; set; } - public StorageFileApi(string url, string bucketId, ClientOptions? options, - Dictionary? headers = null) : this(url, headers, bucketId) + public StorageFileApi( + string url, + string bucketId, + ClientOptions? options, + Dictionary? headers = null + ) + : this(url, headers, bucketId) { Options = options ?? new ClientOptions(); } - public StorageFileApi(string url, Dictionary? headers = null, string? bucketId = null) + public StorageFileApi( + string url, + Dictionary? headers = null, + string? bucketId = null + ) { Url = url; BucketId = bucketId; @@ -44,11 +55,15 @@ public StorageFileApi(string url, Dictionary? headers = null, st /// /// /// - public string GetPublicUrl(string path, TransformOptions? transformOptions, DownloadOptions? downloadOptions = null) + public string GetPublicUrl( + string path, + TransformOptions? transformOptions, + DownloadOptions? downloadOptions = null + ) { var queryParams = HttpUtility.ParseQueryString(string.Empty); - - if (downloadOptions != null) + + if (downloadOptions != null) queryParams.Add(downloadOptions.ToQueryCollection()); if (transformOptions == null) @@ -60,7 +75,7 @@ public string GetPublicUrl(string path, TransformOptions? transformOptions, Down queryParams.Add(transformOptions.ToQueryCollection()); var builder = new UriBuilder($"{Url}/render/image/public/{GetFinalPath(path)}") { - Query = queryParams.ToString() + Query = queryParams.ToString(), }; return builder.ToString(); @@ -74,24 +89,40 @@ public string GetPublicUrl(string path, TransformOptions? transformOptions, Down /// /// /// - public async Task CreateSignedUrl(string path, int expiresIn, TransformOptions? transformOptions = null, DownloadOptions? downloadOptions = null) + public async Task CreateSignedUrl( + string path, + int expiresIn, + TransformOptions? transformOptions = null, + DownloadOptions? downloadOptions = null + ) { var body = new Dictionary { { "expiresIn", expiresIn } }; var url = $"{Url}/object/sign/{GetFinalPath(path)}"; if (transformOptions != null) { - var transformOptionsJson = JsonConvert.SerializeObject(transformOptions, new StringEnumConverter()); - var transformOptionsObj = JsonConvert.DeserializeObject>(transformOptionsJson); + var transformOptionsJson = JsonConvert.SerializeObject( + transformOptions, + new StringEnumConverter() + ); + var transformOptionsObj = JsonConvert.DeserializeObject>( + transformOptionsJson + ); body.Add("transform", transformOptionsObj); } - var response = await Helpers.MakeRequest(HttpMethod.Post, url, body, Headers); + var response = await Helpers.MakeRequest( + HttpMethod.Post, + url, + body, + Headers + ); if (response == null || string.IsNullOrEmpty(response.SignedUrl)) throw new SupabaseStorageException( - $"Signed Url for {path} returned empty, do you have permission?"); - + $"Signed Url for {path} returned empty, do you have permission?" + ); + var downloadQueryParams = downloadOptions?.ToQueryCollection().ToString(); return $"{Url}{response.SignedUrl}?{downloadQueryParams}"; @@ -104,11 +135,23 @@ public async Task CreateSignedUrl(string path, int expiresIn, TransformO /// The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute. /// /// - public async Task?> CreateSignedUrls(List paths, int expiresIn, DownloadOptions? downloadOptions = null) + public async Task?> CreateSignedUrls( + List paths, + int expiresIn, + DownloadOptions? downloadOptions = null + ) { - var body = new Dictionary { { "expiresIn", expiresIn }, { "paths", paths } }; - var response = await Helpers.MakeRequest>(HttpMethod.Post, - $"{Url}/object/sign/{BucketId}", body, Headers); + var body = new Dictionary + { + { "expiresIn", expiresIn }, + { "paths", paths }, + }; + var response = await Helpers.MakeRequest>( + HttpMethod.Post, + $"{Url}/object/sign/{BucketId}", + body, + Headers + ); var downloadQueryParams = downloadOptions?.ToQueryCollection().ToString(); if (response != null) @@ -117,7 +160,8 @@ public async Task CreateSignedUrl(string path, int expiresIn, TransformO { if (string.IsNullOrEmpty(item.SignedUrl)) throw new SupabaseStorageException( - $"Signed Url for {item.Path} returned empty, do you have permission?"); + $"Signed Url for {item.Path} returned empty, do you have permission?" + ); item.SignedUrl = $"{Url}{item.SignedUrl}?{downloadQueryParams}"; } @@ -142,13 +186,16 @@ public async Task CreateSignedUrl(string path, int expiresIn, TransformO if (body != null) body.Add("prefix", string.IsNullOrEmpty(path) ? "" : path); - var response = - await Helpers.MakeRequest>(HttpMethod.Post, $"{Url}/object/list/{BucketId}", body, - Headers); + var response = await Helpers.MakeRequest>( + HttpMethod.Post, + $"{Url}/object/list/{BucketId}", + body, + Headers + ); return response; } - + /// /// Retrieves the details of an existing file. /// @@ -156,8 +203,12 @@ await Helpers.MakeRequest>(HttpMethod.Post, $"{Url}/object/list /// public async Task Info(string path) { - var response = - await Helpers.MakeRequest(HttpMethod.Get, $"{Url}/object/info/{BucketId}/{path}", null, Headers); + var response = await Helpers.MakeRequest( + HttpMethod.Get, + $"{Url}/object/info/{BucketId}/{path}", + null, + Headers + ); return response; } @@ -171,8 +222,13 @@ await Helpers.MakeRequest>(HttpMethod.Post, $"{Url}/object/list /// /// /// - public async Task Upload(string localFilePath, string supabasePath, FileOptions? options = null, - EventHandler? onProgress = null, bool inferContentType = true) + public async Task Upload( + string localFilePath, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ) { options ??= new FileOptions(); @@ -192,8 +248,13 @@ public async Task Upload(string localFilePath, string supabasePath, File /// /// /// - public async Task Upload(byte[] data, string supabasePath, FileOptions? options = null, - EventHandler? onProgress = null, bool inferContentType = true) + public async Task Upload( + byte[] data, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ) { options ??= new FileOptions(); @@ -213,8 +274,13 @@ public async Task Upload(byte[] data, string supabasePath, FileOptions? /// /// /// - public async Task UploadToSignedUrl(string localFilePath, UploadSignedUrl signedUrl, - FileOptions? options = null, EventHandler? onProgress = null, bool inferContentType = true) + public async Task UploadToSignedUrl( + string localFilePath, + UploadSignedUrl signedUrl, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ) { options ??= new FileOptions(); @@ -225,7 +291,7 @@ public async Task UploadToSignedUrl(string localFilePath, UploadSignedUr { ["Authorization"] = $"Bearer {signedUrl.Token}", ["cache-control"] = $"max-age={options.CacheControl}", - ["content-type"] = options.ContentType + ["content-type"] = options.ContentType, }; if (options.Upsert) @@ -236,7 +302,12 @@ public async Task UploadToSignedUrl(string localFilePath, UploadSignedUr if (onProgress != null) progress.ProgressChanged += onProgress; - await Helpers.HttpUploadClient!.UploadFileAsync(signedUrl.SignedUrl, localFilePath, headers, progress); + await Helpers.HttpUploadClient!.UploadFileAsync( + signedUrl.SignedUrl, + localFilePath, + headers, + progress + ); return GetFinalPath(signedUrl.Key); } @@ -250,8 +321,13 @@ public async Task UploadToSignedUrl(string localFilePath, UploadSignedUr /// /// /// - public async Task UploadToSignedUrl(byte[] data, UploadSignedUrl signedUrl, FileOptions? options = null, - EventHandler? onProgress = null, bool inferContentType = true) + public async Task UploadToSignedUrl( + byte[] data, + UploadSignedUrl signedUrl, + FileOptions? options = null, + EventHandler? onProgress = null, + bool inferContentType = true + ) { options ??= new FileOptions(); @@ -262,7 +338,7 @@ public async Task UploadToSignedUrl(byte[] data, UploadSignedUrl signedU { ["Authorization"] = $"Bearer {signedUrl.Token}", ["cache-control"] = $"max-age={options.CacheControl}", - ["content-type"] = options.ContentType + ["content-type"] = options.ContentType, }; if (options.Upsert) @@ -273,12 +349,16 @@ public async Task UploadToSignedUrl(byte[] data, UploadSignedUrl signedU if (onProgress != null) progress.ProgressChanged += onProgress; - await Helpers.HttpUploadClient!.UploadBytesAsync(signedUrl.SignedUrl, data, headers, progress); + await Helpers.HttpUploadClient!.UploadBytesAsync( + signedUrl.SignedUrl, + data, + headers, + progress + ); return GetFinalPath(signedUrl.Key); } - /// /// Replaces an existing file at the specified path with a new one. /// @@ -287,8 +367,12 @@ public async Task UploadToSignedUrl(byte[] data, UploadSignedUrl signedU /// HTTP headers. /// /// - public Task Update(string localFilePath, string supabasePath, FileOptions? options = null, - EventHandler? onProgress = null) + public Task Update( + string localFilePath, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null + ) { options ??= new FileOptions(); return UploadOrUpdate(localFilePath, supabasePath, options, onProgress); @@ -302,13 +386,66 @@ public Task Update(string localFilePath, string supabasePath, FileOption /// HTTP headers. /// /// - public Task Update(byte[] data, string supabasePath, FileOptions? options = null, - EventHandler? onProgress = null) + public Task Update( + byte[] data, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null + ) { options ??= new FileOptions(); return UploadOrUpdate(data, supabasePath, options, onProgress); } + /// + /// Attempts to upload a file to Supabase storage. If the upload process is interrupted or incomplete, it will attempt to resume the upload. + /// + /// The local file path of the file to be uploaded. + /// The destination path in Supabase Storage where the file will be stored. + /// Optional file options to specify metadata or other upload configurations. + /// An optional event handler for tracking and reporting upload progress as a percentage. + /// Cancellation token to observe while waiting for the task to complete. + /// Returns a task that resolves to a string representing the URL or path of the uploaded file in the storage. + public Task UploadOrResume( + string localPath, + string fileName, + FileOptions? options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ) + { + options ??= new FileOptions(); + return UploadOrContinue( + localPath, + fileName, + options, + onProgress, + cancellationToken + ); + } + + /// + /// Uploads a file to the specified path in Supabase storage or resumes an interrupted upload process. + /// Allows customization through provided file options and supports tracking upload progress via an event handler. + /// + /// The byte array containing the file data to upload. + /// The destination path within Supabase storage where the file should be stored. + /// Optional configuration settings for the upload process. + /// An optional event handler for monitoring the upload progress, reporting it as a percentage. + /// A cancellation token to observe while awaiting the task, allowing the operation to be canceled. + /// A task representing the asynchronous operation, resolving to the path of the uploaded file upon successful completion. + public Task UploadOrResume( + byte[] data, + string fileName, + FileOptions? options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ) + { + options ??= new FileOptions(); + return UploadOrContinue(data, fileName, options, onProgress, cancellationToken); + } + /// /// Moves an existing file to a new location, optionally allowing renaming. /// @@ -316,16 +453,25 @@ public Task Update(byte[] data, string supabasePath, FileOptions? option /// The target file path, including the new file name (e.g., `folder/image-copy.png`). /// Optional parameters for specifying the destination bucket and other settings. /// Returns a boolean value indicating whether the operation was successful. - public async Task Move(string fromPath, string toPath, DestinationOptions? options = null) + public async Task Move( + string fromPath, + string toPath, + DestinationOptions? options = null + ) { var body = new Dictionary { { "bucketId", BucketId }, { "sourceKey", fromPath }, { "destinationKey", toPath }, - { "destinationBucket", options?.DestinationBucket } + { "destinationBucket", options?.DestinationBucket }, }; - await Helpers.MakeRequest(HttpMethod.Post, $"{Url}/object/move", body, Headers); + await Helpers.MakeRequest( + HttpMethod.Post, + $"{Url}/object/move", + body, + Headers + ); return true; } @@ -336,17 +482,26 @@ public async Task Move(string fromPath, string toPath, DestinationOptions? /// The destination path for the copied file/object. /// Optional parameters such as the destination bucket. /// True if the copy operation was successful. - public async Task Copy(string fromPath, string toPath, DestinationOptions? options = null) + public async Task Copy( + string fromPath, + string toPath, + DestinationOptions? options = null + ) { var body = new Dictionary { { "bucketId", BucketId }, { "sourceKey", fromPath }, { "destinationKey", toPath }, - { "destinationBucket", options?.DestinationBucket } + { "destinationBucket", options?.DestinationBucket }, }; - await Helpers.MakeRequest(HttpMethod.Post, $"{Url}/object/copy", body, Headers); + await Helpers.MakeRequest( + HttpMethod.Post, + $"{Url}/object/copy", + body, + Headers + ); return true; } @@ -358,12 +513,17 @@ public async Task Copy(string fromPath, string toPath, DestinationOptions? /// /// /// - public Task Download(string supabasePath, string localPath, TransformOptions? transformOptions = null, - EventHandler? onProgress = null) + public Task Download( + string supabasePath, + string localPath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { - var url = transformOptions != null - ? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}" - : $"{Url}/object/{GetFinalPath(supabasePath)}"; + var url = + transformOptions != null + ? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}" + : $"{Url}/object/{GetFinalPath(supabasePath)}"; return DownloadFile(url, localPath, transformOptions, onProgress); } @@ -374,8 +534,11 @@ public Task Download(string supabasePath, string localPath, TransformOpt /// /// /// - public Task Download(string supabasePath, string localPath, EventHandler? onProgress = null) => - Download(supabasePath, localPath, null, onProgress: onProgress); + public Task Download( + string supabasePath, + string localPath, + EventHandler? onProgress = null + ) => Download(supabasePath, localPath, null, onProgress: onProgress); /// /// Downloads a byte array from a private bucket to be used programmatically. For public buckets @@ -384,8 +547,11 @@ public Task Download(string supabasePath, string localPath, EventHandler /// /// /// - public Task Download(string supabasePath, TransformOptions? transformOptions = null, - EventHandler? onProgress = null) + public Task Download( + string supabasePath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { var url = $"{Url}/object/{GetFinalPath(supabasePath)}"; return DownloadBytes(url, transformOptions, onProgress); @@ -408,8 +574,12 @@ public Task Download(string supabasePath, EventHandler? onProgres /// /// /// - public Task DownloadPublicFile(string supabasePath, string localPath, - TransformOptions? transformOptions = null, EventHandler? onProgress = null) + public Task DownloadPublicFile( + string supabasePath, + string localPath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { var url = GetPublicUrl(supabasePath, transformOptions); return DownloadFile(url, localPath, transformOptions, onProgress); @@ -422,8 +592,11 @@ public Task DownloadPublicFile(string supabasePath, string localPath, /// /// /// - public Task DownloadPublicFile(string supabasePath, TransformOptions? transformOptions = null, - EventHandler? onProgress = null) + public Task DownloadPublicFile( + string supabasePath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { var url = GetPublicUrl(supabasePath, transformOptions); return DownloadBytes(url, transformOptions, onProgress); @@ -448,9 +621,12 @@ public Task DownloadPublicFile(string supabasePath, TransformOptions? tr public async Task?> Remove(List paths) { var data = new Dictionary { { "prefixes", paths } }; - var response = - await Helpers.MakeRequest>(HttpMethod.Delete, $"{Url}/object/{BucketId}", data, - Headers); + var response = await Helpers.MakeRequest>( + HttpMethod.Delete, + $"{Url}/object/{BucketId}", + data, + Headers + ); return response; } @@ -465,12 +641,21 @@ public async Task CreateUploadSignedUrl(string supabasePath) var path = GetFinalPath(supabasePath); var url = $"{Url}/object/upload/sign/{path}"; - var response = - await Helpers.MakeRequest(HttpMethod.Post, url, null, Headers); - - if (response == null || string.IsNullOrEmpty(response.Url) || !response.Url!.Contains("token")) + var response = await Helpers.MakeRequest( + HttpMethod.Post, + url, + null, + Headers + ); + + if ( + response == null + || string.IsNullOrEmpty(response.Url) + || !response.Url!.Contains("token") + ) throw new SupabaseStorageException( - "Response did not return with expected data. Does this token have proper permission to generate a url?"); + "Response did not return with expected data. Does this token have proper permission to generate a url?" + ); var generatedUri = new Uri($"{Url}{response.Url}"); var query = HttpUtility.ParseQueryString(generatedUri.Query); @@ -479,15 +664,19 @@ public async Task CreateUploadSignedUrl(string supabasePath) return new UploadSignedUrl(generatedUri, token, supabasePath); } - private async Task UploadOrUpdate(string localPath, string supabasePath, FileOptions options, - EventHandler? onProgress = null) + private async Task UploadOrUpdate( + string localPath, + string supabasePath, + FileOptions options, + EventHandler? onProgress = null + ) { Uri uri = new Uri($"{Url}/object/{GetFinalPath(supabasePath)}"); var headers = new Dictionary(Headers) { { "cache-control", $"max-age={options.CacheControl}" }, - { "content-type", options.ContentType } + { "content-type", options.ContentType }, }; if (options.Upsert) @@ -495,12 +684,12 @@ private async Task UploadOrUpdate(string localPath, string supabasePath, if (options.Metadata != null) headers.Add("x-metadata", ParseMetadata(options.Metadata)); - + options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); - + if (options.Duplex != null) headers.Add("x-duplex", options.Duplex.ToLower()); - + var progress = new Progress(); if (onProgress != null) @@ -511,23 +700,123 @@ private async Task UploadOrUpdate(string localPath, string supabasePath, return GetFinalPath(supabasePath); } + private async Task UploadOrContinue( + string localPath, + string fileName, + FileOptions options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ) + { + var uri = new Uri($"{Url}/upload/resumable"); + + var headers = new Dictionary(Headers) + { + { "cache-control", $"max-age={options.CacheControl}" }, + }; + + var metadata = new MetadataCollection + { + ["bucketName"] = BucketId, + ["objectName"] = fileName, + ["contentType"] = options.ContentType, + }; + + if (options.Upsert) + headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + + if (options.Metadata != null) + headers.Add("x-metadata", ParseMetadata(options.Metadata)); + + options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); + + if (options.Duplex != null) + headers.Add("x-duplex", options.Duplex.ToLower()); + + var progress = new Progress(); + + if (onProgress != null) + progress.ProgressChanged += onProgress; + + await Helpers.HttpUploadClient!.UploadOrContinueFileAsync( + uri, + localPath, + headers, + metadata, + progress, + cancellationToken + ); + } + + private async Task UploadOrContinue( + byte[] data, + string fileName, + FileOptions options, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ) + { + var uri = new Uri($"{Url}/upload/resumable"); + + var headers = new Dictionary(Headers) + { + { "cache-control", $"max-age={options.CacheControl}" }, + }; + + var metadata = new MetadataCollection + { + ["bucketName"] = BucketId, + ["objectName"] = fileName, + ["contentType"] = options.ContentType, + }; + + if (options.Upsert) + headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + + if (options.Metadata != null) + metadata["metadata"] = JsonConvert.SerializeObject(options.Metadata); + + options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); + + if (options.Duplex != null) + headers.Add("x-duplex", options.Duplex.ToLower()); + + var progress = new Progress(); + + if (onProgress != null) + progress.ProgressChanged += onProgress; + + await Helpers.HttpUploadClient!.UploadOrContinueByteAsync( + uri, + data, + headers, + metadata, + progress, + cancellationToken + ); + } + private static string ParseMetadata(Dictionary metadata) { var json = JsonConvert.SerializeObject(metadata); var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)); - + return base64; } - private async Task UploadOrUpdate(byte[] data, string supabasePath, FileOptions options, - EventHandler? onProgress = null) + private async Task UploadOrUpdate( + byte[] data, + string supabasePath, + FileOptions options, + EventHandler? onProgress = null + ) { Uri uri = new Uri($"{Url}/object/{GetFinalPath(supabasePath)}"); var headers = new Dictionary(Headers) { { "cache-control", $"max-age={options.CacheControl}" }, - { "content-type", options.ContentType } + { "content-type", options.ContentType }, }; if (options.Upsert) @@ -535,12 +824,12 @@ private async Task UploadOrUpdate(byte[] data, string supabasePath, File if (options.Metadata != null) headers.Add("x-metadata", ParseMetadata(options.Metadata)); - + options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); - + if (options.Duplex != null) headers.Add("x-duplex", options.Duplex.ToLower()); - + var progress = new Progress(); if (onProgress != null) @@ -551,8 +840,12 @@ private async Task UploadOrUpdate(byte[] data, string supabasePath, File return GetFinalPath(supabasePath); } - private async Task DownloadFile(string url, string localPath, TransformOptions? transformOptions = null, - EventHandler? onProgress = null) + private async Task DownloadFile( + string url, + string localPath, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { var builder = new UriBuilder(url); var progress = new Progress(); @@ -563,17 +856,28 @@ private async Task DownloadFile(string url, string localPath, TransformO if (onProgress != null) progress.ProgressChanged += onProgress; - var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync(builder.Uri, Headers, progress); + var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync( + builder.Uri, + Headers, + progress + ); - using var fileStream = new FileStream(localPath, FileMode.OpenOrCreate, FileAccess.Write); + using var fileStream = new FileStream( + localPath, + FileMode.OpenOrCreate, + FileAccess.Write + ); stream.WriteTo(fileStream); return localPath; } - private async Task DownloadBytes(string url, TransformOptions? transformOptions = null, - EventHandler? onProgress = null) + private async Task DownloadBytes( + string url, + TransformOptions? transformOptions = null, + EventHandler? onProgress = null + ) { var builder = new UriBuilder(url); var progress = new Progress(); @@ -584,11 +888,16 @@ private async Task DownloadBytes(string url, TransformOptions? transform if (onProgress != null) progress.ProgressChanged += onProgress; - var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync(builder.Uri, Headers, progress); + var stream = await Helpers.HttpDownloadClient!.DownloadDataAsync( + builder.Uri, + Headers, + progress + ); return stream.ToArray(); } private string GetFinalPath(string path) => $"{BucketId}/{path}"; } -} \ No newline at end of file +} + diff --git a/StorageTests/StorageFileTests.cs b/StorageTests/StorageFileTests.cs index 4182e4a..e7bceef 100644 --- a/StorageTests/StorageFileTests.cs +++ b/StorageTests/StorageFileTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Storage; @@ -56,13 +57,22 @@ public async Task UploadFile() var asset = "supabase-csharp.png"; var name = $"{Guid.NewGuid()}.png"; - var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)?.Replace("file:", ""); + var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + ?.Replace("file:", ""); Assert.IsNotNull(basePath); var imagePath = Path.Combine(basePath, "Assets", asset); - await _bucket.Upload(imagePath, name, null, (_, _) => { didTriggerProgress.TrySetResult(true); }); + await _bucket.Upload( + imagePath, + name, + null, + (_, _) => + { + didTriggerProgress.TrySetResult(true); + } + ); var list = await _bucket.List(); @@ -76,7 +86,250 @@ public async Task UploadFile() await _bucket.Remove(new List { name }); } - + + [TestMethod("File: Resume Upload File")] + public async Task UploadResumableFile() + { + var didTriggerProgress = new TaskCompletionSource(); + var name = $"{Guid.NewGuid()}.png"; + var tempFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.png"); + + var data = new byte[2 * 1024 * 1024]; + var rng = new Random(); + rng.NextBytes(data); + await File.WriteAllBytesAsync(tempFilePath, data); + + try + { + var metadata = new Dictionary + { + ["custom"] = "metadata", + ["local_file"] = "local_file", + }; + + var headers = new Dictionary { ["x-version"] = "123" }; + + var options = new FileOptions + { + Duplex = "duplex", + Metadata = metadata, + Headers = headers, + }; + + await _bucket.UploadOrResume( + tempFilePath, + name, + options, + (x, y) => + { + Console.WriteLine($"Progress {y}"); + didTriggerProgress.TrySetResult(true); + } + ); + + var list = await _bucket.List(); + + Assert.IsNotNull(list); + + var existing = list.Find(item => item.Name == name); + Assert.IsNotNull(existing); + + var sentProgressEvent = await didTriggerProgress.Task; + Assert.IsTrue(sentProgressEvent); + + await _bucket.Remove([name]); + } + finally + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + } + + [TestMethod("File Resume Upload File as Byte")] + public async Task UploadResumableByte() + { + var didTriggerProgress = new TaskCompletionSource(); + var data = new byte[1 * 1024 * 1024]; + var rng = new Random(); + rng.NextBytes(data); + var name = $"{Guid.NewGuid()}.png"; + var metadata = new Dictionary + { + ["custom"] = "metadata", + ["local_file"] = "local_file", + }; + + var headers = new Dictionary { ["x-version"] = "123" }; + + var options = new FileOptions + { + Duplex = "duplex", + Metadata = metadata, + Headers = headers, + }; + + await _bucket.UploadOrResume( + data, + name, + options, + (x, y) => + { + Console.WriteLine($"Progress {y}"); + didTriggerProgress.TrySetResult(true); + } + ); + + var list = await _bucket.List(); + + Assert.IsNotNull(list); + + var existing = list.Find(item => item.Name == name); + Assert.IsNotNull(existing); + + var sentProgressEvent = await didTriggerProgress.Task; + Assert.IsTrue(sentProgressEvent); + + await _bucket.Remove([name]); + } + + [TestMethod("File: Resume Upload as Byte override existing one")] + public async Task UploadResumableByteDuplicate() + { + var didTriggerProgress = new TaskCompletionSource(); + var data = new byte[1 * 1024 * 1024]; + var rng = new Random(); + rng.NextBytes(data); + var name = $"{Guid.NewGuid()}.png"; + var metadata = new Dictionary + { + ["custom"] = "metadata", + ["local_file"] = "local_file", + }; + + var options = new FileOptions + { + Duplex = "duplex", + Metadata = metadata, + Upsert = true, + }; + + await _bucket.UploadOrResume( + data, + name, + options, + (x, y) => + { + Console.WriteLine($"Progress {y}"); + didTriggerProgress.TrySetResult(true); + } + ); + + await _bucket.UploadOrResume( + data, + name, + options, + (x, y) => + { + Console.WriteLine($"Progress {y}"); + didTriggerProgress.TrySetResult(true); + } + ); + + var list = await _bucket.List(); + + Assert.IsNotNull(list); + + var existing = list.Find(item => item.Name == name); + Assert.IsNotNull(existing); + + var sentProgressEvent = await didTriggerProgress.Task; + Assert.IsTrue(sentProgressEvent); + + await _bucket.Remove([name]); + } + + [TestMethod("File: Resume Upload with interruption and resume using CancellationToken")] + public async Task UploadOrResumeByteWithInterruptionAndResume() + { + var firstUploadProgressTriggered = new TaskCompletionSource(); + var resumeUploadProgressTriggered = new TaskCompletionSource(); + + var data = new byte[200 * 1024 * 1024]; + var rng = new Random(); + rng.NextBytes(data); + var name = $"{Guid.NewGuid()}.bin"; + + var metadata = new Dictionary + { + ["custom"] = "metadata", + ["local_file"] = "local_file", + }; + + var options = new FileOptions { Duplex = "duplex", Metadata = metadata }; + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1)); + + try + { + await _bucket.UploadOrResume( + data, + name, + options, + (_, progress) => + { + Console.WriteLine($"First upload progress: {progress}"); + firstUploadProgressTriggered.TrySetResult(true); + }, + cts.Token + ); + } + catch (OperationCanceledException) + { + Console.WriteLine("First upload was cancelled as expected"); + } + catch (Exception ex) + { + Console.WriteLine($"First upload failed with unexpected error: {ex.Message}"); + Assert.Fail($"First upload should have been cancelled, but failed with: {ex.Message}"); + } + + var firstProgressTriggered = + await Task.WhenAny( + firstUploadProgressTriggered.Task, + Task.Delay(TimeSpan.FromSeconds(2)) + ) == firstUploadProgressTriggered.Task; + + Assert.IsTrue( + firstProgressTriggered, + "First upload progress event should have been triggered" + ); + + await _bucket.UploadOrResume( + data, + name, + options, + (_, progress) => + { + Console.WriteLine($"Resume progress: {progress}"); + resumeUploadProgressTriggered.TrySetResult(true); + } + ); + + var resumeProgressTriggered = await resumeUploadProgressTriggered.Task; + Assert.IsTrue(resumeProgressTriggered, "Resume progress event should have been triggered"); + + var list = await _bucket.List(); + Assert.IsNotNull(list); + + var existing = list.Find(item => item.Name == name); + Assert.IsNotNull(existing, "File should exist in bucket after resumed upload"); + + await _bucket.Remove([name]); + } + [TestMethod("File: Upload File With FileOptions")] public async Task UploadFileWithFileOptions() { @@ -84,7 +337,8 @@ public async Task UploadFileWithFileOptions() var asset = "supabase-csharp.png"; var name = $"{Guid.NewGuid()}.png"; - var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)?.Replace("file:", ""); + var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + ?.Replace("file:", ""); Assert.IsNotNull(basePath); @@ -93,13 +347,10 @@ public async Task UploadFileWithFileOptions() var metadata = new Dictionary { ["custom"] = "metadata", - ["local_file"] = "local_file" + ["local_file"] = "local_file", }; - var headers = new Dictionary - { - ["x-version"] = "123" - }; + var headers = new Dictionary { ["x-version"] = "123" }; var options = new FileOptions { @@ -107,7 +358,15 @@ public async Task UploadFileWithFileOptions() Metadata = metadata, Headers = headers, }; - await _bucket.Upload(imagePath, name, options, (_, _) => { didTriggerProgress.TrySetResult(true); }); + await _bucket.Upload( + imagePath, + name, + options, + (_, _) => + { + didTriggerProgress.TrySetResult(true); + } + ); var item = await _bucket.Info(name); @@ -129,7 +388,12 @@ public async Task UploadArbitraryByteArray() var name = $"{Guid.NewGuid()}.bin"; - await _bucket.Upload(new Byte[] { 0x0, 0x0, 0x0 }, name, null, (_, _) => tsc.TrySetResult(true)); + await _bucket.Upload( + new Byte[] { 0x0, 0x0, 0x0 }, + name, + null, + (_, _) => tsc.TrySetResult(true) + ); var list = await _bucket.List(); Assert.IsNotNull(list); @@ -150,7 +414,8 @@ public async Task DownloadFile() var asset = "supabase-csharp.png"; var name = $"{Guid.NewGuid()}.png"; - var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)?.Replace("file:", ""); + var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + ?.Replace("file:", ""); Assert.IsNotNull(basePath); var imagePath = Path.Combine(basePath, "Assets", asset); @@ -201,7 +466,7 @@ public async Task Move() Assert.IsNotNull(items.Find((f) => f.Name == "new-file.bin")); Assert.IsNull(items.Find((f) => f.Name == name)); } - + [TestMethod("File: Copy")] public async Task Copy() { @@ -215,17 +480,21 @@ public async Task Copy() Assert.IsNotNull(items.Find((f) => f.Name == "new-file.bin")); Assert.IsNotNull(items.Find((f) => f.Name == name)); } - + [TestMethod("File: Copy to another Bucket")] public async Task CopyToAnotherBucket() { await Storage.CreateBucket("copyfile", new BucketUpsertOptions { Public = true }); var localBucket = Storage.From("copyfile"); - + var name = $"{Guid.NewGuid()}.bin"; await _bucket.Upload([0x0, 0x1], name); - - await _bucket.Copy(name, "new-file.bin", new DestinationOptions { DestinationBucket = "copyfile" }); + + await _bucket.Copy( + name, + "new-file.bin", + new DestinationOptions { DestinationBucket = "copyfile" } + ); var items = await _bucket.List(); var copied = await localBucket.List(); @@ -254,25 +523,33 @@ public async Task GetPublicLink() Assert.IsNotNull(url); } - + [TestMethod("File: Get Public Link with download options")] public async Task GetPublicLinkWithDownloadOptions() { var name = $"{Guid.NewGuid()}.bin"; await _bucket.Upload(new Byte[] { 0x0, 0x1 }, name); - var url = _bucket.GetPublicUrl(name, null, new DownloadOptions { FileName = "custom-file.png"}); + var url = _bucket.GetPublicUrl( + name, + null, + new DownloadOptions { FileName = "custom-file.png" } + ); await _bucket.Remove(new List { name }); Assert.IsNotNull(url); StringAssert.Contains(url, "download=custom-file.png"); } - + [TestMethod("File: Get Public Link with download and transform options")] public async Task GetPublicLinkWithDownloadAndTransformOptions() { var name = $"{Guid.NewGuid()}.bin"; await _bucket.Upload(new Byte[] { 0x0, 0x1 }, name); - var url = _bucket.GetPublicUrl(name, new TransformOptions { Height = 100, Width = 100}, DownloadOptions.UseOriginalFileName); + var url = _bucket.GetPublicUrl( + name, + new TransformOptions { Height = 100, Width = 100 }, + DownloadOptions.UseOriginalFileName + ); await _bucket.Remove(new List { name }); Assert.IsNotNull(url); @@ -297,19 +574,28 @@ public async Task GetSignedLinkWithTransformOptions() var name = $"{Guid.NewGuid()}.bin"; await _bucket.Upload(new Byte[] { 0x0, 0x1 }, name); - var url = await _bucket.CreateSignedUrl(name, 3600, new TransformOptions { Width = 100, Height = 100 }); + var url = await _bucket.CreateSignedUrl( + name, + 3600, + new TransformOptions { Width = 100, Height = 100 } + ); Assert.IsTrue(Uri.IsWellFormedUriString(url, UriKind.Absolute)); await _bucket.Remove(new List { name }); } - + [TestMethod("File: Get Signed Link with download options")] public async Task GetSignedLinkWithDownloadOptions() { var name = $"{Guid.NewGuid()}.bin"; await _bucket.Upload(new Byte[] { 0x0, 0x1 }, name); - var url = await _bucket.CreateSignedUrl(name, 3600, null, new DownloadOptions { FileName = "custom-file.png"}); + var url = await _bucket.CreateSignedUrl( + name, + 3600, + null, + new DownloadOptions { FileName = "custom-file.png" } + ); Assert.IsTrue(Uri.IsWellFormedUriString(url, UriKind.Absolute)); StringAssert.Contains(url, "download=custom-file.png"); @@ -325,7 +611,11 @@ public async Task GetMultipleSignedLinks() var name2 = $"{Guid.NewGuid()}.bin"; await _bucket.Upload(new Byte[] { 0x0, 0x1 }, name2); - var urls = await _bucket.CreateSignedUrls(new List { name1, name2 }, 3600, DownloadOptions.UseOriginalFileName); + var urls = await _bucket.CreateSignedUrls( + new List { name1, name2 }, + 3600, + DownloadOptions.UseOriginalFileName + ); Assert.IsNotNull(urls); @@ -344,4 +634,5 @@ public async Task CanCreateSignedUploadUrl() var result = await _bucket.CreateUploadSignedUrl("test.png"); Assert.IsTrue(Uri.IsWellFormedUriString(result.SignedUrl.ToString(), UriKind.Absolute)); } -} \ No newline at end of file +} +