OLD | NEW |
| (Empty) |
1 library googleapis.common_internal; | |
2 | |
3 import "dart:async"; | |
4 import "dart:convert"; | |
5 import "dart:collection" as collection; | |
6 | |
7 import "package:crypto/crypto.dart" as crypto; | |
8 import "../common/common.dart" as common_external; | |
9 import "package:http/http.dart" as http; | |
10 | |
11 const String USER_AGENT_STRING = | |
12 'google-api-dart-client googleapis/0.6.1'; | |
13 | |
14 const CONTENT_TYPE_JSON_UTF8 = 'application/json; charset=utf-8'; | |
15 | |
16 /** | |
17 * Base class for all API clients, offering generic methods for | |
18 * HTTP Requests to the API | |
19 */ | |
20 class ApiRequester { | |
21 final http.Client _httpClient; | |
22 final String _rootUrl; | |
23 final String _basePath; | |
24 | |
25 ApiRequester(this._httpClient, this._rootUrl, this._basePath) { | |
26 assert(_rootUrl.endsWith('/')); | |
27 } | |
28 | |
29 | |
30 /** | |
31 * Sends a HTTPRequest using [method] (usually GET or POST) to [requestUrl] | |
32 * using the specified [urlParams] and [queryParams]. Optionally include a | |
33 * [body] and/or [uploadMedia] in the request. | |
34 * | |
35 * If [uploadMedia] was specified [downloadOptions] must be | |
36 * [DownloadOptions.Metadata] or `null`. | |
37 * | |
38 * If [downloadOptions] is [DownloadOptions.Metadata] the result will be | |
39 * decoded as JSON. | |
40 * | |
41 * If [downloadOptions] is `null` the result will be a Future completing with | |
42 * `null`. | |
43 * | |
44 * Otherwise the result will be downloaded as a [common_external.Media] | |
45 */ | |
46 Future request(String requestUrl, String method, | |
47 {String body, Map queryParams, | |
48 common_external.Media uploadMedia, | |
49 common_external.UploadOptions uploadOptions, | |
50 common_external.DownloadOptions downloadOptions: | |
51 common_external.DownloadOptions.Metadata}) { | |
52 if (uploadMedia != null && | |
53 downloadOptions != common_external.DownloadOptions.Metadata) { | |
54 throw new ArgumentError('When uploading a [Media] you cannot download a ' | |
55 '[Media] at the same time!'); | |
56 } | |
57 common_external.ByteRange downloadRange; | |
58 if (downloadOptions is common_external.PartialDownloadOptions && | |
59 !downloadOptions.isFullDownload) { | |
60 downloadRange = downloadOptions.range; | |
61 } | |
62 | |
63 return _request(requestUrl, method, body, queryParams, | |
64 uploadMedia, uploadOptions, | |
65 downloadOptions, | |
66 downloadRange) | |
67 .then(_validateResponse).then((http.StreamedResponse response) { | |
68 if (downloadOptions == null) { | |
69 // If no download options are given, the response is of no interest | |
70 // and we will drain the stream. | |
71 return response.stream.drain(); | |
72 } else if (downloadOptions == common_external.DownloadOptions.Metadata) { | |
73 // Downloading JSON Metadata | |
74 var stringStream = _decodeStreamAsText(response); | |
75 if (stringStream != null) { | |
76 return stringStream.join('').then((String bodyString) { | |
77 if (bodyString == '') return null; | |
78 return JSON.decode(bodyString); | |
79 }); | |
80 } else { | |
81 throw new common_external.ApiRequestError( | |
82 "Unable to read response with content-type " | |
83 "${response.headers['content-type']}."); | |
84 } | |
85 } else { | |
86 // Downloading Media. | |
87 var contentType = response.headers['content-type']; | |
88 if (contentType == null) { | |
89 throw new common_external.ApiRequestError( | |
90 "No 'content-type' header in media response."); | |
91 } | |
92 var contentLength; | |
93 try { | |
94 contentLength = int.parse(response.headers['content-length']); | |
95 } catch (_) { | |
96 // We silently ignore errors here. If no content-length was specified | |
97 // we use `null`. | |
98 // Please note that the code below still asserts the content-length | |
99 // is correct for range downloads. | |
100 } | |
101 | |
102 if (downloadRange != null) { | |
103 if (contentLength != downloadRange.length) { | |
104 throw new common_external.ApiRequestError( | |
105 "Content length of response does not match requested range " | |
106 "length."); | |
107 } | |
108 var contentRange = response.headers['content-range']; | |
109 var expected = 'bytes ${downloadRange.start}-${downloadRange.end}/'; | |
110 if (contentRange == null || !contentRange.startsWith(expected)) { | |
111 throw new common_external.ApiRequestError("Attempting partial " | |
112 "download but got invalid 'Content-Range' header " | |
113 "(was: $contentRange, expected: $expected)."); | |
114 } | |
115 } | |
116 | |
117 return new common_external.Media( | |
118 response.stream, contentLength, contentType: contentType); | |
119 } | |
120 }); | |
121 } | |
122 | |
123 Future _request(String requestUrl, String method, | |
124 String body, Map queryParams, | |
125 common_external.Media uploadMedia, | |
126 common_external.UploadOptions uploadOptions, | |
127 common_external.DownloadOptions downloadOptions, | |
128 common_external.ByteRange downloadRange) { | |
129 bool downloadAsMedia = | |
130 downloadOptions != null && | |
131 downloadOptions != common_external.DownloadOptions.Metadata; | |
132 | |
133 if (queryParams == null) queryParams = {}; | |
134 | |
135 if (uploadMedia != null) { | |
136 if (uploadOptions is common_external.ResumableUploadOptions) { | |
137 queryParams['uploadType'] = const ['resumable']; | |
138 } else if (body == null) { | |
139 queryParams['uploadType'] = const ['media']; | |
140 } else { | |
141 queryParams['uploadType'] = const ['multipart']; | |
142 } | |
143 } | |
144 | |
145 if (downloadAsMedia) { | |
146 queryParams['alt'] = const ['media']; | |
147 } else if (downloadOptions != null) { | |
148 queryParams['alt'] = const ['json']; | |
149 } | |
150 | |
151 var path; | |
152 if (requestUrl.startsWith('/')) { | |
153 path ="$_rootUrl${requestUrl.substring(1)}"; | |
154 } else { | |
155 path ="$_rootUrl${_basePath}$requestUrl"; | |
156 } | |
157 | |
158 bool containsQueryParameter = path.contains('?'); | |
159 addQueryParameter(String name, String value) { | |
160 name = Escaper.escapeQueryComponent(name); | |
161 value = Escaper.escapeQueryComponent(value); | |
162 if (containsQueryParameter) { | |
163 path = '$path&$name=$value'; | |
164 } else { | |
165 path = '$path?$name=$value'; | |
166 } | |
167 containsQueryParameter = true; | |
168 } | |
169 queryParams.forEach((String key, List<String> values) { | |
170 for (var value in values) { | |
171 addQueryParameter(key, value); | |
172 } | |
173 }); | |
174 | |
175 var uri = Uri.parse(path); | |
176 | |
177 Future simpleUpload() { | |
178 var bodyStream = uploadMedia.stream; | |
179 var request = new RequestImpl(method, uri, bodyStream); | |
180 request.headers.addAll({ | |
181 'user-agent' : USER_AGENT_STRING, | |
182 'content-type' : uploadMedia.contentType, | |
183 'content-length' : '${uploadMedia.length}' | |
184 }); | |
185 return _httpClient.send(request); | |
186 } | |
187 | |
188 Future simpleRequest() { | |
189 var length = 0; | |
190 var bodyController = new StreamController<List<int>>(); | |
191 if (body != null) { | |
192 var bytes = UTF8.encode(body); | |
193 bodyController.add(bytes); | |
194 length = bytes.length; | |
195 } | |
196 bodyController.close(); | |
197 | |
198 var headers; | |
199 if (downloadRange != null) { | |
200 headers = { | |
201 'user-agent' : USER_AGENT_STRING, | |
202 'content-type' : CONTENT_TYPE_JSON_UTF8, | |
203 'content-length' : '$length', | |
204 'range' : 'bytes=${downloadRange.start}-${downloadRange.end}', | |
205 }; | |
206 } else { | |
207 headers = { | |
208 'user-agent' : USER_AGENT_STRING, | |
209 'content-type' : CONTENT_TYPE_JSON_UTF8, | |
210 'content-length' : '$length', | |
211 }; | |
212 } | |
213 | |
214 var request = new RequestImpl(method, uri, bodyController.stream); | |
215 request.headers.addAll(headers); | |
216 return _httpClient.send(request); | |
217 } | |
218 | |
219 if (uploadMedia != null) { | |
220 // Three upload types: | |
221 // 1. Resumable: Upload of data + metdata with multiple requests. | |
222 // 2. Simple: Upload of media. | |
223 // 3. Multipart: Upload of data + metadata. | |
224 | |
225 if (uploadOptions is common_external.ResumableUploadOptions) { | |
226 var helper = new ResumableMediaUploader( | |
227 _httpClient, uploadMedia, body, uri, method, uploadOptions); | |
228 return helper.upload(); | |
229 } | |
230 | |
231 if (uploadMedia.length == null) { | |
232 throw new ArgumentError( | |
233 'For non-resumable uploads you need to specify the length of the ' | |
234 'media to upload.'); | |
235 } | |
236 | |
237 if (body == null) { | |
238 return simpleUpload(); | |
239 } else { | |
240 var uploader = new MultipartMediaUploader( | |
241 _httpClient, uploadMedia, body, uri, method); | |
242 return uploader.upload(); | |
243 } | |
244 } | |
245 return simpleRequest(); | |
246 } | |
247 } | |
248 | |
249 | |
250 /** | |
251 * Does media uploads using the multipart upload protocol. | |
252 */ | |
253 class MultipartMediaUploader { | |
254 static final _boundary = '314159265358979323846'; | |
255 static final _base64Encoder = new Base64Encoder(); | |
256 | |
257 final http.Client _httpClient; | |
258 final common_external.Media _uploadMedia; | |
259 final Uri _uri; | |
260 final String _body; | |
261 final String _method; | |
262 | |
263 MultipartMediaUploader( | |
264 this._httpClient, this._uploadMedia, this._body, this._uri, this._method); | |
265 | |
266 Future<http.StreamedResponse> upload() { | |
267 var base64MediaStream = | |
268 _uploadMedia.stream.transform(_base64Encoder).transform(ASCII.encoder); | |
269 var base64MediaStreamLength = | |
270 Base64Encoder.lengthOfBase64Stream(_uploadMedia.length); | |
271 | |
272 // NOTE: We assume that [_body] is encoded JSON without any \r or \n in it. | |
273 // This guarantees us that [_body] cannot contain a valid multipart | |
274 // boundary. | |
275 var bodyHead = | |
276 '--$_boundary\r\n' | |
277 "Content-Type: $CONTENT_TYPE_JSON_UTF8\r\n\r\n" | |
278 + _body + | |
279 '\r\n--$_boundary\r\n' | |
280 "Content-Type: ${_uploadMedia.contentType}\r\n" | |
281 "Content-Transfer-Encoding: base64\r\n\r\n"; | |
282 var bodyTail = '\r\n--$_boundary--'; | |
283 | |
284 var totalLength = | |
285 bodyHead.length + base64MediaStreamLength + bodyTail.length; | |
286 | |
287 var bodyController = new StreamController<List<int>>(); | |
288 bodyController.add(UTF8.encode(bodyHead)); | |
289 bodyController.addStream(base64MediaStream).then((_) { | |
290 bodyController.add(UTF8.encode(bodyTail)); | |
291 }).catchError((error, stack) { | |
292 bodyController.addError(error, stack); | |
293 }).then((_) { | |
294 bodyController.close(); | |
295 }); | |
296 | |
297 var headers = { | |
298 'user-agent' : USER_AGENT_STRING, | |
299 'content-type' : "multipart/related; boundary=\"$_boundary\"", | |
300 'content-length' : '$totalLength' | |
301 }; | |
302 var bodyStream = bodyController.stream; | |
303 var request = new RequestImpl(_method, _uri, bodyStream); | |
304 request.headers.addAll(headers); | |
305 return _httpClient.send(request); | |
306 } | |
307 } | |
308 | |
309 | |
310 /** | |
311 * Base64 encodes a stream of bytes. | |
312 */ | |
313 class Base64Encoder implements StreamTransformer<List<int>, String> { | |
314 static int lengthOfBase64Stream(int lengthOfByteStream) { | |
315 return ((lengthOfByteStream + 2) ~/ 3) * 4; | |
316 } | |
317 | |
318 Stream<String> bind(Stream<List<int>> stream) { | |
319 StreamController<String> controller; | |
320 | |
321 // Holds between 0 and 3 bytes and is used as a buffer. | |
322 List<int> remainingBytes = []; | |
323 | |
324 void onData(List<int> bytes) { | |
325 if ((remainingBytes.length + bytes.length) < 3) { | |
326 remainingBytes.addAll(bytes); | |
327 return; | |
328 } | |
329 int start; | |
330 if (remainingBytes.length == 0) { | |
331 start = 0; | |
332 } else if (remainingBytes.length == 1) { | |
333 remainingBytes.add(bytes[0]); | |
334 remainingBytes.add(bytes[1]); | |
335 start = 2; | |
336 } else if (remainingBytes.length == 2) { | |
337 remainingBytes.add(bytes[0]); | |
338 start = 1; | |
339 } | |
340 | |
341 // Convert & Send bytes from buffer (if necessary). | |
342 if (remainingBytes.length > 0) { | |
343 controller.add(crypto.CryptoUtils.bytesToBase64(remainingBytes)); | |
344 remainingBytes.clear(); | |
345 } | |
346 | |
347 int chunksOf3 = (bytes.length - start) ~/ 3; | |
348 int end = start + 3 * chunksOf3; | |
349 int remaining = bytes.length - end; | |
350 | |
351 // Convert & Send main bytes. | |
352 if (start == 0 && end == bytes.length) { | |
353 // Fast path if [bytes] are devisible by 3. | |
354 controller.add(crypto.CryptoUtils.bytesToBase64(bytes)); | |
355 } else { | |
356 controller.add( | |
357 crypto.CryptoUtils.bytesToBase64(bytes.sublist(start, end))); | |
358 | |
359 // Buffer remaining bytes if necessary. | |
360 if (end < bytes.length) { | |
361 remainingBytes.addAll(bytes.sublist(end)); | |
362 } | |
363 } | |
364 } | |
365 | |
366 void onError(error, stack) { | |
367 controller.addError(error, stack); | |
368 } | |
369 | |
370 void onDone() { | |
371 if (remainingBytes.length > 0) { | |
372 controller.add(crypto.CryptoUtils.bytesToBase64(remainingBytes)); | |
373 remainingBytes.clear(); | |
374 } | |
375 controller.close(); | |
376 } | |
377 | |
378 var subscription; | |
379 controller = new StreamController<String>( | |
380 onListen: () { | |
381 subscription = stream.listen( | |
382 onData, onError: onError, onDone: onDone); | |
383 }, | |
384 onPause: () { | |
385 subscription.pause(); | |
386 }, | |
387 onResume: () { | |
388 subscription.resume(); | |
389 }, | |
390 onCancel: () { | |
391 subscription.cancel(); | |
392 }); | |
393 return controller.stream; | |
394 } | |
395 } | |
396 | |
397 | |
398 // TODO: Buffer less if we know the content length in advance. | |
399 /** | |
400 * Does media uploads using the resumable upload protocol. | |
401 */ | |
402 class ResumableMediaUploader { | |
403 final http.Client _httpClient; | |
404 final common_external.Media _uploadMedia; | |
405 final Uri _uri; | |
406 final String _body; | |
407 final String _method; | |
408 final common_external.ResumableUploadOptions _options; | |
409 | |
410 ResumableMediaUploader( | |
411 this._httpClient, this._uploadMedia, this._body, this._uri, this._method, | |
412 this._options); | |
413 | |
414 /** | |
415 * Returns the final [http.StreamedResponse] if the upload succeded and | |
416 * completes with an error otherwise. | |
417 * | |
418 * The returned response stream has not been listened to. | |
419 */ | |
420 Future<http.StreamedResponse> upload() { | |
421 return _startSession().then((Uri uploadUri) { | |
422 StreamSubscription subscription; | |
423 | |
424 var completer = new Completer<http.StreamedResponse>(); | |
425 bool completed = false; | |
426 | |
427 var chunkStack = new ChunkStack(_options.chunkSize); | |
428 subscription = _uploadMedia.stream.listen((List<int> bytes) { | |
429 chunkStack.addBytes(bytes); | |
430 | |
431 // Upload all but the last chunk. | |
432 // The final send will be done in the [onDone] handler. | |
433 bool hasPartialChunk = chunkStack.hasPartialChunk; | |
434 if (chunkStack.length > 1 || | |
435 (chunkStack.length == 1 && hasPartialChunk)) { | |
436 // Pause the input stream. | |
437 subscription.pause(); | |
438 | |
439 // Upload all chunks except the last one. | |
440 var fullChunks; | |
441 if (hasPartialChunk) { | |
442 fullChunks = chunkStack.removeSublist(0, chunkStack.length); | |
443 } else { | |
444 fullChunks = chunkStack.removeSublist(0, chunkStack.length - 1); | |
445 } | |
446 Future.forEach(fullChunks, | |
447 (c) => _uploadChunkDrained(uploadUri, c)).then((_) { | |
448 // All chunks uploaded, we can continue consuming data. | |
449 subscription.resume(); | |
450 }).catchError((error, stack) { | |
451 subscription.cancel(); | |
452 completed = true; | |
453 completer.completeError(error, stack); | |
454 }); | |
455 } | |
456 }, onError: (error, stack) { | |
457 subscription.cancel(); | |
458 if (!completed) { | |
459 completed = true; | |
460 completer.completeError(error, stack); | |
461 } | |
462 }, onDone: () { | |
463 if (!completed) { | |
464 chunkStack.finalize(); | |
465 | |
466 var lastChunk; | |
467 if (chunkStack.length == 1) { | |
468 lastChunk = chunkStack.removeSublist(0, chunkStack.length).first; | |
469 } else { | |
470 completer.completeError(new StateError( | |
471 'Resumable uploads need to result in at least one non-empty ' | |
472 'chunk at the end.')); | |
473 return; | |
474 } | |
475 var end = lastChunk.endOfChunk; | |
476 | |
477 // Validate that we have the correct number of bytes if length was | |
478 // specified. | |
479 if (_uploadMedia.length != null) { | |
480 if (end < _uploadMedia.length) { | |
481 completer.completeError(new common_external.ApiRequestError( | |
482 'Received less bytes than indicated by [Media.length].')); | |
483 return; | |
484 } else if (end > _uploadMedia.length) { | |
485 completer.completeError(new common_external.ApiRequestError( | |
486 'Received more bytes than indicated by [Media.length].')); | |
487 return; | |
488 } | |
489 } | |
490 | |
491 // Upload last chunk and *do not drain the response* but complete | |
492 // with it. | |
493 _uploadChunkResumable(uploadUri, lastChunk, lastChunk: true) | |
494 .then((response) { | |
495 completer.complete(response); | |
496 }).catchError((error, stack) { | |
497 completer.completeError(error, stack); | |
498 }); | |
499 } | |
500 }); | |
501 | |
502 return completer.future; | |
503 }); | |
504 } | |
505 | |
506 /** | |
507 * Starts a resumable upload. | |
508 * | |
509 * Returns the [Uri] which should be used for uploading all content. | |
510 */ | |
511 Future<Uri> _startSession() { | |
512 var length = 0; | |
513 var bytes; | |
514 if (_body != null) { | |
515 bytes = UTF8.encode(_body); | |
516 length = bytes.length; | |
517 } | |
518 var bodyStream = _bytes2Stream(bytes); | |
519 | |
520 var request = new RequestImpl(_method, _uri, bodyStream); | |
521 request.headers.addAll({ | |
522 'user-agent' : USER_AGENT_STRING, | |
523 'content-type' : CONTENT_TYPE_JSON_UTF8, | |
524 'content-length' : '$length', | |
525 'x-upload-content-type' : _uploadMedia.contentType, | |
526 'x-upload-content-length' : '${_uploadMedia.length}', | |
527 }); | |
528 | |
529 return _httpClient.send(request).then((http.StreamedResponse response) { | |
530 return response.stream.drain().then((_) { | |
531 var uploadUri = response.headers['location']; | |
532 if (response.statusCode != 200 || uploadUri == null) { | |
533 throw new common_external.ApiRequestError( | |
534 'Invalid response for resumable upload attempt ' | |
535 '(status was: ${response.statusCode})'); | |
536 } | |
537 return Uri.parse(uploadUri); | |
538 }); | |
539 }); | |
540 } | |
541 | |
542 /** | |
543 * Uploads [chunk], retries upon server errors. The response stream will be | |
544 * drained. | |
545 */ | |
546 Future _uploadChunkDrained(Uri uri, ResumableChunk chunk) { | |
547 return _uploadChunkResumable(uri, chunk).then((response) { | |
548 return response.stream.drain(); | |
549 }); | |
550 } | |
551 | |
552 /** | |
553 * Does repeated attempts to upload [chunk]. | |
554 */ | |
555 Future _uploadChunkResumable(Uri uri, | |
556 ResumableChunk chunk, | |
557 {bool lastChunk: false}) { | |
558 tryUpload(int attemptsLeft) { | |
559 return _uploadChunk(uri, chunk, lastChunk: lastChunk) | |
560 .then((http.StreamedResponse response) { | |
561 var status = response.statusCode; | |
562 if (attemptsLeft > 0 && | |
563 (status == 500 || (502 <= status && status < 504))) { | |
564 return response.stream.drain().then((_) { | |
565 // Delay the next attempt. Default backoff function is exponential. | |
566 int failedAttemts = _options.numberOfAttempts - attemptsLeft; | |
567 var duration = _options.backoffFunction(failedAttemts); | |
568 if (duration == null) { | |
569 throw new common_external.DetailedApiRequestError( | |
570 status, | |
571 'Resumable upload: Uploading a chunk resulted in status ' | |
572 '$status. Maximum number of retries reached.'); | |
573 } | |
574 | |
575 return new Future.delayed(duration).then((_) { | |
576 return tryUpload(attemptsLeft - 1); | |
577 }); | |
578 }); | |
579 } else if (!lastChunk && status != 308) { | |
580 return response.stream.drain().then((_) { | |
581 throw new common_external.DetailedApiRequestError( | |
582 status, | |
583 'Resumable upload: Uploading a chunk resulted in status ' | |
584 '$status instead of 308.'); | |
585 }); | |
586 } else if (lastChunk && status != 201 && status != 200) { | |
587 return response.stream.drain().then((_) { | |
588 throw new common_external.DetailedApiRequestError( | |
589 status, | |
590 'Resumable upload: Uploading a chunk resulted in status ' | |
591 '$status instead of 200 or 201.'); | |
592 }); | |
593 } else { | |
594 return response; | |
595 } | |
596 }); | |
597 } | |
598 | |
599 return tryUpload(_options.numberOfAttempts - 1); | |
600 } | |
601 | |
602 /** | |
603 * Uploads [length] bytes in [byteArrays] and ensures the upload was | |
604 * successful. | |
605 * | |
606 * Content-Range: [start ... (start + length)[ | |
607 * | |
608 * Returns the returned [http.StreamedResponse] or completes with an error if | |
609 * the upload did not succeed. The response stream will not be listened to. | |
610 */ | |
611 Future _uploadChunk(Uri uri, ResumableChunk chunk, {bool lastChunk: false}) { | |
612 // If [uploadMedia.length] is null, we do not know the length. | |
613 var mediaTotalLength = _uploadMedia.length; | |
614 if (mediaTotalLength == null || lastChunk) { | |
615 if (lastChunk) { | |
616 mediaTotalLength = '${chunk.endOfChunk}'; | |
617 } else { | |
618 mediaTotalLength = '*'; | |
619 } | |
620 } | |
621 | |
622 var headers = { | |
623 'user-agent' : USER_AGENT_STRING, | |
624 'content-type' : _uploadMedia.contentType, | |
625 'content-length' : '${chunk.length}', | |
626 'content-range' : | |
627 'bytes ${chunk.offset}-${chunk.endOfChunk - 1}/$mediaTotalLength', | |
628 }; | |
629 | |
630 var stream = _listOfBytes2Stream(chunk.byteArrays); | |
631 var request = new RequestImpl('PUT', uri, stream); | |
632 request.headers.addAll(headers); | |
633 return _httpClient.send(request); | |
634 } | |
635 | |
636 Stream<List<int>> _bytes2Stream(List<int> bytes) { | |
637 var bodyController = new StreamController<List<int>>(); | |
638 if (bytes != null) { | |
639 bodyController.add(bytes); | |
640 } | |
641 bodyController.close(); | |
642 return bodyController.stream; | |
643 } | |
644 | |
645 Stream<List<int>> _listOfBytes2Stream(List<List<int>> listOfBytes) { | |
646 var controller = new StreamController(); | |
647 for (var array in listOfBytes) { | |
648 controller.add(array); | |
649 } | |
650 controller.close(); | |
651 return controller.stream; | |
652 } | |
653 } | |
654 | |
655 | |
656 /** | |
657 * Represents a stack of [ResumableChunk]s. | |
658 */ | |
659 class ChunkStack { | |
660 final int _chunkSize; | |
661 final List<ResumableChunk> _chunkStack = []; | |
662 | |
663 // Currently accumulated data. | |
664 List<List<int>> _byteArrays = []; | |
665 int _length = 0; | |
666 int _offset = 0; | |
667 | |
668 bool _finalized = false; | |
669 | |
670 ChunkStack(this._chunkSize); | |
671 | |
672 /** | |
673 * Whether data for a not-yet-finished [ResumableChunk] is present. A call to | |
674 * `finalize` will create a [ResumableChunk] of this data. | |
675 */ | |
676 bool get hasPartialChunk => _length > 0; | |
677 | |
678 /** | |
679 * The number of chunks in this [ChunkStack]. | |
680 */ | |
681 int get length => _chunkStack.length; | |
682 | |
683 /** | |
684 * The total number of bytes which have been converted to [ResumableChunk]s. | |
685 * Can only be called once this [ChunkStack] has been finalized. | |
686 */ | |
687 int get totalByteLength { | |
688 if (!_finalized) { | |
689 throw new StateError('ChunkStack has not been finalized yet.'); | |
690 } | |
691 | |
692 return _offset; | |
693 } | |
694 | |
695 /** | |
696 * Returns the chunks [from] ... [to] and deletes it from the stack. | |
697 */ | |
698 List<ResumableChunk> removeSublist(int from, int to) { | |
699 var sublist = _chunkStack.sublist(from, to); | |
700 _chunkStack.removeRange(from, to); | |
701 return sublist; | |
702 } | |
703 | |
704 /** | |
705 * Adds [bytes] to the buffer. If the buffer is larger than the given chunk | |
706 * size a new [ResumableChunk] will be created. | |
707 */ | |
708 void addBytes(List<int> bytes) { | |
709 if (_finalized) { | |
710 throw new StateError('ChunkStack has already been finalized.'); | |
711 } | |
712 | |
713 var remaining = _chunkSize - _length; | |
714 | |
715 if (bytes.length >= remaining) { | |
716 var left = bytes.sublist(0, remaining); | |
717 var right = bytes.sublist(remaining); | |
718 | |
719 _byteArrays.add(left); | |
720 _length += left.length; | |
721 | |
722 _chunkStack.add(new ResumableChunk(_byteArrays, _offset, _length)); | |
723 | |
724 _byteArrays = []; | |
725 _offset += _length; | |
726 _length = 0; | |
727 | |
728 addBytes(right); | |
729 } else if (bytes.length > 0) { | |
730 _byteArrays.add(bytes); | |
731 _length += bytes.length; | |
732 } | |
733 } | |
734 | |
735 /** | |
736 * Finalizes this [ChunkStack] and creates the last chunk (may have less bytes | |
737 * than the chunk size, but not zero). | |
738 */ | |
739 void finalize() { | |
740 if (_finalized) { | |
741 throw new StateError('ChunkStack has already been finalized.'); | |
742 } | |
743 _finalized = true; | |
744 | |
745 if (_length > 0) { | |
746 _chunkStack.add(new ResumableChunk(_byteArrays, _offset, _length)); | |
747 _offset += _length; | |
748 } | |
749 } | |
750 } | |
751 | |
752 | |
753 /** | |
754 * Represents a chunk of data that will be transferred in one http request. | |
755 */ | |
756 class ResumableChunk { | |
757 final List<List<int>> byteArrays; | |
758 final int offset; | |
759 final int length; | |
760 | |
761 /** | |
762 * Index of the next byte after this chunk. | |
763 */ | |
764 int get endOfChunk => offset + length; | |
765 | |
766 ResumableChunk(this.byteArrays, this.offset, this.length); | |
767 } | |
768 | |
769 class RequestImpl extends http.BaseRequest { | |
770 final Stream<List<int>> _stream; | |
771 | |
772 RequestImpl(String method, Uri url, [Stream<List<int>> stream]) | |
773 : _stream = stream == null ? new Stream.fromIterable([]) : stream, | |
774 super(method, url); | |
775 | |
776 http.ByteStream finalize() { | |
777 super.finalize(); | |
778 return new http.ByteStream(_stream); | |
779 } | |
780 } | |
781 | |
782 | |
783 class Escaper { | |
784 // Character class definitions from RFC 6570 | |
785 // (see http://tools.ietf.org/html/rfc6570) | |
786 // ALPHA = %x41-5A / %x61-7A ; A-Z / a-z | |
787 // DIGIT = %x30-39 ; 0 | |
788 // HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" | |
789 // pct-encoded = "%" HEXDIG HEXDIG | |
790 // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" | |
791 // reserved = gen-delims / sub-delims | |
792 // gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" | |
793 // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" | |
794 // / "*" / "+" / "," / ";" / "=" | |
795 | |
796 // NOTE: Uri.encodeQueryComponent() does the following: | |
797 // ... | |
798 // Then the resulting bytes are "percent-encoded". This transforms spaces | |
799 // (U+0020) to a plus sign ('+') and all bytes that are not the ASCII decimal | |
800 // digits, letters or one of '-._~' are written as a percent sign '%' | |
801 // followed by the two-digit hexadecimal representation of the byte. | |
802 // ... | |
803 | |
804 // NOTE: Uri.encodeFull() does the following: | |
805 // ... | |
806 // All characters except uppercase and lowercase letters, digits and the | |
807 // characters !#$&'()*+,-./:;=?@_~ are percent-encoded. | |
808 // ... | |
809 | |
810 static String ecapeVariableReserved(String name) { | |
811 // ... perform variable expansion, as defined in Section 3.2.1, with the | |
812 // allowed characters being those in the set | |
813 // (unreserved / reserved / pct-encoded) | |
814 | |
815 // NOTE: The chracters [ and ] need (according to URI Template spec) not be | |
816 // percent encoded. The dart implementation does percent-encode [ and ]. | |
817 // This gives us in effect a conservative encoding, since the server side | |
818 // must interpret percent-encoded parts anyway due to arbitrary unicode. | |
819 | |
820 // NOTE: This is broken in the discovery protocol. It allows ? and & to be | |
821 // expanded via URI Templates which may generate completely bogus URIs. | |
822 // TODO/FIXME: Should we change this to _encodeUnreserved() as well | |
823 // (disadvantage, slashes get encoded at this point)? | |
824 return Uri.encodeFull(name); | |
825 } | |
826 | |
827 static String ecapePathComponent(String name) { | |
828 // For each defined variable in the variable-list, append "/" to the | |
829 // result string and then perform variable expansion, as defined in | |
830 // Section 3.2.1, with the allowed characters being those in the | |
831 // *unreserved set*. | |
832 return _encodeUnreserved(name); | |
833 } | |
834 | |
835 static String ecapeVariable(String name) { | |
836 // ... perform variable expansion, as defined in Section 3.2.1, with the | |
837 // allowed characters being those in the *unreserved set*. | |
838 return _encodeUnreserved(name); | |
839 } | |
840 | |
841 static String escapeQueryComponent(String name) { | |
842 // This method will not be used by UriTemplate, but rather for encoding | |
843 // normal query name/value pairs. | |
844 | |
845 // NOTE: For safety reasons we use '%20' instead of '+' here as well. | |
846 // TODO/FIXME: Should we do this? | |
847 return _encodeUnreserved(name); | |
848 } | |
849 | |
850 static String _encodeUnreserved(String name) { | |
851 // The only difference between dart's [Uri.encodeQueryComponent] and the | |
852 // encoding defined by RFC 6570 for the above-defined unreserved character | |
853 // set is the encoding of space. | |
854 // Dart's Uri class will convert spaces to '+' which we replace by '%20'. | |
855 return Uri.encodeQueryComponent(name).replaceAll('+', '%20'); | |
856 } | |
857 } | |
858 | |
859 | |
860 Future<http.StreamedResponse> _validateResponse( | |
861 http.StreamedResponse response) { | |
862 var statusCode = response.statusCode; | |
863 | |
864 // TODO: We assume that status codes between [200..400[ are OK. | |
865 // Can we assume this? | |
866 if (statusCode < 200 || statusCode >= 400) { | |
867 throwGeneralError() { | |
868 throw new common_external.DetailedApiRequestError( | |
869 statusCode, 'No error details. HTTP status was: ${statusCode}.'); | |
870 } | |
871 | |
872 // Some error happened, try to decode the response and fetch the error. | |
873 Stream<String> stringStream = _decodeStreamAsText(response); | |
874 if (stringStream != null) { | |
875 return stringStream.transform(JSON.decoder).first.then((json) { | |
876 if (json is Map && json['error'] is Map) { | |
877 var error = json['error']; | |
878 var code = error['code']; | |
879 var message = error['message']; | |
880 throw new common_external.DetailedApiRequestError(code, message); | |
881 } else { | |
882 throwGeneralError(); | |
883 } | |
884 }); | |
885 } else { | |
886 throwGeneralError(); | |
887 } | |
888 } | |
889 | |
890 return new Future.value(response); | |
891 } | |
892 | |
893 | |
894 Stream<String> _decodeStreamAsText(http.StreamedResponse response) { | |
895 // TODO: Correctly handle the response content-types, using correct | |
896 // decoder. | |
897 // Currently we assume that the api endpoint is responding with json | |
898 // encoded in UTF8. | |
899 String contentType = response.headers['content-type']; | |
900 if (contentType != null && | |
901 contentType.toLowerCase().startsWith('application/json')) { | |
902 return response.stream.transform(new Utf8Decoder(allowMalformed: true)); | |
903 } else { | |
904 return null; | |
905 } | |
906 } | |
907 | |
908 Map mapMap(Map source, [Object convert(Object source) = null]) { | |
909 assert(source != null); | |
910 var result = new collection.LinkedHashMap(); | |
911 source.forEach((String key, value) { | |
912 assert(key != null); | |
913 if(convert == null) { | |
914 result[key] = value; | |
915 } else { | |
916 result[key] = convert(value); | |
917 } | |
918 }); | |
919 return result; | |
920 } | |
921 | |
OLD | NEW |