diff --git a/infra/storage_client/storage/Dockerfile b/infra/storage_client/storage/Dockerfile index 666b7ad52..5ca7670e7 100644 --- a/infra/storage_client/storage/Dockerfile +++ b/infra/storage_client/storage/Dockerfile @@ -1,3 +1,3 @@ -FROM supabase/storage-api:v1.8.2 +FROM supabase/storage-api:v1.18.1 RUN apk add curl --no-cache diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 1d4bce5f8..7362a2983 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -27,8 +27,19 @@ class Fetch { return MediaType.parse(mime ?? 'application/octet-stream'); } - StorageException _handleError(dynamic error, StackTrace stack, Uri? url) { + StorageException _handleError( + dynamic error, + StackTrace stack, + Uri? url, + FetchOptions? options, + ) { if (error is http.Response) { + if (options?.noResolveJson == true) { + return StorageException( + error.body.isEmpty ? error.reasonPhrase ?? '' : error.body, + statusCode: '${error.statusCode}', + ); + } try { final data = json.decode(error.body) as Map; @@ -79,7 +90,7 @@ class Fetch { return _handleResponse(streamedResponse, options); } - Future _handleMultipartRequest( + Future _handleFileRequest( String method, String url, File file, @@ -88,7 +99,6 @@ class Fetch { int retryAttempts, StorageRetryController? retryController, ) async { - final headers = options?.headers ?? {}; final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) : _parseMediaType(file.path); @@ -98,31 +108,15 @@ class Fetch { filename: file.path, contentType: contentType, ); - final request = http.MultipartRequest(method, Uri.parse(url)) - ..headers.addAll(headers) - ..files.add(multipartFile) - ..fields['cacheControl'] = fileOptions.cacheControl - ..headers['x-upsert'] = fileOptions.upsert.toString(); - - final http.StreamedResponse streamedResponse; - final r = RetryOptions(maxAttempts: (retryAttempts + 1)); - var attempts = 0; - streamedResponse = await r.retry( - () async { - attempts++; - _log.finest('Request: attempt: $attempts $method $url $headers'); - if (httpClient != null) { - return httpClient!.send(request); - } else { - return request.send(); - } - }, - retryIf: (error) => - retryController?.cancelled != true && - (error is ClientException || error is TimeoutException), + return _handleMultipartRequest( + method, + url, + multipartFile, + fileOptions, + options, + retryAttempts, + retryController, ); - - return _handleResponse(streamedResponse, options); } Future _handleBinaryFileRequest( @@ -134,7 +128,6 @@ class Fetch { int retryAttempts, StorageRetryController? retryController, ) async { - final headers = options?.headers ?? {}; final contentType = fileOptions.contentType != null ? MediaType.parse(fileOptions.contentType!) : _parseMediaType(url); @@ -145,11 +138,38 @@ class Fetch { filename: '', contentType: contentType, ); + return _handleMultipartRequest( + method, + url, + multipartFile, + fileOptions, + options, + retryAttempts, + retryController, + ); + } + + Future _handleMultipartRequest( + String method, + String url, + MultipartFile multipartFile, + FileOptions fileOptions, + FetchOptions? options, + int retryAttempts, + StorageRetryController? retryController, + ) async { + final headers = options?.headers ?? {}; final request = http.MultipartRequest(method, Uri.parse(url)) ..headers.addAll(headers) ..files.add(multipartFile) ..fields['cacheControl'] = fileOptions.cacheControl ..headers['x-upsert'] = fileOptions.upsert.toString(); + if (fileOptions.metadata != null) { + request.fields['metadata'] = json.encode(fileOptions.metadata); + } + if (fileOptions.headers != null) { + request.headers.addAll(fileOptions.headers!); + } final http.StreamedResponse streamedResponse; final r = RetryOptions(maxAttempts: (retryAttempts + 1)); @@ -185,10 +205,24 @@ class Fetch { return jsonBody; } } else { - throw _handleError(response, StackTrace.current, response.request?.url); + throw _handleError( + response, + StackTrace.current, + response.request?.url, + options, + ); } } + Future head(String url, {FetchOptions? options}) async { + return _handleRequest( + 'HEAD', + url, + null, + FetchOptions(headers: options?.headers, noResolveJson: true), + ); + } + Future get(String url, {FetchOptions? options}) async { return _handleRequest('GET', url, null, options); } @@ -225,8 +259,15 @@ class Fetch { required int retryAttempts, required StorageRetryController? retryController, }) async { - return _handleMultipartRequest('POST', url, file, fileOptions, options, - retryAttempts, retryController); + return _handleFileRequest( + 'POST', + url, + file, + fileOptions, + options, + retryAttempts, + retryController, + ); } Future putFile( @@ -237,7 +278,7 @@ class Fetch { required int retryAttempts, required StorageRetryController? retryController, }) async { - return _handleMultipartRequest( + return _handleFileRequest( 'PUT', url, file, diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index 848cd98b2..a45624535 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -411,6 +411,36 @@ class StorageFileApi { return response as Uint8List; } + /// Retrieves the details of an existing file + Future info(String path) async { + final finalPath = _getFinalPath(path); + final options = FetchOptions(headers: headers); + final response = await _storageFetch.get( + '$url/object/info/$finalPath', + options: options, + ); + final fileObjects = FileObjectV2.fromJson(response); + return fileObjects; + } + + /// Checks the existence of a file + Future exists(String path) async { + final finalPath = _getFinalPath(path); + final options = FetchOptions(headers: headers); + try { + await _storageFetch.head( + '$url/object/$finalPath', + options: options, + ); + return true; + } on StorageException catch (e) { + if (e.statusCode == '400' || e.statusCode == '404') { + return false; + } + rethrow; + } + } + /// Retrieve URLs for assets in public buckets /// /// [path] is the file path to be downloaded, including the current file name. diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index a73d3b615..1f7647c82 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -75,6 +75,53 @@ class FileObject { json['buckets'] != null ? Bucket.fromJson(json['buckets']) : null; } +class FileObjectV2 { + final String id; + final String version; + final String name; + final String bucketId; + final String? updatedAt; + final String createdAt; + final String? lastAccessedAt; + final int? size; + final String? cacheControl; + final String? contentType; + final String? etag; + final String? lastModified; + final Map? metadata; + + const FileObjectV2({ + required this.id, + required this.version, + required this.name, + required this.bucketId, + required this.updatedAt, + required this.createdAt, + required this.lastAccessedAt, + required this.size, + required this.cacheControl, + required this.contentType, + required this.etag, + required this.lastModified, + required this.metadata, + }); + + FileObjectV2.fromJson(Map json) + : id = json['id'] as String, + version = json['version'] as String, + name = json['name'] as String, + bucketId = json['bucket_id'] as String, + updatedAt = json['updated_at'] as String?, + createdAt = json['created_at'] as String, + lastAccessedAt = json['last_accessed_at'] as String?, + size = json['size'] as int?, + cacheControl = json['cache_control'] as String?, + contentType = json['content_type'] as String?, + etag = json['etag'] as String?, + lastModified = json['last_modified'] as String?, + metadata = json['metadata'] as Map?; +} + /// [public] 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. @@ -115,10 +162,20 @@ class FileOptions { /// Throws a FormatError if the media type is invalid. final String? contentType; + /// The metadata option is an object that allows you to store additional + /// information about the file. This information can be used to filter and + /// search for files. + final Map? metadata; + + /// Optionally add extra headers. + final Map? headers; + const FileOptions({ this.cacheControl = '3600', this.upsert = false, this.contentType, + this.metadata, + this.headers, }); } diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 931dbe7c5..62080dc69 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -35,8 +35,12 @@ String get objectUrl => '$supabaseUrl/storage/v1/object'; void main() { late SupabaseStorageClient client; late CustomHttpClient customHttpClient = CustomHttpClient(); + tearDown(() { + final file = File('a.txt'); + if (file.existsSync()) file.deleteSync(); + }); - group('Client with default http client', () { + group('Client with custom http client', () { setUp(() { // init SupabaseClient with test url & test key client = SupabaseStorageClient( @@ -48,11 +52,6 @@ void main() { ); }); - tearDown(() { - final file = File('a.txt'); - if (file.existsSync()) file.deleteSync(); - }); - test('should list buckets', () async { customHttpClient.response = [testBucketJson, testBucketJson]; diff --git a/packages/storage_client/test/client_test.dart b/packages/storage_client/test/client_test.dart index 4cca4bd35..2cf50cd46 100644 --- a/packages/storage_client/test/client_test.dart +++ b/packages/storage_client/test/client_test.dart @@ -240,11 +240,15 @@ void main() { final downloadedFile = await File('${Directory.current.path}/public-image.jpg').create(); - await downloadedFile.writeAsBytes(bytesArray); - final size = await downloadedFile.length(); - final type = lookupMimeType(downloadedFile.path); - expect(size, isPositive); - expect(type, 'image/jpeg'); + try { + await downloadedFile.writeAsBytes(bytesArray); + final size = await downloadedFile.length(); + final type = lookupMimeType(downloadedFile.path); + expect(size, isPositive); + expect(type, 'image/jpeg'); + } finally { + await downloadedFile.delete(); + } }); test('will download an authenticated transformed file', () async { @@ -259,15 +263,19 @@ void main() { final downloadedFile = await File('${Directory.current.path}/private-image.jpg').create(); - await downloadedFile.writeAsBytes(bytesArray); - final size = await downloadedFile.length(); - final type = lookupMimeType( - downloadedFile.path, - headerBytes: downloadedFile.readAsBytesSync(), - ); - - expect(size, isPositive); - expect(type, 'image/jpeg'); + try { + await downloadedFile.writeAsBytes(bytesArray); + final size = await downloadedFile.length(); + final type = lookupMimeType( + downloadedFile.path, + headerBytes: downloadedFile.readAsBytesSync(), + ); + + expect(size, isPositive); + expect(type, 'image/jpeg'); + } finally { + await downloadedFile.delete(); + } }); test('will return the image as webp when the browser support it', () async { @@ -283,15 +291,19 @@ void main() { ); final downloadedFile = await File('${Directory.current.path}/webpimage').create(); - await downloadedFile.writeAsBytes(bytesArray); - final size = await downloadedFile.length(); - final type = lookupMimeType( - downloadedFile.path, - headerBytes: downloadedFile.readAsBytesSync(), - ); - - expect(size, isPositive); - expect(type, 'image/webp'); + try { + await downloadedFile.writeAsBytes(bytesArray); + final size = await downloadedFile.length(); + final type = lookupMimeType( + downloadedFile.path, + headerBytes: downloadedFile.readAsBytesSync(), + ); + + expect(size, isPositive); + expect(type, 'image/webp'); + } finally { + await downloadedFile.delete(); + } }); test('will return the original image format when format is origin', @@ -309,15 +321,19 @@ void main() { ); final downloadedFile = await File('${Directory.current.path}/jpegimage').create(); - await downloadedFile.writeAsBytes(bytesArray); - final size = await downloadedFile.length(); - final type = lookupMimeType( - downloadedFile.path, - headerBytes: downloadedFile.readAsBytesSync(), - ); - - expect(size, isPositive); - expect(type, 'image/jpeg'); + try { + await downloadedFile.writeAsBytes(bytesArray); + final size = await downloadedFile.length(); + final type = lookupMimeType( + downloadedFile.path, + headerBytes: downloadedFile.readAsBytesSync(), + ); + + expect(size, isPositive); + expect(type, 'image/jpeg'); + } finally { + await downloadedFile.delete(); + } }); }); @@ -397,7 +413,7 @@ void main() { await storage.from('bucket2').download(uploadPath); fail('File that does not exist was found'); } on StorageException catch (error) { - expect(error.error, 'not_found'); + expect(error.statusCode, '400'); } await storage .from(newBucketName) @@ -417,7 +433,7 @@ void main() { await storage.from('bucket2').download('$uploadPath 3'); fail('File that does not exist was found'); } on StorageException catch (error) { - expect(error.error, 'not_found'); + expect(error.statusCode, '400'); } await storage .from(newBucketName) @@ -431,8 +447,36 @@ void main() { await storage.from(newBucketName).download(uploadPath); fail('File that was moved was found'); } on StorageException catch (error) { - expect(error.error, 'not_found'); + expect(error.statusCode, '400'); } }); }); + + test('upload with custom metadata', () async { + final metadata = { + 'custom': 'metadata', + 'second': 'second', + 'third': 'third', + }; + final path = "$uploadPath-metadata"; + await storage.from(newBucketName).upload( + path, + file, + fileOptions: FileOptions( + metadata: metadata, + ), + ); + + final updateRes = await storage.from(newBucketName).info(path); + expect(updateRes.metadata, metadata); + }); + + test('check if object exists', () async { + await storage.from(newBucketName).upload('$uploadPath-exists', file); + final res = await storage.from(newBucketName).exists('$uploadPath-exists'); + expect(res, true); + + final res2 = await storage.from(newBucketName).exists('not-exist'); + expect(res2, false); + }); }