Skip to content

Commit da1f2c9

Browse files
committed
feat: initial implementation resumable upload
1 parent 6e84b32 commit da1f2c9

File tree

5 files changed

+470
-0
lines changed

5 files changed

+470
-0
lines changed

Storage/Extensions/HttpClientProgress.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
using System.Threading.Tasks;
77
using Newtonsoft.Json;
88
using Supabase.Storage.Exceptions;
9+
using BirdMessenger;
10+
using BirdMessenger.Collections;
911

1012
namespace Supabase.Storage.Extensions
1113
{
@@ -138,5 +140,103 @@ public static async Task<HttpResponseMessage> UploadAsync(this HttpClient client
138140

139141
return response;
140142
}
143+
144+
public static Task<HttpResponseMessage> UploadOrContinueFileAsync(
145+
this HttpClient client,
146+
Uri uri,
147+
string filePath,
148+
Dictionary<string, string>? headers = null,
149+
MetadataCollection? metadata = null,
150+
Progress<float>? progress = null,
151+
CancellationToken cancellationToken = default)
152+
{
153+
var fileStream = new FileStream(filePath, mode: FileMode.Open, FileAccess.Read);
154+
return ResumableUploadAsync(client, uri, fileStream, headers, metadata, progress, cancellationToken);
155+
}
156+
157+
public static Task<HttpResponseMessage> UploadOrContinueByteAsync(
158+
this HttpClient client,
159+
Uri uri,
160+
byte[] data,
161+
Dictionary<string, string>? headers = null,
162+
MetadataCollection? metadata = null,
163+
Progress<float>? progress = null,
164+
CancellationToken cancellationToken = default)
165+
{
166+
var stream = new MemoryStream(data);
167+
return ResumableUploadAsync(client, uri, stream, headers, metadata, progress, cancellationToken);
168+
}
169+
170+
private static async Task<HttpResponseMessage> ResumableUploadAsync(
171+
this HttpClient client,
172+
Uri uri,
173+
Stream fileStream,
174+
Dictionary<string, string>? headers = null,
175+
MetadataCollection? metadata = null,
176+
IProgress<float>? progress = null,
177+
CancellationToken cancellationToken = default)
178+
{
179+
if (fileStream == null)
180+
throw new ArgumentNullException(nameof(fileStream));
181+
182+
if (fileStream.Position != 0 && fileStream.CanSeek)
183+
{
184+
fileStream.Seek(0, SeekOrigin.Begin);
185+
}
186+
187+
if (headers != null)
188+
{
189+
client.DefaultRequestHeaders.Clear();
190+
foreach (var header in headers)
191+
{
192+
client.DefaultRequestHeaders.Add(header.Key, header.Value);
193+
}
194+
}
195+
196+
var createOption = new TusCreateRequestOption()
197+
{
198+
Endpoint = uri,
199+
Metadata = metadata,
200+
UploadLength = fileStream.Length
201+
};
202+
203+
var responseCreate = await client.TusCreateAsync(createOption, cancellationToken);
204+
205+
var patchOption = new TusPatchRequestOption
206+
{
207+
FileLocation = responseCreate.FileLocation,
208+
Stream = fileStream,
209+
UploadBufferSize = 6 * 1024 * 1024,
210+
UploadType = UploadType.Chunk,
211+
OnProgressAsync = x =>
212+
{
213+
if (progress == null) return Task.CompletedTask;
214+
215+
var uploadedProgress = (float)x.UploadedSize / x.TotalSize * 100f;
216+
progress.Report(uploadedProgress);
217+
218+
return Task.CompletedTask;
219+
},
220+
OnCompletedAsync = _ => Task.CompletedTask,
221+
OnFailedAsync = _ => Task.CompletedTask
222+
};
223+
224+
var responsePatch = await client.TusPatchAsync(patchOption, cancellationToken);
225+
226+
if (responsePatch.OriginResponseMessage.IsSuccessStatusCode)
227+
return responsePatch.OriginResponseMessage;
228+
229+
var httpContent = await responsePatch.OriginResponseMessage.Content.ReadAsStringAsync();
230+
var errorResponse = JsonConvert.DeserializeObject<ErrorResponse>(httpContent);
231+
var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent)
232+
{
233+
Content = httpContent,
234+
Response = responsePatch.OriginResponseMessage,
235+
StatusCode = errorResponse?.StatusCode ?? (int)responsePatch.OriginResponseMessage.StatusCode
236+
};
237+
238+
e.AddReason();
239+
throw e;
240+
}
141241
}
142242
}

Storage/Interfaces/IStorageFileApi.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Threading;
34
using System.Threading.Tasks;
45

56
namespace Supabase.Storage.Interfaces
@@ -27,6 +28,8 @@ public interface IStorageFileApi<TFileObject>
2728
Task<string> Update(string localFilePath, string supabasePath, FileOptions? options = null, EventHandler<float>? onProgress = null);
2829
Task<string> Upload(byte[] data, string supabasePath, FileOptions? options = null, EventHandler<float>? onProgress = null, bool inferContentType = true);
2930
Task<string> Upload(string localFilePath, string supabasePath, FileOptions? options = null, EventHandler<float>? onProgress = null, bool inferContentType = true);
31+
Task UploadOrResume(string localPath, string supabasePath, FileOptions options, EventHandler<float>? onProgress = null, CancellationToken cancellationToken = default);
32+
Task UploadOrResume(byte[] data, string supabasePath, FileOptions options, EventHandler<float>? onProgress = null, CancellationToken cancellationToken = default);
3033
Task<string> UploadToSignedUrl(byte[] data, UploadSignedUrl url, FileOptions? options = null, EventHandler<float>? onProgress = null, bool inferContentType = true);
3134
Task<string> UploadToSignedUrl(string localFilePath, UploadSignedUrl url, FileOptions? options = null, EventHandler<float>? onProgress = null, bool inferContentType = true);
3235
Task<UploadSignedUrl> CreateUploadSignedUrl(string supabasePath);

Storage/Storage.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
</PropertyGroup>
4141

4242
<ItemGroup>
43+
<PackageReference Include="BirdMessenger" Version="3.1.4" />
4344
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
4445
<PackageReference Include="MimeMapping" Version="3.0.1" />
4546
<PackageReference Include="Supabase.Core" Version="1.0.0" />

Storage/StorageFileApi.cs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
using System.IO;
55
using System.Linq;
66
using System.Net.Http;
7+
using System.Threading;
78
using System.Threading.Tasks;
89
using System.Web;
10+
using BirdMessenger.Collections;
911
using Newtonsoft.Json;
1012
using Newtonsoft.Json.Converters;
1113
using Supabase.Storage.Exceptions;
@@ -309,6 +311,47 @@ public Task<string> Update(byte[] data, string supabasePath, FileOptions? option
309311
return UploadOrUpdate(data, supabasePath, options, onProgress);
310312
}
311313

314+
/// <summary>
315+
/// Attempts to upload a file to Supabase storage. If the upload process is interrupted or incomplete, it will attempt to resume the upload.
316+
/// </summary>
317+
/// <param name="localPath">The local file path of the file to be uploaded.</param>
318+
/// <param name="supabasePath">The destination path in Supabase Storage where the file will be stored.</param>
319+
/// <param name="options">Optional file options to specify metadata or other upload configurations.</param>
320+
/// <param name="onProgress">An optional event handler for tracking and reporting upload progress as a percentage.</param>
321+
/// <returns>Returns a task that resolves to a string representing the URL or path of the uploaded file in the storage.</returns>
322+
public Task UploadOrResume(
323+
string localPath,
324+
string supabasePath,
325+
FileOptions? options,
326+
EventHandler<float>? onProgress = null,
327+
CancellationToken cancellationToken = default
328+
)
329+
{
330+
options ??= new FileOptions();
331+
return UploadOrContinue(localPath, supabasePath, options, onProgress, cancellationToken);
332+
}
333+
334+
/// <summary>
335+
/// Uploads a file to the provided Supabase path or resumes an interrupted upload.
336+
/// Uses provided options and allows for a progress event handler to be specified.
337+
/// </summary>
338+
/// <param name="data">The byte array representing the file data to be uploaded.</param>
339+
/// <param name="supabasePath">The path in Supabase where the file should be stored.</param>
340+
/// <param name="options">The file upload options determining any specific behaviors or settings for the upload.</param>
341+
/// <param name="onProgress">An optional event handler to monitor and report upload progress as a float percentage.</param>
342+
/// <returns>A task that resolves to the file's path once the upload is complete.</returns>
343+
public Task UploadOrResume(
344+
byte[] data,
345+
string supabasePath,
346+
FileOptions? options,
347+
EventHandler<float>? onProgress = null,
348+
CancellationToken cancellationToken = default
349+
)
350+
{
351+
options ??= new FileOptions();
352+
return UploadOrContinue(data, supabasePath, options, onProgress, cancellationToken);
353+
}
354+
312355
/// <summary>
313356
/// Moves an existing file to a new location, optionally allowing renaming.
314357
/// </summary>
@@ -511,6 +554,103 @@ private async Task<string> UploadOrUpdate(string localPath, string supabasePath,
511554
return GetFinalPath(supabasePath);
512555
}
513556

557+
private async Task UploadOrContinue(
558+
string localPath,
559+
string supabasePath,
560+
FileOptions options,
561+
EventHandler<float>? onProgress = null,
562+
CancellationToken cancellationToken = default
563+
)
564+
{
565+
566+
var uri = new Uri($"{Url}/upload/resumable");
567+
568+
var headers = new Dictionary<string, string>(Headers)
569+
{
570+
{ "cache-control", $"max-age={options.CacheControl}" },
571+
};
572+
573+
var metadata = new MetadataCollection
574+
{
575+
["bucketName"] = BucketId,
576+
["objectName"] = supabasePath,
577+
["contentType"] = options.ContentType
578+
};
579+
580+
if (options.Upsert)
581+
headers.Add("x-upsert", options.Upsert.ToString().ToLower());
582+
583+
if (options.Metadata != null)
584+
headers.Add("x-metadata", ParseMetadata(options.Metadata));
585+
586+
options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value));
587+
588+
if (options.Duplex != null)
589+
headers.Add("x-duplex", options.Duplex.ToLower());
590+
591+
var progress = new Progress<float>();
592+
593+
if (onProgress != null)
594+
progress.ProgressChanged += onProgress;
595+
596+
await Helpers.HttpUploadClient!.UploadOrContinueFileAsync(
597+
uri,
598+
localPath,
599+
headers,
600+
metadata,
601+
progress,
602+
cancellationToken
603+
);
604+
}
605+
606+
private async Task UploadOrContinue(
607+
byte[] data,
608+
string supabasePath,
609+
FileOptions options,
610+
EventHandler<float>? onProgress = null,
611+
CancellationToken cancellationToken = default
612+
)
613+
{
614+
var uri = new Uri($"{Url}/upload/resumable");
615+
616+
var headers = new Dictionary<string, string>(Headers)
617+
{
618+
{ "cache-control", $"max-age={options.CacheControl}" },
619+
};
620+
621+
var metadata = new MetadataCollection
622+
{
623+
["bucketName"] = BucketId,
624+
["objectName"] = supabasePath,
625+
["contentType"] = options.ContentType,
626+
};
627+
628+
if (options.Upsert)
629+
headers.Add("x-upsert", options.Upsert.ToString().ToLower());
630+
631+
if (options.Metadata != null)
632+
metadata["metadata"] = JsonConvert.SerializeObject(options.Metadata);
633+
634+
options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value));
635+
636+
if (options.Duplex != null)
637+
headers.Add("x-duplex", options.Duplex.ToLower());
638+
639+
var progress = new Progress<float>();
640+
641+
if (onProgress != null)
642+
progress.ProgressChanged += onProgress;
643+
644+
await Helpers.HttpUploadClient!.UploadOrContinueByteAsync(
645+
uri,
646+
data,
647+
headers,
648+
metadata,
649+
progress,
650+
cancellationToken
651+
);
652+
}
653+
514654
private static string ParseMetadata(Dictionary<string, string> metadata)
515655
{
516656
var json = JsonConvert.SerializeObject(metadata);

0 commit comments

Comments
 (0)