Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Custom-metadata, exists, info methods #1023

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
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
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))
..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