From 3754b9b8fbe5e9964e14d5cc20415c65110cd527 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 10 Jul 2026 14:00:24 +0200 Subject: [PATCH 1/3] feat(storage): add cacheNonce parameter for cache invalidation Adds an optional cacheNonce parameter to getPublicUrl, createSignedUrl, createSignedUrls and download, appending a cacheNonce query parameter to the generated URL to bypass CDN caching for a specific file version. Ref: SDK-875 --- .../lib/src/storage_file_api.dart | 67 ++++++++++++++++--- packages/storage_client/test/basic_test.dart | 56 ++++++++++++++++ sdk-compliance.yaml | 2 +- 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index a302846ed..4518b4203 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -372,11 +372,15 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to the URL to bypass + /// CDN caching for a specific file version. Future createSignedUrl( String path, int expiresIn, { TransformOptions? transform, DownloadBehavior? download, + String? cacheNonce, }) async { final finalPath = _getFinalPath(path); final options = _fetchOptions; @@ -393,7 +397,11 @@ class StorageFileApi { if (signedUrlPath == null) { throw StorageException('No signed URL returned by API'); } - return _withDownload('$url$signedUrlPath', download); + return _withUrlOptions( + '$url$signedUrlPath', + download: download, + cacheNonce: cacheNonce, + ); } // TODO(v3): Remove this deprecated overload and rename createSignedUrlsResult @@ -414,11 +422,13 @@ class StorageFileApi { List paths, int expiresIn, { DownloadBehavior? download, + String? cacheNonce, }) async { final results = await createSignedUrlsResult( paths, expiresIn, download: download, + cacheNonce: cacheNonce, ); return results .whereType() @@ -443,10 +453,14 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to each URL to bypass + /// CDN caching for a specific file version. Future> createSignedUrlsResult( List paths, int expiresIn, { DownloadBehavior? download, + String? cacheNonce, }) async { final options = _fetchOptions; final response = await _storageFetch.post( @@ -463,7 +477,11 @@ class StorageFileApi { if (signedUrlPath != null) { return SignedUrlSuccess( path: path, - signedUrl: _withDownload('$url$signedUrlPath', download), + signedUrl: _withUrlOptions( + '$url$signedUrlPath', + download: download, + cacheNonce: cacheNonce, + ), ); } return SignedUrlFailure( @@ -481,10 +499,14 @@ class StorageFileApi { /// [transform] download a transformed variant of the image with the provided options /// /// [queryParams] additional query parameters to be added to the URL + /// + /// [cacheNonce] adds a `cacheNonce` query parameter to bypass CDN caching for + /// a specific file version. Future download( String path, { TransformOptions? transform, Map? queryParams, + String? cacheNonce, }) async { final transformationQuery = transform?.toQueryParams ?? {}; final wantsTransformations = transformationQuery.isNotEmpty; @@ -493,7 +515,11 @@ class StorageFileApi { ? 'render/image/authenticated' : 'object'; - final query = {...transformationQuery, ...?queryParams}; + final query = { + ...transformationQuery, + ...?queryParams, + 'cacheNonce': ?cacheNonce, + }; final options = FetchOptions(headers, noResolveJson: true); @@ -548,10 +574,14 @@ class StorageFileApi { /// browser by setting the response's `Content-Disposition` header. Use /// [DownloadBehavior.withOriginalName] to keep the original file name or /// [DownloadBehavior.named] to override it. + /// + /// [cacheNonce] appends a `cacheNonce` query parameter to the URL to bypass + /// CDN caching for a specific file version. String getPublicUrl( String path, { TransformOptions? transform, DownloadBehavior? download, + String? cacheNonce, }) { final finalPath = _getFinalPath(path); @@ -566,18 +596,33 @@ class StorageFileApi { publicUrl = publicUrl.replace(queryParameters: transformationQuery); } - return _withDownload(publicUrl.toString(), download); + return _withUrlOptions( + publicUrl.toString(), + download: download, + cacheNonce: cacheNonce, + ); } - /// Appends a `download` query parameter to [urlString] when [download] is - /// set, so the response is served with a `Content-Disposition` header. - String _withDownload(String urlString, DownloadBehavior? download) { - if (download == null) { - return urlString; + /// Appends the optional `download` and `cacheNonce` query parameters to + /// [urlString], in that order, when they are set. + String _withUrlOptions( + String urlString, { + DownloadBehavior? download, + String? cacheNonce, + }) { + var result = urlString; + if (download != null) { + result = _appendQueryParameter(result, 'download', download.queryValue); } + if (cacheNonce != null) { + result = _appendQueryParameter(result, 'cacheNonce', cacheNonce); + } + return result; + } + + String _appendQueryParameter(String urlString, String key, String value) { final separator = urlString.contains('?') ? '&' : '?'; - return '$urlString${separator}download=' - '${Uri.encodeQueryComponent(download.queryValue)}'; + return '$urlString$separator$key=${Uri.encodeQueryComponent(value)}'; } /// Deletes files within the same bucket diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 9a482ee80..240d8bab7 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -442,6 +442,62 @@ void main() { expect(success.signedUrl, endsWith('?token=abc&download=')); }); + test('getPublicUrl appends cacheNonce', () { + final response = client + .from('files') + .getPublicUrl('b.txt', cacheNonce: 'v2'); + expect(response, '$objectUrl/public/files/b.txt?cacheNonce=v2'); + }); + + test('getPublicUrl appends download before cacheNonce', () { + final response = client + .from('files') + .getPublicUrl( + 'b.txt', + download: DownloadBehavior.withOriginalName, + cacheNonce: 'v2', + ); + expect(response, '$objectUrl/public/files/b.txt?download=&cacheNonce=v2'); + }); + + test('download appends cacheNonce query parameter', () async { + final file = File('a.txt'); + file.writeAsStringSync('Updated content'); + customHttpClient.response = file.readAsBytesSync(); + + await client.from('public_bucket').download('b.txt', cacheNonce: 'v2'); + + final request = customHttpClient.receivedRequests.first; + expect(request.url.queryParameters, {'cacheNonce': 'v2'}); + }); + + test('createSignedUrl appends cacheNonce to the token query', () async { + customHttpClient.response = { + 'signedURL': '/object/sign/public/b.txt?token=abc', + }; + + final response = await client + .from('public') + .createSignedUrl('b.txt', 60, cacheNonce: 'v2'); + expect(response, endsWith('?token=abc&cacheNonce=v2')); + }); + + test('createSignedUrlsResult appends cacheNonce to each URL', () async { + customHttpClient.response = [ + { + 'path': 'exists.txt', + 'signedURL': '/object/sign/public/exists.txt?token=abc', + }, + ]; + + final results = await client + .from('public') + .createSignedUrlsResult(['exists.txt'], 60, cacheNonce: 'v2'); + + final success = results.single as SignedUrlSuccess; + expect(success.signedUrl, endsWith('?token=abc&cacheNonce=v2')); + }); + test('should remove file', () async { customHttpClient.response = [testFileObjectJson, testFileObjectJson]; diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index e08cd845d..e9792521f 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -394,7 +394,7 @@ features: storage.file_buckets.create_signed_urls: implemented storage.file_buckets.create_signed_upload_url: implemented storage.file_buckets.get_public_url: implemented - storage.file_buckets.url_cache_nonce: not_implemented + storage.file_buckets.url_cache_nonce: implemented storage.file_buckets.file_exists: implemented storage.file_buckets.file_info: implemented storage.file_buckets.create_file_bucket: implemented From 5653c6a8368bb19fc892dd1b74f950c3652a3437 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 10 Jul 2026 14:06:03 +0200 Subject: [PATCH 2/3] feat(storage): add listPaginated for cursor-based file listing Adds StorageFileApi.listPaginated, backed by the storage list-v2 endpoint, supporting cursor-based pagination and hierarchical (delimiter) listing via PaginatedSearchOptions and returning a PaginatedListResult of PaginatedFile and PaginatedFolder entries. Implements storage.file_buckets.list_files_paginated. --- .../lib/src/storage_file_api.dart | 20 ++ packages/storage_client/lib/src/types.dart | 180 ++++++++++++++++++ packages/storage_client/test/basic_test.dart | 69 +++++++ sdk-compliance.yaml | 44 ++++- 4 files changed, 312 insertions(+), 1 deletion(-) diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index 4518b4203..ab02f496b 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -670,4 +670,24 @@ class StorageFileApi { ); return fileObjects; } + + /// Lists files and folders within a bucket with cursor-based pagination and + /// hierarchical (delimiter) listing. + /// + /// [options] includes `prefix`, `cursor`, `limit`, `withDelimiter` and + /// `sortBy`. + /// + /// Folder entries in [PaginatedListResult.folders] only contain a name (and + /// optionally a key); full metadata is only available on the file entries in + /// [PaginatedListResult.objects]. + Future listPaginated({ + PaginatedSearchOptions options = const PaginatedSearchOptions(), + }) async { + final response = await _storageFetch.post( + '$url/object/list-v2/$bucketId', + options.toMap(), + options: _fetchOptions, + ); + return PaginatedListResult.fromJson(response as Map); + } } diff --git a/packages/storage_client/lib/src/types.dart b/packages/storage_client/lib/src/types.dart index 34b3e754d..df468ab02 100644 --- a/packages/storage_client/lib/src/types.dart +++ b/packages/storage_client/lib/src/types.dart @@ -290,6 +290,186 @@ class SortBy { } } +/// The column that [StorageFileApi.listPaginated] can sort its results by. +enum FileSortColumn { name, updatedAt, createdAt } + +/// The direction that [StorageFileApi.listPaginated] sorts its results in. +enum FileSortOrder { + ascending('asc'), + descending('desc'); + + const FileSortOrder(this.value); + + /// The value sent to the storage API. + final String value; +} + +/// The column and direction that [StorageFileApi.listPaginated] sorts its +/// results by. +class FileSort { + /// The column to sort by. + final FileSortColumn column; + + /// The sort direction. + final FileSortOrder order; + + const FileSort({ + this.column = FileSortColumn.name, + this.order = FileSortOrder.ascending, + }); + + Map toMap() { + return { + 'column': column.snakeCase, + 'order': order.value, + }; + } +} + +/// Options for [StorageFileApi.listPaginated]. +class PaginatedSearchOptions { + /// The number of files to return. + /// + /// Defaults to `1000` on the server when omitted. + final int? limit; + + /// The prefix to filter files by. + final String? prefix; + + /// The cursor used for pagination. Pass the [PaginatedListResult.nextCursor] + /// value from the previous request to fetch the next page. + final String? cursor; + + /// Whether to emulate a hierarchical listing of objects using delimiters. + /// + /// When `false` (default) all objects are listed as a flat list. When `true` + /// the response groups objects by delimiter, separating them into + /// [PaginatedListResult.folders] and [PaginatedListResult.objects]. + final bool? withDelimiter; + + /// The column and direction to sort by. + final FileSort? sortBy; + + const PaginatedSearchOptions({ + this.limit, + this.prefix, + this.cursor, + this.withDelimiter, + this.sortBy, + }); + + Map toMap() { + return { + 'limit': ?limit, + 'prefix': ?prefix, + 'cursor': ?cursor, + 'with_delimiter': ?withDelimiter, + 'sortBy': ?sortBy?.toMap(), + }; + } +} + +/// A file entry returned by [StorageFileApi.listPaginated]. +class PaginatedFile { + /// The file name. + final String name; + + /// The full object key/path. + final String? key; + + /// The unique identifier of the file. + final String? id; + + /// The last update timestamp. + final String? updatedAt; + + /// The creation timestamp. + final String? createdAt; + + /// The file metadata, including size and mimetype. `null` when not yet set. + final Map? metadata; + + const PaginatedFile({ + required this.name, + required this.key, + required this.id, + required this.updatedAt, + required this.createdAt, + required this.metadata, + }); + + factory PaginatedFile.fromJson(Map json) { + return PaginatedFile( + name: json['name'] as String, + key: json['key'] as String?, + id: json['id'] as String?, + updatedAt: json['updated_at'] as String?, + createdAt: json['created_at'] as String?, + metadata: json['metadata'] as Map?, + ); + } +} + +/// A folder entry returned by [StorageFileApi.listPaginated] when using a +/// delimiter. +class PaginatedFolder { + /// The folder name/prefix. + final String name; + + /// The full folder key/path. + final String? key; + + const PaginatedFolder({ + required this.name, + required this.key, + }); + + factory PaginatedFolder.fromJson(Map json) { + return PaginatedFolder( + name: json['name'] as String, + key: json['key'] as String?, + ); + } +} + +/// The result of [StorageFileApi.listPaginated]. +class PaginatedListResult { + /// Whether there are more results available on a subsequent page. + final bool hasNext; + + /// The folders in this page. Only populated when a delimiter is used. + final List folders; + + /// The files in this page. + final List objects; + + /// The cursor to pass as [PaginatedSearchOptions.cursor] to fetch the next + /// page. + final String? nextCursor; + + const PaginatedListResult({ + required this.hasNext, + required this.folders, + required this.objects, + required this.nextCursor, + }); + + factory PaginatedListResult.fromJson(Map json) { + final folders = json['folders'] as List? ?? const []; + final objects = json['objects'] as List? ?? const []; + return PaginatedListResult( + hasNext: json['hasNext'] as bool? ?? false, + folders: folders + .map((e) => PaginatedFolder.fromJson(e as Map)) + .toList(), + objects: objects + .map((e) => PaginatedFile.fromJson(e as Map)) + .toList(), + nextCursor: json['nextCursor'] as String?, + ); + } +} + class SignedUrl { /// The file path, including the current file name. For example `folder/image.png`. final String path; diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 240d8bab7..9c7757e99 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:http/http.dart'; import 'package:storage_client/storage_client.dart'; import 'package:test/test.dart'; @@ -293,6 +295,73 @@ void main() { expect(response.length, 2); }); + test('listPaginated posts options and parses the result', () async { + customHttpClient.response = { + 'hasNext': true, + 'nextCursor': 'cursor-2', + 'folders': [ + {'name': 'folder', 'key': 'prefix/folder/'}, + ], + 'objects': [ + { + 'name': 'image.png', + 'key': 'prefix/image.png', + 'id': 'object-id', + 'updated_at': '2026-01-01T00:00:00Z', + 'created_at': '2026-01-01T00:00:00Z', + 'metadata': {'size': 10}, + }, + ], + }; + + final result = await client + .from('public') + .listPaginated( + options: const PaginatedSearchOptions( + prefix: 'prefix/', + limit: 100, + withDelimiter: true, + sortBy: FileSort( + column: FileSortColumn.createdAt, + order: FileSortOrder.descending, + ), + ), + ); + + final request = customHttpClient.receivedRequests.single; + expect(request.url.toString(), '$objectUrl/list-v2/public'); + expect(jsonDecode((request as Request).body), { + 'prefix': 'prefix/', + 'limit': 100, + 'with_delimiter': true, + 'sortBy': {'column': 'created_at', 'order': 'desc'}, + }); + + expect(result.hasNext, isTrue); + expect(result.nextCursor, 'cursor-2'); + expect(result.folders.single.name, 'folder'); + expect(result.folders.single.key, 'prefix/folder/'); + expect(result.objects.single.name, 'image.png'); + expect(result.objects.single.id, 'object-id'); + expect(result.objects.single.metadata, {'size': 10}); + }); + + test('listPaginated defaults to an empty body and empty result', () async { + customHttpClient.response = { + 'hasNext': false, + 'objects': [], + }; + + final result = await client.from('public').listPaginated(); + + final request = customHttpClient.receivedRequests.single as Request; + expect(jsonDecode(request.body), {}); + expect(result.hasNext, isFalse); + expect(result.folders, isEmpty); + expect(result.objects, isEmpty); + expect(result.nextCursor, isNull); + }); + test('should download public file', () async { final file = File('a.txt'); file.writeAsStringSync('Updated content'); diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index e9792521f..27f5eab40 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -379,7 +379,49 @@ features: storage.file_buckets.download_with_transform: implemented storage.file_buckets.download_as_stream: not_implemented storage.file_buckets.list_files: implemented - storage.file_buckets.list_files_paginated: not_implemented + storage.file_buckets.list_files_paginated: + status: implemented + note: "Exposed as listPaginated (paired with descriptive Dart type names) rather than the supabase-js listV2 name." + symbols: + - StorageFileApi.listPaginated + - PaginatedSearchOptions + - PaginatedSearchOptions.PaginatedSearchOptions + - PaginatedSearchOptions.cursor + - PaginatedSearchOptions.limit + - PaginatedSearchOptions.prefix + - PaginatedSearchOptions.sortBy + - PaginatedSearchOptions.toMap + - PaginatedSearchOptions.withDelimiter + - PaginatedListResult + - PaginatedListResult.PaginatedListResult + - PaginatedListResult.folders + - PaginatedListResult.fromJson + - PaginatedListResult.hasNext + - PaginatedListResult.nextCursor + - PaginatedListResult.objects + - PaginatedFile + - PaginatedFile.PaginatedFile + - PaginatedFile.createdAt + - PaginatedFile.fromJson + - PaginatedFile.id + - PaginatedFile.key + - PaginatedFile.metadata + - PaginatedFile.name + - PaginatedFile.updatedAt + - PaginatedFolder + - PaginatedFolder.PaginatedFolder + - PaginatedFolder.fromJson + - PaginatedFolder.key + - PaginatedFolder.name + - FileSort + - FileSort.FileSort + - FileSort.column + - FileSort.order + - FileSort.toMap + - FileSortColumn + - FileSortOrder + - FileSortOrder.FileSortOrder + - FileSortOrder.value storage.file_buckets.move: implemented storage.file_buckets.move_cross_bucket: implemented storage.file_buckets.copy: implemented From 1b049ee9f73567ee4cc4ecc9c4d46d716ecc96f3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Fri, 10 Jul 2026 14:11:29 +0200 Subject: [PATCH 3/3] feat(storage): add downloadStream for streaming file downloads Adds StorageFileApi.downloadStream, returning the response body as a Stream> for memory-efficient handling of large files instead of buffering into a Uint8List. A non-success response throws a StorageException before the stream is returned. Implements storage.file_buckets.download_as_stream. --- packages/storage_client/lib/src/fetch.dart | 38 ++++++++++ .../lib/src/storage_file_api.dart | 69 ++++++++++++++++--- packages/storage_client/test/basic_test.dart | 55 +++++++++++++++ sdk-compliance.yaml | 6 +- 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/packages/storage_client/lib/src/fetch.dart b/packages/storage_client/lib/src/fetch.dart index 9b440859c..ba612da42 100644 --- a/packages/storage_client/lib/src/fetch.dart +++ b/packages/storage_client/lib/src/fetch.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; import 'package:mime/mime.dart'; import 'package:storage_client/src/types.dart'; import 'package:supabase_common/supabase_common.dart'; @@ -234,6 +235,43 @@ class Fetch { return _handleRequest('GET', url, null, options); } + /// Performs a GET request and yields the response body as a byte stream + /// without buffering it in memory. + /// + /// The status code is inspected before the body is yielded, so a non-success + /// response surfaces as a [StorageException] on the stream before any bytes + /// are emitted. + @internal + Stream getStream( + String url, { + FetchOptions? options, + }) async* { + final request = http.Request('GET', Uri.parse(url)) + ..headers.addAll({...?options?.headers}); + + _log.finest('Request: GET (stream) $url ${request.headers}'); + final http.StreamedResponse streamedResponse; + if (httpClient != null) { + streamedResponse = await httpClient!.send(request); + } else { + streamedResponse = await request.send(); + } + + if (!isSuccessStatusCode(streamedResponse.statusCode)) { + final response = await http.Response.fromStream(streamedResponse); + throw _handleError( + response, + StackTrace.current, + response.request?.url, + FetchOptions(options?.headers, noResolveJson: true), + ); + } + + yield* streamedResponse.stream.map( + (chunk) => chunk is Uint8List ? chunk : Uint8List.fromList(chunk), + ); + } + Future post( String url, Map? body, { diff --git a/packages/storage_client/lib/src/storage_file_api.dart b/packages/storage_client/lib/src/storage_file_api.dart index ab02f496b..6ff36c841 100644 --- a/packages/storage_client/lib/src/storage_file_api.dart +++ b/packages/storage_client/lib/src/storage_file_api.dart @@ -508,10 +508,31 @@ class StorageFileApi { Map? queryParams, String? cacheNonce, }) async { + final fetchUrl = _downloadUri( + path, + transform: transform, + queryParams: queryParams, + cacheNonce: cacheNonce, + ); + + final response = await _storageFetch.get( + fetchUrl.toString(), + options: FetchOptions(headers, noResolveJson: true), + ); + return response as Uint8List; + } + + /// Builds the download URL shared by [download] and [downloadStream], + /// selecting the render endpoint when an image transformation is requested + /// and appending transform, [queryParams] and [cacheNonce] query parameters. + Uri _downloadUri( + String path, { + TransformOptions? transform, + Map? queryParams, + String? cacheNonce, + }) { final transformationQuery = transform?.toQueryParams ?? {}; - final wantsTransformations = transformationQuery.isNotEmpty; - final finalPath = _getFinalPath(path); - final renderPath = wantsTransformations + final renderPath = transformationQuery.isNotEmpty ? 'render/image/authenticated' : 'object'; @@ -521,16 +542,46 @@ class StorageFileApi { 'cacheNonce': ?cacheNonce, }; - final options = FetchOptions(headers, noResolveJson: true); + return Uri.parse( + '$url/$renderPath/${_getFinalPath(path)}', + ).replace(queryParameters: query); + } - var fetchUrl = Uri.parse('$url/$renderPath/$finalPath'); - fetchUrl = fetchUrl.replace(queryParameters: query); + /// Downloads a file as a byte stream for memory-efficient handling of large + /// files. + /// + /// Unlike [download], the response body is not buffered into a [Uint8List]; + /// the stream yields the bytes as they arrive. The request is sent when the + /// stream is listened to, and a non-success response surfaces as a + /// [StorageException] on the stream before any bytes are emitted. + /// + /// [path] is the file path to be downloaded, including the path and file + /// name. For example `downloadStream('folder/image.png')`. + /// + /// [transform] download a transformed variant of the image with the provided + /// options. + /// + /// [queryParams] additional query parameters to be added to the URL. + /// + /// [cacheNonce] adds a `cacheNonce` query parameter to bypass CDN caching for + /// a specific file version. + Stream downloadStream( + String path, { + TransformOptions? transform, + Map? queryParams, + String? cacheNonce, + }) { + final fetchUrl = _downloadUri( + path, + transform: transform, + queryParams: queryParams, + cacheNonce: cacheNonce, + ); - final response = await _storageFetch.get( + return _storageFetch.getStream( fetchUrl.toString(), - options: options, + options: FetchOptions(headers, noResolveJson: true), ); - return response as Uint8List; } /// Retrieves the details of an existing file diff --git a/packages/storage_client/test/basic_test.dart b/packages/storage_client/test/basic_test.dart index 9c7757e99..fef0bb5df 100644 --- a/packages/storage_client/test/basic_test.dart +++ b/packages/storage_client/test/basic_test.dart @@ -391,6 +391,61 @@ void main() { expect(request.url.queryParameters, {'version': '1'}); }); + test('downloadStream yields the response body as a byte stream', () async { + final file = File('a.txt'); + file.writeAsStringSync('Streamed content'); + customHttpClient.response = file.readAsBytesSync(); + + final stream = client.from('public_bucket').downloadStream('b.txt'); + expect(stream, isA>()); + final bytes = await stream.expand((chunk) => chunk).toList(); + + expect(String.fromCharCodes(bytes), 'Streamed content'); + + final request = customHttpClient.receivedRequests.single; + expect(request.url.toString(), contains('/object/public_bucket/b.txt')); + }); + + test('downloadStream appends transform and cacheNonce', () async { + customHttpClient.response = Uint8List.fromList([1, 2, 3]); + + await client + .from('public_bucket') + .downloadStream( + 'b.txt', + transform: const TransformOptions(width: 200), + cacheNonce: 'v2', + ) + .drain(); + + final request = customHttpClient.receivedRequests.single; + expect( + request.url.toString(), + contains('/render/image/authenticated/public_bucket/b.txt'), + ); + expect(request.url.queryParameters, {'width': '200', 'cacheNonce': 'v2'}); + }); + + test('downloadStream surfaces an error status on the stream', () async { + addTearDown(() => customHttpClient.statusCode = 201); + customHttpClient.statusCode = 404; + customHttpClient.response = {'message': 'Object not found'}; + + await expectLater( + client + .from('public_bucket') + .downloadStream('missing.txt') + .drain(), + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + '404', + ), + ), + ); + }); + test('should get public URL of a path', () { final response = client.from('files').getPublicUrl('b.txt'); expect(response, '$objectUrl/public/files/b.txt'); diff --git a/sdk-compliance.yaml b/sdk-compliance.yaml index 27f5eab40..f2625ee61 100644 --- a/sdk-compliance.yaml +++ b/sdk-compliance.yaml @@ -377,7 +377,11 @@ features: storage.file_buckets.update_file: implemented storage.file_buckets.download: implemented storage.file_buckets.download_with_transform: implemented - storage.file_buckets.download_as_stream: not_implemented + storage.file_buckets.download_as_stream: + status: implemented + note: "Exposed as a dedicated downloadStream method returning a lazy Stream rather than an asStream() builder off download(); the request is sent on listen and a non-success status surfaces as a StorageException on the stream." + symbols: + - StorageFileApi.downloadStream storage.file_buckets.list_files: implemented storage.file_buckets.list_files_paginated: status: implemented