| OLD | NEW |
| (Empty) |
| 1 library googleapis_beta.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_beta/0.10.0'; | |
| 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 |