diff --git a/Storage/Bucket.cs b/Storage/Bucket.cs index b68308a..af381b1 100644 --- a/Storage/Bucket.cs +++ b/Storage/Bucket.cs @@ -1,31 +1,31 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { public class Bucket { - [JsonProperty("id")] + [JsonPropertyName("id")] public string? Id { get; set; } - [JsonProperty("name")] + [JsonPropertyName("name")] public string? Name { get; set; } - [JsonProperty("owner")] + [JsonPropertyName("owner")] public string? Owner { get; set; } - [JsonProperty("created_at")] + [JsonPropertyName("created_at")] public DateTime? CreatedAt { get; set; } - [JsonProperty("updated_at")] + [JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } /// /// The visibility of the bucket. Public buckets don't require an authorization token to download objects, /// but still require a valid token for all other operations. By default, buckets are private. /// - [JsonProperty("public")] + [JsonPropertyName("public")] public bool Public { get; set; } /// @@ -33,7 +33,7 @@ public class Bucket /// /// Expects a string value following a format like: '1kb', '50mb', '150kb', etc. /// - [JsonProperty("file_size_limit", NullValueHandling = NullValueHandling.Include)] + [JsonPropertyName("file_size_limit")] public string? FileSizeLimit { get; set; } /// @@ -41,7 +41,8 @@ public class Bucket /// /// Expects a List of values such as: ['image/jpeg', 'image/png', etc] /// - [JsonProperty("allowed_mime_types", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("allowed_mime_types")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? AllowedMimes { get; set; } } } diff --git a/Storage/BucketUpsertOptions.cs b/Storage/BucketUpsertOptions.cs index 3faf8ba..3223c6b 100644 --- a/Storage/BucketUpsertOptions.cs +++ b/Storage/BucketUpsertOptions.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { @@ -9,7 +9,7 @@ public class BucketUpsertOptions /// The visibility of the bucket. Public buckets don't require an authorization token to download objects, /// but still require a valid token for all other operations. By default, buckets are private. /// - [JsonProperty("public")] + [JsonPropertyName("public")] public bool Public { get; set; } = false; /// @@ -17,7 +17,7 @@ public class BucketUpsertOptions /// /// Expects a string value following a format like: '1kb', '50mb', '150kb', etc. /// - [JsonProperty("file_size_limit", NullValueHandling = NullValueHandling.Include)] + [JsonPropertyName("file_size_limit")] public string? FileSizeLimit { get; set; } /// @@ -25,7 +25,8 @@ public class BucketUpsertOptions /// /// Expects a List of values such as: ['image/jpeg', 'image/png', etc] /// - [JsonProperty("allowed_mime_types", NullValueHandling = NullValueHandling.Ignore)] + [JsonPropertyName("allowed_mime_types")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? AllowedMimes { get; set; } } } diff --git a/Storage/CreateSignedUrlResponse.cs b/Storage/CreateSignedUrlResponse.cs index a6a0a44..b2fac28 100644 --- a/Storage/CreateSignedUrlResponse.cs +++ b/Storage/CreateSignedUrlResponse.cs @@ -1,16 +1,34 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { + /// + /// Represents the response received when creating a signed URL for file access through Supabase Storage. + /// public class CreateSignedUrlResponse { - [JsonProperty("signedURL")] + /// + /// Represents the signed URL returned as part of a response when requesting access to a file + /// stored in Supabase Storage. This URL can be used to access the file directly with + /// the defined expiration and optional transformations or download options applied. + /// + [JsonPropertyName("signedURL")] public string? SignedUrl { get; set; } } + /// + /// Represents the extended response received when creating multiple signed URLs + /// for file access through Supabase Storage. In addition to the signed URL, it includes + /// the associated file path. + /// public class CreateSignedUrlsResponse: CreateSignedUrlResponse { - [JsonProperty("path")] + /// + /// Represents the file path associated with a signed URL in the response. + /// This property indicates the specific file path for which the signed URL + /// was generated, allowing identification of the file within the storage bucket. + /// + [JsonPropertyName("path")] public string? Path { get; set; } } } diff --git a/Storage/DownloadOptions.cs b/Storage/DownloadOptions.cs index 44f7c00..274b59d 100644 --- a/Storage/DownloadOptions.cs +++ b/Storage/DownloadOptions.cs @@ -1,7 +1,8 @@ -using Newtonsoft.Json; - namespace Supabase.Storage { + /// + /// Represents options used when downloading files from storage. + /// public class DownloadOptions { /// diff --git a/Storage/Extensions/HttpClientProgress.cs b/Storage/Extensions/HttpClientProgress.cs index 6b768bb..5301e93 100644 --- a/Storage/Extensions/HttpClientProgress.cs +++ b/Storage/Extensions/HttpClientProgress.cs @@ -2,13 +2,13 @@ using System.Collections.Generic; using System.IO; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using BirdMessenger; using BirdMessenger.Collections; using BirdMessenger.Delegates; using BirdMessenger.Infrastructure; -using Newtonsoft.Json; using Supabase.Storage.Exceptions; namespace Supabase.Storage.Extensions @@ -47,7 +47,10 @@ public static async Task DownloadDataAsync( if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(); - var errorResponse = JsonConvert.DeserializeObject(content); + var errorResponse = JsonSerializer.Deserialize( + content, + Helpers.JsonOptions + ); var e = new SupabaseStorageException(errorResponse?.Message ?? content) { Content = content, @@ -182,7 +185,10 @@ public static async Task UploadAsync( if (!response.IsSuccessStatusCode) { var httpContent = await response.Content.ReadAsStringAsync(); - var errorResponse = JsonConvert.DeserializeObject(httpContent); + var errorResponse = JsonSerializer.Deserialize( + httpContent, + Helpers.JsonOptions + ); var e = new SupabaseStorageException(errorResponse?.Message ?? httpContent) { Content = httpContent, @@ -343,7 +349,10 @@ HttpResponseMessage response ) { var httpContent = await response.Content.ReadAsStringAsync(); - var errorResponse = JsonConvert.DeserializeObject(httpContent); + var errorResponse = JsonSerializer.Deserialize( + httpContent, + Helpers.JsonOptions + ); var error = new SupabaseStorageException(errorResponse?.Message ?? httpContent) { Content = httpContent, diff --git a/Storage/FileObject.cs b/Storage/FileObject.cs index d907d13..0b18786 100644 --- a/Storage/FileObject.cs +++ b/Storage/FileObject.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { @@ -10,32 +10,23 @@ public class FileObject /// Flag representing if this object is a folder, all properties will be null but the name /// public bool IsFolder => !string.IsNullOrEmpty(Name) && Id == null && CreatedAt == null && UpdatedAt == null; - - [JsonProperty("name")] - public string? Name { get; set; } - [JsonProperty("bucket_id")] - public string? BucketId { get; set; } + [JsonPropertyName("name")] public string? Name { get; set; } - [JsonProperty("owner")] - public string? Owner { get; set; } + [JsonPropertyName("bucket_id")] public string? BucketId { get; set; } - [JsonProperty("id")] - public string? Id { get; set; } + [JsonPropertyName("owner")] public string? Owner { get; set; } - [JsonProperty("updated_at")] - public DateTime? UpdatedAt { get; set; } + [JsonPropertyName("id")] public string? Id { get; set; } - [JsonProperty("created_at")] - public DateTime? CreatedAt { get; set; } + [JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } - [JsonProperty("last_accessed_at")] - public DateTime? LastAccessedAt { get; set; } + [JsonPropertyName("created_at")] public DateTime? CreatedAt { get; set; } - [JsonProperty("metadata")] - public Dictionary MetaData = new Dictionary(); + [JsonPropertyName("last_accessed_at")] public DateTime? LastAccessedAt { get; set; } - [JsonProperty("buckets")] - public Bucket? Buckets { get; set; } + [JsonPropertyName("metadata")] public Dictionary MetaData = new Dictionary(); + + [JsonPropertyName("buckets")] public Bucket? Buckets { get; set; } } -} +} \ No newline at end of file diff --git a/Storage/FileObjectV2.cs b/Storage/FileObjectV2.cs index 7cbc181..337d174 100644 --- a/Storage/FileObjectV2.cs +++ b/Storage/FileObjectV2.cs @@ -1,49 +1,92 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { + /// + /// Represents a file object in Supabase Storage with its associated metadata and properties. + /// This class is used for version 2 of the Storage API. + /// public class FileObjectV2 { - [JsonProperty("id")] + /// + /// The unique identifier of the file. + /// + [JsonPropertyName("id")] public string Id { get; set; } - - [JsonProperty("version")] + + /// + /// The version of the file. + /// + [JsonPropertyName("version")] public string Version { get; set; } - - [JsonProperty("name")] + + /// + /// The name of the file. + /// + [JsonPropertyName("name")] public string? Name { get; set; } - [JsonProperty("bucket_id")] + /// + /// The identifier of the bucket containing the file. + /// + [JsonPropertyName("bucket_id")] public string? BucketId { get; set; } - [JsonProperty("updated_at")] + /// + /// The timestamp when the file was last updated. + /// + [JsonPropertyName("updated_at")] public DateTime? UpdatedAt { get; set; } - [JsonProperty("created_at")] + /// + /// The timestamp when the file was created. + /// + [JsonPropertyName("created_at")] public DateTime? CreatedAt { get; set; } - [JsonProperty("last_accessed_at")] + /// + /// The timestamp when the file was last accessed. + /// + [JsonPropertyName("last_accessed_at")] public DateTime? LastAccessedAt { get; set; } - - [JsonProperty("size")] + + /// + /// The size of the file in bytes. + /// + [JsonPropertyName("size")] public int? Size { get; set; } - - [JsonProperty("cache_control")] + + /// + /// The cache control directives for the file. + /// + [JsonPropertyName("cache_control")] public string? CacheControl { get; set; } - - [JsonProperty("content_type")] + + /// + /// The MIME type of the file. + /// + [JsonPropertyName("content_type")] public string? ContentType { get; set; } - - [JsonProperty("etag")] + + /// + /// The ETag of the file for caching purposes. + /// + [JsonPropertyName("etag")] public string? Etag { get; set; } - - [JsonProperty("last_modified")] + + /// + /// The timestamp when the file was last modified. + /// + [JsonPropertyName("last_modified")] public DateTime? LastModified { get; set; } - - [JsonProperty("metadata")] + + /// + /// The custom metadata associated with the file. + /// + [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } } } diff --git a/Storage/FileOptions.cs b/Storage/FileOptions.cs index f633da3..93654ce 100644 --- a/Storage/FileOptions.cs +++ b/Storage/FileOptions.cs @@ -1,26 +1,47 @@ using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { + /// + /// Represents configuration options for file operations in Supabase Storage. + /// public class FileOptions { - [JsonProperty("cacheControl")] + /// + /// Controls caching behavior for the file. Default value is "3600". + /// + [JsonPropertyName("cacheControl")] public string CacheControl { get; set; } = "3600"; - [JsonProperty("contentType")] + /// + /// Specifies the content type of the file. Default value is "text/plain;charset=UTF-8". + /// + [JsonPropertyName("contentType")] public string ContentType { get; set; } = "text/plain;charset=UTF-8"; - [JsonProperty("upsert")] + /// + /// Determines whether to perform an upsert operation (update if exists, insert if not). + /// + [JsonPropertyName("upsert")] public bool Upsert { get; set; } - - [JsonProperty("duplex")] + + /// + /// Specifies the duplex mode for the file operation. + /// + [JsonPropertyName("duplex")] public string? Duplex { get; set; } - - [JsonProperty("metadata")] + + /// + /// Additional metadata associated with the file. + /// + [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } - - [JsonProperty("headers")] + + /// + /// Custom headers to be included with the file operation. + /// + [JsonPropertyName("headers")] public Dictionary? Headers { get; set; } } } diff --git a/Storage/Helpers.cs b/Storage/Helpers.cs index 18eb3ef..e22d668 100644 --- a/Storage/Helpers.cs +++ b/Storage/Helpers.cs @@ -1,125 +1,165 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Runtime.CompilerServices; using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; using System.Web; -using Newtonsoft.Json; -using System.Runtime.CompilerServices; using Supabase.Storage.Exceptions; -using System.Threading; [assembly: InternalsVisibleTo("StorageTests")] + namespace Supabase.Storage { - internal static class Helpers - { - internal static HttpClient? HttpRequestClient; - - internal static HttpClient? HttpUploadClient; - - internal static HttpClient? HttpDownloadClient; - - /// - /// Initializes HttpClients with their appropriate timeouts. Called at the initialization of StorageBucketApi. - /// - /// - internal static void Initialize(ClientOptions options) - { - HttpRequestClient = new HttpClient { Timeout = options.HttpRequestTimeout }; - HttpDownloadClient = new HttpClient { Timeout = options.HttpDownloadTimeout }; - HttpUploadClient = new HttpClient { Timeout = options.HttpUploadTimeout }; - } - - /// - /// Helper to make a request using the defined parameters to an API Endpoint and coerce into a model. - /// - /// - /// - /// - /// - /// - /// - public static async Task MakeRequest(HttpMethod method, string url, object? data = null, - Dictionary? headers = null) where T : class - { - var response = await MakeRequest(method, url, data, headers); - var content = await response.Content.ReadAsStringAsync(); - - return JsonConvert.DeserializeObject(content); - } - - /// - /// Helper to make a request using the defined parameters to an API Endpoint. - /// - /// - /// - /// - /// - /// - /// - public static async Task MakeRequest(HttpMethod method, string url, object? data = null, Dictionary? headers = null, CancellationToken cancellationToken = default) - { - var builder = new UriBuilder(url); - var query = HttpUtility.ParseQueryString(builder.Query); - - if (data != null && method != HttpMethod.Get) - { - // Case if it's a Get request the data object is a dictionary - if (data is Dictionary reqParams) - { - foreach (var param in reqParams) - query[param.Key] = param.Value; - } - } - - builder.Query = query.ToString(); - - using var requestMessage = new HttpRequestMessage(method, builder.Uri); - - if (data != null && method != HttpMethod.Get) - requestMessage.Content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); - - if (headers != null) - { - foreach (var kvp in headers) - requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); - } - - var response = await HttpRequestClient!.SendAsync(requestMessage, cancellationToken); - - var content = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - 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; - } - - return response; - } - } - - public class GenericResponse - { - [JsonProperty("message")] - public string? Message { get; set; } - } - - public class ErrorResponse - { - [JsonProperty("statusCode")] - public int StatusCode { get; set; } - - [JsonProperty("message")] - public string? Message { get; set; } - } -} \ No newline at end of file + internal static class Helpers + { + internal static HttpClient? HttpRequestClient; + + internal static HttpClient? HttpUploadClient; + + internal static HttpClient? HttpDownloadClient; + + internal static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + /// + /// Initializes HttpClients with their appropriate timeouts. Called at the initialization of StorageBucketApi. + /// + /// + internal static void Initialize(ClientOptions options) + { + HttpRequestClient = new HttpClient { Timeout = options.HttpRequestTimeout }; + HttpDownloadClient = new HttpClient { Timeout = options.HttpDownloadTimeout }; + HttpUploadClient = new HttpClient { Timeout = options.HttpUploadTimeout }; + } + + /// + /// Helper to make a request using the defined parameters to an API Endpoint and coerce into a model. + /// + /// + /// + /// + /// + /// + /// + public static async Task MakeRequest( + HttpMethod method, + string url, + object? data = null, + Dictionary? headers = null + ) + where T : class + { + var response = await MakeRequest(method, url, data, headers); + var content = await response.Content.ReadAsStringAsync(); + + return JsonSerializer.Deserialize(content, JsonOptions); + } + + /// + /// Helper to make a request using the defined parameters to an API Endpoint. + /// + /// + /// + /// + /// + /// + /// + public static async Task MakeRequest( + HttpMethod method, + string url, + object? data = null, + Dictionary? headers = null, + CancellationToken cancellationToken = default + ) + { + var builder = new UriBuilder(url); + var query = HttpUtility.ParseQueryString(builder.Query); + + if (data != null && method != HttpMethod.Get) + { + // Case if it's a Get request the data object is a dictionary + if (data is Dictionary reqParams) + { + foreach (var param in reqParams) + query[param.Key] = param.Value; + } + } + + builder.Query = query.ToString(); + + using var requestMessage = new HttpRequestMessage(method, builder.Uri); + + if (data != null && method != HttpMethod.Get) + requestMessage.Content = new StringContent( + JsonSerializer.Serialize(data, JsonOptions), + Encoding.UTF8, + "application/json" + ); + + if (headers != null) + { + foreach (var kvp in headers) + requestMessage.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + + var response = await HttpRequestClient!.SendAsync(requestMessage, cancellationToken); + + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var errorResponse = JsonSerializer.Deserialize(content, JsonOptions); + var e = new SupabaseStorageException(errorResponse?.Message ?? content) + { + Content = content, + Response = response, + StatusCode = errorResponse?.StatusCode ?? (int)response.StatusCode, + }; + + e.AddReason(); + throw e; + } + + return response; + } + } + + /// + /// Represents a generic response returned by certain API operations. + /// + public class GenericResponse + { + /// + /// Gets or sets the message associated with the response. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + } + + /// + /// Represents an error response returned by the Supabase Storage service. + /// + public class ErrorResponse + { + /// + /// Gets or sets the status code associated with the response. + /// + [JsonPropertyName("statusCode")] + public int StatusCode { get; set; } + + /// + /// Gets or sets the error message associated with the response. + /// + [JsonPropertyName("message")] + public string? Message { get; set; } + } +} diff --git a/Storage/Interfaces/IStorageFileApi.cs b/Storage/Interfaces/IStorageFileApi.cs index 5cb3a17..cbef9f0 100644 --- a/Storage/Interfaces/IStorageFileApi.cs +++ b/Storage/Interfaces/IStorageFileApi.cs @@ -118,4 +118,3 @@ Task UploadToSignedUrl( Task CreateUploadSignedUrl(string supabasePath); } } - diff --git a/Storage/Responses/CreatedUploadSignedUrlResponse.cs b/Storage/Responses/CreatedUploadSignedUrlResponse.cs index 70db004..ab8fedd 100644 --- a/Storage/Responses/CreatedUploadSignedUrlResponse.cs +++ b/Storage/Responses/CreatedUploadSignedUrlResponse.cs @@ -1,10 +1,10 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage.Responses { internal class CreatedUploadSignedUrlResponse { - [JsonProperty("url")] + [JsonPropertyName("url")] public string? Url { get; set; } } } diff --git a/Storage/SearchOptions.cs b/Storage/SearchOptions.cs index dfc29af..796bc73 100644 --- a/Storage/SearchOptions.cs +++ b/Storage/SearchOptions.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { @@ -7,25 +7,25 @@ public class SearchOptions /// /// Number of files to be returned /// - [JsonProperty("limit")] + [JsonPropertyName("limit")] public int Limit { get; set; } = 100; /// /// Starting position of query /// - [JsonProperty("offset")] + [JsonPropertyName("offset")] public int Offset { get; set; } = 0; - + /// /// The search string to filter files by /// - [JsonProperty("search")] + [JsonPropertyName("search")] public string Search { get; set; } = string.Empty; /// /// Column to sort by. Can be any colum inside of a /// - [JsonProperty("sortBy")] + [JsonPropertyName("sortBy")] public SortBy SortBy { get; set; } = new SortBy { Column = "name", Order = "asc" }; } } diff --git a/Storage/SortBy.cs b/Storage/SortBy.cs index 4e226cb..ad10d05 100644 --- a/Storage/SortBy.cs +++ b/Storage/SortBy.cs @@ -1,13 +1,22 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Supabase.Storage { + /// + /// Represents sorting configuration for Storage queries. + /// public class SortBy { - [JsonProperty("column")] + /// + /// The column name to sort by. + /// + [JsonPropertyName("column")] public string? Column { get; set; } - [JsonProperty("order")] + /// + /// The sort order direction. + /// + [JsonPropertyName("order")] public string? Order { get; set; } } } diff --git a/Storage/Storage.csproj b/Storage/Storage.csproj index 7753bf6..e85c856 100644 --- a/Storage/Storage.csproj +++ b/Storage/Storage.csproj @@ -41,9 +41,9 @@ - + diff --git a/Storage/StorageApiConstants.cs b/Storage/StorageApiConstants.cs new file mode 100644 index 0000000..7d29282 --- /dev/null +++ b/Storage/StorageApiConstants.cs @@ -0,0 +1,48 @@ +namespace Supabase.Storage +{ + /// + /// Internal constants used by the Storage API. + /// + internal static class StorageConstants + { + /// + /// HTTP header names used in Storage API requests. + /// + public static class Headers + { + public const string Authorization = "Authorization"; + public const string CacheControl = "cache-control"; + public const string ContentType = "content-type"; + public const string Upsert = "x-upsert"; + public const string Metadata = "x-metadata"; + public const string Duplex = "x-duplex"; + } + + /// + /// API endpoint paths. + /// + public static class Endpoints + { + public const string Object = "/object"; + public const string ObjectPublic = "/object/public"; + public const string ObjectSign = "/object/sign"; + public const string ObjectList = "/object/list"; + public const string ObjectInfo = "/object/info"; + public const string ObjectMove = "/object/move"; + public const string ObjectCopy = "/object/copy"; + public const string RenderImageAuthenticated = "/render/image/authenticated"; + public const string RenderImagePublic = "/render/image/public"; + public const string UploadResumable = "/upload/resumable"; + public const string UploadSign = "/object/upload/sign"; + } + + /// + /// Default values. + /// + public static class Defaults + { + public const int CacheControlMaxAge = 3600; + public const int UploadChunkSize = 6 * 1024 * 1024; // 6MB + } + } +} diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index a8b9f5c..0f3fea1 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -4,12 +4,11 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using System.Web; using BirdMessenger.Collections; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; using Supabase.Storage.Exceptions; using Supabase.Storage.Extensions; using Supabase.Storage.Interfaces; @@ -17,13 +16,38 @@ namespace Supabase.Storage { + /// + /// Provides API methods for interacting with Supabase Storage files. + /// public class StorageFileApi : IStorageFileApi { + /// + /// Gets or sets the client options for the Storage API. + /// public ClientOptions Options { get; protected set; } + + /// + /// Gets or sets the base URL for the Storage API. + /// protected string Url { get; set; } + + /// + /// Gets or sets the HTTP headers used for API requests. + /// protected Dictionary Headers { get; set; } + + /// + /// Gets or sets the current bucket identifier. + /// protected string? BucketId { get; set; } + /// + /// Initializes a new instance of the StorageFileApi class with specified URL, bucket ID, options, and headers. + /// + /// The base URL for the Storage API. + /// The identifier of the bucket to operate on. + /// Client options for configuring the API behavior. + /// Optional HTTP headers to include with requests. public StorageFileApi( string url, string bucketId, @@ -35,6 +59,12 @@ public StorageFileApi( Options = options ?? new ClientOptions(); } + /// + /// Initializes a new instance of the StorageFileApi class with specified URL, optional headers, and bucket ID. + /// + /// The base URL for the Storage API. + /// Optional HTTP headers to include with requests. + /// Optional bucket identifier to operate on. public StorageFileApi( string url, Dictionary? headers = null, @@ -101,12 +131,13 @@ public async Task CreateSignedUrl( if (transformOptions != null) { - var transformOptionsJson = JsonConvert.SerializeObject( + var transformOptionsJson = JsonSerializer.Serialize( transformOptions, - new StringEnumConverter() + Helpers.JsonOptions ); - var transformOptionsObj = JsonConvert.DeserializeObject>( - transformOptionsJson + var transformOptionsObj = JsonSerializer.Deserialize>( + transformOptionsJson, + Helpers.JsonOptions ); body.Add("transform", transformOptionsObj); } @@ -180,11 +211,10 @@ public async Task CreateSignedUrl( { options ??= new SearchOptions(); - var json = JsonConvert.SerializeObject(options); - var body = JsonConvert.DeserializeObject>(json); + var json = JsonSerializer.Serialize(options, Helpers.JsonOptions); + var body = JsonSerializer.Deserialize>(json, Helpers.JsonOptions); - if (body != null) - body.Add("prefix", string.IsNullOrEmpty(path) ? "" : path); + body?.Add("prefix", string.IsNullOrEmpty(path) ? "" : path); var response = await Helpers.MakeRequest>( HttpMethod.Post, @@ -221,6 +251,7 @@ public async Task CreateSignedUrl( /// /// /// + /// /// public async Task Upload( string localFilePath, @@ -248,6 +279,7 @@ public async Task Upload( /// /// /// + /// /// public async Task Upload( byte[] data, @@ -291,13 +323,13 @@ public async Task UploadToSignedUrl( var headers = new Dictionary(Headers) { - ["Authorization"] = $"Bearer {signedUrl.Token}", - ["cache-control"] = $"max-age={options.CacheControl}", - ["content-type"] = options.ContentType, + [StorageConstants.Headers.Authorization] = $"Bearer {signedUrl.Token}", + [StorageConstants.Headers.CacheControl] = $"max-age={options.CacheControl}", + [StorageConstants.Headers.ContentType] = options.ContentType, }; if (options.Upsert) - headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + headers.Add(StorageConstants.Headers.Upsert, options.Upsert.ToString().ToLower()); var progress = new Progress(); @@ -338,13 +370,13 @@ public async Task UploadToSignedUrl( var headers = new Dictionary(Headers) { - ["Authorization"] = $"Bearer {signedUrl.Token}", - ["cache-control"] = $"max-age={options.CacheControl}", - ["content-type"] = options.ContentType, + [StorageConstants.Headers.Authorization] = $"Bearer {signedUrl.Token}", + [StorageConstants.Headers.CacheControl] = $"max-age={options.CacheControl}", + [StorageConstants.Headers.ContentType] = options.ContentType, }; if (options.Upsert) - headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + headers.Add(StorageConstants.Headers.Upsert, options.Upsert.ToString().ToLower()); var progress = new Progress(); @@ -676,22 +708,7 @@ private async Task UploadOrUpdate( { Uri uri = new Uri($"{Url}/object/{GetFinalPath(supabasePath)}"); - var headers = new Dictionary(Headers) - { - { "cache-control", $"max-age={options.CacheControl}" }, - { "content-type", 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 headers = BuildUploadHeaders(options); var progress = new Progress(); @@ -715,7 +732,7 @@ private async Task UploadOrContinue( var headers = new Dictionary(Headers) { - { "cache-control", $"max-age={options.CacheControl}" }, + { StorageConstants.Headers.CacheControl, $"max-age={options.CacheControl}" }, }; var metadata = new MetadataCollection @@ -726,15 +743,15 @@ private async Task UploadOrContinue( }; if (options.Upsert) - headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + headers.Add(StorageConstants.Headers.Upsert, options.Upsert.ToString().ToLower()); if (options.Metadata != null) - headers.Add("x-metadata", ParseMetadata(options.Metadata)); + headers.Add(StorageConstants.Headers.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()); + headers.Add(StorageConstants.Headers.Duplex, options.Duplex.ToLower()); var progress = new Progress(); @@ -763,7 +780,7 @@ private async Task UploadOrContinue( var headers = new Dictionary(Headers) { - { "cache-control", $"max-age={options.CacheControl}" }, + { StorageConstants.Headers.CacheControl, $"max-age={options.CacheControl}" }, }; var metadata = new MetadataCollection @@ -774,15 +791,15 @@ private async Task UploadOrContinue( }; if (options.Upsert) - headers.Add("x-upsert", options.Upsert.ToString().ToLower()); + headers.Add(StorageConstants.Headers.Upsert, options.Upsert.ToString().ToLower()); if (options.Metadata != null) - metadata["metadata"] = JsonConvert.SerializeObject(options.Metadata); + metadata["metadata"] = JsonSerializer.Serialize(options.Metadata, Helpers.JsonOptions); options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); if (options.Duplex != null) - headers.Add("x-duplex", options.Duplex.ToLower()); + headers.Add(StorageConstants.Headers.Duplex, options.Duplex.ToLower()); var progress = new Progress(); @@ -801,7 +818,7 @@ private async Task UploadOrContinue( private static string ParseMetadata(Dictionary metadata) { - var json = JsonConvert.SerializeObject(metadata); + var json = JsonSerializer.Serialize(metadata, Helpers.JsonOptions); var base64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json)); return base64; @@ -817,22 +834,7 @@ private async Task UploadOrUpdate( { Uri uri = new Uri($"{Url}/object/{GetFinalPath(supabasePath)}"); - var headers = new Dictionary(Headers) - { - { "cache-control", $"max-age={options.CacheControl}" }, - { "content-type", 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 headers = BuildUploadHeaders(options); var progress = new Progress(); @@ -902,6 +904,27 @@ private async Task DownloadBytes( } private string GetFinalPath(string path) => $"{BucketId}/{path}"; - } -} + private Dictionary BuildUploadHeaders(FileOptions options) + { + var headers = new Dictionary(Headers) + { + { StorageConstants.Headers.CacheControl, $"max-age={options.CacheControl}" }, + { StorageConstants.Headers.ContentType, options.ContentType }, + }; + + if (options.Upsert) + headers.Add(StorageConstants.Headers.Upsert, options.Upsert.ToString().ToLower()); + + if (options.Metadata != null) + headers.Add(StorageConstants.Headers.Metadata, ParseMetadata(options.Metadata)); + + options.Headers?.ToList().ForEach(x => headers.Add(x.Key, x.Value)); + + if (options.Duplex != null) + headers.Add(StorageConstants.Headers.Duplex, options.Duplex.ToLower()); + + return headers; + } + } +} \ No newline at end of file diff --git a/Storage/TransformOptions.cs b/Storage/TransformOptions.cs index 041f473..d6afe1f 100644 --- a/Storage/TransformOptions.cs +++ b/Storage/TransformOptions.cs @@ -1,5 +1,5 @@ using System.Runtime.Serialization; -using Newtonsoft.Json; +using System.Text.Json.Serialization; using Supabase.Core.Attributes; namespace Supabase.Storage @@ -25,13 +25,13 @@ public enum ResizeType /// /// The width of the image in pixels. /// - [JsonProperty("width")] + [JsonPropertyName("width")] public int? Width { get; set; } /// /// The height of the image in pixels. /// - [JsonProperty("height")] + [JsonPropertyName("height")] public int? Height { get; set; } /// @@ -40,13 +40,13 @@ public enum ResizeType /// - Contain resizes the image to maintain it's aspect ratio while fitting the entire image within the width and height. /// - Fill resizes the image to fill the entire width and height.If the object's aspect ratio does not match the width and height, the image will be stretched to fit. /// - [JsonProperty("resize")] + [JsonPropertyName("resize")] public ResizeType Resize { get; set; } = ResizeType.Cover; /// /// Set the quality of the returned image, this is percentage based, default 80 /// - [JsonProperty("quality")] + [JsonPropertyName("quality")] public int Quality { get; set; } = 80; /// @@ -55,7 +55,7 @@ public enum ResizeType /// When using 'origin' we force the format to be the same as the original image, /// bypassing automatic browser optimisation such as webp conversion /// - [JsonProperty("format")] + [JsonPropertyName("format")] public string Format { get; set; } = "origin"; } } diff --git a/StorageTests/StorageFileTests.cs b/StorageTests/StorageFileTests.cs index 3a80175..8299ca7 100644 --- a/StorageTests/StorageFileTests.cs +++ b/StorageTests/StorageFileTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Storage; +using Supabase.Storage.Exceptions; using Supabase.Storage.Interfaces; using FileOptions = Supabase.Storage.FileOptions; @@ -94,7 +95,7 @@ public async Task UploadResumableFile() var name = $"{Guid.NewGuid()}.png"; var tempFilePath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.png"); - var data = new byte[2 * 1024 * 1024]; + var data = new byte[2 * 1024 * 1024]; var rng = new Random(); rng.NextBytes(data); await File.WriteAllBytesAsync(tempFilePath, data); @@ -195,6 +196,66 @@ await _bucket.UploadOrResume( await _bucket.Remove([name]); } + [TestMethod("File Resume Upload File as Byte Not Override Existing")] + public async Task UploadResumableByteNotOverrideExisting() + { + 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) => + { + didTriggerProgress.TrySetResult(true); + } + ); + + var action = async () => + { + await _bucket.UploadOrResume( + data, + name, + options, + (x, y) => + { + didTriggerProgress.TrySetResult(true); + } + ); + }; + + await Assert.ThrowsExceptionAsync(action); + + 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() { @@ -257,6 +318,12 @@ public async Task UploadOrResumeByteWithInterruptionAndResume() var firstUploadProgressTriggered = new TaskCompletionSource(); var resumeUploadProgressTriggered = new TaskCompletionSource(); + var assetFileName = "jetbrains.dmg"; + var basePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)?.Replace("file:", ""); + Assert.IsNotNull(basePath); + + var imagePath = Path.Combine(basePath!, "Assets", assetFileName); + var data = new byte[200 * 1024 * 1024]; var rng = new Random(); rng.NextBytes(data); @@ -275,7 +342,7 @@ public async Task UploadOrResumeByteWithInterruptionAndResume() try { await _bucket.UploadOrResume( - data, + imagePath, name, options, (_, progress) => @@ -284,6 +351,11 @@ await _bucket.UploadOrResume( cts.Cancel(); Console.WriteLine($"First upload progress: {progress}"); + if (progress >= 30) + { + cts.Cancel(); + Console.WriteLine("Cancelling first upload"); + } firstUploadProgressTriggered.TrySetResult(true); }, cts.Token @@ -311,7 +383,7 @@ await Task.WhenAny( ); await _bucket.UploadOrResume( - data, + imagePath, name, options, (_, progress) => @@ -409,7 +481,7 @@ await _bucket.Upload( await _bucket.Remove(new List { name }); } - + [TestMethod("File: Cancel Upload Arbitrary Byte Array")] public async Task UploadArbitraryByteArrayCanceled() { @@ -423,7 +495,14 @@ public async Task UploadArbitraryByteArrayCanceled() var action = async () => { - await _bucket.Upload(data, name, null, (_, _) => tsc.TrySetResult(true), true, ctk.Token); + await _bucket.Upload( + data, + name, + null, + (_, _) => tsc.TrySetResult(true), + true, + ctk.Token + ); }; await Assert.ThrowsExceptionAsync(action); @@ -665,4 +744,3 @@ public async Task CanCreateSignedUploadUrl() Assert.IsTrue(Uri.IsWellFormedUriString(result.SignedUrl.ToString(), UriKind.Absolute)); } } -