diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..f189cdde73 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +.github/workflows/dart.yml linguist-generated=true +tool/ci.sh linguist-generated=true diff --git a/.gitignore b/.gitignore index ac98e87d12..19aebaa9c7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .dart_tool .packages pubspec.lock +.idea diff --git a/README.md b/README.md index fa4f19934b..c6eae3c70b 100644 --- a/README.md +++ b/README.md @@ -1,102 +1,14 @@ -A composable, Future-based library for making HTTP requests. - -[![pub package](https://img.shields.io/pub/v/http.svg)](https://pub.dev/packages/http) [![Build Status](https://github.com/dart-lang/http/workflows/Dart%20CI/badge.svg)](https://github.com/dart-lang/http/actions?query=workflow%3A"Dart+CI"+branch%3Amaster) -This package contains a set of high-level functions and classes that make it +A composable, Future-based library for making HTTP requests. + +`package:http` contains a set of high-level functions and classes that make it easy to consume HTTP resources. It's multi-platform, and supports mobile, desktop, and the browser. -## Using - -The easiest way to use this library is via the top-level functions. They allow -you to make individual HTTP requests with minimal hassle: - -```dart -import 'package:http/http.dart' as http; - -var url = Uri.parse('https://example.com/whatsit/create'); -var response = await http.post(url, body: {'name': 'doodle', 'color': 'blue'}); -print('Response status: ${response.statusCode}'); -print('Response body: ${response.body}'); - -print(await http.read(Uri.parse('https://example.com/foobar.txt'))); -``` - -If you're making multiple requests to the same server, you can keep open a -persistent connection by using a [Client][] rather than making one-off requests. -If you do this, make sure to close the client when you're done: - -```dart -var client = http.Client(); -try { - var response = await client.post( - Uri.https('example.com', 'whatsit/create'), - body: {'name': 'doodle', 'color': 'blue'}); - var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; - var uri = Uri.parse(decodedResponse['uri'] as String); - print(await client.get(uri)); -} finally { - client.close(); -} -``` - -You can also exert more fine-grained control over your requests and responses by -creating [Request][] or [StreamedRequest][] objects yourself and passing them to -[Client.send][]. - -[Request]: https://pub.dev/documentation/http/latest/http/Request-class.html -[StreamedRequest]: https://pub.dev/documentation/http/latest/http/StreamedRequest-class.html -[Client.send]: https://pub.dev/documentation/http/latest/http/Client/send.html - -This package is designed to be composable. This makes it easy for external -libraries to work with one another to add behavior to it. Libraries wishing to -add behavior should create a subclass of [BaseClient][] that wraps another -[Client][] and adds the desired behavior: - -[BaseClient]: https://pub.dev/documentation/http/latest/http/BaseClient-class.html -[Client]: https://pub.dev/documentation/http/latest/http/Client-class.html - -```dart -class UserAgentClient extends http.BaseClient { - final String userAgent; - final http.Client _inner; - - UserAgentClient(this.userAgent, this._inner); - - Future send(http.BaseRequest request) { - request.headers['user-agent'] = userAgent; - return _inner.send(request); - } -} -``` - -## Retrying requests - -`package:http/retry.dart` provides a class [`RetryClient`][RetryClient] to wrap -an underlying [`http.Client`][Client] which transparently retries failing -requests. - -[RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient-class.html -[Client]: https://pub.dev/documentation/http/latest/http/Client-class.html - -```dart -import 'package:http/http.dart' as http; -import 'package:http/retry.dart'; - -Future main() async { - final client = RetryClient(http.Client()); - try { - print(await client.read(Uri.parse('http://example.org'))); - } finally { - client.close(); - } -} -``` - -By default, this retries any request whose response has status code 503 -Temporary Failure up to three retries. It waits 500ms before the first retry, -and increases the delay by 1.5x each time. All of this can be customized using -the [`RetryClient()`][new RetryClient] constructor. +## Packages -[new RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient/RetryClient.html +| Package | Description | Version | +|---|---|---| +| [http](pkgs/http/) | A composable, multi-platform, Future-based API for HTTP requests. | [![pub package](https://img.shields.io/pub/v/http.svg)](https://pub.dev/packages/http) | +| [http_client_conformance_tests](pkgs/http_client_conformance_tests/) | A library that tests whether implementations of package:http's `Client` class behave as expected. | | diff --git a/mono_repo.yaml b/mono_repo.yaml new file mode 100644 index 0000000000..7dc1d9ed19 --- /dev/null +++ b/mono_repo.yaml @@ -0,0 +1,3 @@ +merge_stages: + - analyze_and_format + - unit_test diff --git a/CHANGELOG.md b/pkgs/http/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to pkgs/http/CHANGELOG.md diff --git a/pkgs/http/LICENSE b/pkgs/http/LICENSE new file mode 100644 index 0000000000..000cd7beca --- /dev/null +++ b/pkgs/http/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/http/README.md b/pkgs/http/README.md new file mode 100644 index 0000000000..c889f2b54a --- /dev/null +++ b/pkgs/http/README.md @@ -0,0 +1,102 @@ +[![pub package](https://img.shields.io/pub/v/http.svg)](https://pub.dev/packages/http) +[![package publisher](https://img.shields.io/pub/publisher/http.svg)](https://pub.dev/packages/http/publisher) + +A composable, Future-based library for making HTTP requests. + +This package contains a set of high-level functions and classes that make it +easy to consume HTTP resources. It's multi-platform, and supports mobile, desktop, +and the browser. + +## Using + +The easiest way to use this library is via the top-level functions. They allow +you to make individual HTTP requests with minimal hassle: + +```dart +import 'package:http/http.dart' as http; + +var url = Uri.https('example.com', 'whatsit/create'); +var response = await http.post(url, body: {'name': 'doodle', 'color': 'blue'}); +print('Response status: ${response.statusCode}'); +print('Response body: ${response.body}'); + +print(await http.read(Uri.https('example.com', 'foobar.txt'))); +``` + +If you're making multiple requests to the same server, you can keep open a +persistent connection by using a [Client][] rather than making one-off requests. +If you do this, make sure to close the client when you're done: + +```dart +var client = http.Client(); +try { + var response = await client.post( + Uri.https('example.com', 'whatsit/create'), + body: {'name': 'doodle', 'color': 'blue'}); + var decodedResponse = jsonDecode(utf8.decode(response.bodyBytes)) as Map; + var uri = Uri.parse(decodedResponse['uri'] as String); + print(await client.get(uri)); +} finally { + client.close(); +} +``` + +You can also exert more fine-grained control over your requests and responses by +creating [Request][] or [StreamedRequest][] objects yourself and passing them to +[Client.send][]. + +[Request]: https://pub.dev/documentation/http/latest/http/Request-class.html +[StreamedRequest]: https://pub.dev/documentation/http/latest/http/StreamedRequest-class.html +[Client.send]: https://pub.dev/documentation/http/latest/http/Client/send.html + +This package is designed to be composable. This makes it easy for external +libraries to work with one another to add behavior to it. Libraries wishing to +add behavior should create a subclass of [BaseClient][] that wraps another +[Client][] and adds the desired behavior: + +[BaseClient]: https://pub.dev/documentation/http/latest/http/BaseClient-class.html +[Client]: https://pub.dev/documentation/http/latest/http/Client-class.html + +```dart +class UserAgentClient extends http.BaseClient { + final String userAgent; + final http.Client _inner; + + UserAgentClient(this.userAgent, this._inner); + + Future send(http.BaseRequest request) { + request.headers['user-agent'] = userAgent; + return _inner.send(request); + } +} +``` + +## Retrying requests + +`package:http/retry.dart` provides a class [`RetryClient`][RetryClient] to wrap +an underlying [`http.Client`][Client] which transparently retries failing +requests. + +[RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient-class.html +[Client]: https://pub.dev/documentation/http/latest/http/Client-class.html + +```dart +import 'package:http/http.dart' as http; +import 'package:http/retry.dart'; + +Future main() async { + final client = RetryClient(http.Client()); + try { + print(await client.read(Uri.http('example.org', ''))); + } finally { + client.close(); + } +} +``` + +By default, this retries any request whose response has status code 503 +Temporary Failure up to three retries. It waits 500ms before the first retry, +and increases the delay by 1.5x each time. All of this can be customized using +the [`RetryClient()`][new RetryClient] constructor. + +[new RetryClient]: https://pub.dev/documentation/http/latest/retry/RetryClient/RetryClient.html diff --git a/example/main.dart b/pkgs/http/example/main.dart similarity index 100% rename from example/main.dart rename to pkgs/http/example/main.dart diff --git a/example/retry.dart b/pkgs/http/example/retry.dart similarity index 100% rename from example/retry.dart rename to pkgs/http/example/retry.dart diff --git a/lib/browser_client.dart b/pkgs/http/lib/browser_client.dart similarity index 100% rename from lib/browser_client.dart rename to pkgs/http/lib/browser_client.dart diff --git a/lib/http.dart b/pkgs/http/lib/http.dart similarity index 99% rename from lib/http.dart rename to pkgs/http/lib/http.dart index 1ea751eab1..34eebbbd0a 100644 --- a/lib/http.dart +++ b/pkgs/http/lib/http.dart @@ -16,7 +16,7 @@ export 'src/base_client.dart'; export 'src/base_request.dart'; export 'src/base_response.dart'; export 'src/byte_stream.dart'; -export 'src/client.dart'; +export 'src/client.dart' hide zoneClient; export 'src/exception.dart'; export 'src/multipart_file.dart'; export 'src/multipart_request.dart'; diff --git a/lib/io_client.dart b/pkgs/http/lib/io_client.dart similarity index 100% rename from lib/io_client.dart rename to pkgs/http/lib/io_client.dart diff --git a/lib/retry.dart b/pkgs/http/lib/retry.dart similarity index 97% rename from lib/retry.dart rename to pkgs/http/lib/retry.dart index e2ca33f2a5..265c66a9bc 100644 --- a/lib/retry.dart +++ b/pkgs/http/lib/retry.dart @@ -8,6 +8,7 @@ import 'dart:math' as math; import 'package:async/async.dart'; import 'http.dart'; +import 'src/utils.dart'; /// An HTTP client wrapper that automatically retries failing requests. class RetryClient extends BaseClient { @@ -101,7 +102,8 @@ class RetryClient extends BaseClient { ); @override - Future send(BaseRequest request) async { + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}) async { final splitter = StreamSplitter(request.finalize()); var i = 0; diff --git a/lib/src/base_client.dart b/pkgs/http/lib/src/base_client.dart similarity index 90% rename from lib/src/base_client.dart rename to pkgs/http/lib/src/base_client.dart index 9020495b88..1769ed1586 100644 --- a/lib/src/base_client.dart +++ b/pkgs/http/lib/src/base_client.dart @@ -12,6 +12,7 @@ import 'exception.dart'; import 'request.dart'; import 'response.dart'; import 'streamed_response.dart'; +import 'utils.dart'; /// The abstract base class for an HTTP client. /// @@ -67,8 +68,16 @@ abstract class BaseClient implements Client { /// state of the stream; it could have data written to it asynchronously at a /// later point, or it could already be closed when it's returned. Any /// internal HTTP errors should be wrapped as [ClientException]s. + /// + /// If [onUploadProgress] callback is provided and length is computable, + /// [onUploadProgress] will execute for each chunk was sent. + /// + /// lengthComputable : + /// library.html : xhr.lengthComputable + /// library.io : content-length is provided (MultipartRequest provide) @override - Future send(BaseRequest request); + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}); /// Sends a non-streaming [Request] and returns a non-streaming [Response]. Future _sendUnstreamed( diff --git a/lib/src/base_request.dart b/pkgs/http/lib/src/base_request.dart similarity index 89% rename from lib/src/base_request.dart rename to pkgs/http/lib/src/base_request.dart index fd18bad332..3adc956e39 100644 --- a/lib/src/base_request.dart +++ b/pkgs/http/lib/src/base_request.dart @@ -89,6 +89,7 @@ abstract class BaseRequest { bool _finalized = false; static final _tokenRE = RegExp(r"^[\w!#%&'*+\-.^`|~]+$"); + static String _validateMethod(String method) { if (!_tokenRE.hasMatch(method)) { throw ArgumentError.value(method, 'method', 'Not a valid method'); @@ -96,7 +97,18 @@ abstract class BaseRequest { return method; } - BaseRequest(String method, this.url) + /// On upload progress callback. + /// + /// If defined, this callback will be called when the upload progress changes. + /// + /// In browser, uses XMLHttpRequest's "xhr.upload.onLoad" event. + /// + /// In IO, uses the yield length of the stream. The total length of the bytes + /// yielded by the stream at any given moment is "uploaded" and the total + /// length of the stream is "total" + final OnUploadProgress? onUploadProgress; + + BaseRequest(String method, this.url, {this.onUploadProgress}) : method = _validateMethod(method), headers = LinkedHashMap( equals: (key1, key2) => key1.toLowerCase() == key2.toLowerCase(), @@ -130,7 +142,8 @@ abstract class BaseRequest { var client = Client(); try { - var response = await client.send(this); + var response = + await client.send(this, onUploadProgress: onUploadProgress); var stream = onDone(response.stream, client.close); return StreamedResponse(ByteStream(stream), response.statusCode, contentLength: response.contentLength, diff --git a/lib/src/base_response.dart b/pkgs/http/lib/src/base_response.dart similarity index 100% rename from lib/src/base_response.dart rename to pkgs/http/lib/src/base_response.dart diff --git a/lib/src/boundary_characters.dart b/pkgs/http/lib/src/boundary_characters.dart similarity index 100% rename from lib/src/boundary_characters.dart rename to pkgs/http/lib/src/boundary_characters.dart diff --git a/lib/src/browser_client.dart b/pkgs/http/lib/src/browser_client.dart similarity index 81% rename from lib/src/browser_client.dart rename to pkgs/http/lib/src/browser_client.dart index b046b01993..16195e4f99 100644 --- a/lib/src/browser_client.dart +++ b/pkgs/http/lib/src/browser_client.dart @@ -11,6 +11,7 @@ import 'base_request.dart'; import 'byte_stream.dart'; import 'exception.dart'; import 'streamed_response.dart'; +import 'utils.dart'; /// Create a [BrowserClient]. /// @@ -38,8 +39,16 @@ class BrowserClient extends BaseClient { bool withCredentials = false; /// Sends an HTTP request and asynchronously returns the response. + /// + /// If [onUploadProgress] callback is provided and length is computable, + /// [onUploadProgress] will execute for each chunk was sent. + /// + /// lengthComputable : + /// library.html : xhr.lengthComputable + /// library.io : content-length is provided (MultipartRequest provide) @override - Future send(BaseRequest request) async { + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}) async { var bytes = await request.finalize().toBytes(); var xhr = HttpRequest(); _xhrs.add(xhr); @@ -51,6 +60,14 @@ class BrowserClient extends BaseClient { var completer = Completer(); + if (onUploadProgress != null) { + xhr.upload.onLoad.listen((event) { + if (event.lengthComputable) { + onUploadProgress(event.total!, event.loaded!); + } + }); + } + unawaited(xhr.onLoad.first.then((_) { var body = (xhr.response as ByteBuffer).asUint8List(); completer.complete(StreamedResponse( diff --git a/lib/src/byte_stream.dart b/pkgs/http/lib/src/byte_stream.dart similarity index 100% rename from lib/src/byte_stream.dart rename to pkgs/http/lib/src/byte_stream.dart diff --git a/lib/src/client.dart b/pkgs/http/lib/src/client.dart similarity index 73% rename from lib/src/client.dart rename to pkgs/http/lib/src/client.dart index 12695e7123..aec3b54103 100644 --- a/lib/src/client.dart +++ b/pkgs/http/lib/src/client.dart @@ -2,9 +2,13 @@ // 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. +import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:meta/meta.dart'; + +import '../http.dart' as http; import 'base_client.dart'; import 'base_request.dart'; import 'client_stub.dart' @@ -13,12 +17,14 @@ import 'client_stub.dart' import 'exception.dart'; import 'response.dart'; import 'streamed_response.dart'; +import 'utils.dart'; /// The interface for HTTP clients that take care of maintaining persistent /// connections across multiple requests to the same server. /// /// If you only need to send a single request, it's usually easier to use -/// [head], [get], [post], [put], [patch], or [delete] instead. +/// [http.head], [http.get], [http.post], [http.put], [http.patch], or +/// [http.delete] instead. /// /// When creating an HTTP client class with additional functionality, you must /// extend [BaseClient] rather than [Client]. In most cases, you can wrap @@ -29,7 +35,7 @@ abstract class Client { /// /// Creates an `IOClient` if `dart:io` is available and a `BrowserClient` if /// `dart:html` is available, otherwise it will throw an unsupported error. - factory Client() => createClient(); + factory Client() => zoneClient ?? createClient(); /// Sends an HTTP HEAD request with the given headers to the given URL. /// @@ -45,9 +51,11 @@ abstract class Client { /// URL. /// /// [body] sets the body of the request. It can be a [String], a [List] - /// or a [Map]. If it's a String, it's encoded using - /// [encoding] and used as the body of the request. The content-type of the - /// request will default to "text/plain". + /// or a [Map]. + /// + /// If [body] is a String, it's encoded using [encoding] and used as the body + /// of the request. The content-type of the request will default to + /// "text/plain". /// /// If [body] is a List, it's used as a list of bytes for the body of the /// request. @@ -132,7 +140,11 @@ abstract class Client { Future readBytes(Uri url, {Map? headers}); /// Sends an HTTP request and asynchronously returns the response. - Future send(BaseRequest request); + /// + /// If [onUploadProgress] defined, the callback will be called when the + /// upload progress changes. + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}); /// Closes the client and cleans up any resources associated with it. /// @@ -140,3 +152,45 @@ abstract class Client { /// do so can cause the Dart process to hang. void close(); } + +/// The [Client] for the current [Zone], if one has been set. +/// +/// NOTE: This property is explicitly hidden from the public API. +@internal +Client? get zoneClient { + final client = Zone.current[#_clientToken]; + return client == null ? null : (client as Client Function())(); +} + +/// Runs [body] in its own [Zone] with the [Client] returned by [clientFactory] +/// set as the default [Client]. +/// +/// For example: +/// +/// ``` +/// class MyAndroidHttpClient extends BaseClient { +/// @override +/// Future send(http.BaseRequest request) { +/// // your implementation here +/// } +/// } +/// +/// void main() { +/// Client client = Platform.isAndroid ? MyAndroidHttpClient() : Client(); +/// runWithClient(myFunction, () => client); +/// } +/// +/// void myFunction() { +/// // Uses the `Client` configured in `main`. +/// final response = await get(Uri.https('www.example.com', '')); +/// final client = Client(); +/// } +/// ``` +/// +/// The [Client] returned by [clientFactory] is used by the [Client.new] factory +/// and the convenience HTTP functions (e.g. [http.get]) +R runWithClient(R Function() body, Client Function() clientFactory, + {ZoneSpecification? zoneSpecification}) => + runZoned(body, + zoneValues: {#_clientToken: Zone.current.bindCallback(clientFactory)}, + zoneSpecification: zoneSpecification); diff --git a/lib/src/client_stub.dart b/pkgs/http/lib/src/client_stub.dart similarity index 100% rename from lib/src/client_stub.dart rename to pkgs/http/lib/src/client_stub.dart diff --git a/lib/src/exception.dart b/pkgs/http/lib/src/exception.dart similarity index 100% rename from lib/src/exception.dart rename to pkgs/http/lib/src/exception.dart diff --git a/lib/src/io_client.dart b/pkgs/http/lib/src/io_client.dart similarity index 58% rename from lib/src/io_client.dart rename to pkgs/http/lib/src/io_client.dart index b26ec0486b..6ef10065a7 100644 --- a/lib/src/io_client.dart +++ b/pkgs/http/lib/src/io_client.dart @@ -2,18 +2,44 @@ // 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. +import 'dart:async'; import 'dart:io'; +import '../http.dart' show ByteStream; import 'base_client.dart'; import 'base_request.dart'; import 'exception.dart'; import 'io_streamed_response.dart'; +import 'utils.dart'; /// Create an [IOClient]. /// /// Used from conditional imports, matches the definition in `client_stub.dart`. BaseClient createClient() => IOClient(); +/// Exception thrown when the underlying [HttpClient] throws a +/// [SocketException]. +/// +/// Implemenents [SocketException] to avoid breaking existing users of +/// [IOClient] that may catch that exception. +class _ClientSocketException extends ClientException + implements SocketException { + final SocketException cause; + + _ClientSocketException(SocketException e, Uri url) + : cause = e, + super(e.message, url); + + @override + InternetAddress? get address => cause.address; + + @override + OSError? get osError => cause.osError; + + @override + int? get port => cause.port; +} + /// A `dart:io`-based HTTP client. class IOClient extends BaseClient { /// The underlying `dart:io` HTTP client. @@ -22,8 +48,16 @@ class IOClient extends BaseClient { IOClient([HttpClient? inner]) : _inner = inner ?? HttpClient(); /// Sends an HTTP request and asynchronously returns the response. + /// + /// If [onUploadProgress] callback is provided and length is computable, + /// [onUploadProgress] will execute for each chunk was sent. + /// + /// lengthComputable : + /// library.html : xhr.lengthComputable + /// library.io : content-length is provided (MultipartRequest provide) @override - Future send(BaseRequest request) async { + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}) async { if (_inner == null) { throw ClientException( 'HTTP request failed. Client is already closed.', request.url); @@ -31,17 +65,33 @@ class IOClient extends BaseClient { var stream = request.finalize(); + ByteStream? handledStream; + + var contentLength = request.contentLength; + if (onUploadProgress != null && contentLength != null) { + var load = 0; + handledStream = + ByteStream(stream.transform(StreamTransformer.fromBind((d) async* { + await for (var data in d) { + load += data.length; + onUploadProgress(contentLength, load); + yield data; + } + }))); + } + try { var ioRequest = (await _inner!.openUrl(request.method, request.url)) ..followRedirects = request.followRedirects ..maxRedirects = request.maxRedirects - ..contentLength = (request.contentLength ?? -1) + ..contentLength = (contentLength ?? -1) ..persistentConnection = request.persistentConnection; request.headers.forEach((name, value) { ioRequest.headers.set(name, value); }); - var response = await stream.pipe(ioRequest) as HttpClientResponse; + var response = + await (handledStream ?? stream).pipe(ioRequest) as HttpClientResponse; var headers = {}; response.headers.forEach((key, values) { @@ -62,6 +112,8 @@ class IOClient extends BaseClient { persistentConnection: response.persistentConnection, reasonPhrase: response.reasonPhrase, inner: response); + } on SocketException catch (error) { + throw _ClientSocketException(error, request.url); } on HttpException catch (error) { throw ClientException(error.message, error.uri); } diff --git a/lib/src/io_streamed_response.dart b/pkgs/http/lib/src/io_streamed_response.dart similarity index 100% rename from lib/src/io_streamed_response.dart rename to pkgs/http/lib/src/io_streamed_response.dart diff --git a/lib/src/mock_client.dart b/pkgs/http/lib/src/mock_client.dart similarity index 83% rename from lib/src/mock_client.dart rename to pkgs/http/lib/src/mock_client.dart index bf2df40ee7..8fd00304e3 100644 --- a/lib/src/mock_client.dart +++ b/pkgs/http/lib/src/mock_client.dart @@ -2,6 +2,8 @@ // 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. +import 'dart:async'; + import 'base_client.dart'; import 'base_request.dart'; import 'byte_stream.dart'; @@ -9,6 +11,7 @@ import 'request.dart'; import 'response.dart'; import 'streamed_request.dart'; import 'streamed_response.dart'; +import 'utils.dart'; // TODO(nweiz): once Dart has some sort of Rack- or WSGI-like standard for // server APIs, MockClient should conform to it. @@ -65,9 +68,26 @@ class MockClient extends BaseClient { }); @override - Future send(BaseRequest request) async { + Future send(BaseRequest request, + {OnUploadProgress? onUploadProgress}) async { var bodyStream = request.finalize(); - return await _handler(request, bodyStream); + ByteStream? handledStream; + + var contentLength = request.contentLength; + + if (onUploadProgress != null && contentLength != null) { + var load = 0; + handledStream = ByteStream( + bodyStream.transform(StreamTransformer.fromBind((d) async* { + await for (var data in d) { + load += data.length; + onUploadProgress(contentLength, load); + yield data; + } + }))); + } + + return await _handler(request, handledStream ?? bodyStream); } } diff --git a/lib/src/multipart_file.dart b/pkgs/http/lib/src/multipart_file.dart similarity index 100% rename from lib/src/multipart_file.dart rename to pkgs/http/lib/src/multipart_file.dart diff --git a/lib/src/multipart_file_io.dart b/pkgs/http/lib/src/multipart_file_io.dart similarity index 100% rename from lib/src/multipart_file_io.dart rename to pkgs/http/lib/src/multipart_file_io.dart diff --git a/lib/src/multipart_file_stub.dart b/pkgs/http/lib/src/multipart_file_stub.dart similarity index 100% rename from lib/src/multipart_file_stub.dart rename to pkgs/http/lib/src/multipart_file_stub.dart diff --git a/lib/src/multipart_request.dart b/pkgs/http/lib/src/multipart_request.dart similarity index 91% rename from lib/src/multipart_request.dart rename to pkgs/http/lib/src/multipart_request.dart index 9aeffa23eb..dcc58dfca4 100644 --- a/lib/src/multipart_request.dart +++ b/pkgs/http/lib/src/multipart_request.dart @@ -21,7 +21,7 @@ final _newlineRegExp = RegExp(r'\r\n|\r|\n'); /// This request automatically sets the Content-Type header to /// `multipart/form-data`. This value will override any value set by the user. /// -/// var uri = Uri.parse('https://example.com/create'); +/// var uri = Uri.https('example.com', 'create'); /// var request = http.MultipartRequest('POST', uri) /// ..fields['user'] = 'nweiz@google.com' /// ..files.add(await http.MultipartFile.fromPath( @@ -45,7 +45,14 @@ class MultipartRequest extends BaseRequest { /// The list of files to upload for this request. final files = []; - MultipartRequest(String method, Uri url) : super(method, url); + /// If [onUploadProgress] callback is provided and length is computable, + /// [onUploadProgress] will execute for each chunk was sent. + /// + /// lengthComputable : + /// library.html : xhr.lengthComputable + /// library.io : content-length is provided (MultipartRequest provide) + MultipartRequest(String method, Uri url, {OnUploadProgress? onUploadProgress}) + : super(method, url, onUploadProgress: onUploadProgress); /// The total length of the request body, in bytes. /// diff --git a/lib/src/request.dart b/pkgs/http/lib/src/request.dart similarity index 100% rename from lib/src/request.dart rename to pkgs/http/lib/src/request.dart diff --git a/lib/src/response.dart b/pkgs/http/lib/src/response.dart similarity index 100% rename from lib/src/response.dart rename to pkgs/http/lib/src/response.dart diff --git a/lib/src/streamed_request.dart b/pkgs/http/lib/src/streamed_request.dart similarity index 100% rename from lib/src/streamed_request.dart rename to pkgs/http/lib/src/streamed_request.dart diff --git a/lib/src/streamed_response.dart b/pkgs/http/lib/src/streamed_response.dart similarity index 100% rename from lib/src/streamed_response.dart rename to pkgs/http/lib/src/streamed_response.dart diff --git a/lib/src/utils.dart b/pkgs/http/lib/src/utils.dart similarity index 95% rename from lib/src/utils.dart rename to pkgs/http/lib/src/utils.dart index e79108e88a..329500c048 100644 --- a/lib/src/utils.dart +++ b/pkgs/http/lib/src/utils.dart @@ -74,3 +74,8 @@ Stream onDone(Stream stream, void Function() onDone) => sink.close(); onDone(); })); + +/// On upload progress callback. +/// +/// See : "MultipartRequest" +typedef OnUploadProgress = void Function(int uploaded, int total); diff --git a/lib/testing.dart b/pkgs/http/lib/testing.dart similarity index 100% rename from lib/testing.dart rename to pkgs/http/lib/testing.dart diff --git a/pkgs/http/mono_pkg.yaml b/pkgs/http/mono_pkg.yaml new file mode 100644 index 0000000000..8b6b5d1cb9 --- /dev/null +++ b/pkgs/http/mono_pkg.yaml @@ -0,0 +1,17 @@ +sdk: +- 2.14.0 +- dev + +stages: +- analyze_and_format: + - analyze: --fatal-infos + - format: + sdk: + - dev +- unit_test: + - test: --platform vm + os: + - linux + - test: --platform chrome + os: + - linux diff --git a/pubspec.yaml b/pkgs/http/pubspec.yaml similarity index 65% rename from pubspec.yaml rename to pkgs/http/pubspec.yaml index 2689e46d44..06fa0c94bf 100644 --- a/pubspec.yaml +++ b/pkgs/http/pubspec.yaml @@ -1,7 +1,7 @@ name: http version: 0.13.5-dev description: A composable, multi-platform, Future-based API for HTTP requests. -repository: https://github.com/dart-lang/http +repository: https://github.com/dart-lang/http/tree/master/pkgs/http environment: sdk: '>=2.14.0 <3.0.0' @@ -14,6 +14,9 @@ dependencies: dev_dependencies: fake_async: ^1.2.0 + http_client_conformance_tests: + path: ../http_client_conformance_tests/ lints: ^1.0.0 shelf: ^1.1.0 + stream_channel: ^2.1.0 test: ^1.16.0 diff --git a/pkgs/http/test/html/client_conformance_test.dart b/pkgs/http/test/html/client_conformance_test.dart new file mode 100644 index 0000000000..422b7cc2c0 --- /dev/null +++ b/pkgs/http/test/html/client_conformance_test.dart @@ -0,0 +1,18 @@ +// 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. + +@TestOn('browser') + +import 'package:http/browser_client.dart'; +import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; +import 'package:test/test.dart'; + +void main() { + final client = BrowserClient(); + + testAll(client, + redirectAlwaysAllowed: true, + canStreamRequestBody: false, + canStreamResponseBody: false); +} diff --git a/test/html/client_test.dart b/pkgs/http/test/html/client_test.dart similarity index 96% rename from test/html/client_test.dart rename to pkgs/http/test/html/client_test.dart index 22ed98654e..24b20dc0e0 100644 --- a/test/html/client_test.dart +++ b/pkgs/http/test/html/client_test.dart @@ -28,7 +28,7 @@ void main() { test('#send with an invalid URL', () { var client = BrowserClient(); - var url = Uri.parse('http://http.invalid'); + var url = Uri.http('http.invalid', ''); var request = http.StreamedRequest('POST', url); expect( diff --git a/test/html/streamed_request_test.dart b/pkgs/http/test/html/streamed_request_test.dart similarity index 100% rename from test/html/streamed_request_test.dart rename to pkgs/http/test/html/streamed_request_test.dart diff --git a/test/html/utils.dart b/pkgs/http/test/html/utils.dart similarity index 100% rename from test/html/utils.dart rename to pkgs/http/test/html/utils.dart diff --git a/test/http_retry_test.dart b/pkgs/http/test/http_retry_test.dart similarity index 98% rename from test/http_retry_test.dart rename to pkgs/http/test/http_retry_test.dart index 42ff3b5198..da51154c4a 100644 --- a/test/http_retry_test.dart +++ b/pkgs/http/test/http_retry_test.dart @@ -208,13 +208,13 @@ void main() { expect(request.maxRedirects, equals(12)); expect(request.method, equals('POST')); expect(request.persistentConnection, isFalse); - expect(request.url, equals(Uri.parse('http://example.org'))); + expect(request.url, equals(Uri.http('example.org', ''))); expect(request.body, equals('hello')); return Response('', 503); }, count: 2)), [Duration.zero]); - final request = Request('POST', Uri.parse('http://example.org')) + final request = Request('POST', Uri.http('example.org', '')) ..body = 'hello' ..followRedirects = false ..headers['foo'] = 'bar' diff --git a/pkgs/http/test/io/client_conformance_test.dart b/pkgs/http/test/io/client_conformance_test.dart new file mode 100644 index 0000000000..0706031ec3 --- /dev/null +++ b/pkgs/http/test/io/client_conformance_test.dart @@ -0,0 +1,15 @@ +// 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. + +@TestOn('vm') + +import 'package:http/io_client.dart'; +import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; +import 'package:test/test.dart'; + +void main() { + final client = IOClient(); + + testAll(client); +} diff --git a/test/io/client_test.dart b/pkgs/http/test/io/client_test.dart similarity index 73% rename from test/io/client_test.dart rename to pkgs/http/test/io/client_test.dart index d856efba43..3be2a17558 100644 --- a/test/io/client_test.dart +++ b/pkgs/http/test/io/client_test.dart @@ -8,14 +8,32 @@ import 'dart:io'; import 'package:http/http.dart' as http; import 'package:http/io_client.dart' as http_io; +import 'package:http/src/utils.dart'; import 'package:test/test.dart'; -import 'utils.dart'; +import '../utils.dart'; -void main() { - setUp(startServer); +class TestClient extends http.BaseClient { + @override + Future send(http.BaseRequest request, + {OnUploadProgress? onUploadProgress}) { + throw UnimplementedError(); + } +} + +class TestClient2 extends http.BaseClient { + @override + Future send(http.BaseRequest request, + {OnUploadProgress? onUploadProgress}) { + throw UnimplementedError(); + } +} - tearDown(stopServer); +void main() { + late Uri serverUrl; + setUpAll(() async { + serverUrl = await startServer(); + }); test('#send a StreamedRequest', () async { var client = http.Client(); @@ -96,12 +114,12 @@ void main() { test('#send with an invalid URL', () { var client = http.Client(); - var url = Uri.parse('http://http.invalid'); + var url = Uri.http('http.invalid', ''); var request = http.StreamedRequest('POST', url); request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=utf-8'; - expect(client.send(request), throwsSocketException); + expect(client.send(request), throwsA(isA())); request.sink.add('{"hello": "world"}'.codeUnits); request.sink.close(); @@ -132,4 +150,29 @@ void main() { expect(socket, isNotNull); }); + + test('runWithClient', () { + final client = http.runWithClient(() => http.Client(), () => TestClient()); + expect(client, isA()); + }); + + test('runWithClient nested', () { + late final http.Client client; + late final http.Client nestedClient; + http.runWithClient(() { + http.runWithClient( + () => nestedClient = http.Client(), () => TestClient2()); + client = http.Client(); + }, () => TestClient()); + expect(client, isA()); + expect(nestedClient, isA()); + }); + + test('runWithClient recursion', () { + // Verify that calling the http.Client() factory inside nested Zones does + // not provoke an infinite recursion. + http.runWithClient(() { + http.runWithClient(() => http.Client(), () => http.Client()); + }, () => http.Client()); + }); } diff --git a/test/io/http_test.dart b/pkgs/http/test/io/http_test.dart similarity index 88% rename from test/io/http_test.dart rename to pkgs/http/test/io/http_test.dart index 58b1d862df..758d20cba7 100644 --- a/test/io/http_test.dart +++ b/pkgs/http/test/io/http_test.dart @@ -3,24 +3,40 @@ // BSD-style license that can be found in the LICENSE file. @TestOn('vm') - import 'package:http/http.dart' as http; +import 'package:http/src/utils.dart'; import 'package:test/test.dart'; -import 'utils.dart'; +import '../utils.dart'; -void main() { - group('http.', () { - setUp(startServer); +class TestClient extends http.BaseClient { + @override + Future send(http.BaseRequest request, + {OnUploadProgress? onUploadProgress}) { + throw UnimplementedError(); + } +} - tearDown(stopServer); +void main() { + late Uri serverUrl; + setUpAll(() async { + serverUrl = await startServer(); + }); + group('http.', () { test('head', () async { var response = await http.head(serverUrl); expect(response.statusCode, equals(200)); expect(response.body, equals('')); }); + test('head runWithClient', () { + expect( + () => http.runWithClient( + () => http.head(serverUrl), () => TestClient()), + throwsUnimplementedError); + }); + test('get', () async { var response = await http.get(serverUrl, headers: { 'X-Random-Header': 'Value', @@ -42,6 +58,13 @@ void main() { containsPair('x-other-header', ['Other Value'])))))); }); + test('get runWithClient', () { + expect( + () => + http.runWithClient(() => http.get(serverUrl), () => TestClient()), + throwsUnimplementedError); + }); + test('post', () async { var response = await http.post(serverUrl, headers: { 'X-Random-Header': 'Value', @@ -150,6 +173,13 @@ void main() { }))); }); + test('post runWithClient', () { + expect( + () => http.runWithClient( + () => http.post(serverUrl, body: 'testing'), () => TestClient()), + throwsUnimplementedError); + }); + test('put', () async { var response = await http.put(serverUrl, headers: { 'X-Random-Header': 'Value', @@ -258,6 +288,13 @@ void main() { }))); }); + test('put runWithClient', () { + expect( + () => http.runWithClient( + () => http.put(serverUrl, body: 'testing'), () => TestClient()), + throwsUnimplementedError); + }); + test('patch', () async { var response = await http.patch(serverUrl, headers: { 'X-Random-Header': 'Value', @@ -387,6 +424,13 @@ void main() { containsPair('x-other-header', ['Other Value'])))))); }); + test('patch runWithClient', () { + expect( + () => http.runWithClient( + () => http.patch(serverUrl, body: 'testing'), () => TestClient()), + throwsUnimplementedError); + }); + test('read', () async { var response = await http.read(serverUrl, headers: { 'X-Random-Header': 'Value', @@ -408,7 +452,14 @@ void main() { }); test('read throws an error for a 4** status code', () { - expect(http.read(serverUrl.resolve('/error')), throwsClientException); + expect(http.read(serverUrl.resolve('/error')), throwsClientException()); + }); + + test('read runWithClient', () { + expect( + () => http.runWithClient( + () => http.read(serverUrl), () => TestClient()), + throwsUnimplementedError); }); test('readBytes', () async { @@ -434,7 +485,14 @@ void main() { test('readBytes throws an error for a 4** status code', () { expect( - http.readBytes(serverUrl.resolve('/error')), throwsClientException); + http.readBytes(serverUrl.resolve('/error')), throwsClientException()); + }); + + test('readBytes runWithClient', () { + expect( + () => http.runWithClient( + () => http.readBytes(serverUrl), () => TestClient()), + throwsUnimplementedError); }); }); } diff --git a/test/io/multipart_test.dart b/pkgs/http/test/io/multipart_test.dart similarity index 97% rename from test/io/multipart_test.dart rename to pkgs/http/test/io/multipart_test.dart index f2333f5322..070ea5f3df 100644 --- a/test/io/multipart_test.dart +++ b/pkgs/http/test/io/multipart_test.dart @@ -10,7 +10,7 @@ import 'package:http/http.dart' as http; import 'package:path/path.dart' as path; import 'package:test/test.dart'; -import 'utils.dart'; +import '../utils.dart'; void main() { late Directory tempDir; diff --git a/test/io/request_test.dart b/pkgs/http/test/io/request_test.dart similarity index 94% rename from test/io/request_test.dart rename to pkgs/http/test/io/request_test.dart index 97555f9782..80a7f24170 100644 --- a/test/io/request_test.dart +++ b/pkgs/http/test/io/request_test.dart @@ -7,12 +7,13 @@ import 'package:http/http.dart' as http; import 'package:test/test.dart'; -import 'utils.dart'; +import '../utils.dart'; void main() { - setUp(startServer); - - tearDown(stopServer); + late Uri serverUrl; + setUpAll(() async { + serverUrl = await startServer(); + }); test('send happy case', () async { final request = http.Request('GET', serverUrl) diff --git a/test/io/streamed_request_test.dart b/pkgs/http/test/io/streamed_request_test.dart similarity index 62% rename from test/io/streamed_request_test.dart rename to pkgs/http/test/io/streamed_request_test.dart index 9dd5d3eb15..d9f63d8ad1 100644 --- a/test/io/streamed_request_test.dart +++ b/pkgs/http/test/io/streamed_request_test.dart @@ -9,12 +9,13 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:test/test.dart'; -import 'utils.dart'; +import '../utils.dart'; void main() { - setUp(startServer); - - tearDown(stopServer); + late Uri serverUrl; + setUpAll(() async { + serverUrl = await startServer(); + }); group('contentLength', () { test('controls the Content-Length header', () async { @@ -49,4 +50,35 @@ void main() { var response = await request.send(); expect(await utf8.decodeStream(response.stream), equals('body')); }); + + + test('sends a MultipartRequest with onUploadProgress', () async { + var totalLoaded = 0; + + var loadedNotifications = []; + + int? contentLength; + + final request = + http.MultipartRequest('GET', serverUrl.resolve('/multipart'), + onUploadProgress: (total, loaded) { + contentLength = total; + totalLoaded = loaded; + loadedNotifications.add(loaded); + }); + request.files.add(http.MultipartFile.fromBytes( + 'file', List.generate(1500, (index) => 100))); + + var response = await (await request.send()).stream.bytesToString(); + + print(response); + + expect(response, '1739'); + expect(contentLength, 1739); + expect(totalLoaded, 1739); + expect(loadedNotifications.length, 5); + expect(loadedNotifications, [74, 161, 1661, 1663, 1739]); + }); + + } diff --git a/test/mock_client_test.dart b/pkgs/http/test/mock_client_test.dart similarity index 100% rename from test/mock_client_test.dart rename to pkgs/http/test/mock_client_test.dart diff --git a/test/multipart_test.dart b/pkgs/http/test/multipart_test.dart similarity index 100% rename from test/multipart_test.dart rename to pkgs/http/test/multipart_test.dart diff --git a/test/request_test.dart b/pkgs/http/test/request_test.dart similarity index 100% rename from test/request_test.dart rename to pkgs/http/test/request_test.dart diff --git a/test/response_test.dart b/pkgs/http/test/response_test.dart similarity index 100% rename from test/response_test.dart rename to pkgs/http/test/response_test.dart diff --git a/test/streamed_request_test.dart b/pkgs/http/test/streamed_request_test.dart similarity index 100% rename from test/streamed_request_test.dart rename to pkgs/http/test/streamed_request_test.dart diff --git a/pkgs/http/test/stub_server.dart b/pkgs/http/test/stub_server.dart new file mode 100644 index 0000000000..d593a384ac --- /dev/null +++ b/pkgs/http/test/stub_server.dart @@ -0,0 +1,115 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart'; +import 'package:http/src/utils.dart'; +import 'package:stream_channel/stream_channel.dart'; + +void hybridMain(StreamChannel channel) async { + final server = await HttpServer.bind('localhost', 0); + final url = Uri.http('localhost:${server.port}', ''); + server.listen((request) async { + var path = request.uri.path; + var response = request.response; + + if (path == '/error') { + response + ..statusCode = 400 + ..contentLength = 0; + unawaited(response.close()); + return; + } + + if (path == '/loop') { + var n = int.parse(request.uri.query); + response + ..statusCode = 302 + ..headers.set('location', url.resolve('/loop?${n + 1}').toString()) + ..contentLength = 0; + unawaited(response.close()); + return; + } + + if (path == '/redirect') { + response + ..statusCode = 302 + ..headers.set('location', url.resolve('/').toString()) + ..contentLength = 0; + unawaited(response.close()); + return; + } + + if (path == '/no-content-length') { + response + ..statusCode = 200 + ..contentLength = -1 + ..write('body'); + unawaited(response.close()); + return; + } + + if (path == '/multipart') { + var completer = Completer(); + var length = 0; + request.listen((event) { + length += event.length; + }).onDone(completer.complete); + await completer.future; + response + ..statusCode = 200 + ..contentLength = length.toString().codeUnits.length + ..write(length.toString()); + unawaited(response.close()); + return; + } + + var requestBodyBytes = await ByteStream(request).toBytes(); + var encodingName = request.uri.queryParameters['response-encoding']; + var outputEncoding = + encodingName == null ? ascii : requiredEncodingForCharset(encodingName); + + response.headers.contentType = + ContentType('application', 'json', charset: outputEncoding.name); + response.headers.set('single', 'value'); + + dynamic requestBody; + if (requestBodyBytes.isEmpty) { + requestBody = null; + } else if (request.headers.contentType?.charset != null) { + var encoding = + requiredEncodingForCharset(request.headers.contentType!.charset!); + requestBody = encoding.decode(requestBodyBytes); + } else { + requestBody = requestBodyBytes; + } + + final headers = >{}; + + request.headers.forEach((name, values) { + // These headers are automatically generated by dart:io, so we don't + // want to test them here. + if (name == 'cookie' || name == 'host') return; + + headers[name] = values; + }); + + var content = { + 'method': request.method, + 'path': request.uri.path, + if (requestBody != null) 'body': requestBody, + 'headers': headers, + }; + + var body = json.encode(content); + response + ..contentLength = body.length + ..write(body); + unawaited(response.close()); + }); + channel.sink.add(server.port); +} diff --git a/test/utils.dart b/pkgs/http/test/utils.dart similarity index 84% rename from test/utils.dart rename to pkgs/http/test/utils.dart index e3bf41102f..d4c319f73f 100644 --- a/test/utils.dart +++ b/pkgs/http/test/utils.dart @@ -9,7 +9,7 @@ import 'package:http_parser/http_parser.dart'; import 'package:test/test.dart'; /// A dummy URL for constructing requests that won't be sent. -Uri get dummyUrl => Uri.parse('http://dart.dev/'); +Uri get dummyUrl => Uri.http('dart.dev', ''); /// Removes eight spaces of leading indentation from a multiline string. /// @@ -110,5 +110,19 @@ class _BodyMatches extends Matcher { /// [http.ClientException] with the given [message]. /// /// [message] can be a String or a [Matcher]. -Matcher throwsClientException(String message) => throwsA( - isA().having((e) => e.message, 'message', message)); +Matcher throwsClientException([String? message]) { + var exception = isA(); + if (message != null) { + exception = exception.having((e) => e.message, 'message', message); + } + return throwsA(exception); +} + +/// Spawn an isolate in the test runner with an http server. +/// +/// The server isolate will be killed on teardown. +Future startServer() async { + final channel = spawnHybridUri(Uri(path: '/test/stub_server.dart')); + final port = await channel.stream.first as int; + return Uri.http('localhost:$port', ''); +} diff --git a/pkgs/http_client_conformance_tests/CHANGELOG.md b/pkgs/http_client_conformance_tests/CHANGELOG.md new file mode 100644 index 0000000000..b78d64c626 --- /dev/null +++ b/pkgs/http_client_conformance_tests/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version. diff --git a/pkgs/http_client_conformance_tests/LICENSE b/pkgs/http_client_conformance_tests/LICENSE new file mode 100644 index 0000000000..000cd7beca --- /dev/null +++ b/pkgs/http_client_conformance_tests/LICENSE @@ -0,0 +1,27 @@ +Copyright 2014, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pkgs/http_client_conformance_tests/README.md b/pkgs/http_client_conformance_tests/README.md new file mode 100644 index 0000000000..e96e364654 --- /dev/null +++ b/pkgs/http_client_conformance_tests/README.md @@ -0,0 +1,39 @@ +[![pub package](https://img.shields.io/pub/v/http_client_conformance_tests.svg)](https://pub.dev/packages/http_client_conformance_tests) + +A library that tests whether implementations of `package:http` +[`Client`](https://pub.dev/documentation/http/latest/http/Client-class.html) +behave as expected. + +This package is intended to be used in the tests of packages that implement +`package:http` +[`Client`](https://pub.dev/documentation/http/latest/http/Client-class.html). + +## Usage + +`package:http_client_conformance_tests` is meant to be used in the tests suite +of a `package:http` +[`Client`](https://pub.dev/documentation/http/latest/http/Client-class.html) +like: + +```dart +import 'package:http/http.dart'; +import 'package:test/test.dart'; + +import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; + +class MyHttpClient extends BaseClient { + @override + Future send(BaseRequest request) async { + // Your implementation here. + } +} + +void main() { + group('client conformance tests', () { + testAll(MyHttpClient()); + }); +} +``` + +**Note**: This package does not have it's own tests, instead it is +exercised by the tests in `package:http`. diff --git a/pkgs/http_client_conformance_tests/example/client_test.dart b/pkgs/http_client_conformance_tests/example/client_test.dart new file mode 100644 index 0000000000..87e90940f0 --- /dev/null +++ b/pkgs/http_client_conformance_tests/example/client_test.dart @@ -0,0 +1,17 @@ +import 'package:http/http.dart'; +import 'package:http_client_conformance_tests/http_client_conformance_tests.dart'; +import 'package:test/test.dart'; + +class MyHttpClient extends BaseClient { + @override + Future send(BaseRequest request) async { + // Your implementation here. + throw UnsupportedError('implement this method'); + } +} + +void main() { + group('client conformance tests', () { + testAll(MyHttpClient()); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart new file mode 100644 index 0000000000..dfdc919ffa --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/http_client_conformance_tests.dart @@ -0,0 +1,49 @@ +// 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. + +import 'package:http/http.dart'; + +import 'src/redirect_tests.dart'; +import 'src/request_body_streamed_tests.dart'; +import 'src/request_body_tests.dart'; +import 'src/request_headers_tests.dart'; +import 'src/response_body_streamed_test.dart'; +import 'src/response_body_tests.dart'; +import 'src/response_headers_tests.dart'; +import 'src/server_errors_test.dart'; + +export 'src/redirect_tests.dart' show testRedirect; +export 'src/request_body_streamed_tests.dart' show testRequestBodyStreamed; +export 'src/request_body_tests.dart' show testRequestBody; +export 'src/request_headers_tests.dart' show testRequestHeaders; +export 'src/response_body_streamed_test.dart' show testResponseBodyStreamed; +export 'src/response_body_tests.dart' show testResponseBody; +export 'src/response_headers_tests.dart' show testResponseHeaders; + +/// Runs the entire test suite against the given [Client]. +/// +/// If [canStreamRequestBody] is `false` then tests that assume that the +/// [Client] supports sending HTTP requests with unbounded body sizes will be +/// skipped. +// +/// If [canStreamResponseBody] is `false` then tests that assume that the +/// [Client] supports receiving HTTP responses with unbounded body sizes will +/// be skipped +/// +/// If [redirectAlwaysAllowed] is `true` then tests that require the [Client] +/// to limit redirects will be skipped. +void testAll(Client client, + {bool canStreamRequestBody = true, + bool canStreamResponseBody = true, + bool redirectAlwaysAllowed = false}) { + testRequestBody(client); + testRequestBodyStreamed(client, canStreamRequestBody: canStreamRequestBody); + testResponseBody(client, canStreamResponseBody: canStreamResponseBody); + testResponseBodyStreamed(client, + canStreamResponseBody: canStreamResponseBody); + testRequestHeaders(client); + testResponseHeaders(client); + testRedirect(client, redirectAlwaysAllowed: redirectAlwaysAllowed); + testServerErrors(client); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/redirect_server.dart b/pkgs/http_client_conformance_tests/lib/src/redirect_server.dart new file mode 100644 index 0000000000..cf1fafddb8 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/redirect_server.dart @@ -0,0 +1,45 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server and sends the port back on the given channel. +/// +/// Quits when anything is received on the channel. +/// +/// URI | Redirects TO +/// ===========|============== +/// ".../loop" | ".../loop" +/// ".../10" | ".../9" +/// ".../9" | ".../8" +/// ... | ... +/// ".../1" | "/" +/// "/" | <200 return> +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = await HttpServer.bind('localhost', 0) + ..listen((request) async { + request.response.headers.set('Access-Control-Allow-Origin', '*'); + if (request.requestedUri.pathSegments.isEmpty) { + unawaited(request.response.close()); + } else if (request.requestedUri.pathSegments.last == 'loop') { + unawaited(request.response + .redirect(Uri.http('localhost:${server.port}', '/loop'))); + } else { + final n = int.parse(request.requestedUri.pathSegments.last); + final nextPath = n - 1 == 0 ? '' : '${n - 1}'; + unawaited(request.response + .redirect(Uri.http('localhost:${server.port}', '/$nextPath'))); + } + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/redirect_tests.dart b/pkgs/http_client_conformance_tests/lib/src/redirect_tests.dart new file mode 100644 index 0000000000..c1d5e2da23 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/redirect_tests.dart @@ -0,0 +1,86 @@ +// 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. + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly implements HTTP redirect logic. +/// +/// If [redirectAlwaysAllowed] is `true` then tests that require the [Client] +/// to limit redirects will be skipped. +void testRedirect(Client client, {bool redirectAlwaysAllowed = false}) async { + group('redirects', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer('redirect_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('disallow redirect', () async { + final request = Request('GET', Uri.http(host, '/1')) + ..followRedirects = false; + final response = await client.send(request); + expect(response.statusCode, 302); + expect(response.isRedirect, true); + }, skip: redirectAlwaysAllowed ? 'redirects always allowed' : false); + + test('allow redirect', () async { + final request = Request('GET', Uri.http(host, '/1')) + ..followRedirects = true; + final response = await client.send(request); + expect(response.statusCode, 200); + expect(response.isRedirect, false); + }); + + test('allow redirect, 0 maxRedirects, ', () async { + final request = Request('GET', Uri.http(host, '/1')) + ..followRedirects = true + ..maxRedirects = 0; + expect( + client.send(request), + throwsA(isA() + .having((e) => e.message, 'message', 'Redirect limit exceeded'))); + }, + skip: 'Re-enable after https://github.com/dart-lang/sdk/issues/49012 ' + 'is fixed'); + + test('exactly the right number of allowed redirects', () async { + final request = Request('GET', Uri.http(host, '/5')) + ..followRedirects = true + ..maxRedirects = 5; + final response = await client.send(request); + expect(response.statusCode, 200); + expect(response.isRedirect, false); + }, skip: redirectAlwaysAllowed ? 'redirects always allowed' : false); + + test('too many redirects', () async { + final request = Request('GET', Uri.http(host, '/6')) + ..followRedirects = true + ..maxRedirects = 5; + expect( + client.send(request), + throwsA(isA() + .having((e) => e.message, 'message', 'Redirect limit exceeded'))); + }, skip: redirectAlwaysAllowed ? 'redirects always allowed' : false); + + test( + 'loop', + () async { + final request = Request('GET', Uri.http(host, '/loop')) + ..followRedirects = true + ..maxRedirects = 5; + expect(client.send(request), throwsA(isA())); + }, + ); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_body_server.dart b/pkgs/http_client_conformance_tests/lib/src/request_body_server.dart new file mode 100644 index 0000000000..03108c1db2 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_body_server.dart @@ -0,0 +1,47 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that captures the content type header and request +/// body. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Request Received: +/// - send "Content-Type" header +/// - send request body +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + request.response.headers.set('Access-Control-Allow-Origin', '*'); + if (request.method == 'OPTIONS') { + // Handle a CORS preflight request: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests + request.response.headers + ..set('Access-Control-Allow-Methods', 'POST, DELETE') + ..set('Access-Control-Allow-Headers', 'Content-Type'); + } else { + channel.sink.add(request.headers[HttpHeaders.contentTypeHeader]); + final serverReceivedBody = + await const Utf8Decoder().bind(request).fold('', (p, e) => '$p$e'); + channel.sink.add(serverReceivedBody); + } + unawaited(request.response.close()); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_server.dart b/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_server.dart new file mode 100644 index 0000000000..a019591fdd --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_server.dart @@ -0,0 +1,42 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that absorbes a request stream of integers and +/// signals the client to quit after 1000 have been received. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Integer == 1000 received: +/// - send 1000 +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + request.response.headers.set('Access-Control-Allow-Origin', '*'); + await const LineSplitter() + .bind(const Utf8Decoder().bind(request)) + .forEach((s) { + final lastReceived = int.parse(s.trim()); + if (lastReceived == 1000) { + channel.sink.add(lastReceived); + } + }); + unawaited(request.response.close()); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_tests.dart b/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_tests.dart new file mode 100644 index 0000000000..7f27c8bf41 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_body_streamed_tests.dart @@ -0,0 +1,65 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly implements streamed request body +/// uploading. +/// +/// If [canStreamRequestBody] is `false` then tests that assume that the +/// [Client] supports sending HTTP requests with unbounded body sizes will be +/// skipped. +void testRequestBodyStreamed(Client client, + {bool canStreamRequestBody = true}) { + group('streamed requests', () { + late String host; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + + setUp(() async { + httpServerChannel = + await startServer('request_body_streamed_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDown(() => httpServerChannel.sink.add(null)); + + test('client.send() with StreamedRequest', () async { + // The client continuously streams data to the server until + // instructed to stop (by setting `clientWriting` to `false`). + // The server sets `serverWriting` to `false` after it has + // already received some data. + // + // This ensures that the client supports streamed data sends. + var lastReceived = 0; + + Stream count() async* { + var i = 0; + unawaited( + httpServerQueue.next.then((value) => lastReceived = value as int)); + do { + yield '${i++}\n'; + // Let the event loop run. + await Future.delayed(const Duration()); + } while (lastReceived < 1000); + } + + final request = StreamedRequest('POST', Uri.http(host, '')); + const Utf8Encoder() + .bind(count()) + .listen(request.sink.add, onDone: request.sink.close); + await client.send(request); + + expect(lastReceived, greaterThanOrEqualTo(1000)); + }); + }, skip: canStreamRequestBody ? false : 'does not stream request bodies'); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_body_tests.dart b/pkgs/http_client_conformance_tests/lib/src/request_body_tests.dart new file mode 100644 index 0000000000..0843955e2b --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_body_tests.dart @@ -0,0 +1,155 @@ +// 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. + +import 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +class _Plus2Decoder extends Converter, String> { + @override + String convert(List input) => + const Utf8Decoder().convert(input.map((e) => e + 2).toList()); +} + +class _Plus2Encoder extends Converter> { + @override + List convert(String input) => + const Utf8Encoder().convert(input).map((e) => e - 2).toList(); +} + +/// An encoding, meant for testing, the just decrements input bytes by 2. +class _Plus2Encoding extends Encoding { + @override + Converter, String> get decoder => _Plus2Decoder(); + + @override + Converter> get encoder => _Plus2Encoder(); + + @override + String get name => 'plus2'; +} + +/// Tests that the [Client] correctly implements HTTP requests with bodies e.g. +/// 'POST'. +void testRequestBody(Client client) { + group('request body', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer('request_body_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('client.post() with string body', () async { + await client.post(Uri.http(host, ''), body: 'Hello World!'); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next; + + expect(serverReceivedContentType, ['text/plain; charset=utf-8']); + expect(serverReceivedBody, 'Hello World!'); + }); + + test('client.post() with string body and custom encoding', () async { + await client.post(Uri.http(host, ''), + body: 'Hello', encoding: _Plus2Encoding()); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next; + + expect(serverReceivedContentType, ['text/plain; charset=plus2']); + expect(serverReceivedBody, 'Fcjjm'); + }); + + test('client.post() with map body', () async { + await client.post(Uri.http(host, ''), body: {'key': 'value'}); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next; + + expect(serverReceivedContentType, + ['application/x-www-form-urlencoded; charset=utf-8']); + expect(serverReceivedBody, 'key=value'); + }); + + test('client.post() with map body and encoding', () async { + await client.post(Uri.http(host, ''), + body: {'key': 'value'}, encoding: _Plus2Encoding()); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next; + + expect(serverReceivedContentType, + ['application/x-www-form-urlencoded; charset=plus2']); + expect(serverReceivedBody, 'gau;r]hqa'); // key=value + }); + + test('client.post() with List', () async { + await client.post(Uri.http(host, ''), body: [1, 2, 3, 4, 5]); + + await httpServerQueue.next; // Content-Type. + final serverReceivedBody = await httpServerQueue.next as String; + + // RFC 2616 7.2.1 says that: + // Any HTTP/1.1 message containing an entity-body SHOULD include a + // Content-Type header field defining the media type of that body. + // But we didn't set one explicitly so don't verify what the server + // received. + expect(serverReceivedBody.codeUnits, [1, 2, 3, 4, 5]); + }); + + test('client.post() with List and content-type', () async { + await client.post(Uri.http(host, ''), + headers: {'Content-Type': 'image/png'}, body: [1, 2, 3, 4, 5]); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next as String; + + expect(serverReceivedContentType, ['image/png']); + expect(serverReceivedBody.codeUnits, [1, 2, 3, 4, 5]); + }); + + test('client.post() with List with encoding', () async { + // Encoding should not affect binary payloads. + await client.post(Uri.http(host, ''), + body: [1, 2, 3, 4, 5], encoding: _Plus2Encoding()); + + await httpServerQueue.next; // Content-Type. + final serverReceivedBody = await httpServerQueue.next as String; + + // RFC 2616 7.2.1 says that: + // Any HTTP/1.1 message containing an entity-body SHOULD include a + // Content-Type header field defining the media type of that body. + // But we didn't set one explicitly so don't verify what the server + // received. + expect(serverReceivedBody.codeUnits, [1, 2, 3, 4, 5]); + }); + + test('client.post() with List with encoding and content-type', + () async { + // Encoding should not affect the payload but it should affect the + // content-type. + + await client.post(Uri.http(host, ''), + headers: {'Content-Type': 'image/png'}, + body: [1, 2, 3, 4, 5], + encoding: _Plus2Encoding()); + + final serverReceivedContentType = await httpServerQueue.next; + final serverReceivedBody = await httpServerQueue.next as String; + + expect(serverReceivedContentType, ['image/png; charset=plus2']); + expect(serverReceivedBody.codeUnits, [1, 2, 3, 4, 5]); + }); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_headers_server.dart b/pkgs/http_client_conformance_tests/lib/src/request_headers_server.dart new file mode 100644 index 0000000000..c443ad0034 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_headers_server.dart @@ -0,0 +1,45 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that captures the request headers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Request Received: +/// - send headers as Map> +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + request.response.headers.set('Access-Control-Allow-Origin', '*'); + if (request.method == 'OPTIONS') { + // Handle a CORS preflight request: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests + request.response.headers + ..set('Access-Control-Allow-Methods', 'GET') + ..set('Access-Control-Allow-Headers', '*'); + } else { + final headers = >{}; + request.headers.forEach((field, value) { + headers[field] = value; + }); + channel.sink.add(headers); + } + unawaited(request.response.close()); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/request_headers_tests.dart b/pkgs/http_client_conformance_tests/lib/src/request_headers_tests.dart new file mode 100644 index 0000000000..ee5f4ede17 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/request_headers_tests.dart @@ -0,0 +1,71 @@ +// 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. + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly sends headers in the request. +void testRequestHeaders(Client client) async { + group('client headers', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer('request_headers_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('single header', () async { + await client.get(Uri.http(host, ''), headers: {'foo': 'bar'}); + + final headers = await httpServerQueue.next as Map; + expect(headers['foo'], ['bar']); + }); + + test('UPPER case header', () async { + await client.get(Uri.http(host, ''), headers: {'FOO': 'BAR'}); + + final headers = await httpServerQueue.next as Map; + // RFC 2616 14.44 states that header field names are case-insensive. + // http.Client canonicalizes field names into lower case. + expect(headers['foo'], ['BAR']); + }); + + test('test headers different only in case', () async { + await client + .get(Uri.http(host, ''), headers: {'foo': 'bar', 'Foo': 'Bar'}); + + final headers = await httpServerQueue.next as Map; + // ignore: avoid_dynamic_calls + expect(headers['foo']!.single, isIn(['bar', 'Bar'])); + }); + + test('multiple headers', () async { + // The `http.Client` API does not offer a way of sending the name field + // more than once. + await client + .get(Uri.http(host, ''), headers: {'fruit': 'apple', 'color': 'red'}); + + final headers = await httpServerQueue.next as Map; + expect(headers['fruit'], ['apple']); + expect(headers['color'], ['red']); + }); + + test('multiple values per header', () async { + // The `http.Client` API does not offer a way of sending the same field + // more than once. + await client.get(Uri.http(host, ''), headers: {'list': 'apple, orange'}); + + final headers = await httpServerQueue.next as Map; + expect(headers['list'], ['apple, orange']); + }); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_body_server.dart b/pkgs/http_client_conformance_tests/lib/src/response_body_server.dart new file mode 100644 index 0000000000..238a4a17fa --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_body_server.dart @@ -0,0 +1,31 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that responds with "Hello World!" +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + final server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + await request.drain(); + request.response.headers.set('Access-Control-Allow-Origin', '*'); + request.response.headers.set('Content-Type', 'text/plain'); + request.response.write('Hello World!'); + await request.response.close(); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_server.dart b/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_server.dart new file mode 100644 index 0000000000..5f211d6dec --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_server.dart @@ -0,0 +1,42 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that sends a stream of integers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// When Receive Anything: +/// - close current request +/// - exit server +void hybridMain(StreamChannel channel) async { + final channelQueue = StreamQueue(channel.stream); + var serverWriting = true; + + late HttpServer server; + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + await request.drain(); + request.response.headers.set('Access-Control-Allow-Origin', '*'); + request.response.headers.set('Content-Type', 'text/plain'); + serverWriting = true; + for (var i = 0; serverWriting; ++i) { + request.response.write('$i\n'); + await request.response.flush(); + // Let the event loop run. + await Future(() {}); + } + await request.response.close(); + unawaited(server.close()); + }); + + channel.sink.add(server.port); + unawaited(channelQueue.next.then((value) => serverWriting = false)); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_test.dart b/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_test.dart new file mode 100644 index 0000000000..ab861f2fe5 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_body_streamed_test.dart @@ -0,0 +1,56 @@ +// 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. + +import 'dart:convert'; + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly implements HTTP responses with bodies of +/// unbounded size. +/// +/// If [canStreamResponseBody] is `false` then tests that assume that the +/// [Client] supports receiving HTTP responses with unbounded body sizes will +/// be skipped +void testResponseBodyStreamed(Client client, + {bool canStreamResponseBody = true}) async { + group('streamed response body', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = + await startServer('response_body_streamed_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('large response streamed without content length', () async { + // The server continuously streams data to the client until + // instructed to stop. + // + // This ensures that the client supports streamed responses. + + final request = Request('GET', Uri.http(host, '')); + final response = await client.send(request); + var lastReceived = 0; + await const LineSplitter() + .bind(const Utf8Decoder().bind(response.stream)) + .forEach((s) { + lastReceived = int.parse(s.trim()); + if (lastReceived == 1000) { + httpServerChannel.sink.add(true); + } + }); + expect(response.headers['content-type'], 'text/plain'); + expect(lastReceived, greaterThanOrEqualTo(1000)); + }, skip: canStreamResponseBody ? false : 'does not stream response bodies'); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_body_tests.dart b/pkgs/http_client_conformance_tests/lib/src/response_body_tests.dart new file mode 100644 index 0000000000..b5f0f750d2 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_body_tests.dart @@ -0,0 +1,55 @@ +// 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. + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly implements HTTP responses with bodies. +/// +/// If [canStreamResponseBody] is `false` then tests that assume that the +/// [Client] supports receiving HTTP responses with unbounded body sizes will +/// be skipped +void testResponseBody(Client client, + {bool canStreamResponseBody = true}) async { + group('response body', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + const message = 'Hello World!'; + + setUpAll(() async { + httpServerChannel = await startServer('response_body_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('small response with content length', () async { + final response = await client.get(Uri.http(host, '')); + expect(response.body, message); + expect(response.bodyBytes, message.codeUnits); + expect(response.contentLength, message.length); + expect(response.headers['content-type'], 'text/plain'); + }); + + test('small response streamed without content length', () async { + final request = Request('GET', Uri.http(host, '')); + final response = await client.send(request); + expect(await response.stream.bytesToString(), message); + if (canStreamResponseBody) { + expect(response.contentLength, null); + } else { + // If the response body is small then the Client can emulate a streamed + // response without streaming. But `response.contentLength` may or + // may not be set. + expect(response.contentLength, isIn([null, 12])); + } + expect(response.headers['content-type'], 'text/plain'); + }); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_headers_server.dart b/pkgs/http_client_conformance_tests/lib/src/response_headers_server.dart new file mode 100644 index 0000000000..3f2d4d3f0f --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_headers_server.dart @@ -0,0 +1,37 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that returns custom headers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// On Request Received: +/// - load response header map from channel +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + final clientQueue = StreamQueue(channel.stream); + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + request.response.headers.set('Access-Control-Allow-Origin', '*'); + request.response.headers.set('Access-Control-Expose-Headers', '*'); + + (await clientQueue.next as Map).forEach((key, value) => request + .response.headers + .set(key as String, value as String, preserveHeaderCase: true)); + + await request.response.close(); + unawaited(server.close()); + }); + + channel.sink.add(server.port); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/response_headers_tests.dart b/pkgs/http_client_conformance_tests/lib/src/response_headers_tests.dart new file mode 100644 index 0000000000..2864bdaca7 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/response_headers_tests.dart @@ -0,0 +1,58 @@ +// 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. + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly processes response headers. +void testResponseHeaders(Client client) async { + group('server headers', () { + late String host; + late StreamChannel httpServerChannel; + late StreamQueue httpServerQueue; + + setUp(() async { + httpServerChannel = await startServer('response_headers_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + + test('single header', () async { + httpServerChannel.sink.add({'foo': 'bar'}); + + final response = await client.get(Uri.http(host, '')); + expect(response.headers['foo'], 'bar'); + }); + + test('UPPERCASE header', () async { + httpServerChannel.sink.add({'foo': 'BAR'}); + + final response = await client.get(Uri.http(host, '')); + // RFC 2616 14.44 states that header field names are case-insensive. + // http.Client canonicalizes field names into lower case. + expect(response.headers['foo'], 'BAR'); + }); + + test('multiple headers', () async { + httpServerChannel.sink + .add({'field1': 'value1', 'field2': 'value2', 'field3': 'value3'}); + + final response = await client.get(Uri.http(host, '')); + expect(response.headers['field1'], 'value1'); + expect(response.headers['field2'], 'value2'); + expect(response.headers['field3'], 'value3'); + }); + + test('multiple values per header', () async { + httpServerChannel.sink.add({'list': 'apple, orange, banana'}); + + final response = await client.get(Uri.http(host, '')); + expect(response.headers['list'], 'apple, orange, banana'); + }); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/server_errors_server.dart b/pkgs/http_client_conformance_tests/lib/src/server_errors_server.dart new file mode 100644 index 0000000000..47470a41f4 --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/server_errors_server.dart @@ -0,0 +1,31 @@ +// 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. + +import 'dart:async'; +import 'dart:io'; + +import 'package:stream_channel/stream_channel.dart'; + +/// Starts an HTTP server that disconnects before sending it's headers. +/// +/// Channel protocol: +/// On Startup: +/// - send port +/// When Receive Anything: +/// - exit +void hybridMain(StreamChannel channel) async { + late HttpServer server; + + server = (await HttpServer.bind('localhost', 0)) + ..listen((request) async { + await request.drain(); + final socket = await request.response.detachSocket(writeHeaders: false); + socket.destroy(); + }); + + channel.sink.add(server.port); + await channel + .stream.first; // Any writes indicates that the server should exit. + unawaited(server.close()); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/server_errors_test.dart b/pkgs/http_client_conformance_tests/lib/src/server_errors_test.dart new file mode 100644 index 0000000000..076502f39a --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/server_errors_test.dart @@ -0,0 +1,41 @@ +// 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. + +import 'package:async/async.dart'; +import 'package:http/http.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +import 'utils.dart'; + +/// Tests that the [Client] correctly handles server errors. +void testServerErrors(Client client, + {bool redirectAlwaysAllowed = false}) async { + group('server errors', () { + late final String host; + late final StreamChannel httpServerChannel; + late final StreamQueue httpServerQueue; + + setUpAll(() async { + httpServerChannel = await startServer('server_errors_server.dart'); + httpServerQueue = StreamQueue(httpServerChannel.stream); + host = 'localhost:${await httpServerQueue.next}'; + }); + tearDownAll(() => httpServerChannel.sink.add(null)); + + test('no such host', () async { + expect( + client.get(Uri.http('thisisnotahost', '')), + throwsA(isA() + .having((e) => e.uri, 'uri', Uri.http('thisisnotahost', '')))); + }); + + test('disconnect', () async { + expect( + client.get(Uri.http(host, '')), + throwsA(isA() + .having((e) => e.uri, 'uri', Uri.http(host, '')))); + }); + }); +} diff --git a/pkgs/http_client_conformance_tests/lib/src/utils.dart b/pkgs/http_client_conformance_tests/lib/src/utils.dart new file mode 100644 index 0000000000..4b52d9022c --- /dev/null +++ b/pkgs/http_client_conformance_tests/lib/src/utils.dart @@ -0,0 +1,15 @@ +// 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. + +import 'package:stream_channel/stream_channel.dart'; +import 'package:test/test.dart'; + +/// Starts a test server using a relative path name e.g. +/// 'redirect_server.dart'. +/// +/// See [spawnHybridUri]. +Future> startServer(String fileName) async => + spawnHybridUri(Uri( + scheme: 'package', + path: 'http_client_conformance_tests/src/$fileName')); diff --git a/pkgs/http_client_conformance_tests/mono_pkg.yaml b/pkgs/http_client_conformance_tests/mono_pkg.yaml new file mode 100644 index 0000000000..6a18a15c47 --- /dev/null +++ b/pkgs/http_client_conformance_tests/mono_pkg.yaml @@ -0,0 +1,10 @@ +sdk: +- 2.14.0 +- dev + +stages: +- analyze_and_format: + - analyze: --fatal-infos + - format: + sdk: + - dev diff --git a/pkgs/http_client_conformance_tests/pubspec.yaml b/pkgs/http_client_conformance_tests/pubspec.yaml new file mode 100644 index 0000000000..dd8ea26b22 --- /dev/null +++ b/pkgs/http_client_conformance_tests/pubspec.yaml @@ -0,0 +1,17 @@ +name: http_client_conformance_tests +description: > + A library that tests whether implementations of package:http's `Client` class + behave as expected. +version: 0.0.1-dev +repository: https://github.com/dart-lang/http/tree/master/pkgs/http_client_conformance_tests + +environment: + sdk: '>=2.14.0 <3.0.0' + +dependencies: + async: ^2.8.2 + http: ^0.13.4 + test: ^1.21.2 + +dev_dependencies: + lints: ^1.0.0 diff --git a/test/io/utils.dart b/test/io/utils.dart deleted file mode 100644 index d2208fbbed..0000000000 --- a/test/io/utils.dart +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright (c) 2014, 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. - -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart'; -import 'package:http/src/utils.dart'; -import 'package:test/test.dart'; - -export '../utils.dart'; - -/// The current server instance. -HttpServer? _server; - -/// The URL for the current server instance. -Uri get serverUrl => Uri.parse('http://localhost:${_server!.port}'); - -/// Starts a new HTTP server. -Future startServer() async { - _server = (await HttpServer.bind('localhost', 0)) - ..listen((request) async { - var path = request.uri.path; - var response = request.response; - - if (path == '/error') { - response - ..statusCode = 400 - ..contentLength = 0; - unawaited(response.close()); - return; - } - - if (path == '/loop') { - var n = int.parse(request.uri.query); - response - ..statusCode = 302 - ..headers - .set('location', serverUrl.resolve('/loop?${n + 1}').toString()) - ..contentLength = 0; - unawaited(response.close()); - return; - } - - if (path == '/redirect') { - response - ..statusCode = 302 - ..headers.set('location', serverUrl.resolve('/').toString()) - ..contentLength = 0; - unawaited(response.close()); - return; - } - - if (path == '/no-content-length') { - response - ..statusCode = 200 - ..contentLength = -1 - ..write('body'); - unawaited(response.close()); - return; - } - - var requestBodyBytes = await ByteStream(request).toBytes(); - var encodingName = request.uri.queryParameters['response-encoding']; - var outputEncoding = encodingName == null - ? ascii - : requiredEncodingForCharset(encodingName); - - response.headers.contentType = - ContentType('application', 'json', charset: outputEncoding.name); - response.headers.set('single', 'value'); - - dynamic requestBody; - if (requestBodyBytes.isEmpty) { - requestBody = null; - } else if (request.headers.contentType?.charset != null) { - var encoding = - requiredEncodingForCharset(request.headers.contentType!.charset!); - requestBody = encoding.decode(requestBodyBytes); - } else { - requestBody = requestBodyBytes; - } - - final headers = >{}; - - request.headers.forEach((name, values) { - // These headers are automatically generated by dart:io, so we don't - // want to test them here. - if (name == 'cookie' || name == 'host') return; - - headers[name] = values; - }); - - var content = { - 'method': request.method, - 'path': request.uri.path, - if (requestBody != null) 'body': requestBody, - 'headers': headers, - }; - - var body = json.encode(content); - response - ..contentLength = body.length - ..write(body); - unawaited(response.close()); - }); -} - -/// Stops the current HTTP server. -void stopServer() { - if (_server != null) { - _server!.close(); - _server = null; - } -} - -/// A matcher for functions that throw HttpException. -Matcher get throwsClientException => - throwsA(const TypeMatcher()); - -/// A matcher for functions that throw SocketException. -final Matcher throwsSocketException = - throwsA(const TypeMatcher()); diff --git a/tool/ci.sh b/tool/ci.sh new file mode 100644 index 0000000000..0706703ca4 --- /dev/null +++ b/tool/ci.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Created with package:mono_repo v6.2.2 + +# Support built in commands on windows out of the box. +# When it is a flutter repo (check the pubspec.yaml for "sdk: flutter") +# then "flutter" is called instead of "pub". +# This assumes that the Flutter SDK has been installed in a previous step. +function pub() { + if grep -Fq "sdk: flutter" "${PWD}/pubspec.yaml"; then + command flutter pub "$@" + else + command dart pub "$@" + fi +} +# When it is a flutter repo (check the pubspec.yaml for "sdk: flutter") +# then "flutter" is called instead of "pub". +# This assumes that the Flutter SDK has been installed in a previous step. +function format() { + if grep -Fq "sdk: flutter" "${PWD}/pubspec.yaml"; then + command flutter format "$@" + else + command dart format "$@" + fi +} +# When it is a flutter repo (check the pubspec.yaml for "sdk: flutter") +# then "flutter" is called instead of "pub". +# This assumes that the Flutter SDK has been installed in a previous step. +function analyze() { + if grep -Fq "sdk: flutter" "${PWD}/pubspec.yaml"; then + command flutter analyze "$@" + else + command dart analyze "$@" + fi +} + +if [[ -z ${PKGS} ]]; then + echo -e '\033[31mPKGS environment variable must be set! - TERMINATING JOB\033[0m' + exit 64 +fi + +if [[ "$#" == "0" ]]; then + echo -e '\033[31mAt least one task argument must be provided! - TERMINATING JOB\033[0m' + exit 64 +fi + +SUCCESS_COUNT=0 +declare -a FAILURES + +for PKG in ${PKGS}; do + echo -e "\033[1mPKG: ${PKG}\033[22m" + EXIT_CODE=0 + pushd "${PKG}" >/dev/null || EXIT_CODE=$? + + if [[ ${EXIT_CODE} -ne 0 ]]; then + echo -e "\033[31mPKG: '${PKG}' does not exist - TERMINATING JOB\033[0m" + exit 64 + fi + + dart pub upgrade || EXIT_CODE=$? + + if [[ ${EXIT_CODE} -ne 0 ]]; then + echo -e "\033[31mPKG: ${PKG}; 'dart pub upgrade' - FAILED (${EXIT_CODE})\033[0m" + FAILURES+=("${PKG}; 'dart pub upgrade'") + else + for TASK in "$@"; do + EXIT_CODE=0 + echo + echo -e "\033[1mPKG: ${PKG}; TASK: ${TASK}\033[22m" + case ${TASK} in + analyze) + echo 'dart analyze --fatal-infos' + dart analyze --fatal-infos || EXIT_CODE=$? + ;; + format) + echo 'dart format --output=none --set-exit-if-changed .' + dart format --output=none --set-exit-if-changed . || EXIT_CODE=$? + ;; + test_0) + echo 'dart test --platform vm' + dart test --platform vm || EXIT_CODE=$? + ;; + test_1) + echo 'dart test --platform chrome' + dart test --platform chrome || EXIT_CODE=$? + ;; + *) + echo -e "\033[31mUnknown TASK '${TASK}' - TERMINATING JOB\033[0m" + exit 64 + ;; + esac + + if [[ ${EXIT_CODE} -ne 0 ]]; then + echo -e "\033[31mPKG: ${PKG}; TASK: ${TASK} - FAILED (${EXIT_CODE})\033[0m" + FAILURES+=("${PKG}; TASK: ${TASK}") + else + echo -e "\033[32mPKG: ${PKG}; TASK: ${TASK} - SUCCEEDED\033[0m" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + fi + + done + fi + + echo + echo -e "\033[32mSUCCESS COUNT: ${SUCCESS_COUNT}\033[0m" + + if [ ${#FAILURES[@]} -ne 0 ]; then + echo -e "\033[31mFAILURES: ${#FAILURES[@]}\033[0m" + for i in "${FAILURES[@]}"; do + echo -e "\033[31m $i\033[0m" + done + fi + + popd >/dev/null || exit 70 + echo +done + +if [ ${#FAILURES[@]} -ne 0 ]; then + exit 1 +fi