Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
2 changes: 1 addition & 1 deletion infra/storage_client/storage/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
FROM supabase/storage-api:v1.8.2
FROM supabase/storage-api:v1.18.1

RUN apk add curl --no-cache
96 changes: 65 additions & 31 deletions packages/storage_client/lib/src/fetch.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>;

Expand Down Expand Up @@ -79,7 +90,7 @@ class Fetch {
return _handleResponse(streamedResponse, options);
}

Future<dynamic> _handleMultipartRequest(
Future<dynamic> _handleFileRequest(
String method,
String url,
File file,
Expand All @@ -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);
Expand All @@ -98,31 +108,15 @@ class Fetch {
filename: file.path,
contentType: contentType,
);
final request = http.MultipartRequest(method, Uri.parse(url))
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deleted code here is the same as in _handleBinaryFileRequest() so I added additional _handleFileRequest() instead to reduce code duplication. These two can now use an actual _handleMultipartRequest() method, which just accepts a MultipartFile which is independent of a passed File or Uint8List.

..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<http.StreamedResponse>(
() 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<dynamic> _handleBinaryFileRequest(
Expand All @@ -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);
Expand All @@ -145,11 +138,38 @@ class Fetch {
filename: '',
contentType: contentType,
);
return _handleMultipartRequest(
method,
url,
multipartFile,
fileOptions,
options,
retryAttempts,
retryController,
);
}

Future<dynamic> _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));
Expand Down Expand Up @@ -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<dynamic> head(String url, {FetchOptions? options}) async {
return _handleRequest(
'HEAD',
url,
null,
FetchOptions(headers: options?.headers, noResolveJson: true),
);
}

Future<dynamic> get(String url, {FetchOptions? options}) async {
return _handleRequest('GET', url, null, options);
}
Expand Down Expand Up @@ -225,7 +259,7 @@ class Fetch {
required int retryAttempts,
required StorageRetryController? retryController,
}) async {
return _handleMultipartRequest('POST', url, file, fileOptions, options,
return _handleFileRequest('POST', url, file, fileOptions, options,
retryAttempts, retryController);
}

Expand All @@ -237,7 +271,7 @@ class Fetch {
required int retryAttempts,
required StorageRetryController? retryController,
}) async {
return _handleMultipartRequest(
return _handleFileRequest(
'PUT',
url,
file,
Expand Down
30 changes: 30 additions & 0 deletions packages/storage_client/lib/src/storage_file_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,36 @@ class StorageFileApi {
return response as Uint8List;
}

/// Retrieves the details of an existing file
Future<FileObjectV2> 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<bool> 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.
Expand Down
57 changes: 57 additions & 0 deletions packages/storage_client/lib/src/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>? 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<String, dynamic> 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<String, dynamic>?;
}

/// [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.
Expand Down Expand Up @@ -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<String, dynamic>? metadata;

/// Optionally add extra headers.
final Map<String, String>? headers;

const FileOptions({
this.cacheControl = '3600',
this.upsert = false,
this.contentType,
this.metadata,
this.headers,
});
}

Expand Down
11 changes: 5 additions & 6 deletions packages/storage_client/test/basic_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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];

Expand Down
Loading