feat(client): support GraphQL upload files on browser
To support GraphQL upload files, we are now accepting `MultipartFile` only.
`io.File` is still being supported with deprecation warning.
In near future, we will remove support.
To ease the transition, `package:graphql/utility.dart` `multipartFileFrom`
is provided, whose input must be either `io.File` or `html.File`.
MODIFY
packages/graphql/example/bin/main.dart
packages/graphql/example/bin/main.dart
@@ -25,7 +25,7 @@ GraphQLClient client() {
getToken: () async => 'Bearer $YOUR_PERSONAL_ACCESS_TOKEN',
);
- final Link _link = _authLink.concat(_httpLink as Link);
+ final Link _link = _authLink.concat(_httpLink);
return GraphQLClient(
cache: InMemoryCache(storageProvider: null),

MODIFY
packages/graphql/lib/src/core/raw_operation_data.dart
packages/graphql/lib/src/core/raw_operation_data.dart
import 'dart:collection' show SplayTreeMap;
import 'dart:convert' show json;
-import 'dart:io' show File;
+import 'package:http/http.dart';
import 'package:meta/meta.dart';
import 'package:graphql/src/utilities/get_from_ast.dart' show getOperationName;
+import 'package:graphql/src/link/http//link_http_helper_deprecated_stub.dart'
+ if (dart.library.io) 'package:graphql/src/link/http/link_http_helper_deprecated_io.dart';
class RawOperationData {
RawOperationData({
@@ -47,7 +49,13 @@ class RawOperationData {
/// SplayTreeMap is always sorted
final String encodedVariables =
json.encode(variables, toEncodable: (dynamic object) {
- if (object is File) {
+ if (object is MultipartFile) {
+ return object.filename;
+ }
+ // @deprecated, backward compatible only
+ // in case the body is io.File
+ // in future release, io.File will no longer be supported
+ if (isIoFile(object)) {
return object.path;
}
return object;

MODIFY
packages/graphql/lib/src/link/http/link_http.dart
packages/graphql/lib/src/link/http/link_http.dart
import 'dart:async';
import 'dart:convert';
-import 'dart:io';
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
-import 'package:path/path.dart';
-import 'package:mime/mime.dart';
import 'package:graphql/src/utilities/helpers.dart' show notNull;
import 'package:graphql/src/link/link.dart';
@@ -15,6 +12,8 @@ import 'package:graphql/src/link/operation.dart';
import 'package:graphql/src/link/fetch_result.dart';
import 'package:graphql/src/link/http/fallback_http_config.dart';
import 'package:graphql/src/link/http/http_config.dart';
+import './link_http_helper_deprecated_stub.dart'
+ if (dart.library.io) './link_http_helper_deprecated_io.dart';
class HttpLink extends Link {
HttpLink({
@@ -110,16 +109,16 @@ class HttpLink extends Link {
);
}
-Map<String, File> _getFileMap(
+Future<Map<String, MultipartFile>> _getFileMap(
dynamic body, {
- Map<String, File> currentMap,
+ Map<String, MultipartFile> currentMap,
List<String> currentPath = const <String>[],
-}) {
- currentMap ??= <String, File>{};
+}) async {
+ currentMap ??= <String, MultipartFile>{};
if (body is Map<String, dynamic>) {
final Iterable<MapEntry<String, dynamic>> entries = body.entries;
for (MapEntry<String, dynamic> element in entries) {
- currentMap.addAll(_getFileMap(
+ currentMap.addAll(await _getFileMap(
element.value,
currentMap: currentMap,
currentPath: List<String>.from(currentPath)..add(element.key),
@@ -129,7 +128,7 @@ Map<String, File> _getFileMap(
}
if (body is List<dynamic>) {
for (int i = 0; i < body.length; i++) {
- currentMap.addAll(_getFileMap(
+ currentMap.addAll(await _getFileMap(
body[i],
currentMap: currentMap,
currentPath: List<String>.from(currentPath)..add(i.toString()),
@@ -137,9 +136,18 @@ Map<String, File> _getFileMap(
}
return currentMap;
}
- if (body is File) {
- return currentMap..addAll(<String, File>{currentPath.join('.'): body});
+ if (body is MultipartFile) {
+ return currentMap
+ ..addAll(<String, MultipartFile>{currentPath.join('.'): body});
}
+
+ // @deprecated, backward compatible only
+ // in case the body is io.File
+ // in future release, io.File will no longer be supported
+ if (isIoFile(body)) {
+ return deprecatedHelper(body, currentMap, currentPath);
+ }
+
// else should only be either String, num, null; NOTHING else
return currentMap;
}
@@ -149,7 +157,7 @@ Future<BaseRequest> _prepareRequest(
Map<String, dynamic> body,
Map<String, String> httpHeaders,
) async {
- final Map<String, File> fileMap = _getFileMap(body);
+ final Map<String, MultipartFile> fileMap = await _getFileMap(body);
if (fileMap.isEmpty) {
final Request r = Request('post', Uri.parse(url));
r.headers.addAll(httpHeaders);
@@ -160,7 +168,13 @@ Future<BaseRequest> _prepareRequest(
final MultipartRequest r = MultipartRequest('post', Uri.parse(url));
r.headers.addAll(httpHeaders);
r.fields['operations'] = json.encode(body, toEncodable: (dynamic object) {
- if (object is File) {
+ if (object is MultipartFile) {
+ return null;
+ }
+ // @deprecated, backward compatible only
+ // in case the body is io.File
+ // in future release, io.File will no longer be supported
+ if (isIoFile(body)) {
return null;
}
return object.toJson();
@@ -169,23 +183,22 @@ Future<BaseRequest> _prepareRequest(
final Map<String, List<String>> fileMapping = <String, List<String>>{};
final List<MultipartFile> fileList = <MultipartFile>[];
- final List<MapEntry<String, File>> fileMapEntries =
+ final List<MapEntry<String, MultipartFile>> fileMapEntries =
fileMap.entries.toList(growable: false);
for (int i = 0; i < fileMapEntries.length; i++) {
- final MapEntry<String, File> entry = fileMapEntries[i];
+ final MapEntry<String, MultipartFile> entry = fileMapEntries[i];
final String indexString = i.toString();
fileMapping.addAll(<String, List<String>>{
indexString: <String>[entry.key],
});
- final File f = entry.value;
- final String fileName = basename(f.path);
+ final MultipartFile f = entry.value;
fileList.add(MultipartFile(
indexString,
- f.openRead(),
- await f.length(),
- contentType: MediaType.parse(lookupMimeType(fileName)),
- filename: fileName,
+ f.finalize(),
+ f.length,
+ contentType: f.contentType,
+ filename: f.filename,
));
}

ADD
packages/graphql/lib/src/link/http/link_http_helper_deprecated_io.dart
+import 'dart:io' as io;
+
+import 'package:http/http.dart';
+import 'package:graphql/src/utilities/file_io.dart' show multipartFileFrom;
+
+// @deprecated, backward compatible only
+// in case the body is io.File
+// in future release, io.File will no longer be supported
+Future<Map<String, MultipartFile>> deprecatedHelper(
+ body, currentMap, currentPath) async {
+ if (body is io.File) {
+ return currentMap
+ ..addAll(<String, MultipartFile>{
+ currentPath.join('.'): await multipartFileFrom(body)
+ });
+ }
+ return null;
+}
+
+bool isIoFile(object) => object is io.File;

ADD
packages/graphql/lib/src/link/http/link_http_helper_deprecated_stub.dart
+import 'package:http/http.dart';
+
+// @deprecated, backward compatible only
+// in case the body is io.File
+// in future release, io.File will no longer be supported
+// but this stub is noop
+Future<Map<String, MultipartFile>> deprecatedHelper(body, currentMap, currentPath) async => null;
+
+// @deprecated, backward compatible only
+// in case the body is io.File
+// in future release, io.File will no longer be supported
+// but this stub always returns false
+bool isIoFile(object) => false;

ADD
packages/graphql/lib/src/utilities/file.dart
+import 'dart:async';
+import 'package:http/http.dart';
+
+import './file_stub.dart' as impl
+ if (dart.library.html) './file_html.dart'
+ if (dart.library.io) './file_io.dart';
+
+/// Input's type must be either `io.File` or `html.File`
+FutureOr<MultipartFile> multipartFileFrom(/*io.File or html.File*/ f) => impl.multipartFileFrom(f);

ADD
packages/graphql/lib/src/utilities/file_html.dart
+import 'dart:async';
+import 'dart:html' as html;
+import 'package:http/http.dart';
+import 'package:http_parser/http_parser.dart';
+
+Stream<List<int>> _readFile(html.File file) {
+ final reader = html.FileReader();
+ final streamController = StreamController<List<int>>();
+
+ reader.onLoad.listen((_) {
+ // streamController.add(reader.result);
+ streamController.close();
+ });
+
+ reader.onError.listen((error) => streamController.addError(error));
+
+ reader.readAsArrayBuffer(file);
+
+ return streamController.stream;
+ }
+
+MultipartFile multipartFileFrom(html.File f) => MultipartFile(
+ '',
+ _readFile(f),
+ f.size,
+ contentType: MediaType.parse(f.type),
+ filename: f.name,
+ );

ADD
packages/graphql/lib/src/utilities/file_io.dart
+import 'dart:async';
+import 'dart:io' as io;
+import 'package:http/http.dart';
+import 'package:http_parser/http_parser.dart';
+import 'package:mime/mime.dart';
+import 'package:path/path.dart';
+
+Future<MultipartFile> multipartFileFrom(io.File f) async => MultipartFile(
+ '',
+ f.openRead(),
+ await f.length(),
+ contentType: MediaType.parse(lookupMimeType(f.path)),
+ filename: basename(f.path),
+ );

ADD
packages/graphql/lib/src/utilities/file_stub.dart
+import 'dart:async';
+
+import 'package:http/http.dart';
+
+FutureOr<MultipartFile> multipartFileFrom(/*io.File or html.File*/ f) => throw UnsupportedError('io or html');

ADD
packages/graphql/lib/utility.dart
+library graphql;
+
+export 'package:graphql/src/utilities/file.dart' show multipartFileFrom;

MODIFY
packages/graphql/test/anonymous_operations_test.dart
packages/graphql/test/anonymous_operations_test.dart
@@ -48,7 +48,7 @@ void main() {
getToken: () async => 'Bearer my-special-bearer-token',
);
- link = authLink.concat(httpLink as Link);
+ link = authLink.concat(httpLink);
graphQLClientClient = GraphQLClient(
cache: getTestCache(),

MODIFY
packages/graphql/test/graphql_client_test.dart
packages/graphql/test/graphql_client_test.dart
@@ -50,7 +50,7 @@ void main() {
getToken: () async => 'Bearer my-special-bearer-token',
);
- link = authLink.concat(httpLink as Link);
+ link = authLink.concat(httpLink);
graphQLClientClient = GraphQLClient(
cache: getTestCache(),

MODIFY
packages/graphql/test/helpers.dart
packages/graphql/test/helpers.dart
-// This
-import 'dart:mirrors';
+import 'dart:async';
import 'dart:convert';
-import 'dart:io' show File, Directory;
import 'package:meta/meta.dart';
-import 'package:path/path.dart' show dirname, join;
import 'package:http/http.dart' as http;
import 'package:graphql/client.dart';
NormalizedInMemoryCache getTestCache() => NormalizedInMemoryCache(
dataIdFromObject: typenameDataIdFromObject,
- storageProvider: () => Directory.systemTemp.createTempSync('file_test_'),
+ storageProvider: () => null,
);
http.StreamedResponse simpleResponse({@required String body, int status}) {
@@ -23,18 +20,3 @@ http.StreamedResponse simpleResponse({@required String body, int status}) {
return r;
}
-
-class _TestUtils {
- static String _path;
-
- static String get path {
- if (_path == null) {
- final String basePath =
- dirname((reflectClass(_TestUtils).owner as LibraryMirror).uri.path);
- _path = basePath.endsWith('test') ? basePath : join(basePath, 'test');
- }
- return _path;
- }
-}
-
-File tempFile(String fileName) => File(join(_TestUtils.path, fileName));

MODIFY
packages/graphql/test/multipart_upload_test.dart
packages/graphql/test/multipart_upload_test.dart
-import 'dart:io' show File, Directory;
-import 'dart:typed_data' show Uint8List;
-
+import 'package:http_parser/http_parser.dart';
import 'package:test/test.dart';
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
@@ -13,7 +11,7 @@ class MockHttpClient extends Mock implements http.Client {}
NormalizedInMemoryCache getTestCache() => NormalizedInMemoryCache(
dataIdFromObject: typenameDataIdFromObject,
- storageProvider: () => Directory.systemTemp.createTempSync('file_test_'),
+ storageProvider: () => null,
);
void main() {
@@ -45,7 +43,7 @@ void main() {
getToken: () async => 'Bearer my-special-bearer-token',
);
- link = authLink.concat(httpLink as Link);
+ link = authLink.concat(httpLink);
graphQLClientClient = GraphQLClient(
cache: getTestCache(),
@@ -54,17 +52,17 @@ void main() {
});
test('upload success', () async {
- Future<void> expectUploadBody(http.ByteStream bodyBytesStream,
- String boundary, List<File> files) async {
+ Future<void> expectUploadBody(
+ http.ByteStream bodyBytesStream, String boundary) async {
final List<Function> expectContinuationList = (() {
int i = 0;
return <Function>[
// ExpectString
- (Uint8List actual, String expected) => expect(
+ (List<int> actual, String expected) => expect(
String.fromCharCodes(actual.sublist(i, i += expected.length)),
expected),
// ExpectBytes
- (Uint8List actual, Uint8List expected) =>
+ (List<int> actual, List<int> expected) =>
expect(actual.sublist(i, i += expected.length), expected),
// Expect final length
(int expectedLength) => expect(i, expectedLength),
@@ -73,7 +71,7 @@ void main() {
final Function expectContinuationString = expectContinuationList[0];
final Function expectContinuationBytes = expectContinuationList[1];
final Function expectContinuationLength = expectContinuationList[2];
- final Uint8List bodyBytes = await bodyBytesStream.toBytes();
+ final bodyBytes = await bodyBytesStream.toBytes();
expectContinuationString(bodyBytes, '--');
expectContinuationString(bodyBytes, boundary);
expectContinuationString(bodyBytes,
@@ -89,12 +87,12 @@ void main() {
expectContinuationString(bodyBytes, boundary);
expectContinuationString(bodyBytes,
'\r\ncontent-type: image/jpeg\r\ncontent-disposition: form-data; name="0"; filename="sample_upload.jpg"\r\n\r\n');
- expectContinuationBytes(bodyBytes, await files[0].readAsBytes());
+ expectContinuationBytes(bodyBytes, [0, 1, 254, 255]);
expectContinuationString(bodyBytes, '\r\n--');
expectContinuationString(bodyBytes, boundary);
expectContinuationString(bodyBytes,
- '\r\ncontent-type: video/quicktime\r\ncontent-disposition: form-data; name="1"; filename="sample_upload.mov"\r\n\r\n');
- expectContinuationBytes(bodyBytes, await files[1].readAsBytes());
+ '\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-disposition: form-data; name="1"; filename="sample_upload.txt"\r\n\r\n');
+ expectContinuationString(bodyBytes, 'just plain text');
expectContinuationString(bodyBytes, '\r\n--');
expectContinuationString(bodyBytes, boundary);
expectContinuationString(bodyBytes, '--\r\n');
@@ -116,9 +114,9 @@ void main() {
},
{
"id": "5Ea18qlMur",
- "filename": "sample_upload.mov",
- "mimetype": "video/quicktime",
- "path": "./uploads/5Ea18qlMur-sample_upload.mov"
+ "filename": "sample_upload.txt",
+ "mimetype": "text/plain",
+ "path": "./uploads/5Ea18qlMur-sample_upload.txt"
}
]
}
@@ -126,15 +124,23 @@ void main() {
''');
});
- final List<File> files = <String>[
- 'sample_upload.jpg',
- 'sample_upload.mov'
- ].map((String fileName) => tempFile(fileName)).toList();
-
final MutationOptions _options = MutationOptions(
document: uploadMutation,
variables: <String, dynamic>{
- 'files': files,
+ 'files': [
+ http.MultipartFile.fromBytes(
+ '',
+ [0, 1, 254, 255],
+ filename: 'sample_upload.jpg',
+ contentType: MediaType('image', 'jpeg'),
+ ),
+ http.MultipartFile.fromString(
+ '',
+ 'just plain text',
+ filename: 'sample_upload.txt',
+ contentType: MediaType('text', 'plain'),
+ ),
+ ],
},
);
final QueryResult r = await graphQLClientClient.mutate(_options);
@@ -153,7 +159,7 @@ void main() {
final List<String> contentTypeStringSplit =
request.headers['content-type'].split('; boundary=');
expect(contentTypeStringSplit[0], 'multipart/form-data');
- await expectUploadBody(bodyBytes, contentTypeStringSplit[1], files);
+ await expectUploadBody(bodyBytes, contentTypeStringSplit[1]);
final List<Map<String, dynamic>> multipleUpload =
(r.data['multipleUpload'] as List<dynamic>)
@@ -168,13 +174,11 @@ void main() {
},
<String, String>{
'id': '5Ea18qlMur',
- 'filename': 'sample_upload.mov',
- 'mimetype': 'video/quicktime',
- 'path': './uploads/5Ea18qlMur-sample_upload.mov'
+ 'filename': 'sample_upload.txt',
+ 'mimetype': 'text/plain',
+ 'path': './uploads/5Ea18qlMur-sample_upload.txt'
},
]);
- }, onPlatform: {
- "browser": Skip("Browser does not support dart:mirrors"),
});
//test('upload fail error response', () {

MODIFY
packages/graphql_flutter/example/lib/graphql_bloc/bloc.dart
packages/graphql_flutter/example/lib/graphql_bloc/bloc.dart
@@ -64,7 +64,7 @@ class Bloc {
getToken: () async => 'Bearer $YOUR_PERSONAL_ACCESS_TOKEN',
);
- static final Link _link = _authLink.concat(_httpLink as Link);
+ static final Link _link = _authLink.concat(_httpLink);
static final GraphQLClient _client = GraphQLClient(
cache: NormalizedInMemoryCache(

MODIFY
packages/graphql_flutter/example/lib/graphql_widget/main.dart
packages/graphql_flutter/example/lib/graphql_widget/main.dart
@@ -27,7 +27,7 @@ class GraphQLWidgetScreen extends StatelessWidget {
);
// TODO don't think we have to cast here, maybe covariant
- Link link = authLink.concat(httpLink as Link);
+ Link link = authLink.concat(httpLink);
if (ENABLE_WEBSOCKETS) {
final WebSocketLink websocketLink = WebSocketLink(
url: 'ws://localhost:8080/ws/graphql',

feat(client): support GraphQL upload files on browser

To support GraphQL upload files, we are now accepting `MultipartFile` only.
`io.File` is still being supported with deprecation warning.
In near future, we will remove support.

To ease the transition, `package:graphql/utility.dart` `multipartFileFrom`
is provided, whose input must be either `io.File` or `html.File`.