-
-
Notifications
You must be signed in to change notification settings - Fork 630
/
Copy pathlink_http.dart
358 lines (305 loc) · 11.1 KB
/
link_http.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:graphql/src/exceptions/exceptions.dart' as ex;
import 'package:meta/meta.dart';
import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart';
import 'package:gql/language.dart';
import 'package:graphql/src/utilities/helpers.dart' show notNull;
import 'package:graphql/src/link/link.dart';
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({
@required String uri,
bool includeExtensions,
bool useGETForQueries = false,
/// pass on customized httpClient, especially handy for mocking and testing
Client httpClient,
Map<String, String> headers,
Map<String, dynamic> credentials,
Map<String, dynamic> fetchOptions,
}) : super(
// @todo possibly this is a bug in dart analyzer
// ignore: undefined_named_parameter
request: (
Operation operation, [
NextLink forward,
]) {
final parsedUri = Uri.parse(uri);
if (operation.isSubscription) {
if (forward == null) {
throw Exception('This link does not support subscriptions.');
}
return forward(operation);
}
final Client fetcher = httpClient ?? Client();
final HttpConfig linkConfig = HttpConfig(
http: HttpQueryOptions(
includeExtensions: includeExtensions,
useGETForQueries: useGETForQueries,
),
options: fetchOptions,
credentials: credentials,
headers: headers,
);
final Map<String, dynamic> context = operation.getContext();
HttpConfig contextConfig;
if (context != null) {
// TODO: for backwards-compatability fallback to overall context for http options
dynamic httpContext = context['http'] ?? context ?? {};
// TODO: refactor context to use a [HttpConfig] object to avoid dynamic types
contextConfig = HttpConfig(
http: HttpQueryOptions(
includeQuery: httpContext['includeQuery'] as bool,
includeExtensions: httpContext['includeExtensions'] as bool,
useGETForQueries: httpContext['useGETForQueries'] as bool,
),
options: context['fetchOptions'] as Map<String, dynamic>,
credentials: context['credentials'] as Map<String, dynamic>,
headers: context['headers'] as Map<String, String>,
);
}
final HttpConfig config = _mergeHttpConfigs(
fallbackHttpConfig,
linkConfig,
contextConfig,
);
StreamController<FetchResult> controller;
Future<void> onListen() async {
StreamedResponse response;
try {
// httpOptionsAndBody.body as String
final BaseRequest request = await _prepareRequest(parsedUri, operation, config);
response = await fetcher.send(request);
operation.setContext(<String, StreamedResponse>{
'response': response,
});
final FetchResult parsedResponse = await _parseResponse(response);
controller.add(parsedResponse);
} catch (failure) {
// we overwrite socket uri for now:
// https://github.com/dart-lang/sdk/issues/12693
dynamic translated = ex.translateFailure(failure);
if (translated is ex.NetworkException) {
translated.uri = parsedUri;
}
controller.addError(translated);
}
await controller.close();
}
controller = StreamController<FetchResult>(onListen: onListen);
return controller.stream;
},
);
}
Future<Map<String, MultipartFile>> _getFileMap(
dynamic body, {
Map<String, MultipartFile> currentMap,
List<String> currentPath = const <String>[],
}) 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(await _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(await _getFileMap(
body[i],
currentMap: currentMap,
currentPath: List<String>.from(currentPath)..add(i.toString()),
));
}
return currentMap;
}
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;
}
Future<BaseRequest> _prepareRequest(
Uri uri,
Operation operation,
HttpConfig config,
) async {
final httpHeaders = config.headers;
final body = _buildBody(operation, config);
final Map<String, MultipartFile> fileMap = await _getFileMap(body);
if (fileMap.isEmpty) {
if (operation.isQuery && config.http.useGETForQueries) {
config.options['method'] = 'GET';
}
final httpMethod = config.options['method']?.toString()?.toUpperCase() ?? 'POST';
if (httpMethod == 'GET') {
uri = uri.replace(queryParameters: body.map((k, v) => MapEntry(k, v is String ? v : json.encode(v))));
}
final Request r = Request(httpMethod, uri);
r.headers.addAll(httpHeaders);
if (httpMethod != 'GET') {
r.body = json.encode(body);
}
return r;
}
final MultipartRequest r = MultipartRequest('POST', uri);
r.headers.addAll(httpHeaders);
r.fields['operations'] = json.encode(body, toEncodable: (dynamic object) {
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(object)) {
return null;
}
return object.toJson();
});
final Map<String, List<String>> fileMapping = <String, List<String>>{};
final List<MultipartFile> fileList = <MultipartFile>[];
final List<MapEntry<String, MultipartFile>> fileMapEntries =
fileMap.entries.toList(growable: false);
for (int i = 0; i < fileMapEntries.length; i++) {
final MapEntry<String, MultipartFile> entry = fileMapEntries[i];
final String indexString = i.toString();
fileMapping.addAll(<String, List<String>>{
indexString: <String>[entry.key],
});
final MultipartFile f = entry.value;
fileList.add(MultipartFile(
indexString,
f.finalize(),
f.length,
contentType: f.contentType,
filename: f.filename,
));
}
r.fields['map'] = json.encode(fileMapping);
r.files.addAll(fileList);
return r;
}
HttpConfig _mergeHttpConfigs(
HttpConfig fallbackConfig, [
HttpConfig linkConfig,
HttpConfig contextConfig,
]) {
// http options
final HttpQueryOptions httpQueryOptions = HttpQueryOptions();
// initialize with fallback http options
httpQueryOptions.addAll(fallbackConfig.http);
// inject the configured http options
if (linkConfig.http != null) {
httpQueryOptions.addAll(linkConfig.http);
}
// override with context http options
if (contextConfig.http != null) {
httpQueryOptions.addAll(contextConfig.http);
}
return HttpConfig(
http: httpQueryOptions,
options: {
...fallbackConfig.options,
...(linkConfig != null ? linkConfig.options ?? {} : {}),
...(contextConfig != null ? contextConfig.options ?? {} : {}),
},
credentials: {
...fallbackConfig.credentials,
...(linkConfig != null ? linkConfig.credentials ?? {} : {}),
...(contextConfig != null ? contextConfig.credentials ?? {} : {}),
},
headers: {
...fallbackConfig.headers,
...(linkConfig != null ? linkConfig.headers ?? {} : {}),
...(contextConfig != null ? contextConfig.headers ?? {} : {}),
},
);
}
Map<String, dynamic> _buildBody(
Operation operation,
HttpConfig config,
) {
// the body depends on the http options
final Map<String, dynamic> body = <String, dynamic>{
'operationName': operation.operationName,
'variables': operation.variables,
};
// not sending the query (i.e persisted queries)
if (config.http.includeExtensions) {
body['extensions'] = operation.extensions;
}
if (config.http.includeQuery) {
body['query'] = printNode(operation.documentNode);
}
return body;
}
Future<FetchResult> _parseResponse(StreamedResponse response) async {
final int statusCode = response.statusCode;
final Encoding encoding = _determineEncodingFromResponse(response);
// @todo limit bodyBytes
final Uint8List responseByte = await response.stream.toBytes();
final String decodedBody = encoding.decode(responseByte);
Map<String, dynamic> jsonResponse;
try {
jsonResponse = json.decode(decodedBody) as Map<String, dynamic>;
} catch(e) {
throw ClientException('Invalid response body: $decodedBody');
}
final FetchResult fetchResult = FetchResult(
statusCode: statusCode,
);
if (jsonResponse['errors'] != null) {
fetchResult.errors =
(jsonResponse['errors'] as List<dynamic>).where(notNull).toList();
}
if (jsonResponse['data'] != null) {
fetchResult.data = jsonResponse['data'];
}
if (fetchResult.data == null && fetchResult.errors == null) {
if (statusCode < 200 || statusCode >= 400) {
throw ClientException(
'Network Error: $statusCode $decodedBody',
);
}
throw ClientException('Invalid response body: $decodedBody');
}
return fetchResult;
}
/// Returns the charset encoding for the given response.
///
/// The default fallback encoding is set to UTF-8 according to the IETF RFC4627 standard
/// which specifies the application/json media type:
/// "JSON text SHALL be encoded in Unicode. The default encoding is UTF-8."
Encoding _determineEncodingFromResponse(BaseResponse response,
[Encoding fallback = utf8]) {
final String contentType = response.headers['content-type'];
if (contentType == null) {
return fallback;
}
final MediaType mediaType = MediaType.parse(contentType);
final String charset = mediaType.parameters['charset'];
if (charset == null) {
return fallback;
}
final Encoding encoding = Encoding.getByName(charset);
return encoding == null ? fallback : encoding;
}