Support GraphQL Upload spec as proposed at
ADD
.gitmodules
+[submodule "example/server"]
+ path = example/server
+ url = https://github.com/jaydenseric/apollo-upload-examples.git

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

MODIFY
lib/src/core/query_manager.dart
lib/src/core/query_manager.dart
@@ -119,7 +119,10 @@ class QueryManager {
).first;
// save the data from fetchResult to the cache
- if (fetchResult.data != null &&
+ if (
+ // should never cache a mutation
+ !(options is MutationOptions) &&
+ fetchResult.data != null &&
options.fetchPolicy != FetchPolicy.noCache) {
cache.write(
operation.toKey(),

MODIFY
lib/src/link/http/http_config.dart
lib/src/link/http/http_config.dart
@@ -32,12 +32,12 @@ class HttpConfig {
Map<String, String> headers;
}
-class HttpOptionsAndBody {
- HttpOptionsAndBody({
- this.options,
+class HttpHeadersAndBody {
+ HttpHeadersAndBody({
+ this.headers,
this.body,
});
- final Map<String, dynamic> options;
- final String body;
+ final Map<String, String> headers;
+ final Map<String, dynamic> body;
}

MODIFY
lib/src/link/http/link_http.dart
lib/src/link/http/link_http.dart
@@ -5,6 +5,8 @@ import 'dart:io';
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_flutter/src/link/link.dart';
import 'package:graphql_flutter/src/link/operation.dart';
@@ -53,7 +55,7 @@ class HttpLink extends Link {
);
}
- final HttpOptionsAndBody httpOptionsAndBody =
+ final HttpHeadersAndBody httpHeadersAndBody =
_selectHttpOptionsAndBody(
operation,
fallbackHttpConfig,
@@ -61,9 +63,7 @@ class HttpLink extends Link {
contextConfig,
);
- final Map<String, dynamic> options = httpOptionsAndBody.options;
- final Map<String, String> httpHeaders =
- options['headers'] as Map<String, String>;
+ final Map<String, String> httpHeaders = httpHeadersAndBody.headers;
StreamController<FetchResult> controller;
@@ -71,17 +71,17 @@ class HttpLink extends Link {
StreamedResponse response;
try {
+ // httpOptionsAndBody.body as String
+ final BaseRequest request = await _prepareRequest(
+ uri, httpHeadersAndBody.body, httpHeaders);
- Request r = Request('post', Uri.parse(uri))
- ..headers.addAll(httpHeaders)
- ;
- r.body = httpOptionsAndBody.body as String;
- response = await fetcher.send(r);
+ response = await fetcher.send(request);
operation.setContext(<String, StreamedResponse>{
'response': response,
});
- final FetchResult parsedResponse = await _parseResponse(response);
+ final FetchResult parsedResponse =
+ await _parseResponse(response);
controller.add(parsedResponse);
} catch (error) {
@@ -98,7 +98,95 @@ class HttpLink extends Link {
);
}
-HttpOptionsAndBody _selectHttpOptionsAndBody(
+Map<String, File> _getFileMap(
+ dynamic body, {
+ Map<String, File> currentMap,
+ List<String> currentPath = const <String>[],
+}) {
+ currentMap ??= <String, File>{};
+ if (body is Map<String, dynamic>) {
+ final entries = body.entries;
+ for (MapEntry<String, dynamic> element in entries) {
+ currentMap.addAll(_getFileMap(
+ element.value,
+ currentMap: currentMap,
+ currentPath: List<String>.from(currentPath)..add(element.key),
+ ));
+ }
+ return currentMap;
+ }
+ if (body is List<dynamic>) {
+ for (int i = 0; i < body.length; i++) {
+ currentMap.addAll(_getFileMap(
+ body[i],
+ currentMap: currentMap,
+ currentPath: List<String>.from(currentPath)..add(i.toString()),
+ ));
+ }
+ return currentMap;
+ }
+ if (body is File) {
+ return currentMap..addAll(<String, File>{currentPath.join('.'): body});
+ }
+ // else should only be either String, num, null; NOTHING else
+ return currentMap;
+}
+
+Future<BaseRequest> _prepareRequest(
+ String url,
+ Map<String, dynamic> body,
+ Map<String, String> httpHeaders,
+) async {
+ final Map<String, File> fileMap = _getFileMap(body);
+ if (fileMap.isEmpty) {
+ final Request r = Request('post', Uri.parse(url));
+ r.headers.addAll(httpHeaders);
+ r.body = json.encode(body);
+ return r;
+ }
+
+ 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) {
+ return null;
+ }
+ return object.toJson();
+ });
+
+ // @todo fileMap.keys.toList() and fileMap.values.toList() same order????
+ final Map<String, List<String>> adasd =
+ {}; // fileMap.keys.toList().asMap().map((int index, String filePath) => MapEntry(index.toString(),[filePath]));
+ final List<MultipartFile> fileList = [];
+
+ final fEn = fileMap.entries.toList(growable: false);
+
+ for (int i = 0; i < fEn.length; i++) {
+ final MapEntry<String, File> entry = fEn[i];
+ final String indexString = i.toString();
+ adasd.addAll({
+ indexString: [entry.key]
+ });
+ final File f = entry.value;
+ final String fileName = basename(f.path);
+ fileList.add(MultipartFile(
+ indexString,
+ f.openRead(),
+ await f.length(),
+ contentType: MediaType.parse(lookupMimeType(fileName)),
+ filename: fileName,
+ ));
+ }
+
+ final rfieldsmap = json.encode(adasd);
+
+ r.fields['map'] = rfieldsmap;
+
+ r.files.addAll(fileList);
+ return r;
+}
+
+HttpHeadersAndBody _selectHttpOptionsAndBody(
Operation operation,
HttpConfig fallbackConfig, [
HttpConfig linkConfig,
@@ -112,7 +200,7 @@ HttpOptionsAndBody _selectHttpOptionsAndBody(
// http options
- // initialze with fallback http options
+ // initialize with fallback http options
http.addAll(fallbackConfig.http);
// inject the configured http options
@@ -127,7 +215,7 @@ HttpOptionsAndBody _selectHttpOptionsAndBody(
// options
- // initialze with fallback options
+ // initialize with fallback options
options.addAll(fallbackConfig.options);
// inject the configured options
@@ -185,22 +273,12 @@ HttpOptionsAndBody _selectHttpOptionsAndBody(
body['query'] = operation.document;
}
- return HttpOptionsAndBody(
- options: options,
- body: encodeBody(body),
+ return HttpHeadersAndBody(
+ headers: options['headers'] as Map<String, String>,
+ body: body,
);
}
-dynamic encodeBody(dynamic body) {
- final encodedBody = json.encode(body, toEncodable: (dynamic object) {
- if(object is File){
- return null;
- }
- return object.toJson();
- });
- return encodedBody;
-}
-
Future<FetchResult> _parseResponse(StreamedResponse response) async {
final int statusCode = response.statusCode;
final String reasonPhrase = response.reasonPhrase;
@@ -208,7 +286,8 @@ Future<FetchResult> _parseResponse(StreamedResponse response) async {
try {
final Encoding encoding = _determineEncodingFromResponse(response);
// @todo limit bodyBytes
- final String decodedBody = encoding.decode(await response.stream.toBytes());
+ final reponseByte = await response.stream.toBytes();
+ final String decodedBody = encoding.decode(reponseByte);
final Map<String, dynamic> jsonResponse =
json.decode(decodedBody) as Map<String, dynamic>;
@@ -231,6 +310,7 @@ Future<FetchResult> _parseResponse(StreamedResponse response) async {
'Network Error: $statusCode $reasonPhrase',
);
}
+ rethrow;
}
}

MODIFY
pubspec.yaml
pubspec.yaml
@@ -11,6 +11,7 @@ dependencies:
sdk: flutter
meta: ^1.1.6
http: ^0.12.0
+ mime: 0.9.6+2
http_parser: ^3.1.3
path_provider: ^0.5.0
uuid: ^2.0.0

MODIFY
test/graphql_flutter_test.dart
test/graphql_flutter_test.dart
import 'dart:convert';
import 'dart:io' show File, Platform;
+import 'dart:typed_data' show Uint8List;
+import 'package:path/path.dart' show dirname, join;
import 'package:test/test.dart';
import 'package:mockito/mockito.dart';
import 'package:http/http.dart' as http;
@@ -39,7 +41,7 @@ void main() {
Link link;
GraphQLClient graphQLClientClient;
MockHttpClient mockHttpClient;
-
+ group('simple json', () {
setUp(() {
mockHttpClient = MockHttpClient();
@@ -50,7 +52,7 @@ void main() {
getToken: () async => 'Bearer my-special-bearer-token',
);
- link = authLink.concat(httpLink);
+ link = authLink.concat(httpLink as Link);
graphQLClientClient = GraphQLClient(
cache: NormalizedInMemoryCache(
@@ -124,7 +126,8 @@ void main() {
'Authorization': 'Bearer my-special-bearer-token',
},
);
- expect(await capt.finalize().bytesToString(), r'{"operationName":"ReadRepositories","variables":{"nRepositories":42},"query":" query ReadRepositories($nRepositories: Int!) {\n viewer {\n repositories(last: $nRepositories) {\n nodes {\n __typename\n id\n name\n viewerHasStarred\n }\n }\n }\n }\n"}');
+ expect(await capt.finalize().bytesToString(),
+ r'{"operationName":"ReadRepositories","variables":{"nRepositories":42},"query":" query ReadRepositories($nRepositories: Int!) {\n viewer {\n repositories(last: $nRepositories) {\n nodes {\n __typename\n id\n name\n viewerHasStarred\n }\n }\n }\n }\n"}');
expect(r.errors, isNull);
expect(r.data, isNotNull);
@@ -149,9 +152,7 @@ void main() {
'nRepositories': 38,
},
);
- when(
- mockHttpClient.send(any)
- ).thenAnswer((Invocation a) async {
+ when(mockHttpClient.send(any)).thenAnswer((Invocation a) async {
const String body =
'{"data":{"action":{"starrable":{"viewerHasStarred":true}}}}';
@@ -163,30 +164,33 @@ void main() {
return r;
});
- final QueryResult r = await graphQLClientClient.mutate(_options);
+ final QueryResult response = await graphQLClientClient.mutate(_options);
- final http.Request capt = verify(mockHttpClient.send(captureAny))
+ final http.Request request = verify(mockHttpClient.send(captureAny))
.captured
.first as http.Request;
- expect(capt.method, 'post');
- expect(capt.url.toString(), 'https://api.github.com/graphql');
+ expect(request.method, 'post');
+ expect(request.url.toString(), 'https://api.github.com/graphql');
expect(
- capt.headers,
+ request.headers,
<String, String>{
'accept': '*/*',
'content-type': 'application/json; charset=utf-8',
'Authorization': 'Bearer my-special-bearer-token',
},
);
- expect(await capt.finalize().bytesToString(), r'{"operationName":"AddStar","variables":{"nRepositories":38},"query":" mutation AddStar($starrableId: ID!) {\n action: addStar(input: {starrableId: $starrableId}) {\n starrable {\n viewerHasStarred\n }\n }\n }\n"}');
+ expect(await request.finalize().bytesToString(),
+ r'{"operationName":"AddStar","variables":{"nRepositories":38},"query":" mutation AddStar($starrableId: ID!) {\n action: addStar(input: {starrableId: $starrableId}) {\n starrable {\n viewerHasStarred\n }\n }\n }\n"}');
- expect(r.errors, isNull);
- expect(r.data, isNotNull);
+ expect(response.errors, isNull);
+ expect(response.data, isNotNull);
final bool viewerHasStarred =
- r.data['action']['starrable']['viewerHasStarred'] as bool;
+ response.data['action']['starrable']['viewerHasStarred'] as bool;
expect(viewerHasStarred, true);
});
});
+ });
+
group('upload', () {
const String uploadMutation = r'''
mutation($files: [Upload!]!) {
@@ -197,31 +201,161 @@ void main() {
path
}
}
+ ''';
+
+ setUp(() {
+ mockHttpClient = MockHttpClient();
+
+ httpLink = HttpLink(
+ uri: 'http://localhost:3001/graphql', httpClient: mockHttpClient);
+ authLink = AuthLink(
+ getToken: () async => 'Bearer my-special-bearer-token',
+ );
+
+ link = authLink.concat(httpLink as Link);
+
+ graphQLClientClient = GraphQLClient(
+ cache: NormalizedInMemoryCache(
+ dataIdFromObject: typenameDataIdFromObject,
+ ),
+ link: link,
+ );
+ });
+
+ test('upload success', () async {
+ Future<void> expectUploadBody(http.ByteStream bodyBytesStream,
+ String boundary, List<File> files) async {
+ final List<Function> expectContinuationList = (() {
+ int i = 0;
+ return [
+ // ExpectString
+ (Uint8List actual, String expected) => expect(
+ String.fromCharCodes(actual.sublist(i, i += expected.length)),
+ expected),
+ // ExpectBytes
+ (Uint8List actual, Uint8List expected) =>
+ expect(actual.sublist(i, i += expected.length), expected),
+ // Expect final length
+ (int expectedLength) => expect(i, expectedLength),
+ ];
+ })();
+ final Function expectContinuationString = expectContinuationList[0];
+ final Function expectContinuationBytes = expectContinuationList[1];
+ final Function expectContinuationLength = expectContinuationList[2];
+ final Uint8List bodyBytes = await bodyBytesStream.toBytes();
+ expectContinuationString(bodyBytes, '--');
+ expectContinuationString(bodyBytes, boundary);
+ expectContinuationString(bodyBytes,
+ '\r\ncontent-disposition: form-data; name="operations"\r\n\r\n');
+ expectContinuationString(bodyBytes,
+ r'{"operationName":null,"variables":{"files":[null,null]},"query":" mutation($files: [Upload!]!) {\n multipleUpload(files: $files) {\n id\n filename\n mimetype\n path\n }\n }\n "}');
+ expectContinuationString(bodyBytes, '\r\n--');
+ expectContinuationString(bodyBytes, boundary);
+ expectContinuationString(bodyBytes,
+ '\r\ncontent-disposition: form-data; name="map"\r\n\r\n{"0":["variables.files.0"],"1":["variables.files.1"]}');
+ expectContinuationString(bodyBytes, '\r\n--');
+ 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());
+ 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());
+ expectContinuationString(bodyBytes, '\r\n--');
+ expectContinuationString(bodyBytes, boundary);
+ expectContinuationString(bodyBytes, '--\r\n');
+ expectContinuationLength(bodyBytes.lengthInBytes);
+ }
+
+ http.ByteStream bodyBytes;
+ when(mockHttpClient.send(any)).thenAnswer((Invocation a) async {
+ bodyBytes = (a.positionalArguments[0] as http.BaseRequest).finalize();
+ const String body = r'''
+{
+ "data": {
+ "multipleUpload": [
+ {
+ "id": "r1odc4PAz",
+ "filename": "sample_upload.jpg",
+ "mimetype": "image/jpeg",
+ "path": "./uploads/r1odc4PAz-sample_upload.jpg"
+ },
+ {
+ "id": "5Ea18qlMur",
+ "filename": "sample_upload.mov",
+ "mimetype": "video/quicktime",
+ "path": "./uploads/5Ea18qlMur-sample_upload.mov"
+ }
+ ]
+ }
+}
''';
-// test('upload success', () async {
-// final List<File> files = <String>['sample_upload.bin', 'sample_upload.img']
-// .map((String fileName) => join(
-// dirname(Platform.script.path),
-// 'test',
-// ))
-// .map((String filePath) => File(filePath))
-// .toList();
-//
-// final MutationOptions _options = MutationOptions(
-// document: uploadMutation,
-// variables: <String, dynamic>{
-// 'files': files,
-// },
-// );
-//
-//
-// final QueryResult r = await graphQLClientClient.mutate(_options);
-// expect(r.errors, isNull);
-// expect(r.data, isNotNull);
-// final bool viewerHasStarred =
-// r.data['action']['starrable']['viewerHasStarred'] as bool;
-// expect(viewerHasStarred, true);
-// });
+
+ final List<int> bytes = utf8.encode(body);
+ final Stream<List<int>> stream =
+ Stream<List<int>>.fromIterable(<List<int>>[bytes]);
+
+ final http.StreamedResponse r = http.StreamedResponse(stream, 200);
+ return r;
+ });
+
+ final String basePath = dirname(Platform.script.path);
+ final String realPath =
+ basePath.endsWith('test') ? basePath : join(basePath, 'test');
+ final List<File> files =
+ <String>['sample_upload.jpg', 'sample_upload.mov']
+ .map((String fileName) => join(
+ realPath,
+ fileName,
+ ))
+ .map((String filePath) => File(filePath))
+ .toList();
+
+ final MutationOptions _options = MutationOptions(
+ document: uploadMutation,
+ variables: <String, dynamic>{
+ 'files': files,
+ },
+ );
+ final QueryResult r = await graphQLClientClient.mutate(_options);
+
+ expect(r.errors, isNull);
+ expect(r.data, isNotNull);
+
+ final http.MultipartRequest request =
+ verify(mockHttpClient.send(captureAny)).captured.first
+ as http.MultipartRequest;
+ expect(request.method, 'post');
+ expect(request.url.toString(), 'http://localhost:3001/graphql');
+ expect(request.headers['accept'], '*/*');
+ expect(
+ request.headers['Authorization'], 'Bearer my-special-bearer-token');
+ final List<String> contentTypeStringSplit =
+ request.headers['content-type'].split('; boundary=');
+ expect(contentTypeStringSplit[0], 'multipart/form-data');
+ await expectUploadBody(bodyBytes, contentTypeStringSplit[1], files);
+
+ final List<Map<String, dynamic>> multipleUpload =
+ (r.data['multipleUpload'] as List<dynamic>)
+ .cast<Map<String, dynamic>>();
+
+ expect(multipleUpload, <Map<String, String>>[
+ <String, String>{
+ 'id': 'r1odc4PAz',
+ 'filename': 'sample_upload.jpg',
+ 'mimetype': 'image/jpeg',
+ 'path': './uploads/r1odc4PAz-sample_upload.jpg'
+ },
+ <String, String>{
+ 'id': '5Ea18qlMur',
+ 'filename': 'sample_upload.mov',
+ 'mimetype': 'video/quicktime',
+ 'path': './uploads/5Ea18qlMur-sample_upload.mov'
+ },
+ ]);
+ });
});
}

ADD
test/sample_upload.jpg
test/sample_upload.jpg
Binary files /dev/null and b/test/sample_upload.jpg differ

ADD
test/sample_upload.mov
test/sample_upload.mov
Binary files /dev/null and b/test/sample_upload.mov differ

Support GraphQL Upload spec as proposed at https://github.com/jaydenseric/graphql-multipart-request-spec (#2)