| // Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| /// A [Client] implementation based on the |
| /// [Foundation URL Loading System](https://developer.apple.com/documentation/foundation/url_loading_system). |
| library; |
| |
| import 'dart:async'; |
| import 'dart:io'; |
| import 'dart:typed_data'; |
| |
| import 'package:async/async.dart'; |
| import 'package:http/http.dart'; |
| import 'package:http_profile/http_profile.dart'; |
| |
| import 'cupertino_api.dart'; |
| |
| final _digitRegex = RegExp(r'^\d+$'); |
| |
| /// This class can be removed when `package:http` v2 is released. |
| class _StreamedResponseWithUrl extends StreamedResponse |
| implements BaseResponseWithUrl { |
| @override |
| final Uri url; |
| |
| _StreamedResponseWithUrl(super.stream, super.statusCode, |
| {required this.url, |
| super.contentLength, |
| super.request, |
| super.headers, |
| super.isRedirect, |
| super.reasonPhrase}); |
| } |
| |
| class _TaskTracker { |
| final responseCompleter = Completer<URLResponse>(); |
| final BaseRequest request; |
| final responseController = StreamController<Uint8List>(); |
| final HttpClientRequestProfile? profile; |
| int numRedirects = 0; |
| Uri? lastUrl; // The last URL redirected to. |
| |
| _TaskTracker(this.request, this.profile); |
| |
| void close() { |
| responseController.close(); |
| } |
| } |
| |
| /// A HTTP [Client] based on the |
| /// [Foundation URL Loading System](https://developer.apple.com/documentation/foundation/url_loading_system). |
| /// |
| /// For example: |
| /// ``` |
| /// void main() async { |
| /// var client = CupertinoClient.defaultSessionConfiguration(); |
| /// final response = await client.get( |
| /// Uri.https('www.googleapis.com', '/books/v1/volumes', {'q': '{http}'})); |
| /// if (response.statusCode != 200) { |
| /// throw HttpException('bad response: ${response.statusCode}'); |
| /// } |
| /// |
| /// final decodedResponse = |
| /// jsonDecode(utf8.decode(response.bodyBytes)) as Map; |
| /// |
| /// final itemCount = decodedResponse['totalItems']; |
| /// print('Number of books about http: $itemCount.'); |
| /// for (var i = 0; i < min(itemCount, 10); ++i) { |
| /// print(decodedResponse['items'][i]['volumeInfo']['title']); |
| /// } |
| /// } |
| /// ``` |
| class CupertinoClient extends BaseClient { |
| static final Map<URLSessionTask, _TaskTracker> _tasks = {}; |
| |
| URLSession? _urlSession; |
| |
| CupertinoClient._(this._urlSession); |
| |
| String? _findReasonPhrase(int statusCode) { |
| switch (statusCode) { |
| case HttpStatus.continue_: |
| return 'Continue'; |
| case HttpStatus.switchingProtocols: |
| return 'Switching Protocols'; |
| case HttpStatus.ok: |
| return 'OK'; |
| case HttpStatus.created: |
| return 'Created'; |
| case HttpStatus.accepted: |
| return 'Accepted'; |
| case HttpStatus.nonAuthoritativeInformation: |
| return 'Non-Authoritative Information'; |
| case HttpStatus.noContent: |
| return 'No Content'; |
| case HttpStatus.resetContent: |
| return 'Reset Content'; |
| case HttpStatus.partialContent: |
| return 'Partial Content'; |
| case HttpStatus.multipleChoices: |
| return 'Multiple Choices'; |
| case HttpStatus.movedPermanently: |
| return 'Moved Permanently'; |
| case HttpStatus.found: |
| return 'Found'; |
| case HttpStatus.seeOther: |
| return 'See Other'; |
| case HttpStatus.notModified: |
| return 'Not Modified'; |
| case HttpStatus.useProxy: |
| return 'Use Proxy'; |
| case HttpStatus.temporaryRedirect: |
| return 'Temporary Redirect'; |
| case HttpStatus.badRequest: |
| return 'Bad Request'; |
| case HttpStatus.unauthorized: |
| return 'Unauthorized'; |
| case HttpStatus.paymentRequired: |
| return 'Payment Required'; |
| case HttpStatus.forbidden: |
| return 'Forbidden'; |
| case HttpStatus.notFound: |
| return 'Not Found'; |
| case HttpStatus.methodNotAllowed: |
| return 'Method Not Allowed'; |
| case HttpStatus.notAcceptable: |
| return 'Not Acceptable'; |
| case HttpStatus.proxyAuthenticationRequired: |
| return 'Proxy Authentication Required'; |
| case HttpStatus.requestTimeout: |
| return 'Request Time-out'; |
| case HttpStatus.conflict: |
| return 'Conflict'; |
| case HttpStatus.gone: |
| return 'Gone'; |
| case HttpStatus.lengthRequired: |
| return 'Length Required'; |
| case HttpStatus.preconditionFailed: |
| return 'Precondition Failed'; |
| case HttpStatus.requestEntityTooLarge: |
| return 'Request Entity Too Large'; |
| case HttpStatus.requestUriTooLong: |
| return 'Request-URI Too Long'; |
| case HttpStatus.unsupportedMediaType: |
| return 'Unsupported Media Type'; |
| case HttpStatus.requestedRangeNotSatisfiable: |
| return 'Requested range not satisfiable'; |
| case HttpStatus.expectationFailed: |
| return 'Expectation Failed'; |
| case HttpStatus.internalServerError: |
| return 'Internal Server Error'; |
| case HttpStatus.notImplemented: |
| return 'Not Implemented'; |
| case HttpStatus.badGateway: |
| return 'Bad Gateway'; |
| case HttpStatus.serviceUnavailable: |
| return 'Service Unavailable'; |
| case HttpStatus.gatewayTimeout: |
| return 'Gateway Time-out'; |
| case HttpStatus.httpVersionNotSupported: |
| return 'Http Version not supported'; |
| default: |
| return null; |
| } |
| } |
| |
| static _TaskTracker _tracker(URLSessionTask task) => _tasks[task]!; |
| |
| static void _onComplete( |
| URLSession session, URLSessionTask task, Error? error) { |
| final taskTracker = _tracker(task); |
| if (error != null) { |
| final exception = ClientException( |
| error.localizedDescription ?? 'Unknown', taskTracker.request.url); |
| if (taskTracker.profile != null && |
| taskTracker.profile!.requestData.endTime == null) { |
| // Error occurred during the request. |
| taskTracker.profile!.requestData.closeWithError(exception.toString()); |
| } else { |
| // Error occurred during the response. |
| taskTracker.profile?.responseData.closeWithError(exception.toString()); |
| } |
| if (taskTracker.responseCompleter.isCompleted) { |
| taskTracker.responseController.addError(exception); |
| } else { |
| taskTracker.responseCompleter.completeError(exception); |
| } |
| } else { |
| assert(taskTracker.profile == null || |
| taskTracker.profile!.requestData.endTime != null); |
| |
| taskTracker.profile?.responseData.close(); |
| if (!taskTracker.responseCompleter.isCompleted) { |
| taskTracker.responseCompleter.completeError( |
| StateError('task completed without an error or response')); |
| } |
| } |
| taskTracker.close(); |
| _tasks.remove(task); |
| } |
| |
| static void _onData(URLSession session, URLSessionTask task, Data data) { |
| final taskTracker = _tracker(task); |
| taskTracker.responseController.add(data.bytes); |
| taskTracker.profile?.responseData.bodySink.add(data.bytes); |
| } |
| |
| static URLRequest? _onRedirect(URLSession session, URLSessionTask task, |
| HTTPURLResponse response, URLRequest request) { |
| final taskTracker = _tracker(task); |
| ++taskTracker.numRedirects; |
| if (taskTracker.request.followRedirects && |
| taskTracker.numRedirects <= taskTracker.request.maxRedirects) { |
| taskTracker.profile?.responseData.addRedirect(HttpProfileRedirectData( |
| statusCode: response.statusCode, |
| method: request.httpMethod, |
| location: request.url!.toString())); |
| taskTracker.lastUrl = request.url; |
| return request; |
| } |
| return null; |
| } |
| |
| static URLSessionResponseDisposition _onResponse( |
| URLSession session, URLSessionTask task, URLResponse response) { |
| final taskTracker = _tracker(task); |
| taskTracker.responseCompleter.complete(response); |
| unawaited(taskTracker.profile?.requestData.close()); |
| |
| return URLSessionResponseDisposition.urlSessionResponseAllow; |
| } |
| |
| /// A [Client] with the default configuration. |
| factory CupertinoClient.defaultSessionConfiguration() { |
| final config = URLSessionConfiguration.defaultSessionConfiguration(); |
| return CupertinoClient.fromSessionConfiguration(config); |
| } |
| |
| /// A [Client] configured with a [URLSessionConfiguration]. |
| factory CupertinoClient.fromSessionConfiguration( |
| URLSessionConfiguration config) { |
| final session = URLSession.sessionWithConfiguration(config, |
| onComplete: _onComplete, |
| onData: _onData, |
| onRedirect: _onRedirect, |
| onResponse: _onResponse); |
| return CupertinoClient._(session); |
| } |
| |
| @override |
| void close() { |
| _urlSession?.finishTasksAndInvalidate(); |
| _urlSession = null; |
| } |
| |
| /// Returns true if [stream] includes at least one list with an element. |
| /// |
| /// Since [_hasData] consumes [stream], returns a new stream containing the |
| /// equivalent data. |
| static Future<(bool, Stream<List<int>>)> _hasData( |
| Stream<List<int>> stream) async { |
| final queue = StreamQueue(stream); |
| while (await queue.hasNext && (await queue.peek).isEmpty) { |
| await queue.next; |
| } |
| |
| return (await queue.hasNext, queue.rest); |
| } |
| |
| HttpClientRequestProfile? _createProfile(BaseRequest request) => |
| HttpClientRequestProfile.profile( |
| requestStartTime: DateTime.now(), |
| requestMethod: request.method, |
| requestUri: request.url.toString()); |
| |
| @override |
| Future<StreamedResponse> send(BaseRequest request) async { |
| // The expected success case flow (without redirects) is: |
| // 1. send is called by BaseClient |
| // 2. send starts the request with UrlSession.dataTaskWithRequest and waits |
| // on a Completer |
| // 3. _onResponse is called with the HTTP headers, status code, etc. |
| // 4. _onResponse calls complete on the Completer that send is waiting on. |
| // 5. send continues executing and returns a StreamedResponse. |
| // StreamedResponse contains a Stream<UInt8List>. |
| // 6. _onData is called one or more times and adds that to the |
| // StreamController that controls the Stream<UInt8List> |
| // 7. _onComplete is called after all the data is read and closes the |
| // StreamController |
| if (_urlSession == null) { |
| throw ClientException( |
| 'HTTP request failed. Client is already closed.', request.url); |
| } |
| final urlSession = _urlSession!; |
| |
| final stream = request.finalize(); |
| |
| final profile = _createProfile(request); |
| profile?.connectionInfo = { |
| 'package': 'package:cupertino_http', |
| 'client': 'CupertinoClient', |
| 'configuration': _urlSession!.configuration.toString(), |
| }; |
| profile?.requestData |
| ?..contentLength = request.contentLength |
| ..followRedirects = request.followRedirects |
| ..headersCommaValues = request.headers |
| ..maxRedirects = request.maxRedirects; |
| |
| final urlRequest = MutableURLRequest.fromUrl(request.url) |
| ..httpMethod = request.method; |
| |
| if (request.contentLength != null) { |
| profile?.requestData.headersListValues = { |
| 'Content-Length': ['${request.contentLength}'], |
| ...profile.requestData.headers! |
| }; |
| urlRequest.setValueForHttpHeaderField( |
| 'Content-Length', '${request.contentLength}'); |
| } |
| |
| if (request is Request) { |
| // Optimize the (typical) `Request` case since assigning to |
| // `httpBodyStream` requires a lot of expensive setup and data passing. |
| urlRequest.httpBody = Data.fromList(request.bodyBytes); |
| profile?.requestData.bodySink.add(request.bodyBytes); |
| } else if (await _hasData(stream) case (true, final s)) { |
| // If the request is supposed to be bodyless (e.g. GET requests) |
| // then setting `httpBodyStream` will cause the request to fail - |
| // even if the stream is empty. |
| if (profile == null) { |
| urlRequest.httpBodyStream = s; |
| } else { |
| final splitter = StreamSplitter(s); |
| urlRequest.httpBodyStream = splitter.split(); |
| unawaited(profile.requestData.bodySink.addStream(splitter.split())); |
| } |
| } |
| |
| // This will preserve Apple default headers - is that what we want? |
| request.headers.forEach(urlRequest.setValueForHttpHeaderField); |
| final task = urlSession.dataTaskWithRequest(urlRequest); |
| final taskTracker = _TaskTracker(request, profile); |
| _tasks[task] = taskTracker; |
| task.resume(); |
| |
| final maxRedirects = request.followRedirects ? request.maxRedirects : 0; |
| |
| late URLResponse result; |
| result = await taskTracker.responseCompleter.future; |
| |
| final response = result as HTTPURLResponse; |
| |
| if (request.followRedirects && taskTracker.numRedirects > maxRedirects) { |
| throw ClientException('Redirect limit exceeded', request.url); |
| } |
| |
| final responseHeaders = response.allHeaderFields |
| .map((key, value) => MapEntry(key.toLowerCase(), value)); |
| |
| if (responseHeaders['content-length'] case final contentLengthHeader? |
| when !_digitRegex.hasMatch(contentLengthHeader)) { |
| throw ClientException( |
| 'Invalid content-length header [$contentLengthHeader].', |
| request.url, |
| ); |
| } |
| |
| final contentLength = response.expectedContentLength == -1 |
| ? null |
| : response.expectedContentLength; |
| final isRedirect = !request.followRedirects && taskTracker.numRedirects > 0; |
| profile?.responseData |
| ?..contentLength = contentLength |
| ..headersCommaValues = responseHeaders |
| ..isRedirect = isRedirect |
| ..reasonPhrase = _findReasonPhrase(response.statusCode) |
| ..startTime = DateTime.now() |
| ..statusCode = response.statusCode; |
| |
| return _StreamedResponseWithUrl( |
| taskTracker.responseController.stream, |
| response.statusCode, |
| url: taskTracker.lastUrl ?? request.url, |
| contentLength: contentLength, |
| reasonPhrase: _findReasonPhrase(response.statusCode), |
| request: request, |
| isRedirect: isRedirect, |
| headers: responseHeaders, |
| ); |
| } |
| } |
| |
| /// A test-only class that makes the [HttpClientRequestProfile] data available. |
| class CupertinoClientWithProfile extends CupertinoClient { |
| HttpClientRequestProfile? profile; |
| |
| @override |
| HttpClientRequestProfile? _createProfile(BaseRequest request) => |
| profile = super._createProfile(request); |
| |
| CupertinoClientWithProfile._(super._urlSession) : super._(); |
| |
| factory CupertinoClientWithProfile.defaultSessionConfiguration() { |
| final config = URLSessionConfiguration.defaultSessionConfiguration(); |
| final session = URLSession.sessionWithConfiguration(config, |
| onComplete: CupertinoClient._onComplete, |
| onData: CupertinoClient._onData, |
| onRedirect: CupertinoClient._onRedirect, |
| onResponse: CupertinoClient._onResponse); |
| return CupertinoClientWithProfile._(session); |
| } |
| } |