Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions packages/storage_client/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Uint8List> 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<dynamic> post(
String url,
Map<String, dynamic>? body, {
Expand Down
89 changes: 80 additions & 9 deletions packages/storage_client/lib/src/storage_file_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,31 @@ class StorageFileApi {
Map<String, String>? 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<String, String>? 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';

Expand All @@ -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<Uint8List> downloadStream(
String path, {
TransformOptions? transform,
Map<String, String>? 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
Expand Down Expand Up @@ -670,4 +721,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<PaginatedListResult> 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<String, dynamic>);
}
}
180 changes: 180 additions & 0 deletions packages/storage_client/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>?,
);
}
}

/// 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<String, dynamic> 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<PaginatedFolder> folders;

/// The files in this page.
final List<PaginatedFile> 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<String, dynamic> 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<String, dynamic>))
.toList(),
objects: objects
.map((e) => PaginatedFile.fromJson(e as Map<String, dynamic>))
.toList(),
nextCursor: json['nextCursor'] as String?,
);
}
}

class SignedUrl {
/// The file path, including the current file name. For example `folder/image.png`.
final String path;
Expand Down
Loading
Loading