diff --git a/.gitignore b/.gitignore index 7dbf0350d6..8b7331fd82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,27 @@ -# Don’t commit the following directories created by pub. +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub .buildlog +.packages +.project .pub/ build/ -packages -.packages +**/packages/ -# Or the files created by dart2js. +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) *.dart.js -*.js_ +*.part.js *.js.deps *.js.map +*.info.json + +# Directory created by dartdoc +doc/api/ -# Include when developing application packages. -pubspec.lock \ No newline at end of file +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +pubspec.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..980160ff89 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,26 @@ +language: dart +sudo: false + +dart: + - dev + - stable + +dart_task: + - test: --platform vm,firefox + +# Only run one instance of the formatter and the analyzer, rather than running +# them against each Dart version. +matrix: + include: + - dart: stable + dart_task: dartfmt + - dart: dev + dart_task: dartanalyzer + +# Only building master means that we don't run two builds for each pull request. +branches: + only: [master] + +cache: + directories: + - $HOME/.pub-cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 8545e484a7..755eb861d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.11.3+14 + +* Remove single quote ("'" - ASCII 39) from boundary characters. + Causes issues with Google Cloud Storage. + ## 0.11.3+13 * remove boundary characters that package:http_parser cannot parse. diff --git a/README.md b/README.md index 8d852914d4..5df9255fb7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ class UserAgentClient extends http.BaseClient { UserAgentClient(this.userAgent, this._inner); - Future send(BaseRequest request) { + Future send(http.BaseRequest request) { request.headers['user-agent'] = userAgent; return _inner.send(request); } diff --git a/.analysis_options b/analysis_options.yaml similarity index 100% rename from .analysis_options rename to analysis_options.yaml diff --git a/lib/browser_client.dart b/lib/browser_client.dart index 309b3ac7e1..91bd0df9e9 100644 --- a/lib/browser_client.dart +++ b/lib/browser_client.dart @@ -6,13 +6,12 @@ import 'dart:async'; import 'dart:html'; import 'dart:typed_data'; -import 'package:stack_trace/stack_trace.dart'; +import 'package:async/async.dart'; import 'src/base_client.dart'; -import 'src/base_request.dart'; -import 'src/byte_stream.dart'; import 'src/exception.dart'; -import 'src/streamed_response.dart'; +import 'src/request.dart'; +import 'src/response.dart'; // TODO(nweiz): Move this under src/, re-export from lib/http.dart, and use this // automatically from [new Client] once sdk#24581 is fixed. @@ -20,11 +19,22 @@ import 'src/streamed_response.dart'; /// A `dart:html`-based HTTP client that runs in the browser and is backed by /// XMLHttpRequests. /// -/// This client inherits some of the limitations of XMLHttpRequest. It ignores -/// the [BaseRequest.contentLength], [BaseRequest.persistentConnection], -/// [BaseRequest.followRedirects], and [BaseRequest.maxRedirects] fields. It is -/// also unable to stream requests or responses; a request will only be sent and -/// a response will only be returned once all the data is available. +/// This client inherits some of the limitations of XMLHttpRequest. It is +/// unable to directly set some headers, such as `content-length`. It is also +/// unable to stream requests or responses; a request will only be sent and a +/// response will only be returned once all the data is available. +/// +/// You can control the underlying `dart:html` [HttpRequest] by adding values to +/// [Request.context]: +/// +/// * `"http.html.with_credentials"` is a boolean that defaults to `false`. If +/// it's `true`, cross-site requests will include credentials such as cookies +/// or authorization headers. See also [HttpRequest.withCredentials]. +/// +/// * `"http.html.timeout"` is an integer that specifies the time in +/// milliseconds before a request is automatically terminated. It defaults to +/// `0` which prevents a request from timing out. See also +/// [HttpRequest.timeout]. class BrowserClient extends BaseClient { /// The currently active XHRs. /// @@ -34,23 +44,20 @@ class BrowserClient extends BaseClient { /// Creates a new HTTP client. BrowserClient(); - /// Whether to send credentials such as cookies or authorization headers for - /// cross-site requests. - /// - /// Defaults to `false`. - bool withCredentials = false; - - /// Sends an HTTP request and asynchronously returns the response. - Future send(BaseRequest request) async { - var bytes = await request.finalize().toBytes(); + Future send(Request request) async { + var bytes = await collectBytes(request.read()); var xhr = new HttpRequest(); _xhrs.add(xhr); _openHttpRequest(xhr, request.method, request.url.toString(), asynch: true); - xhr.responseType = 'blob'; - xhr.withCredentials = withCredentials; + + xhr + ..responseType = 'blob' + ..withCredentials = request.context['http.html.with_credentials'] ?? false + ..timeout = request.context['http.html.timeout'] ?? 0; + request.headers.forEach(xhr.setRequestHeader); - var completer = new Completer(); + var completer = new Completer(); xhr.onLoad.first.then((_) { // TODO(nweiz): Set the response type to "arraybuffer" when issue 18542 // is fixed. @@ -59,30 +66,34 @@ class BrowserClient extends BaseClient { reader.onLoad.first.then((_) { var body = reader.result as Uint8List; - completer.complete(new StreamedResponse( - new ByteStream.fromBytes(body), - xhr.status, - contentLength: body.length, - request: request, - headers: xhr.responseHeaders, - reasonPhrase: xhr.statusText)); + completer.complete(new Response(xhr.responseUrl, xhr.status, + reasonPhrase: xhr.statusText, + body: new Stream.fromIterable([body]), + headers: xhr.responseHeaders)); }); reader.onError.first.then((error) { completer.completeError( new ClientException(error.toString(), request.url), - new Chain.current()); + StackTrace.current); }); reader.readAsArrayBuffer(blob); }); + xhr.onTimeout.first.then((error) { + completer.completeError( + new ClientException( + 'XMLHttpRequest timeout after ${xhr.timeout}ms.', request.url), + StackTrace.current); + }); + xhr.onError.first.then((_) { // Unfortunately, the underlying XMLHttpRequest API doesn't expose any // specific information about the error itself. completer.completeError( new ClientException("XMLHttpRequest error.", request.url), - new Chain.current()); + StackTrace.current); }); xhr.send(bytes); @@ -100,9 +111,6 @@ class BrowserClient extends BaseClient { request.open(method, url, async: asynch, user: user, password: password); } - /// Closes the client. - /// - /// This terminates all active requests. void close() { for (var xhr in _xhrs) { xhr.abort(); diff --git a/lib/http.dart b/lib/http.dart index 86bcefbc4d..ddf2711ddb 100644 --- a/lib/http.dart +++ b/lib/http.dart @@ -11,18 +11,15 @@ import 'src/client.dart'; import 'src/response.dart'; 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/exception.dart'; +export 'src/handler.dart'; export 'src/io_client.dart'; +export 'src/middleware.dart'; export 'src/multipart_file.dart'; -export 'src/multipart_request.dart'; +export 'src/pipeline.dart'; export 'src/request.dart'; export 'src/response.dart'; -export 'src/streamed_request.dart'; -export 'src/streamed_response.dart'; /// Sends an HTTP HEAD request with the given headers to the given URL, which /// can be a [Uri] or a [String]. @@ -31,9 +28,11 @@ export 'src/streamed_response.dart'; /// the request is complete. If you're planning on making multiple requests to /// the same server, you should use a single [Client] for all of those requests. /// -/// For more fine-grained control over the request, use [Request] instead. +/// This automatically initializes a new [Client] and closes that client once +/// the request is complete. If you're planning on making multiple requests to +/// the same server, you should use a single [Client] for all of those requests. Future head(url, {Map headers}) => - _withClient((client) => client.head(url, headers: headers)); + _withClient((client) => client.head(url, headers: headers)); /// Sends an HTTP GET request with the given headers to the given URL, which can /// be a [Uri] or a [String]. @@ -42,9 +41,11 @@ Future head(url, {Map headers}) => /// the request is complete. If you're planning on making multiple requests to /// the same server, you should use a single [Client] for all of those requests. /// -/// For more fine-grained control over the request, use [Request] instead. +/// This automatically initializes a new [Client] and closes that client once +/// the request is complete. If you're planning on making multiple requests to +/// the same server, you should use a single [Client] for all of those requests. Future get(url, {Map headers}) => - _withClient((client) => client.get(url, headers: headers)); + _withClient((client) => client.get(url, headers: headers)); /// Sends an HTTP POST request with the given headers and body to the given URL, /// which can be a [Uri] or a [String]. @@ -63,12 +64,13 @@ Future get(url, {Map headers}) => /// /// [encoding] defaults to [UTF8]. /// -/// For more fine-grained control over the request, use [Request] or -/// [StreamedRequest] instead. -Future post(url, {Map headers, body, - Encoding encoding}) => - _withClient((client) => client.post(url, - headers: headers, body: body, encoding: encoding)); +/// This automatically initializes a new [Client] and closes that client once +/// the request is complete. If you're planning on making multiple requests to +/// the same server, you should use a single [Client] for all of those requests. +Future post(url, body, + {Map headers, Encoding encoding}) => + _withClient((client) => + client.post(url, body, headers: headers, encoding: encoding)); /// Sends an HTTP PUT request with the given headers and body to the given URL, /// which can be a [Uri] or a [String]. @@ -87,12 +89,13 @@ Future post(url, {Map headers, body, /// /// [encoding] defaults to [UTF8]. /// -/// For more fine-grained control over the request, use [Request] or -/// [StreamedRequest] instead. -Future put(url, {Map headers, body, - Encoding encoding}) => - _withClient((client) => client.put(url, - headers: headers, body: body, encoding: encoding)); +/// This automatically initializes a new [Client] and closes that client once +/// the request is complete. If you're planning on making multiple requests to +/// the same server, you should use a single [Client] for all of those requests. +Future put(url, body, + {Map headers, Encoding encoding}) => + _withClient((client) => + client.put(url, body, headers: headers, encoding: encoding)); /// Sends an HTTP PATCH request with the given headers and body to the given /// URL, which can be a [Uri] or a [String]. @@ -111,12 +114,13 @@ Future put(url, {Map headers, body, /// /// [encoding] defaults to [UTF8]. /// -/// For more fine-grained control over the request, use [Request] or -/// [StreamedRequest] instead. -Future patch(url, {Map headers, body, - Encoding encoding}) => - _withClient((client) => client.patch(url, - headers: headers, body: body, encoding: encoding)); +/// This automatically initializes a new [Client] and closes that client once +/// the request is complete. If you're planning on making multiple requests to +/// the same server, you should use a single [Client] for all of those requests. +Future patch(url, body, + {Map headers, Encoding encoding}) => + _withClient((client) => + client.patch(url, body, headers: headers, encoding: encoding)); /// Sends an HTTP DELETE request with the given headers to the given URL, which /// can be a [Uri] or a [String]. @@ -124,10 +128,8 @@ Future patch(url, {Map headers, body, /// This automatically initializes a new [Client] and closes that client once /// the request is complete. If you're planning on making multiple requests to /// the same server, you should use a single [Client] for all of those requests. -/// -/// For more fine-grained control over the request, use [Request] instead. Future delete(url, {Map headers}) => - _withClient((client) => client.delete(url, headers: headers)); + _withClient((client) => client.delete(url, headers: headers)); /// Sends an HTTP GET request with the given headers to the given URL, which can /// be a [Uri] or a [String], and returns a Future that completes to the body of @@ -139,11 +141,8 @@ Future delete(url, {Map headers}) => /// This automatically initializes a new [Client] and closes that client once /// the request is complete. If you're planning on making multiple requests to /// the same server, you should use a single [Client] for all of those requests. -/// -/// For more fine-grained control over the request and response, use [Request] -/// instead. Future read(url, {Map headers}) => - _withClient((client) => client.read(url, headers: headers)); + _withClient((client) => client.read(url, headers: headers)); /// Sends an HTTP GET request with the given headers to the given URL, which can /// be a [Uri] or a [String], and returns a Future that completes to the body of @@ -155,11 +154,8 @@ Future read(url, {Map headers}) => /// This automatically initializes a new [Client] and closes that client once /// the request is complete. If you're planning on making multiple requests to /// the same server, you should use a single [Client] for all of those requests. -/// -/// For more fine-grained control over the request and response, use [Request] -/// instead. Future readBytes(url, {Map headers}) => - _withClient((client) => client.readBytes(url, headers: headers)); + _withClient((client) => client.readBytes(url, headers: headers)); Future _withClient(Future fn(Client client)) async { var client = new Client(); diff --git a/lib/src/base_client.dart b/lib/src/base_client.dart index 7b3fbfa41e..48784f7d95 100644 --- a/lib/src/base_client.dart +++ b/lib/src/base_client.dart @@ -6,171 +6,55 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; -import 'package:collection/collection.dart'; +import 'package:async/async.dart'; -import 'base_request.dart'; import 'client.dart'; import 'exception.dart'; import 'request.dart'; import 'response.dart'; -import 'streamed_response.dart'; -/// The abstract base class for an HTTP client. This is a mixin-style class; -/// subclasses only need to implement [send] and maybe [close], and then they +/// The abstract base class for an HTTP client. +/// +/// Subclasses only need to implement [send] and maybe [close], and then they /// get various convenience methods for free. abstract class BaseClient implements Client { - /// Sends an HTTP HEAD request with the given headers to the given URL, which - /// can be a [Uri] or a [String]. - /// - /// For more fine-grained control over the request, use [send] instead. Future head(url, {Map headers}) => - _sendUnstreamed("HEAD", url, headers); + send(new Request.head(url, headers: headers)); - /// Sends an HTTP GET request with the given headers to the given URL, which - /// can be a [Uri] or a [String]. - /// - /// For more fine-grained control over the request, use [send] instead. Future get(url, {Map headers}) => - _sendUnstreamed("GET", url, headers); - - /// Sends an HTTP POST request with the given headers and body to the given - /// URL, which can be a [Uri] or a [String]. - /// - /// [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". - /// - /// If [body] is a List, it's used as a list of bytes for the body of the - /// request. - /// - /// If [body] is a Map, it's encoded as form fields using [encoding]. The - /// content-type of the request will be set to - /// `"application/x-www-form-urlencoded"`; this cannot be overridden. - /// - /// [encoding] defaults to UTF-8. - /// - /// For more fine-grained control over the request, use [send] instead. - Future post(url, {Map headers, body, - Encoding encoding}) => - _sendUnstreamed("POST", url, headers, body, encoding); - - /// Sends an HTTP PUT request with the given headers and body to the given - /// URL, which can be a [Uri] or a [String]. - /// - /// [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". - /// - /// If [body] is a List, it's used as a list of bytes for the body of the - /// request. - /// - /// If [body] is a Map, it's encoded as form fields using [encoding]. The - /// content-type of the request will be set to - /// `"application/x-www-form-urlencoded"`; this cannot be overridden. - /// - /// [encoding] defaults to UTF-8. - /// - /// For more fine-grained control over the request, use [send] instead. - Future put(url, {Map headers, body, - Encoding encoding}) => - _sendUnstreamed("PUT", url, headers, body, encoding); - - /// Sends an HTTP PATCH request with the given headers and body to the given - /// URL, which can be a [Uri] or a [String]. - /// - /// [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". - /// - /// If [body] is a List, it's used as a list of bytes for the body of the - /// request. - /// - /// If [body] is a Map, it's encoded as form fields using [encoding]. The - /// content-type of the request will be set to - /// `"application/x-www-form-urlencoded"`; this cannot be overridden. - /// - /// [encoding] defaults to UTF-8. - /// - /// For more fine-grained control over the request, use [send] instead. - Future patch(url, {Map headers, body, - Encoding encoding}) => - _sendUnstreamed("PATCH", url, headers, body, encoding); - - /// Sends an HTTP DELETE request with the given headers to the given URL, - /// which can be a [Uri] or a [String]. - /// - /// For more fine-grained control over the request, use [send] instead. - Future delete(url, {Map headers}) => - _sendUnstreamed("DELETE", url, headers); - - /// Sends an HTTP GET request with the given headers to the given URL, which - /// can be a [Uri] or a [String], and returns a Future that completes to the - /// body of the response as a String. - /// - /// The Future will emit a [ClientException] if the response doesn't have a - /// success status code. - /// - /// For more fine-grained control over the request and response, use [send] or - /// [get] instead. - Future read(url, {Map headers}) { - return get(url, headers: headers).then((response) { - _checkResponseSuccess(url, response); - return response.body; - }); - } + send(new Request.get(url, headers: headers)); - /// Sends an HTTP GET request with the given headers to the given URL, which - /// can be a [Uri] or a [String], and returns a Future that completes to the - /// body of the response as a list of bytes. - /// - /// The Future will emit an [ClientException] if the response doesn't have a - /// success status code. - /// - /// For more fine-grained control over the request and response, use [send] or - /// [get] instead. - Future readBytes(url, {Map headers}) { - return get(url, headers: headers).then((response) { - _checkResponseSuccess(url, response); - return response.bodyBytes; - }); - } + Future post(url, body, + {Map headers, Encoding encoding}) => + send(new Request.post(url, body, headers: headers, encoding: encoding)); - /// Sends an HTTP request and asynchronously returns the response. - /// - /// Implementers should call [BaseRequest.finalize] to get the body of the - /// request as a [ByteStream]. They shouldn't make any assumptions about the - /// 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. - Future send(BaseRequest request); + Future put(url, body, + {Map headers, Encoding encoding}) => + send(new Request.put(url, body, headers: headers, encoding: encoding)); - /// Sends a non-streaming [Request] and returns a non-streaming [Response]. - Future _sendUnstreamed(String method, url, - Map headers, [body, Encoding encoding]) async { + Future patch(url, body, + {Map headers, Encoding encoding}) => + send(new Request.patch(url, body, headers: headers, encoding: encoding)); - if (url is String) url = Uri.parse(url); - var request = new Request(method, url); - - if (headers != null) request.headers.addAll(headers); - if (encoding != null) request.encoding = encoding; - if (body != null) { - if (body is String) { - request.body = body; - } else if (body is List) { - request.bodyBytes = DelegatingList.typed(body); - } else if (body is Map) { - request.bodyFields = DelegatingMap.typed(body); - } else { - throw new ArgumentError('Invalid request body "$body".'); - } - } + Future delete(url, {Map headers}) => + send(new Request.delete(url, headers: headers)); - return Response.fromStream(await send(request)); + Future read(url, {Map headers}) async { + var response = await get(url, headers: headers); + _checkResponseSuccess(url, response); + + return await response.readAsString(); } + Future readBytes(url, {Map headers}) async { + var response = await get(url, headers: headers); + _checkResponseSuccess(url, response); + + return await collectBytes(response.read()); + } + + Future send(Request request); + /// Throws an error if [response] is not successful. void _checkResponseSuccess(url, Response response) { if (response.statusCode < 400) return; @@ -182,8 +66,5 @@ abstract class BaseClient implements Client { throw new ClientException("$message.", url); } - /// Closes the client and cleans up any resources associated with it. It's - /// important to close each client when it's done being used; failing to do so - /// can cause the Dart process to hang. void close() {} } diff --git a/lib/src/base_request.dart b/lib/src/base_request.dart deleted file mode 100644 index b11ef05ae5..0000000000 --- a/lib/src/base_request.dart +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) 2012, 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:collection'; - -import 'byte_stream.dart'; -import 'client.dart'; -import 'streamed_response.dart'; -import 'utils.dart'; - -/// The base class for HTTP requests. -/// -/// Subclasses of [BaseRequest] can be constructed manually and passed to -/// [BaseClient.send], which allows the user to provide fine-grained control -/// over the request properties. However, usually it's easier to use convenience -/// methods like [get] or [BaseClient.get]. -abstract class BaseRequest { - /// The HTTP method of the request. Most commonly "GET" or "POST", less - /// commonly "HEAD", "PUT", or "DELETE". Non-standard method names are also - /// supported. - final String method; - - /// The URL to which the request will be sent. - final Uri url; - - /// The size of the request body, in bytes. - /// - /// This defaults to `null`, which indicates that the size of the request is - /// not known in advance. - int get contentLength => _contentLength; - int _contentLength; - - set contentLength(int value) { - if (value != null && value < 0) { - throw new ArgumentError("Invalid content length $value."); - } - _checkFinalized(); - _contentLength = value; - } - - /// Whether a persistent connection should be maintained with the server. - /// Defaults to true. - bool get persistentConnection => _persistentConnection; - bool _persistentConnection = true; - - set persistentConnection(bool value) { - _checkFinalized(); - _persistentConnection = value; - } - - /// Whether the client should follow redirects while resolving this request. - /// Defaults to true. - bool get followRedirects => _followRedirects; - bool _followRedirects = true; - - set followRedirects(bool value) { - _checkFinalized(); - _followRedirects = value; - } - - /// The maximum number of redirects to follow when [followRedirects] is true. - /// If this number is exceeded the [BaseResponse] future will signal a - /// [RedirectException]. Defaults to 5. - int get maxRedirects => _maxRedirects; - int _maxRedirects = 5; - - set maxRedirects(int value) { - _checkFinalized(); - _maxRedirects = value; - } - - // TODO(nweiz): automatically parse cookies from headers - - // TODO(nweiz): make this a HttpHeaders object - /// The headers for this request. - final Map headers; - - /// Whether the request has been finalized. - bool get finalized => _finalized; - bool _finalized = false; - - /// Creates a new HTTP request. - BaseRequest(this.method, this.url) - : headers = new LinkedHashMap( - equals: (key1, key2) => key1.toLowerCase() == key2.toLowerCase(), - hashCode: (key) => key.toLowerCase().hashCode); - - /// Finalizes the HTTP request in preparation for it being sent. This freezes - /// all mutable fields and returns a single-subscription [ByteStream] that - /// emits the body of the request. - /// - /// The base implementation of this returns null rather than a [ByteStream]; - /// subclasses are responsible for creating the return value, which should be - /// single-subscription to ensure that no data is dropped. They should also - /// freeze any additional mutable fields they add that don't make sense to - /// change after the request headers are sent. - ByteStream finalize() { - // TODO(nweiz): freeze headers - if (finalized) throw new StateError("Can't finalize a finalized Request."); - _finalized = true; - return null; - } - - /// Sends this request. - /// - /// This automatically initializes a new [Client] and closes that client once - /// the request is complete. If you're planning on making multiple requests to - /// the same server, you should use a single [Client] for all of those - /// requests. - Future send() async { - var client = new Client(); - - try { - var response = await client.send(this); - var stream = onDone(response.stream, client.close); - return new StreamedResponse( - new ByteStream(stream), - response.statusCode, - contentLength: response.contentLength, - request: response.request, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase); - } catch (_) { - client.close(); - rethrow; - } - } - - // Throws an error if this request has been finalized. - void _checkFinalized() { - if (!finalized) return; - throw new StateError("Can't modify a finalized Request."); - } - - String toString() => "$method $url"; -} diff --git a/lib/src/base_response.dart b/lib/src/base_response.dart deleted file mode 100644 index 26427f806f..0000000000 --- a/lib/src/base_response.dart +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2012, 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 'base_request.dart'; - -/// The base class for HTTP responses. -/// -/// Subclasses of [BaseResponse] are usually not constructed manually; instead, -/// they're returned by [BaseClient.send] or other HTTP client methods. -abstract class BaseResponse { - /// The (frozen) request that triggered this response. - final BaseRequest request; - - /// The status code of the response. - final int statusCode; - - /// The reason phrase associated with the status code. - final String reasonPhrase; - - /// The size of the response body, in bytes. - /// - /// If the size of the request is not known in advance, this is `null`. - final int contentLength; - - // TODO(nweiz): automatically parse cookies from headers - - // TODO(nweiz): make this a HttpHeaders object. - /// The headers for this response. - final Map headers; - - /// Whether this response is a redirect. - final bool isRedirect; - - /// Whether the server requested that a persistent connection be maintained. - final bool persistentConnection; - - /// Creates a new HTTP response. - BaseResponse( - this.statusCode, - {this.contentLength, - this.request, - this.headers: const {}, - this.isRedirect: false, - this.persistentConnection: true, - this.reasonPhrase}) { - if (statusCode < 100) { - throw new ArgumentError("Invalid status code $statusCode."); - } else if (contentLength != null && contentLength < 0) { - throw new ArgumentError("Invalid content length $contentLength."); - } - } -} diff --git a/lib/src/body.dart b/lib/src/body.dart new file mode 100644 index 0000000000..e3589d0326 --- /dev/null +++ b/lib/src/body.dart @@ -0,0 +1,96 @@ +// Copyright (c) 2017, 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:collection/collection.dart'; + +/// The body of a request or response. +/// +/// This tracks whether the body has been read. It's separate from [Message] +/// because the message may be changed with [Message.change], but each instance +/// should share a notion of whether the body was read. +class Body { + /// The contents of the message body. + /// + /// This will be `null` after [read] is called. + Stream> _stream; + + /// The encoding used to encode the stream returned by [read], or `null` if no + /// encoding was used. + final Encoding encoding; + + /// The length of the stream returned by [read], or `null` if that can't be + /// determined efficiently. + final int contentLength; + + Body._(this._stream, this.encoding, this.contentLength); + + /// Converts [body] to a byte stream and wraps it in a [Body]. + /// + /// [body] may be either a [Body], a [String], a [List], a + /// [Stream>], or `null`. If it's a [String], [encoding] will be + /// used to convert it to a [Stream>]. + factory Body(body, [Encoding encoding]) { + if (body is Body) return body; + + Stream> stream; + int contentLength; + if (body == null) { + contentLength = 0; + stream = new Stream.fromIterable([]); + } else if (body is String) { + if (encoding == null) { + var encoded = UTF8.encode(body); + // If the text is plain ASCII, don't modify the encoding. This means + // that an encoding of "text/plain" will stay put. + if (!_isPlainAscii(encoded, body.length)) encoding = UTF8; + contentLength = encoded.length; + stream = new Stream.fromIterable([encoded]); + } else { + var encoded = encoding.encode(body); + contentLength = encoded.length; + stream = new Stream.fromIterable([encoded]); + } + } else if (body is List) { + contentLength = body.length; + stream = new Stream.fromIterable([DelegatingList.typed(body)]); + } else if (body is Stream) { + stream = DelegatingStream.typed(body); + } else { + throw new ArgumentError('Response body "$body" must be a String or a ' + 'Stream.'); + } + + return new Body._(stream, encoding, contentLength); + } + + /// Returns whether [bytes] is plain ASCII. + /// + /// [codeUnits] is the number of code units in the original string. + static bool _isPlainAscii(List bytes, int codeUnits) { + // Most non-ASCII code units will produce multiple bytes and make the text + // longer. + if (bytes.length != codeUnits) return false; + + // Non-ASCII code units between U+0080 and U+009F produce 8-bit characters + // with the high bit set. + return bytes.every((byte) => byte & 0x80 == 0); + } + + /// Returns a [Stream] representing the body. + /// + /// Can only be called once. + Stream> read() { + if (_stream == null) { + throw new StateError("The 'read' method can only be called once on a " + "http.Request/http.Response object."); + } + var stream = _stream; + _stream = null; + return stream; + } +} diff --git a/lib/src/boundary.dart b/lib/src/boundary.dart new file mode 100644 index 0000000000..6dd352daad --- /dev/null +++ b/lib/src/boundary.dart @@ -0,0 +1,41 @@ +// Copyright (c) 2017, 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:math'; + +/// All character codes that are valid in multipart boundaries. +/// +/// This is the intersection of the characters allowed in the `bcharsnospace` +/// production defined in [RFC 2046][] and those allowed in the `token` +/// production defined in [RFC 1521][]. +/// +/// [RFC 2046]: http://tools.ietf.org/html/rfc2046#section-5.1.1. +/// [RFC 1521]: https://tools.ietf.org/html/rfc1521#section-4 +const List _boundaryCharacters = const [ + 43, 95, 45, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, // + 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, + 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, + 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, + 122 +]; + +/// The total length of the multipart boundaries used when building the +/// request body. +/// +/// According to http://tools.ietf.org/html/rfc1341.html, this can't be longer +/// than 70. +const int _boundaryLength = 70; + +final Random _random = new Random(); + +/// Returns a randomly-generated multipart boundary string +String boundaryString() { + var prefix = 'dart-http-boundary-'; + var list = new List.generate( + _boundaryLength - prefix.length, + (index) => + _boundaryCharacters[_random.nextInt(_boundaryCharacters.length)], + growable: false); + return '$prefix${new String.fromCharCodes(list)}'; +} diff --git a/lib/src/boundary_characters.dart b/lib/src/boundary_characters.dart deleted file mode 100644 index 03b7ac2d5e..0000000000 --- a/lib/src/boundary_characters.dart +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) 2013, 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. - -/// All character codes that are valid in multipart boundaries. This is the -/// intersection of the characters allowed in the `bcharsnospace` production -/// defined in [RFC 2046][] and those allowed in the `token` production -/// defined in [RFC 1521][]. -/// -/// [RFC 2046]: http://tools.ietf.org/html/rfc2046#section-5.1.1. -/// [RFC 1521]: https://tools.ietf.org/html/rfc1521#section-4 -const List BOUNDARY_CHARACTERS = const [ - 39, 43, 95, 45, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, - 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, - 84, 85, 86, 87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, - 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, - 122 -]; diff --git a/lib/src/byte_stream.dart b/lib/src/byte_stream.dart deleted file mode 100644 index a9d47b075f..0000000000 --- a/lib/src/byte_stream.dart +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2013, 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:typed_data'; - -/// A stream of chunks of bytes representing a single piece of data. -class ByteStream extends StreamView> { - ByteStream(Stream> stream) - : super(stream); - - /// Returns a single-subscription byte stream that will emit the given bytes - /// in a single chunk. - factory ByteStream.fromBytes(List bytes) => - new ByteStream(new Stream.fromIterable([bytes])); - - /// Collects the data of this stream in a [Uint8List]. - Future toBytes() { - var completer = new Completer(); - var sink = new ByteConversionSink.withCallback((bytes) => - completer.complete(new Uint8List.fromList(bytes))); - listen(sink.add, onError: completer.completeError, onDone: sink.close, - cancelOnError: true); - return completer.future; - } - - /// Collect the data of this stream in a [String], decoded according to - /// [encoding], which defaults to `UTF8`. - Future bytesToString([Encoding encoding=UTF8]) => - encoding.decodeStream(this); - - Stream toStringStream([Encoding encoding=UTF8]) => - encoding.decoder.bind(this); -} diff --git a/lib/src/client.dart b/lib/src/client.dart index cf1ff784a0..8ce424d2e1 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -7,10 +7,11 @@ import 'dart:convert'; import 'dart:typed_data'; import 'base_client.dart'; -import 'base_request.dart'; +import 'handler.dart'; +import 'handler_client.dart'; import 'io_client.dart'; +import 'request.dart'; import 'response.dart'; -import 'streamed_response.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 @@ -29,6 +30,15 @@ abstract class Client { /// [BrowserClient] if `dart:html` is available. factory Client() => new IOClient(); + /// Creates a new [Client] from a [handler] callback. + /// + /// The [handler] is a function that receives a [Request] and returns a + /// [Future]. It will be called when [Client.send] is invoked. + /// + /// When [Client.close] is called the [onClose] function will be called. + factory Client.handler(Handler handler, {void onClose()}) => + new HandlerClient(handler, onClose ?? () {}); + /// Sends an HTTP HEAD request with the given headers to the given URL, which /// can be a [Uri] or a [String]. /// @@ -59,8 +69,8 @@ abstract class Client { /// [encoding] defaults to [UTF8]. /// /// For more fine-grained control over the request, use [send] instead. - Future post(url, {Map headers, body, - Encoding encoding}); + Future post(url, body, + {Map headers, Encoding encoding}); /// Sends an HTTP PUT request with the given headers and body to the given /// URL, which can be a [Uri] or a [String]. @@ -80,8 +90,8 @@ abstract class Client { /// [encoding] defaults to [UTF8]. /// /// For more fine-grained control over the request, use [send] instead. - Future put(url, {Map headers, body, - Encoding encoding}); + Future put(url, body, + {Map headers, Encoding encoding}); /// Sends an HTTP PATCH request with the given headers and body to the given /// URL, which can be a [Uri] or a [String]. @@ -101,8 +111,8 @@ abstract class Client { /// [encoding] defaults to [UTF8]. /// /// For more fine-grained control over the request, use [send] instead. - Future patch(url, {Map headers, body, - Encoding encoding}); + Future patch(url, body, + {Map headers, Encoding encoding}); /// Sends an HTTP DELETE request with the given headers to the given URL, /// which can be a [Uri] or a [String]. @@ -133,10 +143,11 @@ abstract class Client { Future readBytes(url, {Map headers}); /// Sends an HTTP request and asynchronously returns the response. - Future send(BaseRequest request); + Future send(Request request); - /// Closes the client and cleans up any resources associated with it. It's - /// important to close each client when it's done being used; failing to do so - /// can cause the Dart process to hang. + /// Closes the client and cleans up any resources associated with it. + /// + /// It's important to close each client when it's done being used; failing to + /// do so can cause the Dart process to hang. void close(); } diff --git a/lib/src/content_type.dart b/lib/src/content_type.dart new file mode 100644 index 0000000000..8a1222ac03 --- /dev/null +++ b/lib/src/content_type.dart @@ -0,0 +1,25 @@ +// Copyright (c) 2017, 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:http_parser/http_parser.dart'; + +/// Returns the [Encoding] that corresponds to [charset]. +/// +/// Returns `null` if [charset] is `null` or if no [Encoding] was found that +/// corresponds to [charset]. +Encoding encodingForCharset(String charset) { + if (charset == null) return null; + return Encoding.getByName(charset); +} + +/// Determines the encoding from the media [type]. +/// +/// Returns `null` if the charset is not specified in the [type] or if no +/// [Encoding] was found that corresponds to the `charset`. +Encoding encodingForMediaType(MediaType type) { + if (type == null) return null; + return encodingForCharset(type.parameters['charset']); +} diff --git a/lib/src/exception.dart b/lib/src/exception.dart index db2c2240a4..3373a62ac9 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -4,11 +4,15 @@ /// An exception caused by an error in a pkg/http client. class ClientException implements Exception { + /// Message describing the problem. final String message; /// The URL of the HTTP request or response that failed. final Uri uri; + /// Creates a [ClientException] explained in [message]. + /// + /// The [uri] points to the URL being requested if applicable. ClientException(this.message, [this.uri]); String toString() => message; diff --git a/lib/src/handler.dart b/lib/src/handler.dart new file mode 100644 index 0000000000..f1d3a83f3b --- /dev/null +++ b/lib/src/handler.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2017, 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 'request.dart'; +import 'response.dart'; + +/// The signature of a function which handles a [Request] and returns a +/// [Future]. +/// +/// A [Handler] may call an underlying HTTP implementation, or it may wrap +/// another [Handler] or a [Client]. +typedef Future Handler(Request request); diff --git a/lib/src/handler_client.dart b/lib/src/handler_client.dart new file mode 100644 index 0000000000..3540d3d022 --- /dev/null +++ b/lib/src/handler_client.dart @@ -0,0 +1,30 @@ +// Copyright (c) 2017, 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 'base_client.dart'; +import 'handler.dart'; +import 'request.dart'; +import 'response.dart'; + +/// A [Handler]-based HTTP client. +/// +/// The [HandlerClient] allows composition of a [Client] within a larger +/// application. +class HandlerClient extends BaseClient { + final Handler _handler; + final void Function() _close; + + /// Creates a new client using the [_handler] and [onClose] functions. + HandlerClient(this._handler, void onClose()) : _close = onClose; + + /// Sends an HTTP request and asynchronously returns the response. + Future send(Request request) => _handler(request); + + /// Closes the client and cleans up any resources associated with it. + void close() { + _close(); + } +} diff --git a/lib/src/http_unmodifiable_map.dart b/lib/src/http_unmodifiable_map.dart new file mode 100644 index 0000000000..5769dcc6c8 --- /dev/null +++ b/lib/src/http_unmodifiable_map.dart @@ -0,0 +1,58 @@ +// Copyright (c) 2017, 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:collection'; + +import 'package:collection/collection.dart'; +import 'package:http_parser/http_parser.dart'; + +/// A simple wrapper over [UnmodifiableMapView] which avoids re-wrapping itself. +class HttpUnmodifiableMap extends UnmodifiableMapView { + /// `true` if the key values are already lowercase. + final bool _ignoreKeyCase; + + /// If [source] is a [HttpUnmodifiableMap] with matching [ignoreKeyCase], + /// then [source] is returned. + /// + /// If [source] is `null` it is treated like an empty map. + /// + /// If [ignoreKeyCase] is `true`, the keys will have case-insensitive access. + /// + /// [source] is copied to a new [Map] to ensure changes to the parameter value + /// after constructions are not reflected. + factory HttpUnmodifiableMap(Map source, + {bool ignoreKeyCase: false}) { + if (source is HttpUnmodifiableMap && + // !ignoreKeyCase: no transformation of the input is required + // source._ignoreKeyCase: the input cannot be transformed any more + (!ignoreKeyCase || source._ignoreKeyCase)) { + return source; + } + + if (source == null || source.isEmpty) { + return const _EmptyHttpUnmodifiableMap(); + } + + if (ignoreKeyCase) { + source = new CaseInsensitiveMap.from(source); + } else { + source = new Map.from(source); + } + + return new HttpUnmodifiableMap._(source, ignoreKeyCase); + } + + /// Returns an empty [HttpUnmodifiableMap]. + const factory HttpUnmodifiableMap.empty() = _EmptyHttpUnmodifiableMap; + + HttpUnmodifiableMap._(Map source, this._ignoreKeyCase) + : super(source); +} + +/// A const implementation of an empty [HttpUnmodifiableMap]. +class _EmptyHttpUnmodifiableMap extends MapView + implements HttpUnmodifiableMap { + bool get _ignoreKeyCase => true; + const _EmptyHttpUnmodifiableMap() : super(const {}); +} diff --git a/lib/src/io_client.dart b/lib/src/io_client.dart index 8a7fe6da18..92b1b20cf3 100644 --- a/lib/src/io_client.dart +++ b/lib/src/io_client.dart @@ -8,13 +8,31 @@ import 'dart:io'; import 'package:async/async.dart'; import 'base_client.dart'; -import 'base_request.dart'; import 'exception.dart'; -import 'streamed_response.dart'; +import 'request.dart'; +import 'response.dart'; /// A `dart:io`-based HTTP client. /// /// This is the default client when running on the command line. +/// +/// You can control the underlying `dart:io` [HttpRequest] by adding values to +/// [Request.context]: +/// +/// * `"http.io.follow_redirects"` is a boolean. If it's `true` (the default) +/// then the request will automatically follow HTTP redirects. If it's +/// `false`, the client will need to handle redirects manually. See also +/// [HttpClientRequest.followRedirects]. +/// +/// * `"http.io.max_redirects"` is an integer that specifies the maximum number +/// of redirects that will be followed if `follow_redirects` is `true`. If the +/// site redirects more than this, [send] will throw a [ClientException]. It +/// defaults to `5`. See also [HttpClientRequest.maxRedirects]. +/// +/// * `"http.io.persistent_connection"` is a boolean. If it's `true` (the +/// default) the client will request that the TCP connection be kept alive +/// after the request completes. See also +/// [HttpClientRequest.persistentConnection]. class IOClient extends BaseClient { /// The underlying `dart:io` HTTP client. HttpClient _inner; @@ -22,53 +40,58 @@ class IOClient extends BaseClient { /// Creates a new HTTP client. IOClient([HttpClient inner]) : _inner = inner ?? new HttpClient(); - /// Sends an HTTP request and asynchronously returns the response. - Future send(BaseRequest request) async { - var stream = request.finalize(); - + Future send(Request request) async { try { var ioRequest = await _inner.openUrl(request.method, request.url); + var context = request.context; ioRequest - ..followRedirects = request.followRedirects - ..maxRedirects = request.maxRedirects - ..contentLength = request.contentLength == null - ? -1 - : request.contentLength - ..persistentConnection = request.persistentConnection; + ..followRedirects = context['http.io.follow_redirects'] ?? true + ..maxRedirects = context['http.io.max_redirects'] ?? 5 + ..persistentConnection = + context['http.io.persistent_connection'] ?? true; request.headers.forEach((name, value) { ioRequest.headers.set(name, value); }); - var response = await stream.pipe( - DelegatingStreamConsumer.typed(ioRequest)); + request.read().pipe(DelegatingStreamConsumer.typed>(ioRequest)); + var response = await ioRequest.done; + var headers = {}; response.headers.forEach((key, values) { headers[key] = values.join(','); }); - return new StreamedResponse( - DelegatingStream.typed>(response).handleError((error) => - throw new ClientException(error.message, error.uri), + return new Response(_responseUrl(request, response), response.statusCode, + reasonPhrase: response.reasonPhrase, + body: DelegatingStream.typed>(response).handleError( + (error) => throw new ClientException(error.message, error.uri), test: (error) => error is HttpException), - response.statusCode, - contentLength: response.contentLength == -1 - ? null - : response.contentLength, - request: request, - headers: headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase); + headers: headers); } on HttpException catch (error) { throw new ClientException(error.message, error.uri); + } on SocketException catch (error) { + throw new ClientException(error.message, request.url); } } - /// Closes the client. This terminates all active connections. If a client - /// remains unclosed, the Dart process may not terminate. void close() { if (_inner != null) _inner.close(force: true); _inner = null; } + + /// Determines the finalUrl retrieved by evaluating any redirects received in + /// the [response] based on the initial [request]. + Uri _responseUrl(Request request, HttpClientResponse response) { + var finalUrl = request.url; + + for (var redirect in response.redirects) { + var location = redirect.location; + + // Redirects can either be absolute or relative + finalUrl = location.isAbsolute ? location : finalUrl.resolveUri(location); + } + + return finalUrl; + } } diff --git a/lib/src/message.dart b/lib/src/message.dart new file mode 100644 index 0000000000..6111027948 --- /dev/null +++ b/lib/src/message.dart @@ -0,0 +1,203 @@ +// Copyright (c) 2017, 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:collection/collection.dart'; +import 'package:http_parser/http_parser.dart'; + +import 'body.dart'; +import 'content_type.dart'; +import 'http_unmodifiable_map.dart'; +import 'utils.dart'; + +/// Retrieves the [Body] contained in the [message]. +/// +/// This is meant for internal use by `http` so the message body is accessible +/// for subclasses of [Message] but hidden elsewhere. +Body getBody(Message message) => message._body; + +/// Represents logic shared between [Request] and [Response]. +abstract class Message { + /// The HTTP headers. + /// + /// This is immutable. A copy of this with new headers can be created using + /// [change]. + final Map headers; + + /// Extra context that can be used by middleware and handlers. + /// + /// For requests, this is used to pass data to inner middleware and handlers; + /// for responses, it's used to pass data to outer middleware and handlers. + /// + /// Context properties that are used by a particular package should begin with + /// that package's name followed by a period. For example, if there was a + /// package `foo` which contained a middleware `bar` and it wanted to take + /// a context property, its property would be `"foo.bar"`. + /// + /// This is immutable. A copy of this with new context values can be created + /// using [change]. + final Map context; + + /// The streaming body of the message. + /// + /// This can be read via [read] or [readAsString]. + final Body _body; + + /// Creates a new [Message]. + /// + /// [body] is the message body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. + /// + /// If [headers] is `null`, it's treated as empty. + /// + /// If [encoding] is passed, the "encoding" field of the Content-Type header + /// in [headers] will be set appropriately. If there is no existing + /// Content-Type header, it will be set to "application/octet-stream". + Message(body, + {Encoding encoding, + Map headers, + Map context}) + : this._(new Body(body, encoding), headers, context); + + Message._(Body body, Map headers, Map context) + : _body = body, + headers = new HttpUnmodifiableMap(_adjustHeaders(headers, body), + ignoreKeyCase: true), + context = + new HttpUnmodifiableMap(context, ignoreKeyCase: false); + + /// If `true`, the stream returned by [read] won't emit any bytes. + /// + /// This may have false negatives, but it won't have false positives. + bool get isEmpty => _body.contentLength == 0; + + /// The contents of the content-length field in [headers]. + /// + /// If not set, `null`. + int get contentLength { + if (_contentLengthCache != null) return _contentLengthCache; + var contentLengthHeader = getHeader(headers, 'content-length'); + if (contentLengthHeader == null) return null; + _contentLengthCache = int.parse(contentLengthHeader); + return _contentLengthCache; + } + + int _contentLengthCache; + + /// The MIME type declared in [headers]. + /// + /// This is parsed from the Content-Type header in [headers]. It contains only + /// the MIME type, without any Content-Type parameters. + /// + /// If [headers] doesn't have a Content-Type header, this will be `null`. + String get mimeType => _contentType?.mimeType; + + /// The encoding of the body returned by [read]. + /// + /// This is parsed from the "charset" parameter of the Content-Type header in + /// [headers]. + /// + /// If [headers] doesn't have a Content-Type header or it specifies an + /// encoding that [dart:convert] doesn't support, this will be `null`. + Encoding get encoding => encodingForMediaType(_contentType); + + /// The parsed version of the Content-Type header in [headers]. + /// + /// This is cached for efficient access. + MediaType get _contentType { + if (_contentTypeCache != null) return _contentTypeCache; + var contentLengthHeader = getHeader(headers, 'content-type'); + if (contentLengthHeader == null) return null; + _contentTypeCache = new MediaType.parse(contentLengthHeader); + return _contentTypeCache; + } + + MediaType _contentTypeCache; + + /// Returns the message body as byte chunks. + /// + /// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has + /// already been called. + Stream> read() => _body.read(); + + /// Returns the message body as a list of bytes. + /// + /// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has + /// already been called. + Future> readAsBytes() => collectBytes(read()); + + /// Returns the message body as a string. + /// + /// If [encoding] is passed, that's used to decode the body. Otherwise the + /// encoding is taken from the Content-Type header. If that doesn't exist or + /// doesn't have a "charset" parameter, UTF-8 is used. + /// + /// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has + /// already been called. + Future readAsString([Encoding encoding]) { + encoding ??= this.encoding ?? UTF8; + return encoding.decodeStream(read()); + } + + /// Creates a copy of this by copying existing values and applying specified + /// changes. + Message change( + {Map headers, Map context, body}); +} + +/// Adds information about encoding to [headers]. +/// +/// Returns a new map without modifying [headers]. +Map _adjustHeaders(Map headers, Body body) { + var sameEncoding = _sameEncoding(headers, body); + if (sameEncoding) { + if (body.contentLength == null || + getHeader(headers, 'content-length') == body.contentLength.toString()) { + return headers ?? const HttpUnmodifiableMap.empty(); + } else if (body.contentLength == 0 && + (headers == null || headers.isEmpty)) { + return const HttpUnmodifiableMap.empty(); + } + } + + var newHeaders = headers == null + ? new CaseInsensitiveMap() + : new CaseInsensitiveMap.from(headers); + + if (!sameEncoding) { + if (newHeaders['content-type'] == null) { + newHeaders['content-type'] = + 'application/octet-stream; charset=${body.encoding.name}'; + } else { + var contentType = new MediaType.parse(newHeaders['content-type']) + .change(parameters: {'charset': body.encoding.name}); + newHeaders['content-type'] = contentType.toString(); + } + } + + if (body.contentLength != null) { + var coding = newHeaders['transfer-encoding']; + if (coding == null || equalsIgnoreAsciiCase(coding, 'identity')) { + newHeaders['content-length'] = body.contentLength.toString(); + } + } + + return newHeaders; +} + +/// Returns whether [headers] declares the same encoding as [body]. +bool _sameEncoding(Map headers, Body body) { + if (body.encoding == null) return true; + + var contentType = getHeader(headers, 'content-type'); + if (contentType == null) return false; + + var charset = new MediaType.parse(contentType).parameters['charset']; + return Encoding.getByName(charset) == body.encoding; +} diff --git a/lib/src/middleware.dart b/lib/src/middleware.dart new file mode 100644 index 0000000000..1e9b349695 --- /dev/null +++ b/lib/src/middleware.dart @@ -0,0 +1,64 @@ +// Copyright (c) 2017, 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 'client.dart'; +import 'handler_client.dart'; +import 'request.dart'; +import 'response.dart'; + +/// A function which creates a new [Client] by wrapping a [Client]. +/// +/// You can extend the functions of a [Client] by wrapping it in [Middleware] +/// that can intercept and process a HTTP request before it it sent to a +/// client, a response after it is received by a client, or both. +/// +/// Because [Middleware] consumes a [Client] and returns a new [Client], +/// multiple [Middleware] instances can be composed together to offer rich +/// functionality. +/// +/// Common uses for middleware include caching, logging, and authentication. +/// +/// A simple [Middleware] can be created using [createMiddleware]. +typedef Client Middleware(Client inner); + +/// Creates a [Middleware] using the provided functions. +/// +/// If provided, [requestHandler] receives a [Request]. It replies to the +/// request by returning a [Future]. The modified [Request] is then +/// sent to the inner [Client]. +/// +/// If provided, [responseHandler] is called with the [Response] generated +/// by the inner [Client]. It replies to the response by returning a +/// [Future requestHandler(Request request), + Future responseHandler(Response response), + void onClose(), + void errorHandler(error, [StackTrace stackTrace])}) { + requestHandler ??= (request) async => request; + responseHandler ??= (response) async => response; + + return (inner) { + return new HandlerClient( + (request) => requestHandler(request) + .then((req) => inner.send(req)) + .then((res) => responseHandler(res), onError: errorHandler), + onClose == null + ? inner.close + : () { + onClose(); + inner.close(); + }, + ); + }; +} diff --git a/lib/src/mock_client.dart b/lib/src/mock_client.dart deleted file mode 100644 index acda5fde7a..0000000000 --- a/lib/src/mock_client.dart +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2012, 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 'base_client.dart'; -import 'base_request.dart'; -import 'byte_stream.dart'; -import 'request.dart'; -import 'response.dart'; -import 'streamed_response.dart'; - -// TODO(nweiz): once Dart has some sort of Rack- or WSGI-like standard for -// server APIs, MockClient should conform to it. - -/// A mock HTTP client designed for use when testing code that uses -/// [BaseClient]. This client allows you to define a handler callback for all -/// requests that are made through it so that you can mock a server without -/// having to send real HTTP requests. -class MockClient extends BaseClient { - /// The handler for receiving [StreamedRequest]s and sending - /// [StreamedResponse]s. - final MockClientStreamHandler _handler; - - MockClient._(this._handler); - - /// Creates a [MockClient] with a handler that receives [Request]s and sends - /// [Response]s. - MockClient(MockClientHandler fn) - : this._((baseRequest, bodyStream) { - return bodyStream.toBytes().then((bodyBytes) { - var request = new Request(baseRequest.method, baseRequest.url) - ..persistentConnection = baseRequest.persistentConnection - ..followRedirects = baseRequest.followRedirects - ..maxRedirects = baseRequest.maxRedirects - ..headers.addAll(baseRequest.headers) - ..bodyBytes = bodyBytes - ..finalize(); - - return fn(request); - }).then((response) { - return new StreamedResponse( - new ByteStream.fromBytes(response.bodyBytes), - response.statusCode, - contentLength: response.contentLength, - request: baseRequest, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase); - }); - }); - - /// Creates a [MockClient] with a handler that receives [StreamedRequest]s and - /// sends [StreamedResponse]s. - MockClient.streaming(MockClientStreamHandler fn) - : this._((request, bodyStream) { - return fn(request, bodyStream).then((response) { - return new StreamedResponse( - response.stream, - response.statusCode, - contentLength: response.contentLength, - request: request, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase); - }); - }); - - /// Sends a request. - Future send(BaseRequest request) async { - var bodyStream = request.finalize(); - return await _handler(request, bodyStream); - } -} - -/// A handler function that receives [StreamedRequest]s and sends -/// [StreamedResponse]s. Note that [request] will be finalized. -typedef Future MockClientStreamHandler( - BaseRequest request, ByteStream bodyStream); - -/// A handler function that receives [Request]s and sends [Response]s. Note that -/// [request] will be finalized. -typedef Future MockClientHandler(Request request); diff --git a/lib/src/multipart_body.dart b/lib/src/multipart_body.dart new file mode 100644 index 0000000000..63fd25b1fa --- /dev/null +++ b/lib/src/multipart_body.dart @@ -0,0 +1,156 @@ +// Copyright (c) 2017, 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:typed_data/typed_buffers.dart'; + +import 'body.dart'; +import 'multipart_file.dart'; +import 'utils.dart'; + +/// A `multipart/form-data` request [Body]. +/// +/// Such a request has both string fields, which function as normal form +/// fields, and (potentially streamed) binary files. +class MultipartBody implements Body { + /// The contents of the message body. + /// + /// This will be `null` after [read] is called. + Stream> _stream; + + final int contentLength; + + /// Multipart forms do not have an encoding. + Encoding get encoding => null; + + /// Creates a [MultipartBody] from the given [fields] and [files]. + /// + /// The [boundary] is used to separate key value pairs within the body. + factory MultipartBody(Map fields, + Iterable files, String boundary) { + var controller = new StreamController>(sync: true); + var buffer = new Uint8Buffer(); + + void writeAscii(String string) { + buffer.addAll(string.codeUnits); + } + + void writeUtf8(String string) { + buffer.addAll(UTF8.encode(string)); + } + + void writeLine() { + buffer..add(13)..add(10); // \r\n + } + + // Write the fields to the buffer. + fields.forEach((name, value) { + writeAscii('--$boundary\r\n'); + writeUtf8(_headerForField(name, value)); + writeUtf8(value); + writeLine(); + }); + + controller.add(buffer); + + // Iterate over the files to get the length and compute the headers ahead of + // time so the length can be synchronously accessed. + var fileList = files.toList(); + var fileHeaders = >[]; + var fileContentsLength = 0; + + for (var file in fileList) { + var header = [] + ..addAll('--$boundary\r\n'.codeUnits) + ..addAll(UTF8.encode(_headerForFile(file))); + + fileContentsLength += header.length + file.length + 2; + fileHeaders.add(header); + } + + // Ending characters. + var ending = '--$boundary--\r\n'.codeUnits; + fileContentsLength += ending.length; + + // Write the files to the stream asynchronously. + _writeFilesToStream(controller, fileList, fileHeaders, ending); + + return new MultipartBody._( + controller.stream, buffer.length + fileContentsLength); + } + + MultipartBody._(this._stream, this.contentLength); + + Stream> read() { + if (_stream == null) { + throw new StateError("The 'read' method can only be called once on a " + 'http.Request/http.Response object.'); + } + var stream = _stream; + _stream = null; + return stream; + } + + /// Writes the [files] to the [controller]. + static Future _writeFilesToStream( + StreamController> controller, + List files, + List> fileHeaders, + List ending) async { + for (var i = 0; i < files.length; ++i) { + controller.add(fileHeaders[i]); + + // file.read() can throw synchronously + try { + await writeStreamToSink(files[i].read(), controller); + } catch (exception, stackTrace) { + controller.addError(exception, stackTrace); + } + + controller.add([13, 10]); + } + + controller + ..add(ending) + ..close(); + } + + /// Returns the header string for a field. + static String _headerForField(String name, String value) { + var header = + 'content-disposition: form-data; name="${_browserEncode(name)}"'; + if (!isPlainAscii(value)) { + header = '$header\r\n' + 'content-type: text/plain; charset=utf-8\r\n' + 'content-transfer-encoding: binary'; + } + return '$header\r\n\r\n'; + } + + /// Returns the header string for a file. + /// + /// The return value is guaranteed to contain only ASCII characters. + static String _headerForFile(MultipartFile file) { + var header = 'content-type: ${file.contentType}\r\n' + 'content-disposition: form-data; name="${_browserEncode(file.field)}"'; + + if (file.filename != null) { + header = '$header; filename="${_browserEncode(file.filename)}"'; + } + return '$header\r\n\r\n'; + } + + static final _newlineRegExp = new RegExp(r'\r\n|\r|\n'); + + /// Encode [value] in the same way browsers do. + static String _browserEncode(String value) => + // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for + // field names and file names, but in practice user agents seem not to + // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as + // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII + // characters). We follow their behavior. + value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22'); +} diff --git a/lib/src/multipart_file.dart b/lib/src/multipart_file.dart index da4bface78..de75371c01 100644 --- a/lib/src/multipart_file.dart +++ b/lib/src/multipart_file.dart @@ -4,108 +4,132 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:async/async.dart'; import 'package:http_parser/http_parser.dart'; -import 'package:path/path.dart' as path; +import 'package:mime/mime.dart'; -import 'byte_stream.dart'; -import 'utils.dart'; +import 'content_type.dart'; -/// A file to be uploaded as part of a [MultipartRequest]. This doesn't need to -/// correspond to a physical file. +/// A file to be uploaded as part of a `multipart/form-data` Request. +/// +/// This doesn't need to correspond to a physical file. class MultipartFile { + /// The stream that will emit the file's contents. + Stream> _stream; + /// The name of the form field for the file. final String field; - /// The size of the file in bytes. This must be known in advance, even if this - /// file is created from a [ByteStream]. + /// The size of the file in bytes. + /// + /// This must be known in advance, even if this file is created from a + /// [Stream]. final int length; /// The basename of the file. May be null. final String filename; - /// The content-type of the file. Defaults to `application/octet-stream`. + /// The content-type of the file. + /// + /// Defaults to `application/octet-stream`. final MediaType contentType; - /// The stream that will emit the file's contents. - final ByteStream _stream; - - /// Whether [finalize] has been called. - bool get isFinalized => _isFinalized; - bool _isFinalized = false; - - /// Creates a new [MultipartFile] from a chunked [Stream] of bytes. The length - /// of the file in bytes must be known in advance. If it's not, read the data - /// from the stream and use [MultipartFile.fromBytes] instead. + /// Creates a [MultipartFile] from the [value]. + /// + /// [value] can be either a [String] or a [List]. /// - /// [contentType] currently defaults to `application/octet-stream`, but in the - /// future may be inferred from [filename]. - MultipartFile(this.field, Stream> stream, this.length, - {this.filename, MediaType contentType}) - : this._stream = toByteStream(stream), - this.contentType = contentType != null ? contentType : - new MediaType("application", "octet-stream"); - - /// Creates a new [MultipartFile] from a byte array. + /// For a String [value] the content will be encoded using [encoding] which + /// defaults to [UTF8]. The `charset` from [contentType] is ignored when + /// encoding the String. /// - /// [contentType] currently defaults to `application/octet-stream`, but in the - /// future may be inferred from [filename]. - factory MultipartFile.fromBytes(String field, List value, - {String filename, MediaType contentType}) { - var stream = new ByteStream.fromBytes(value); - return new MultipartFile(field, stream, value.length, - filename: filename, - contentType: contentType); + /// [contentType] if not specified will attempt to be looked up from the + /// bytes contained within the [stream] and the [filename] if provided. It + /// will default to `plain/text` for [String]s and `application/octet-stream` + /// for [List]. + factory MultipartFile(String field, value, + {String filename, MediaType contentType, Encoding encoding}) { + List bytes; + var defaultMediaType; + + if (value is String) { + encoding ??= UTF8; + bytes = encoding.encode(value); + defaultMediaType = new MediaType('text', 'plain'); + } else if (value is List) { + bytes = value; + defaultMediaType = new MediaType('application', 'octet-stream'); + } else { + throw new ArgumentError.value( + value, 'value', 'value must be either a String or a List'); + } + + contentType ??= _lookUpMediaType(filename, bytes) ?? defaultMediaType; + + if (encoding != null) { + contentType = contentType.change(parameters: {'charset': encoding.name}); + } + + return new MultipartFile.fromStream( + field, new Stream.fromIterable([bytes]), bytes.length, + filename: filename, contentType: contentType); } - /// Creates a new [MultipartFile] from a string. + /// Creates a new [MultipartFile] from a chunked [stream] of bytes. /// - /// The encoding to use when translating [value] into bytes is taken from - /// [contentType] if it has a charset set. Otherwise, it defaults to UTF-8. - /// [contentType] currently defaults to `text/plain; charset=utf-8`, but in - /// the future may be inferred from [filename]. - factory MultipartFile.fromString(String field, String value, - {String filename, MediaType contentType}) { - contentType = contentType == null ? new MediaType("text", "plain") - : contentType; - var encoding = encodingForCharset(contentType.parameters['charset'], UTF8); - contentType = contentType.change(parameters: {'charset': encoding.name}); - - return new MultipartFile.fromBytes(field, encoding.encode(value), - filename: filename, - contentType: contentType); - } + /// The [length] of the file in bytes must be known in advance. If it's not + /// then use [loadStream] to create the [MultipartFile] instance. + /// + /// [contentType] if not specified will attempt to be looked up from the + /// [filename] if provided. It will default to `application/octet-stream`. + MultipartFile.fromStream(this.field, Stream> stream, this.length, + {String filename, MediaType contentType}) + : _stream = stream, + filename = filename, + contentType = contentType ?? + _lookUpMediaType(filename) ?? + new MediaType('application', 'octet-stream'); - // TODO(nweiz): Infer the content-type from the filename. - /// Creates a new [MultipartFile] from a path to a file on disk. + /// Creates a new [MultipartFile] from the [stream]. /// - /// [filename] defaults to the basename of [filePath]. [contentType] currently - /// defaults to `application/octet-stream`, but in the future may be inferred - /// from [filename]. + /// This method should be used when the length of [stream] in bytes is not + /// known ahead of time. /// - /// Throws an [UnsupportedError] if `dart:io` isn't supported in this - /// environment. - static Future fromPath(String field, String filePath, + /// [contentType] if not specified will attempt to be looked up from the + /// bytes contained within the [stream] and the [filename] if provided. It + /// will default to `application/octet-stream`. + static Future loadStream( + String field, Stream> stream, {String filename, MediaType contentType}) async { - if (filename == null) filename = path.basename(filePath); - var file = new File(filePath); - var length = await file.length(); - var stream = new ByteStream(DelegatingStream.typed(file.openRead())); - return new MultipartFile(field, stream, length, - filename: filename, - contentType: contentType); + var bytes = await collectBytes(stream); + + return new MultipartFile(field, bytes, + filename: filename, contentType: contentType); } - // Finalizes the file in preparation for it being sent as part of a - // [MultipartRequest]. This returns a [ByteStream] that should emit the body - // of the file. The stream may be closed to indicate an empty file. - ByteStream finalize() { - if (isFinalized) { - throw new StateError("Can't finalize a finalized MultipartFile."); + /// Returns a [Stream] representing the contents of the file. + /// + /// Can only be called once. + Stream> read() { + if (_stream == null) { + throw new StateError('The "read" method can only be called once on a ' + 'http.MultipartFile object.'); } - _isFinalized = true; - return _stream; + var stream = _stream; + _stream = null; + return stream; + } + + /// Looks up the [MediaType] from the [filename]'s extension or from + /// magic numbers contained within a file header's [bytes]. + static MediaType _lookUpMediaType(String filename, [List bytes]) { + if (filename == null && bytes == null) return null; + + // lookupMimeType expects filename to be non-null but its possible that + // this can be called with bytes but no filename. + // FIXME: https://github.com/dart-lang/mime/issues/11 + var mimeType = lookupMimeType(filename ?? '', headerBytes: bytes); + + return mimeType != null ? new MediaType.parse(mimeType) : null; } } diff --git a/lib/src/multipart_request.dart b/lib/src/multipart_request.dart deleted file mode 100644 index 8132f80924..0000000000 --- a/lib/src/multipart_request.dart +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) 2013, 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:math'; - -import 'base_request.dart'; -import 'boundary_characters.dart'; -import 'byte_stream.dart'; -import 'multipart_file.dart'; -import 'utils.dart'; - -final _newlineRegExp = new RegExp(r"\r\n|\r|\n"); - -/// A `multipart/form-data` request. Such a request has both string [fields], -/// which function as normal form fields, and (potentially streamed) binary -/// [files]. -/// -/// 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("http://pub.dartlang.org/packages/create"); -/// var request = new http.MultipartRequest("POST", url); -/// request.fields['user'] = 'nweiz@google.com'; -/// request.files.add(new http.MultipartFile.fromFile( -/// 'package', -/// new File('build/package.tar.gz'), -/// contentType: new MediaType('application', 'x-tar')); -/// request.send().then((response) { -/// if (response.statusCode == 200) print("Uploaded!"); -/// }); -class MultipartRequest extends BaseRequest { - /// The total length of the multipart boundaries used when building the - /// request body. According to http://tools.ietf.org/html/rfc1341.html, this - /// can't be longer than 70. - static const int _BOUNDARY_LENGTH = 70; - - static final Random _random = new Random(); - - /// The form fields to send for this request. - final Map fields; - - /// The private version of [files]. - final List _files; - - /// Creates a new [MultipartRequest]. - MultipartRequest(String method, Uri url) - : fields = {}, - _files = [], - super(method, url); - - /// The list of files to upload for this request. - List get files => _files; - - /// The total length of the request body, in bytes. This is calculated from - /// [fields] and [files] and cannot be set manually. - int get contentLength { - var length = 0; - - fields.forEach((name, value) { - length += "--".length + _BOUNDARY_LENGTH + "\r\n".length + - UTF8.encode(_headerForField(name, value)).length + - UTF8.encode(value).length + "\r\n".length; - }); - - for (var file in _files) { - length += "--".length + _BOUNDARY_LENGTH + "\r\n".length + - UTF8.encode(_headerForFile(file)).length + - file.length + "\r\n".length; - } - - return length + "--".length + _BOUNDARY_LENGTH + "--\r\n".length; - } - - void set contentLength(int value) { - throw new UnsupportedError("Cannot set the contentLength property of " - "multipart requests."); - } - - /// Freezes all mutable fields and returns a single-subscription [ByteStream] - /// that will emit the request body. - ByteStream finalize() { - // TODO(nweiz): freeze fields and files - var boundary = _boundaryString(); - headers['content-type'] = 'multipart/form-data; boundary=$boundary'; - super.finalize(); - - var controller = new StreamController>(sync: true); - - void writeAscii(String string) { - controller.add(UTF8.encode(string)); - } - - writeUtf8(String string) => controller.add(UTF8.encode(string)); - writeLine() => controller.add([13, 10]); // \r\n - - fields.forEach((name, value) { - writeAscii('--$boundary\r\n'); - writeAscii(_headerForField(name, value)); - writeUtf8(value); - writeLine(); - }); - - Future.forEach(_files, (file) { - writeAscii('--$boundary\r\n'); - writeAscii(_headerForFile(file)); - return writeStreamToSink(file.finalize(), controller) - .then((_) => writeLine()); - }).then((_) { - // TODO(nweiz): pass any errors propagated through this future on to - // the stream. See issue 3657. - writeAscii('--$boundary--\r\n'); - controller.close(); - }); - - return new ByteStream(controller.stream); - } - - /// Returns the header string for a field. The return value is guaranteed to - /// contain only ASCII characters. - String _headerForField(String name, String value) { - var header = - 'content-disposition: form-data; name="${_browserEncode(name)}"'; - if (!isPlainAscii(value)) { - header = '$header\r\n' - 'content-type: text/plain; charset=utf-8\r\n' - 'content-transfer-encoding: binary'; - } - return '$header\r\n\r\n'; - } - - /// Returns the header string for a file. The return value is guaranteed to - /// contain only ASCII characters. - String _headerForFile(MultipartFile file) { - var header = 'content-type: ${file.contentType}\r\n' - 'content-disposition: form-data; name="${_browserEncode(file.field)}"'; - - if (file.filename != null) { - header = '$header; filename="${_browserEncode(file.filename)}"'; - } - return '$header\r\n\r\n'; - } - - /// Encode [value] in the same way browsers do. - String _browserEncode(String value) { - // http://tools.ietf.org/html/rfc2388 mandates some complex encodings for - // field names and file names, but in practice user agents seem not to - // follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as - // `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII - // characters). We follow their behavior. - return value.replaceAll(_newlineRegExp, "%0D%0A").replaceAll('"', "%22"); - } - - /// Returns a randomly-generated multipart boundary string - String _boundaryString() { - var prefix = "dart-http-boundary-"; - var list = new List.generate(_BOUNDARY_LENGTH - prefix.length, - (index) => - BOUNDARY_CHARACTERS[_random.nextInt(BOUNDARY_CHARACTERS.length)], - growable: false); - return "$prefix${new String.fromCharCodes(list)}"; - } -} diff --git a/lib/src/pipeline.dart b/lib/src/pipeline.dart new file mode 100644 index 0000000000..b538ae72c9 --- /dev/null +++ b/lib/src/pipeline.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2017, 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 'client.dart'; +import 'handler.dart'; +import 'middleware.dart'; + +/// A helper that makes it easy to compose a set of [Middleware] and a +/// [Client]. +/// +/// var client = const Pipeline() +/// .addMiddleware(loggingMiddleware) +/// .addMiddleware(basicAuthMiddleware) +/// .addClient(new Client()); +class Pipeline { + /// The outer pipeline. + final Pipeline _parent; + + /// The [Middleware] that is invoked at this stage. + final Middleware _middleware; + + /// Creates a [Pipeline]. + const Pipeline() + : _parent = null, + _middleware = null; + + Pipeline._(this._parent, this._middleware); + + /// Returns a new [Pipeline] with [middleware] added to the existing set of + /// [Middleware]. + /// + /// [middleware] will be the last [Middleware] to process a request and + /// the first to process a response. + Pipeline addMiddleware(Middleware middleware) => + new Pipeline._(this, middleware); + + /// Returns a new [Client] with [client] as the final processor of a + /// [Request] if all of the middleware in the pipeline have passed the request + /// through. + Client addClient(Client client) => + _middleware == null ? client : _parent.addClient(_middleware(client)); + + /// Returns a new [Client] with [handler] as the final processor of a + /// [Request] if all of the middleware in the pipeline have passed the request + /// through. + Client addHandler(Handler handler) => addClient(new Client.handler(handler)); + + /// Exposes this pipeline of [Middleware] as a single middleware instance. + Middleware get middleware => addClient; +} diff --git a/lib/src/request.dart b/lib/src/request.dart index 67b664c08e..c50f018648 100644 --- a/lib/src/request.dart +++ b/lib/src/request.dart @@ -3,159 +3,191 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:convert'; -import 'dart:typed_data'; -import 'package:http_parser/http_parser.dart'; - -import 'base_request.dart'; -import 'byte_stream.dart'; +import 'boundary.dart'; +import 'message.dart'; +import 'multipart_body.dart'; +import 'multipart_file.dart'; import 'utils.dart'; -/// An HTTP request where the entire request body is known in advance. -class Request extends BaseRequest { - /// The size of the request body, in bytes. This is calculated from - /// [bodyBytes]. +/// Represents an HTTP request to be sent to a server. +class Request extends Message { + /// The HTTP method of the request. /// - /// The content length cannot be set for [Request], since it's automatically - /// calculated from [bodyBytes]. - int get contentLength => bodyBytes.length; - - set contentLength(int value) { - throw new UnsupportedError("Cannot set the contentLength property of " - "non-streaming Request objects."); - } - - /// The default encoding to use when converting between [bodyBytes] and - /// [body]. This is only used if [encoding] hasn't been manually set and if - /// the content-type header has no encoding information. - Encoding _defaultEncoding; - - /// The encoding used for the request. This encoding is used when converting - /// between [bodyBytes] and [body]. - /// - /// If the request has a `Content-Type` header and that header has a `charset` - /// parameter, that parameter's value is used as the encoding. Otherwise, if - /// [encoding] has been set manually, that encoding is used. If that hasn't - /// been set either, this defaults to [UTF8]. - /// - /// If the `charset` parameter's value is not a known [Encoding], reading this - /// will throw a [FormatException]. - /// - /// If the request has a `Content-Type` header, setting this will set the - /// charset parameter on that header. - Encoding get encoding { - if (_contentType == null || - !_contentType.parameters.containsKey('charset')) { - return _defaultEncoding; - } - return requiredEncodingForCharset(_contentType.parameters['charset']); - } + /// Most commonly "GET" or "POST", less commonly "HEAD", "PUT", or "DELETE". + /// Non-standard method names are also supported. + final String method; - set encoding(Encoding value) { - _checkFinalized(); - _defaultEncoding = value; - var contentType = _contentType; - if (contentType == null) return; - _contentType = contentType.change(parameters: {'charset': value.name}); - } + /// The URL to which the request will be sent. + final Uri url; - // TODO(nweiz): make this return a read-only view - /// The bytes comprising the body of the request. This is converted to and - /// from [body] using [encoding]. + /// Creates a new [Request] for [url], which can be a [Uri] or a [String], + /// using [method]. /// - /// This list should only be set, not be modified in place. - Uint8List get bodyBytes => _bodyBytes; - Uint8List _bodyBytes; - - set bodyBytes(List value) { - _checkFinalized(); - _bodyBytes = toUint8List(value); - } - - /// The body of the request as a string. This is converted to and from - /// [bodyBytes] using [encoding]. - /// - /// When this is set, if the request does not yet have a `Content-Type` - /// header, one will be added with the type `text/plain`. Then the `charset` - /// parameter of the `Content-Type` header (whether new or pre-existing) will - /// be set to [encoding] if it wasn't already set. - String get body => encoding.decode(bodyBytes); - - set body(String value) { - bodyBytes = encoding.encode(value); - var contentType = _contentType; - if (contentType == null) { - _contentType = new MediaType("text", "plain", {'charset': encoding.name}); - } else if (!contentType.parameters.containsKey('charset')) { - _contentType = contentType.change(parameters: {'charset': encoding.name}); - } - } - - /// The form-encoded fields in the body of the request as a map from field - /// names to values. The form-encoded body is converted to and from - /// [bodyBytes] using [encoding] (in the same way as [body]). - /// - /// If the request doesn't have a `Content-Type` header of - /// `application/x-www-form-urlencoded`, reading this will throw a - /// [StateError]. - /// - /// If the request has a `Content-Type` header with a type other than - /// `application/x-www-form-urlencoded`, setting this will throw a - /// [StateError]. Otherwise, the content type will be set to - /// `application/x-www-form-urlencoded`. - /// - /// This map should only be set, not modified in place. - Map get bodyFields { - var contentType = _contentType; - if (contentType == null || - contentType.mimeType != "application/x-www-form-urlencoded") { - throw new StateError('Cannot access the body fields of a Request without ' - 'content-type "application/x-www-form-urlencoded".'); - } - - return Uri.splitQueryString(body, encoding: encoding); - } - - set bodyFields(Map fields) { - var contentType = _contentType; - if (contentType == null) { - _contentType = new MediaType("application", "x-www-form-urlencoded"); - } else if (contentType.mimeType != "application/x-www-form-urlencoded") { - throw new StateError('Cannot set the body fields of a Request with ' - 'content-type "${contentType.mimeType}".'); - } - - this.body = mapToQuery(fields, encoding: encoding); - } + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request(String method, url, + {body, + Encoding encoding, + Map headers, + Map context}) + : this._(method, getUrl(url), body, encoding, headers, context); + + /// Creates a new HEAD [Request] to [url], which can be a [Uri] or a [String]. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.head(url, {Map headers, Map context}) + : this('HEAD', url, headers: headers, context: context); - /// Creates a new HTTP request. - Request(String method, Uri url) - : _defaultEncoding = UTF8, - _bodyBytes = new Uint8List(0), - super(method, url); - - /// Freezes all mutable fields and returns a single-subscription [ByteStream] - /// containing the request body. - ByteStream finalize() { - super.finalize(); - return new ByteStream.fromBytes(bodyBytes); - } + /// Creates a new GET [Request] to [url], which can be a [Uri] or a [String]. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.get(url, {Map headers, Map context}) + : this('GET', url, headers: headers, context: context); - /// The `Content-Type` header of the request (if it exists) as a - /// [MediaType]. - MediaType get _contentType { - var contentType = headers['content-type']; - if (contentType == null) return null; - return new MediaType.parse(contentType); + /// Creates a new POST [Request] to [url], which can be a [Uri] or a [String]. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.post(url, body, + {Encoding encoding, + Map headers, + Map context}) + : this('POST', url, + body: body, encoding: encoding, headers: headers, context: context); + + /// Creates a new PUT [Request] to [url], which can be a [Uri] or a [String]. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.put(url, body, + {Encoding encoding, + Map headers, + Map context}) + : this('PUT', url, + body: body, encoding: encoding, headers: headers, context: context); + + /// Creates a new PATCH [Request] to [url], which can be a [Uri] or a + /// [String]. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.patch(url, body, + {Encoding encoding, + Map headers, + Map context}) + : this('PATCH', url, + body: body, encoding: encoding, headers: headers, context: context); + + /// Creates a new DELETE [Request] to [url], which can be a [Uri] or a + /// [String]. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + Request.delete(url, + {Map headers, Map context}) + : this('DELETE', url, headers: headers, context: context); + + /// Creates a new + /// [`multipart/form-data`](https://en.wikipedia.org/wiki/MIME#Multipart_messages) + /// [Request] to [url], which can be a [Uri] or a [String]. + /// + /// The content of the body is specified through the values of [fields] and + /// [files]. + /// + /// If [method] is not specified it defaults to POST. + /// + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between inner middleware + /// and handlers. + factory Request.multipart(url, + {String method, + Map headers, + Map context, + Map fields, + Iterable files}) { + fields ??= const {}; + files ??= const []; + headers ??= {}; + + var boundary = boundaryString(); + + return new Request._( + method ?? 'POST', + getUrl(url), + new MultipartBody(fields, files, boundary), + null, + updateMap(headers, + {'content-type': 'multipart/form-data; boundary=$boundary'}), + context); } - set _contentType(MediaType value) { - headers['content-type'] = value.toString(); - } + Request._(this.method, this.url, body, Encoding encoding, + Map headers, Map context) + : super(body, encoding: encoding, headers: headers, context: context); - /// Throw an error if this request has been finalized. - void _checkFinalized() { - if (!finalized) return; - throw new StateError("Can't modify a finalized Request."); + /// Creates a new [Request] by copying existing values and applying specified + /// changes. + /// + /// New key-value pairs in [context] and [headers] will be added to the copied + /// [Request]. If [context] or [headers] includes a key that already exists, + /// the key-value pair will replace the corresponding entry in the copied + /// [Request]. All other context and header values from the [Request] will be + /// included in the copied [Request] unchanged. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. + Request change( + {Map headers, Map context, body}) { + var updatedHeaders = updateMap(this.headers, headers); + var updatedContext = updateMap(this.context, context); + + return new Request._(this.method, this.url, body ?? getBody(this), + this.encoding, updatedHeaders, updatedContext); } } diff --git a/lib/src/response.dart b/lib/src/response.dart index 9fa06ee782..8db825c7ec 100644 --- a/lib/src/response.dart +++ b/lib/src/response.dart @@ -2,94 +2,107 @@ // 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:http_parser/http_parser.dart'; -import 'base_request.dart'; -import 'base_response.dart'; -import 'streamed_response.dart'; +import 'message.dart'; import 'utils.dart'; /// An HTTP response where the entire response body is known in advance. -class Response extends BaseResponse { - /// The bytes comprising the body of this response. - final Uint8List bodyBytes; +class Response extends Message { + /// The location of the requested resource. + /// + /// The value takes into account any redirects that occurred during the + /// request. + final Uri finalUrl; + + /// The status code of the response. + final int statusCode; - /// The body of the response as a string. This is converted from [bodyBytes] - /// using the `charset` parameter of the `Content-Type` header field, if - /// available. If it's unavailable or if the encoding name is unknown, - /// [LATIN1] is used by default, as per [RFC 2616][]. + /// The reason phrase associated with the status code. + final String reasonPhrase; + + /// Creates a new HTTP response for a resource at the [finalUrl], which can + /// be a [Uri] or a [String], with the given [statusCode]. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. If it's a [String], + /// [encoding] is used to encode it to a [Stream>]. It defaults to + /// UTF-8. /// - /// [RFC 2616]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html - String get body => _encodingForHeaders(headers).decode(bodyBytes); + /// [headers] are the HTTP headers for the request. If [headers] is `null`, + /// it is treated as empty. + /// + /// Extra [context] can be used to pass information between outer middleware + /// and handlers. + Response(finalUrl, int statusCode, + {String reasonPhrase, + body, + Encoding encoding, + Map headers, + Map context}) + : this._(getUrl(finalUrl), statusCode, reasonPhrase ?? '', body, encoding, + headers, context); + + Response._( + this.finalUrl, + this.statusCode, + this.reasonPhrase, + body, + Encoding encoding, + Map headers, + Map context) + : super(body, encoding: encoding, headers: headers, context: context); - /// Creates a new HTTP response with a string body. - Response( - String body, - int statusCode, - {BaseRequest request, - Map headers: const {}, - bool isRedirect: false, - bool persistentConnection: true, - String reasonPhrase}) - : this.bytes( - _encodingForHeaders(headers).encode(body), - statusCode, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase); + /// Creates a new [Response] by copying existing values and applying specified + /// changes. + /// + /// New key-value pairs in [context] and [headers] will be added to the copied + /// [Response]. + /// + /// If [context] or [headers] includes a key that already exists, the + /// key-value pair will replace the corresponding entry in the copied + /// [Response]. + /// + /// All other context and header values from the [Response] will be included + /// in the copied [Response] unchanged. + /// + /// [body] is the request body. It may be either a [String], a [List], a + /// [Stream>], or `null` to indicate no body. + Response change( + {Map headers, Map context, body}) { + var updatedHeaders = updateMap(this.headers, headers); + var updatedContext = updateMap(this.context, context); - /// Create a new HTTP response with a byte array body. - Response.bytes( - List bodyBytes, - int statusCode, - {BaseRequest request, - Map headers: const {}, - bool isRedirect: false, - bool persistentConnection: true, - String reasonPhrase}) - : bodyBytes = toUint8List(bodyBytes), - super( - statusCode, - contentLength: bodyBytes.length, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase); + return new Response._(this.finalUrl, this.statusCode, this.reasonPhrase, + body ?? getBody(this), this.encoding, updatedHeaders, updatedContext); + } - /// Creates a new HTTP response by waiting for the full body to become - /// available from a [StreamedResponse]. - static Future fromStream(StreamedResponse response) { - return response.stream.toBytes().then((body) { - return new Response.bytes( - body, - response.statusCode, - request: response.request, - headers: response.headers, - isRedirect: response.isRedirect, - persistentConnection: response.persistentConnection, - reasonPhrase: response.reasonPhrase); - }); + /// The date and time after which the response's data should be considered + /// stale. + /// + /// This is parsed from the Expires header in [headers]. If [headers] doesn't + /// have an Expires header, this will be `null`. + DateTime get expires { + if (_expiresCache != null) return _expiresCache; + if (!headers.containsKey('expires')) return null; + _expiresCache = parseHttpDate(headers['expires']); + return _expiresCache; } -} -/// Returns the encoding to use for a response with the given headers. This -/// defaults to [LATIN1] if the headers don't specify a charset or -/// if that charset is unknown. -Encoding _encodingForHeaders(Map headers) => - encodingForCharset(_contentTypeForHeaders(headers).parameters['charset']); + DateTime _expiresCache; + + /// The date and time the source of the response's data was last modified. + /// + /// This is parsed from the Last-Modified header in [headers]. If [headers] + /// doesn't have a Last-Modified header, this will be `null`. + DateTime get lastModified { + if (_lastModifiedCache != null) return _lastModifiedCache; + if (!headers.containsKey('last-modified')) return null; + _lastModifiedCache = parseHttpDate(headers['last-modified']); + return _lastModifiedCache; + } -/// Returns the [MediaType] object for the given headers's content-type. -/// -/// Defaults to `application/octet-stream`. -MediaType _contentTypeForHeaders(Map headers) { - var contentType = headers['content-type']; - if (contentType != null) return new MediaType.parse(contentType); - return new MediaType("application", "octet-stream"); + DateTime _lastModifiedCache; } diff --git a/lib/src/streamed_request.dart b/lib/src/streamed_request.dart deleted file mode 100644 index 6a020bd77a..0000000000 --- a/lib/src/streamed_request.dart +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2012, 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 'byte_stream.dart'; -import 'base_request.dart'; - -/// An HTTP request where the request body is sent asynchronously after the -/// connection has been established and the headers have been sent. -/// -/// When the request is sent via [BaseClient.send], only the headers and -/// whatever data has already been written to [StreamedRequest.stream] will be -/// sent immediately. More data will be sent as soon as it's written to -/// [StreamedRequest.sink], and when the sink is closed the request will end. -class StreamedRequest extends BaseRequest { - /// The sink to which to write data that will be sent as the request body. - /// This may be safely written to before the request is sent; the data will be - /// buffered. - /// - /// Closing this signals the end of the request. - EventSink> get sink => _controller.sink; - - /// The controller for [sink], from which [BaseRequest] will read data for - /// [finalize]. - final StreamController> _controller; - - /// Creates a new streaming request. - StreamedRequest(String method, Uri url) - : _controller = new StreamController>(sync: true), - super(method, url); - - /// Freezes all mutable fields other than [stream] and returns a - /// single-subscription [ByteStream] that emits the data being written to - /// [sink]. - ByteStream finalize() { - super.finalize(); - return new ByteStream(_controller.stream); - } -} diff --git a/lib/src/streamed_response.dart b/lib/src/streamed_response.dart deleted file mode 100644 index 69d8356c1d..0000000000 --- a/lib/src/streamed_response.dart +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2012, 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 'byte_stream.dart'; -import 'base_response.dart'; -import 'base_request.dart'; -import 'utils.dart'; - -/// An HTTP response where the response body is received asynchronously after -/// the headers have been received. -class StreamedResponse extends BaseResponse { - /// The stream from which the response body data can be read. This should - /// always be a single-subscription stream. - final ByteStream stream; - - /// Creates a new streaming response. [stream] should be a single-subscription - /// stream. - StreamedResponse( - Stream> stream, - int statusCode, - {int contentLength, - BaseRequest request, - Map headers: const {}, - bool isRedirect: false, - bool persistentConnection: true, - String reasonPhrase}) - : this.stream = toByteStream(stream), - super( - statusCode, - contentLength: contentLength, - request: request, - headers: headers, - isRedirect: isRedirect, - persistentConnection: persistentConnection, - reasonPhrase: reasonPhrase); -} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 789c2d964f..e4cf005cfe 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -4,9 +4,23 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; -import 'byte_stream.dart'; +import 'package:collection/collection.dart'; + +import 'http_unmodifiable_map.dart'; + +/// Returns a [Map] with the values from [original] and the values from +/// [updates]. +/// +/// For keys that are the same between [original] and [updates], the value in +/// [updates] is used. +/// +/// If [updates] is `null` or empty, [original] is returned unchanged. +Map updateMap(Map original, Map updates) { + if (updates == null || updates.isEmpty) return original; + + return new Map.from(original)..addAll(updates); +} /// Converts a [Map] from parameter names to values to a URL query string. /// @@ -14,128 +28,55 @@ import 'byte_stream.dart'; /// //=> "foo=bar&baz=bang" String mapToQuery(Map map, {Encoding encoding}) { var pairs = >[]; - map.forEach((key, value) => - pairs.add([Uri.encodeQueryComponent(key, encoding: encoding), - Uri.encodeQueryComponent(value, encoding: encoding)])); + map.forEach((key, value) => pairs.add([ + Uri.encodeQueryComponent(key, encoding: encoding), + Uri.encodeQueryComponent(value, encoding: encoding) + ])); return pairs.map((pair) => "${pair[0]}=${pair[1]}").join("&"); } -/// Like [String.split], but only splits on the first occurrence of the pattern. -/// This will always return an array of two elements or fewer. -/// -/// split1("foo,bar,baz", ","); //=> ["foo", "bar,baz"] -/// split1("foo", ","); //=> ["foo"] -/// split1("", ","); //=> [] -List split1(String toSplit, String pattern) { - if (toSplit.isEmpty) return []; - - var index = toSplit.indexOf(pattern); - if (index == -1) return [toSplit]; - return [ - toSplit.substring(0, index), - toSplit.substring(index + pattern.length) - ]; -} - -/// Returns the [Encoding] that corresponds to [charset]. Returns [fallback] if -/// [charset] is null or if no [Encoding] was found that corresponds to -/// [charset]. -Encoding encodingForCharset(String charset, [Encoding fallback = LATIN1]) { - if (charset == null) return fallback; - var encoding = Encoding.getByName(charset); - return encoding == null ? fallback : encoding; -} - - -/// Returns the [Encoding] that corresponds to [charset]. Throws a -/// [FormatException] if no [Encoding] was found that corresponds to [charset]. -/// [charset] may not be null. -Encoding requiredEncodingForCharset(String charset) { - var encoding = Encoding.getByName(charset); - if (encoding != null) return encoding; - throw new FormatException('Unsupported encoding "$charset".'); -} - /// A regular expression that matches strings that are composed entirely of /// ASCII-compatible characters. -final RegExp _ASCII_ONLY = new RegExp(r"^[\x00-\x7F]+$"); +final RegExp _asciiOnly = new RegExp(r"^[\x00-\x7F]+$"); /// Returns whether [string] is composed entirely of ASCII-compatible /// characters. -bool isPlainAscii(String string) => _ASCII_ONLY.hasMatch(string); +bool isPlainAscii(String string) => _asciiOnly.hasMatch(string); -/// Converts [input] into a [Uint8List]. +/// Pipes all data and errors from [stream] into [sink]. /// -/// If [input] is a [TypedData], this just returns a view on [input]. -Uint8List toUint8List(List input) { - if (input is Uint8List) return input; - if (input is TypedData) { - // TODO(nweiz): remove "as" when issue 11080 is fixed. - return new Uint8List.view((input as TypedData).buffer); - } - return new Uint8List.fromList(input); -} - -/// If [stream] is already a [ByteStream], returns it. Otherwise, wraps it in a -/// [ByteStream]. -ByteStream toByteStream(Stream> stream) { - if (stream is ByteStream) return stream; - return new ByteStream(stream); -} - -/// Calls [onDone] once [stream] (a single-subscription [Stream]) is finished. -/// The return value, also a single-subscription [Stream] should be used in -/// place of [stream] after calling this method. -Stream onDone(Stream stream, void onDone()) => - stream.transform(new StreamTransformer.fromHandlers(handleDone: (sink) { - sink.close(); - onDone(); - })); - -// TODO(nweiz): remove this when issue 7786 is fixed. -/// Pipes all data and errors from [stream] into [sink]. When [stream] is done, -/// [sink] is closed and the returned [Future] is completed. -Future store(Stream stream, EventSink sink) { - var completer = new Completer(); - stream.listen(sink.add, - onError: sink.addError, - onDone: () { - sink.close(); - completer.complete(); - }); - return completer.future; -} - -/// Pipes all data and errors from [stream] into [sink]. Completes [Future] once -/// [stream] is done. Unlike [store], [sink] remains open after [stream] is -/// done. +/// Completes [Future] once [stream] is done. [sink] remains open after [stream] +/// is done. Future writeStreamToSink(Stream stream, EventSink sink) { var completer = new Completer(); stream.listen(sink.add, - onError: sink.addError, - onDone: () => completer.complete()); + onError: sink.addError, onDone: () => completer.complete()); return completer.future; } -/// A pair of values. -class Pair { - E first; - F last; - - Pair(this.first, this.last); - - String toString() => '($first, $last)'; - - bool operator==(other) { - if (other is! Pair) return false; - return other.first == first && other.last == last; +/// Returns the header with the given [name] in [headers]. +/// +/// This works even if [headers] is `null`, or if it's not yet a +/// case-insensitive map. +String getHeader(Map headers, String name) { + if (headers == null) return null; + if (headers is HttpUnmodifiableMap) return headers[name]; + + for (var key in headers.keys) { + if (equalsIgnoreAsciiCase(key, name)) return headers[key]; } - - int get hashCode => first.hashCode ^ last.hashCode; + return null; } -/// Configures [future] so that its result (success or exception) is passed on -/// to [completer]. -void chainToCompleter(Future future, Completer completer) { - future.then(completer.complete, onError: completer.completeError); +/// Returns a [Uri] from the [url], which can be a [Uri] or a [String]. +/// +/// If the [url] is not a [Uri] or [String] an [ArgumentError] is thrown. +Uri getUrl(url) { + if (url is Uri) { + return url; + } else if (url is String) { + return Uri.parse(url); + } else { + throw new ArgumentError.value(url, 'url', 'Not a Uri or String'); + } } diff --git a/lib/testing.dart b/lib/testing.dart deleted file mode 100644 index d5a7874201..0000000000 --- a/lib/testing.dart +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2012, 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. - -/// This library contains testing classes for the HTTP library. -/// -/// The [MockClient] class is a drop-in replacement for `http.Client` that -/// allows test code to set up a local request handler in order to fake a server -/// that responds to HTTP requests: -/// -/// import 'dart:convert'; -/// import 'package:http/testing.dart'; -/// -/// var client = new MockClient((request) async { -/// if (request.url.path != "/data.json") { -/// return new Response("", 404); -/// } -/// return new Response( -/// JSON.encode({ -/// 'numbers': [1, 4, 15, 19, 214] -/// }), -/// 200, -/// headers: {'content-type': 'application/json'}); -/// }); -export 'src/mock_client.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 7bad9f065c..fbe50ba65e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,18 @@ name: http -version: 0.11.3+13 +version: 0.12.0-dev author: "Dart Team " homepage: https://github.com/dart-lang/http description: A composable, Future-based API for making HTTP requests. dependencies: - async: "^1.10.0" + async: "^1.13.0" collection: "^1.5.0" http_parser: ">=0.0.1 <4.0.0" - path: ">=0.9.0 <2.0.0" - stack_trace: ">=0.9.1 <2.0.0" + mime: "^0.9.0" + typed_data: "^1.0.0" dev_dependencies: - unittest: ">=0.9.0 <0.12.0" + test: "^0.12.18" +# Override dependency on package_resolver to enable test +dependency_overrides: + package_resolver: '^1.0.0' environment: - sdk: ">=1.23.0-dev.0.0 <2.0.0" + sdk: ">=1.24.0 <2.0.0" diff --git a/test/client.dart b/test/client.dart new file mode 100644 index 0000000000..0fba590a95 --- /dev/null +++ b/test/client.dart @@ -0,0 +1,9 @@ +// Copyright (c) 2017, 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'; + +Client platformClient() => null; + +String userAgent() => null; diff --git a/test/client_test.dart b/test/client_test.dart new file mode 100644 index 0000000000..7db52bd49b --- /dev/null +++ b/test/client_test.dart @@ -0,0 +1,389 @@ +// 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 'package:test/test.dart'; + +import 'client.dart' + if (dart.library.io) 'hybrid/client_io.dart' + if (dart.library.html) 'hybrid/client_html.dart'; +import 'utils.dart'; + +void main() { + group('client', () { + // The server url of the spawned server + var serverUrl; + + setUp(() async { + var channel = spawnHybridUri('hybrid/server.dart'); + serverUrl = Uri.parse(await channel.stream.first); + }); + + test('head', () async { + var response = await platformClient().head(serverUrl); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect(body, equals('')); + }); + + test('get', () async { + var response = await platformClient().get(serverUrl, headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'GET', + 'path': '/', + 'headers': { + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + } + }))); + }); + + test('post with string', () async { + var response = await platformClient().post( + serverUrl, + 'request body', + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'POST', + 'path': '/', + 'headers': { + 'content-type': ['text/plain; charset=utf-8'], + 'content-length': ['12'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'request body' + }))); + }); + + test('post with bytes', () async { + var response = await platformClient().post( + serverUrl, + [104, 101, 108, 108, 111], + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'POST', + 'path': '/', + 'headers': { + 'content-length': ['5'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': [104, 101, 108, 108, 111] + }))); + }); + + test('post with fields', () async { + var response = await platformClient().post( + serverUrl, + {'some-field': 'value', 'other-field': 'other value'}, + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'POST', + 'path': '/', + 'headers': { + 'content-type': [ + 'application/x-www-form-urlencoded; charset=utf-8' + ], + 'content-length': ['40'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'some-field=value&other-field=other+value' + }))); + }); + + test('put with string', () async { + var response = await platformClient().put( + serverUrl, + 'request body', + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PUT', + 'path': '/', + 'headers': { + 'content-type': ['text/plain; charset=utf-8'], + 'content-length': ['12'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'request body' + }))); + }); + + test('put with bytes', () async { + var response = await platformClient().put( + serverUrl, + [104, 101, 108, 108, 111], + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PUT', + 'path': '/', + 'headers': { + 'content-length': ['5'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': [104, 101, 108, 108, 111] + }))); + }); + + test('put with fields', () async { + var response = await platformClient().put( + serverUrl, + {'some-field': 'value', 'other-field': 'other value'}, + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PUT', + 'path': '/', + 'headers': { + 'content-type': [ + 'application/x-www-form-urlencoded; charset=utf-8' + ], + 'content-length': ['40'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'some-field=value&other-field=other+value' + }))); + }); + + test('patch with string', () async { + var response = await platformClient().patch( + serverUrl, + 'request body', + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PATCH', + 'path': '/', + 'headers': { + 'content-type': ['text/plain; charset=utf-8'], + 'content-length': ['12'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'request body' + }))); + }); + + test('patch with bytes', () async { + var response = await platformClient().patch( + serverUrl, + [104, 101, 108, 108, 111], + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PATCH', + 'path': '/', + 'headers': { + 'content-length': ['5'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': [104, 101, 108, 108, 111] + }))); + }); + + test('patch with fields', () async { + var response = await platformClient().patch( + serverUrl, + {'some-field': 'value', 'other-field': 'other value'}, + headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }, + ); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'PATCH', + 'path': '/', + 'headers': { + 'content-type': [ + 'application/x-www-form-urlencoded; charset=utf-8' + ], + 'content-length': ['40'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + }, + 'body': 'some-field=value&other-field=other+value' + }))); + }); + + test('delete', () async { + var response = await platformClient().delete(serverUrl, headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }); + var body = await response.readAsString(); + + expect(response.statusCode, equals(200)); + expect( + body, + parse(equals({ + 'method': 'DELETE', + 'path': '/', + 'headers': { + 'content-length': ['0'], + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + } + }))); + }); + + test('read', () async { + var body = await platformClient().read(serverUrl, headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }); + + expect( + body, + parse(equals({ + 'method': 'GET', + 'path': '/', + 'headers': { + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + } + }))); + }); + + test('read throws an error for a 4** status code', () async { + expect(() => platformClient().read(serverUrl.resolve('/error')), + throwsClientException()); + }); + + test('readBytes', () async { + var body = await platformClient().readBytes(serverUrl, headers: { + 'X-Random-Header': 'Value', + 'X-Other-Header': 'Other Value', + 'User-Agent': userAgent() + }); + + expect( + new String.fromCharCodes(body), + parse(equals({ + 'method': 'GET', + 'path': '/', + 'headers': { + 'user-agent': [userAgent()], + 'x-random-header': ['Value'], + 'x-other-header': ['Other Value'] + } + }))); + }); + + test('readBytes throws an error for a 4** status code', () async { + expect(() => platformClient().readBytes(serverUrl.resolve('/error')), + throwsClientException()); + }); + }); +} diff --git a/test/html/client_test.dart b/test/html/client_test.dart deleted file mode 100644 index ab806546e5..0000000000 --- a/test/html/client_test.dart +++ /dev/null @@ -1,35 +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 'package:http/http.dart' as http; -import 'package:http/browser_client.dart'; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - test('#send a StreamedRequest', () { - var client = new BrowserClient(); - var request = new http.StreamedRequest("POST", echoUrl); - - expect(client.send(request).then((response) { - return response.stream.bytesToString(); - }).whenComplete(client.close), completion(equals('{"hello": "world"}'))); - - request.sink.add('{"hello": "world"}'.codeUnits); - request.sink.close(); - }); - - test('#send with an invalid URL', () { - var client = new BrowserClient(); - var url = Uri.parse('http://http.invalid'); - var request = new http.StreamedRequest("POST", url); - - expect(client.send(request), - throwsClientException("XMLHttpRequest error.")); - - request.sink.add('{"hello": "world"}'.codeUnits); - request.sink.close(); - }); -} diff --git a/test/html/streamed_request_test.dart b/test/html/streamed_request_test.dart deleted file mode 100644 index 6496f4b023..0000000000 --- a/test/html/streamed_request_test.dart +++ /dev/null @@ -1,36 +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 'package:http/http.dart' as http; -import 'package:http/browser_client.dart'; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - group('contentLength', () { - test("works when it's set", () { - var request = new http.StreamedRequest('POST', echoUrl); - request.contentLength = 10; - request.sink.add([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - request.sink.close(); - - return new BrowserClient().send(request).then((response) { - expect(response.stream.toBytes(), - completion(equals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))); - }); - }); - - test("works when it's not set", () { - var request = new http.StreamedRequest('POST', echoUrl); - request.sink.add([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - request.sink.close(); - - return new BrowserClient().send(request).then((response) { - expect(response.stream.toBytes(), - completion(equals([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]))); - }); - }); - }); -} diff --git a/test/html/utils.dart b/test/html/utils.dart deleted file mode 100644 index 5d67765610..0000000000 --- a/test/html/utils.dart +++ /dev/null @@ -1,11 +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:html'; - -export '../utils.dart'; - -/// The test server's echo URL. -Uri get echoUrl => Uri.parse( - '${window.location.protocol}//${window.location.host}/echo'); diff --git a/test/hybrid/client_html.dart b/test/hybrid/client_html.dart new file mode 100644 index 0000000000..1c1abd9310 --- /dev/null +++ b/test/hybrid/client_html.dart @@ -0,0 +1,12 @@ +// Copyright (c) 2017, 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:html' as html; + +import 'package:http/http.dart'; +import 'package:http/browser_client.dart'; + +Client platformClient() => new BrowserClient(); + +String userAgent() => html.window.navigator.userAgent; diff --git a/test/hybrid/client_io.dart b/test/hybrid/client_io.dart new file mode 100644 index 0000000000..fe41139b44 --- /dev/null +++ b/test/hybrid/client_io.dart @@ -0,0 +1,9 @@ +// Copyright (c) 2017, 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'; + +Client platformClient() => new Client(); + +String userAgent() => 'Dart'; diff --git a/test/hybrid/server.dart b/test/hybrid/server.dart new file mode 100644 index 0000000000..2a5e898d10 --- /dev/null +++ b/test/hybrid/server.dart @@ -0,0 +1,143 @@ +// Copyright (c) 2017, 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 'dart:io'; + +import 'package:async/async.dart'; +import 'package:http/src/content_type.dart'; +import "package:stream_channel/stream_channel.dart"; + +/// The list of headers to ignore when sending back confirmation. +final _ignoreHeaders = [ + // Browser headers (Chrome) + 'accept', + 'accept-language', + 'accept-encoding', + 'connection', + 'origin', + 'referer', + + // Dart IO headers + 'cookie', + 'host', +]; + +/// Creates a server used to test a `http` client. +/// +/// On startup the server will bind to `localhost`. Then it will send the url +/// as a string back through the [channel]. +/// +/// The server has the following explicit endpoints used to test individual +/// functionality. +/// * /error - Will return a 400 status code. +/// * /loop - Which is used to check for max redirects. +/// * /redirect - Which is used to test that a redirect works. +/// * /no-content-length - Which returns a body with no content. +/// +/// All other requests will be responded to. This is used to test the +/// individual HTTP methods. The server will return back the following +/// information in a string. +/// +/// { +/// method: 'METHOD_NAME', +/// path: 'ENDPOINT_PATH', +/// headers: { +/// KEY VALUE STORE OF INDIVIDUAL HEADERS +/// }, +/// body: OPTIONAL +/// } +hybridMain(StreamChannel channel) async { + var server = await HttpServer.bind('localhost', 0); + var serverUrl = Uri.parse('http://localhost:${server.port}'); + + server.listen((request) { + var path = request.uri.path; + var response = request.response; + + if (path == '/error') { + response.statusCode = 400; + response.contentLength = 0; + response.close(); + return; + } + + if (path == '/loop') { + var n = int.parse(request.uri.query); + response.statusCode = 302; + response.headers + .set('location', serverUrl.resolve('/loop?${n + 1}').toString()); + response.contentLength = 0; + response.close(); + return; + } + + if (path == '/redirect') { + response.statusCode = 302; + response.headers.set('location', serverUrl.resolve('/').toString()); + response.contentLength = 0; + response.close(); + return; + } + + if (path == '/no-content-length') { + response.statusCode = 200; + response.contentLength = -1; + response.write('body'); + response.close(); + return; + } + + collectBytes(request).then((requestBodyBytes) { + var outputEncoding; + var encodingName = request.uri.queryParameters['response-encoding']; + if (encodingName != null) { + outputEncoding = encodingForCharset(encodingName); + } else { + outputEncoding = ASCII; + } + + response.headers.contentType = + new ContentType("application", "json", charset: outputEncoding.name); + + // Add CORS headers for browser testing + response.headers.set('access-control-allow-origin', '*'); + response.headers.set('access-control-allow-headers', + 'X-Random-Header,X-Other-Header,User-Agent'); + response.headers.set('access-control-allow-methods', + 'GET, PUT, POST, DELETE, PATCH, HEAD'); + + var requestBody; + if (requestBodyBytes.isEmpty) { + requestBody = null; + } else if (request.headers.contentType != null && + request.headers.contentType.charset != null) { + var encoding = encodingForCharset(request.headers.contentType.charset); + requestBody = encoding.decode(requestBodyBytes); + } else { + requestBody = requestBodyBytes; + } + + var content = { + 'method': request.method, + 'path': request.uri.path, + 'headers': {} + }; + if (requestBody != null) content['body'] = requestBody; + request.headers.forEach((name, values) { + // Ignore headers that are generated by the client + if (_ignoreHeaders.contains(name)) return; + + (content['headers'] as Map)[name] = values; + }); + + var body = JSON.encode(content); + response.contentLength = body.length; + response.write(body); + response.close(); + }); + }); + + channel.sink.add(serverUrl.toString()); +} diff --git a/test/io/client_test.dart b/test/io/client_test.dart deleted file mode 100644 index acfa584a3c..0000000000 --- a/test/io/client_test.dart +++ /dev/null @@ -1,98 +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:io'; - -import 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - tearDown(stopServer); - - test('#send a StreamedRequest', () { - expect(startServer().then((_) { - var client = new http.Client(); - var request = new http.StreamedRequest("POST", serverUrl); - request.headers[HttpHeaders.CONTENT_TYPE] = - 'application/json; charset=utf-8'; - request.headers[HttpHeaders.USER_AGENT] = 'Dart'; - - expect(client.send(request).then((response) { - expect(response.request, equals(request)); - expect(response.statusCode, equals(200)); - expect(response.headers['single'], equals('value')); - // dart:io internally normalizes outgoing headers so that they never - // have multiple headers with the same name, so there's no way to test - // whether we handle that case correctly. - - return response.stream.bytesToString(); - }).whenComplete(client.close), completion(parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-type': ['application/json; charset=utf-8'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'transfer-encoding': ['chunked'] - }, - 'body': '{"hello": "world"}' - })))); - - request.sink.add('{"hello": "world"}'.codeUnits); - request.sink.close(); - }), completes); - }); - - test('#send a StreamedRequest with a custom client', () { - expect(startServer().then((_) { - var ioClient = new HttpClient(); - var client = new http.IOClient(ioClient); - var request = new http.StreamedRequest("POST", serverUrl); - request.headers[HttpHeaders.CONTENT_TYPE] = - 'application/json; charset=utf-8'; - request.headers[HttpHeaders.USER_AGENT] = 'Dart'; - - expect(client.send(request).then((response) { - expect(response.request, equals(request)); - expect(response.statusCode, equals(200)); - expect(response.headers['single'], equals('value')); - // dart:io internally normalizes outgoing headers so that they never - // have multiple headers with the same name, so there's no way to test - // whether we handle that case correctly. - - return response.stream.bytesToString(); - }).whenComplete(client.close), completion(parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-type': ['application/json; charset=utf-8'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'transfer-encoding': ['chunked'] - }, - 'body': '{"hello": "world"}' - })))); - - request.sink.add('{"hello": "world"}'.codeUnits); - request.sink.close(); - }), completes); - }); - - test('#send with an invalid URL', () { - expect(startServer().then((_) { - var client = new http.Client(); - var url = Uri.parse('http://http.invalid'); - var request = new http.StreamedRequest("POST", url); - request.headers[HttpHeaders.CONTENT_TYPE] = - 'application/json; charset=utf-8'; - - expect(client.send(request), throwsSocketException); - - request.sink.add('{"hello": "world"}'.codeUnits); - request.sink.close(); - }), completes); - }); -} diff --git a/test/io/http_test.dart b/test/io/http_test.dart deleted file mode 100644 index 2419e198ef..0000000000 --- a/test/io/http_test.dart +++ /dev/null @@ -1,436 +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 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -main() { - group('http.', () { - tearDown(stopServer); - - test('head', () { - expect(startServer().then((_) { - expect(http.head(serverUrl).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, equals('')); - }), completes); - }), completes); - }); - - test('get', () { - expect(startServer().then((_) { - expect(http.get(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'GET', - 'path': '/', - 'headers': { - 'content-length': ['0'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - }))); - }), completes); - }), completes); - }); - - test('post', () { - expect(startServer().then((_) { - expect(http.post(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'Content-Type': 'text/plain', - 'User-Agent': 'Dart' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'accept-encoding': ['gzip'], - 'content-length': ['0'], - 'content-type': ['text/plain'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - } - }))); - }), completes); - }), completes); - }); - - test('post with string', () { - expect(startServer().then((_) { - expect(http.post(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: 'request body').then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-type': ['text/plain; charset=utf-8'], - 'content-length': ['12'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'request body' - }))); - }), completes); - }), completes); - }); - - test('post with bytes', () { - expect(startServer().then((_) { - expect(http.post(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: [104, 101, 108, 108, 111]).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-length': ['5'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': [104, 101, 108, 108, 111] - }))); - }), completes); - }), completes); - }); - - test('post with fields', () { - expect(startServer().then((_) { - expect(http.post(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: { - 'some-field': 'value', - 'other-field': 'other value' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-type': [ - 'application/x-www-form-urlencoded; charset=utf-8' - ], - 'content-length': ['40'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'some-field=value&other-field=other+value' - }))); - }), completes); - }), completes); - }); - - test('put', () { - expect(startServer().then((_) { - expect(http.put(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'Content-Type': 'text/plain', - 'User-Agent': 'Dart' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PUT', - 'path': '/', - 'headers': { - 'accept-encoding': ['gzip'], - 'content-length': ['0'], - 'content-type': ['text/plain'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - } - }))); - }), completes); - }), completes); - }); - - test('put with string', () { - expect(startServer().then((_) { - expect(http.put(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: 'request body').then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PUT', - 'path': '/', - 'headers': { - 'content-type': ['text/plain; charset=utf-8'], - 'content-length': ['12'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'request body' - }))); - }), completes); - }), completes); - }); - - test('put with bytes', () { - expect(startServer().then((_) { - expect(http.put(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: [104, 101, 108, 108, 111]).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PUT', - 'path': '/', - 'headers': { - 'content-length': ['5'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': [104, 101, 108, 108, 111] - }))); - }), completes); - }), completes); - }); - - test('put with fields', () { - expect(startServer().then((_) { - expect(http.put(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: { - 'some-field': 'value', - 'other-field': 'other value' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PUT', - 'path': '/', - 'headers': { - 'content-type': [ - 'application/x-www-form-urlencoded; charset=utf-8' - ], - 'content-length': ['40'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'some-field=value&other-field=other+value' - }))); - }), completes); - }), completes); - }); - - test('patch', () { - expect(startServer().then((_) { - expect(http.patch(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'Content-Type': 'text/plain', - 'User-Agent': 'Dart' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PATCH', - 'path': '/', - 'headers': { - 'accept-encoding': ['gzip'], - 'content-length': ['0'], - 'content-type': ['text/plain'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - } - }))); - }), completes); - }), completes); - }); - - test('patch with string', () { - expect(startServer().then((_) { - expect(http.patch(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: 'request body').then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PATCH', - 'path': '/', - 'headers': { - 'content-type': ['text/plain; charset=utf-8'], - 'content-length': ['12'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'request body' - }))); - }), completes); - }), completes); - }); - - test('patch with bytes', () { - expect(startServer().then((_) { - expect(http.patch(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: [104, 101, 108, 108, 111]).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PATCH', - 'path': '/', - 'headers': { - 'content-length': ['5'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': [104, 101, 108, 108, 111] - }))); - }), completes); - }), completes); - }); - - test('patch with fields', () { - expect(startServer().then((_) { - expect(http.patch(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }, body: { - 'some-field': 'value', - 'other-field': 'other value' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'PATCH', - 'path': '/', - 'headers': { - 'content-type': [ - 'application/x-www-form-urlencoded; charset=utf-8' - ], - 'content-length': ['40'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - 'body': 'some-field=value&other-field=other+value' - }))); - }), completes); - }), completes); - }); - - test('delete', () { - expect(startServer().then((_) { - expect(http.delete(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }).then((response) { - expect(response.statusCode, equals(200)); - expect(response.body, parse(equals({ - 'method': 'DELETE', - 'path': '/', - 'headers': { - 'content-length': ['0'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - } - }))); - }), completes); - }), completes); - }); - - test('read', () { - expect(startServer().then((_) { - expect(http.read(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }).then((val) => val), completion(parse(equals({ - 'method': 'GET', - 'path': '/', - 'headers': { - 'content-length': ['0'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - })))); - }), completes); - }); - - test('read throws an error for a 4** status code', () { - expect(startServer().then((_) { - expect(http.read(serverUrl.resolve('/error')), throwsClientException); - }), completes); - }); - - test('readBytes', () { - expect(startServer().then((_) { - var future = http.readBytes(serverUrl, headers: { - 'X-Random-Header': 'Value', - 'X-Other-Header': 'Other Value', - 'User-Agent': 'Dart' - }).then((bytes) => new String.fromCharCodes(bytes)); - - expect(future, completion(parse(equals({ - 'method': 'GET', - 'path': '/', - 'headers': { - 'content-length': ['0'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'x-random-header': ['Value'], - 'x-other-header': ['Other Value'] - }, - })))); - }), completes); - }); - - test('readBytes throws an error for a 4** status code', () { - expect(startServer().then((_) { - expect(http.readBytes(serverUrl.resolve('/error')), - throwsClientException); - }), completes); - }); - }); -} diff --git a/test/io/multipart_test.dart b/test/io/multipart_test.dart deleted file mode 100644 index 4071dae2eb..0000000000 --- a/test/io/multipart_test.dart +++ /dev/null @@ -1,41 +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:io'; - -import 'package:http/http.dart' as http; -import 'package:path/path.dart' as path; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - var tempDir; - setUp(() { - tempDir = Directory.systemTemp.createTempSync('http_test_'); - }); - - tearDown(() => tempDir.deleteSync(recursive: true)); - - test('with a file from disk', () { - expect(new Future.sync(() { - var filePath = path.join(tempDir.path, 'test-file'); - new File(filePath).writeAsStringSync('hello'); - return http.MultipartFile.fromPath('file', filePath); - }).then((file) { - var request = new http.MultipartRequest('POST', dummyUrl); - request.files.add(file); - - expect(request, bodyMatches(''' - --{{boundary}} - content-type: application/octet-stream - content-disposition: form-data; name="file"; filename="test-file" - - hello - --{{boundary}}-- - ''')); - }), completes); - }); -} diff --git a/test/io/request_test.dart b/test/io/request_test.dart deleted file mode 100644 index 5408ba8829..0000000000 --- a/test/io/request_test.dart +++ /dev/null @@ -1,59 +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 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - test('.send', () { - expect(startServer().then((_) { - - var request = new http.Request('POST', serverUrl); - request.body = "hello"; - request.headers['User-Agent'] = 'Dart'; - - expect(request.send().then((response) { - expect(response.statusCode, equals(200)); - return response.stream.bytesToString(); - }).whenComplete(stopServer), completion(parse(equals({ - 'method': 'POST', - 'path': '/', - 'headers': { - 'content-type': ['text/plain; charset=utf-8'], - 'accept-encoding': ['gzip'], - 'user-agent': ['Dart'], - 'content-length': ['5'] - }, - 'body': 'hello' - })))); - }), completes); - }); - - test('#followRedirects', () { - expect(startServer().then((_) { - var request = new http.Request('POST', serverUrl.resolve('/redirect')) - ..followRedirects = false; - var future = request.send().then((response) { - expect(response.statusCode, equals(302)); - }); - expect(future.catchError((_) {}).then((_) => stopServer()), completes); - expect(future, completes); - }), completes); - }); - - test('#maxRedirects', () { - expect(startServer().then((_) { - var request = new http.Request('POST', serverUrl.resolve('/loop?1')) - ..maxRedirects = 2; - var future = request.send().catchError((error) { - expect(error, isRedirectLimitExceededException); - expect(error.redirects.length, equals(2)); - }); - expect(future.catchError((_) {}).then((_) => stopServer()), completes); - expect(future, completes); - }), completes); - }); -} diff --git a/test/io/streamed_request_test.dart b/test/io/streamed_request_test.dart deleted file mode 100644 index cc05bbb0f5..0000000000 --- a/test/io/streamed_request_test.dart +++ /dev/null @@ -1,56 +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:convert'; - -import 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - group('contentLength', () { - test('controls the Content-Length header', () { - return startServer().then((_) { - var request = new http.StreamedRequest('POST', serverUrl); - request.contentLength = 10; - request.sink.add([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - request.sink.close(); - - return request.send(); - }).then((response) { - expect(UTF8.decodeStream(response.stream), - completion(parse(containsPair('headers', - containsPair('content-length', ['10']))))); - }).whenComplete(stopServer); - }); - - test('defaults to sending no Content-Length', () { - return startServer().then((_) { - var request = new http.StreamedRequest('POST', serverUrl); - request.sink.add([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - request.sink.close(); - - return request.send(); - }).then((response) { - expect(UTF8.decodeStream(response.stream), - completion(parse(containsPair('headers', - isNot(contains('content-length')))))); - }).whenComplete(stopServer); - }); - }); - - // Regression test. - test('.send() with a response with no content length', () { - return startServer().then((_) { - var request = new http.StreamedRequest( - 'GET', serverUrl.resolve('/no-content-length')); - request.sink.close(); - return request.send(); - }).then((response) { - expect(UTF8.decodeStream(response.stream), completion(equals('body'))); - }).whenComplete(stopServer); - }); - -} diff --git a/test/io/utils.dart b/test/io/utils.dart deleted file mode 100644 index 24276faff8..0000000000 --- a/test/io/utils.dart +++ /dev/null @@ -1,149 +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:unittest/unittest.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() { - return HttpServer.bind("localhost", 0).then((s) { - _server = s; - s.listen((request) { - var path = request.uri.path; - var response = request.response; - - if (path == '/error') { - response.statusCode = 400; - response.contentLength = 0; - response.close(); - return; - } - - if (path == '/loop') { - var n = int.parse(request.uri.query); - response.statusCode = 302; - response.headers.set('location', - serverUrl.resolve('/loop?${n + 1}').toString()); - response.contentLength = 0; - response.close(); - return; - } - - if (path == '/redirect') { - response.statusCode = 302; - response.headers.set('location', serverUrl.resolve('/').toString()); - response.contentLength = 0; - response.close(); - return; - } - - if (path == '/no-content-length') { - response.statusCode = 200; - response.contentLength = -1; - response.write('body'); - response.close(); - return; - } - - new ByteStream(request).toBytes().then((requestBodyBytes) { - var outputEncoding; - var encodingName = request.uri.queryParameters['response-encoding']; - if (encodingName != null) { - outputEncoding = requiredEncodingForCharset(encodingName); - } else { - outputEncoding = ASCII; - } - - response.headers.contentType = - new ContentType( - "application", "json", charset: outputEncoding.name); - response.headers.set('single', 'value'); - - var requestBody; - if (requestBodyBytes.isEmpty) { - requestBody = null; - } else if (request.headers.contentType != null && - request.headers.contentType.charset != null) { - var encoding = requiredEncodingForCharset( - request.headers.contentType.charset); - requestBody = encoding.decode(requestBodyBytes); - } else { - requestBody = requestBodyBytes; - } - - var content = { - 'method': request.method, - 'path': request.uri.path, - 'headers': {} - }; - if (requestBody != null) content['body'] = requestBody; - 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; - - content['headers'][name] = values; - }); - - var body = JSON.encode(content); - response.contentLength = body.length; - response.write(body); - 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(new isInstanceOf()); - -/// A matcher for RedirectLimitExceededExceptions. -const isRedirectLimitExceededException = - const _RedirectLimitExceededException(); - -/// A matcher for functions that throw RedirectLimitExceededException. -const Matcher throwsRedirectLimitExceededException = - const Throws(isRedirectLimitExceededException); - -class _RedirectLimitExceededException extends TypeMatcher { - const _RedirectLimitExceededException() : - super("RedirectLimitExceededException"); - - bool matches(item, Map matchState) => - item is RedirectException && item.message == "Redirect limit exceeded"; -} - -/// A matcher for SocketExceptions. -const isSocketException = const _SocketException(); - -/// A matcher for functions that throw SocketException. -const Matcher throwsSocketException = - const Throws(isSocketException); - -class _SocketException extends TypeMatcher { - const _SocketException() : super("SocketException"); - bool matches(item, Map matchState) => item is SocketException; -} diff --git a/test/message_change_test.dart b/test/message_change_test.dart new file mode 100644 index 0000000000..8c5942d191 --- /dev/null +++ b/test/message_change_test.dart @@ -0,0 +1,100 @@ +// Copyright (c) 2017, 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:test/test.dart'; + +import 'package:http/http.dart'; +import 'package:http/src/message.dart'; + +final _uri = Uri.parse('http://localhost/'); + +void main() { + group('Request', () { + _testChange(({body, headers, context}) { + return new Request('GET', _uri, + body: body, headers: headers, context: context); + }); + }); + + group('Response', () { + _testChange(({body, headers, context}) { + return new Response(_uri, 200, + body: body, headers: headers, context: context); + }); + }); +} + +/// Shared test method used by [Request] and [Response] tests to validate +/// the behavior of `change` with different `headers` and `context` values. +void _testChange( + Message factory( + {body, Map headers, Map context})) { + group('body', () { + test('with String', () async { + var request = factory(body: 'Hello, world'); + var copy = request.change(body: 'Goodbye, world'); + + var newBody = await copy.readAsString(); + + expect(newBody, equals('Goodbye, world')); + }); + + test('with Stream', () async { + var request = factory(body: 'Hello, world'); + var copy = request.change( + body: new Stream.fromIterable(['Goodbye, world']) + .transform(UTF8.encoder)); + + var newBody = await copy.readAsString(); + + expect(newBody, equals('Goodbye, world')); + }); + }); + + test('with empty headers returns identical instance', () { + var request = factory(headers: {'header1': 'header value 1'}); + var copy = request.change(headers: {}); + + expect(copy.headers, same(request.headers)); + }); + + test('with empty context returns identical instance', () { + var request = factory(context: {'context1': 'context value 1'}); + var copy = request.change(context: {}); + + expect(copy.context, same(request.context)); + }); + + test('new header values are added', () { + var request = factory(headers: {'test': 'test value'}); + var copy = request.change(headers: {'test2': 'test2 value'}); + + expect(copy.headers, + {'test': 'test value', 'test2': 'test2 value', 'content-length': '0'}); + }); + + test('existing header values are overwritten', () { + var request = factory(headers: {'test': 'test value'}); + var copy = request.change(headers: {'test': 'new test value'}); + + expect(copy.headers, {'test': 'new test value', 'content-length': '0'}); + }); + + test('new context values are added', () { + var request = factory(context: {'test': 'test value'}); + var copy = request.change(context: {'test2': 'test2 value'}); + + expect(copy.context, {'test': 'test value', 'test2': 'test2 value'}); + }); + + test('existing context values are overwritten', () { + var request = factory(context: {'test': 'test value'}); + var copy = request.change(context: {'test': 'new test value'}); + + expect(copy.context, {'test': 'new test value'}); + }); +} diff --git a/test/message_test.dart b/test/message_test.dart new file mode 100644 index 0000000000..c8d6474176 --- /dev/null +++ b/test/message_test.dart @@ -0,0 +1,343 @@ +// Copyright (c) 2017, 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:test/test.dart'; + +import 'package:http/src/message.dart'; + +// "hello," +const HELLO_BYTES = const [104, 101, 108, 108, 111, 44]; + +// " world" +const WORLD_BYTES = const [32, 119, 111, 114, 108, 100]; + +class _TestMessage extends Message { + _TestMessage(Map headers, Map context, body, + Encoding encoding) + : super(body, headers: headers, context: context, encoding: encoding); + + Message change( + {Map headers, Map context, body}) { + throw new UnimplementedError(); + } +} + +Message _createMessage( + {Map headers, + Map context, + body, + Encoding encoding}) { + return new _TestMessage(headers, context, body, encoding); +} + +void main() { + group('headers', () { + test('message headers are case insensitive', () { + var message = _createMessage(headers: {'foo': 'bar'}); + + expect(message.headers, containsPair('foo', 'bar')); + expect(message.headers, containsPair('Foo', 'bar')); + expect(message.headers, containsPair('FOO', 'bar')); + }); + + test('null header value becomes default', () { + var message = _createMessage(); + expect(message.headers.containsKey('content-length'), isFalse); + expect(message.headers, same(_createMessage().headers)); + expect(() => message.headers['h1'] = 'value1', throwsUnsupportedError); + }); + + test('headers are immutable', () { + var message = _createMessage(headers: {'h1': 'value1'}); + expect(() => message.headers['h1'] = 'value1', throwsUnsupportedError); + expect(() => message.headers['h1'] = 'value2', throwsUnsupportedError); + expect(() => message.headers['h2'] = 'value2', throwsUnsupportedError); + }); + }); + + group('context', () { + test('is accessible', () { + var message = _createMessage(context: {'foo': 'bar'}); + expect(message.context, containsPair('foo', 'bar')); + }); + + test('null context value becomes empty and immutable', () { + var message = _createMessage(); + expect(message.context, isEmpty); + expect(() => message.context['key'] = 'value', throwsUnsupportedError); + }); + + test('is immutable', () { + var message = _createMessage(context: {'key': 'value'}); + expect(() => message.context['key'] = 'value', throwsUnsupportedError); + expect(() => message.context['key2'] = 'value', throwsUnsupportedError); + }); + }); + + group("readAsString", () { + test("supports a null body", () { + var request = _createMessage(); + expect(request.readAsString(), completion(equals(""))); + }); + + test("supports a Stream> body", () { + var controller = new StreamController(); + var request = _createMessage(body: controller.stream); + expect(request.readAsString(), completion(equals("hello, world"))); + + controller.add(HELLO_BYTES); + return new Future(() { + controller + ..add(WORLD_BYTES) + ..close(); + }); + }); + + test("defaults to UTF-8", () { + var request = _createMessage( + body: new Stream.fromIterable([ + [195, 168] + ])); + expect(request.readAsString(), completion(equals("è"))); + }); + + test("the content-type header overrides the default", () { + var request = _createMessage( + headers: {'content-type': 'text/plain; charset=iso-8859-1'}, + body: new Stream.fromIterable([ + [195, 168] + ])); + expect(request.readAsString(), completion(equals("è"))); + }); + + test("an explicit encoding overrides the content-type header", () { + var request = _createMessage( + headers: {'content-type': 'text/plain; charset=iso-8859-1'}, + body: new Stream.fromIterable([ + [195, 168] + ])); + expect(request.readAsString(LATIN1), completion(equals("è"))); + }); + }); + + group("read", () { + test("supports a null body", () { + var request = _createMessage(); + expect(request.read().toList(), completion(isEmpty)); + }); + + test("supports a Stream> body", () { + var controller = new StreamController(); + var request = _createMessage(body: controller.stream); + expect(request.read().toList(), + completion(equals([HELLO_BYTES, WORLD_BYTES]))); + + controller.add(HELLO_BYTES); + return new Future(() { + controller + ..add(WORLD_BYTES) + ..close(); + }); + }); + + test("supports a List body", () { + var request = _createMessage(body: HELLO_BYTES); + expect(request.read().toList(), completion(equals([HELLO_BYTES]))); + }); + + test("throws when calling read()/readAsString() multiple times", () { + var request; + + request = _createMessage(); + expect(request.read().toList(), completion(isEmpty)); + expect(() => request.read(), throwsStateError); + + request = _createMessage(); + expect(request.readAsString(), completion(isEmpty)); + expect(() => request.readAsString(), throwsStateError); + + request = _createMessage(); + expect(request.readAsString(), completion(isEmpty)); + expect(() => request.read(), throwsStateError); + + request = _createMessage(); + expect(request.read().toList(), completion(isEmpty)); + expect(() => request.readAsString(), throwsStateError); + }); + }); + + group("content-length", () { + test("is null with a default body and without a content-length header", () { + var request = _createMessage(); + expect(request.contentLength, isNull); + }); + + test("comes from a byte body", () { + var request = _createMessage(body: [1, 2, 3]); + expect(request.contentLength, 3); + expect(request.isEmpty, isFalse); + }); + + test("comes from a string body", () { + var request = _createMessage(body: 'foobar'); + expect(request.contentLength, 6); + expect(request.isEmpty, isFalse); + }); + + test("is set based on byte length for a string body", () { + var request = _createMessage(body: 'fööbär'); + expect(request.contentLength, 9); + expect(request.isEmpty, isFalse); + + request = _createMessage(body: 'fööbär', encoding: LATIN1); + expect(request.contentLength, 6); + expect(request.isEmpty, isFalse); + }); + + test("is null for a stream body", () { + var request = _createMessage(body: new Stream.empty()); + expect(request.contentLength, isNull); + }); + + test("uses the content-length header for a stream body", () { + var request = _createMessage( + body: new Stream.empty(), headers: {'content-length': '42'}); + expect(request.contentLength, 42); + expect(request.isEmpty, isFalse); + }); + + test("real body length takes precedence over content-length header", () { + var request = + _createMessage(body: [1, 2, 3], headers: {'content-length': '42'}); + expect(request.contentLength, 3); + expect(request.isEmpty, isFalse); + }); + + test("is null for a chunked transfer encoding", () { + var request = _createMessage( + body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'chunked'}); + expect(request.contentLength, isNull); + }); + + test("is null for a non-identity transfer encoding", () { + var request = _createMessage( + body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'custom'}); + expect(request.contentLength, isNull); + }); + + test("is set for identity transfer encoding", () { + var request = _createMessage( + body: "1\r\na0\r\n\r\n", headers: {'transfer-encoding': 'identity'}); + expect(request.contentLength, equals(9)); + expect(request.isEmpty, isFalse); + }); + }); + + group("mimeType", () { + test("is null without a content-type header", () { + expect(_createMessage().mimeType, isNull); + }); + + test("comes from the content-type header", () { + expect(_createMessage(headers: {'content-type': 'text/plain'}).mimeType, + equals('text/plain')); + }); + + test("doesn't include parameters", () { + expect( + _createMessage( + headers: {'content-type': 'text/plain; foo=bar; bar=baz'}) + .mimeType, + equals('text/plain')); + }); + }); + + group("encoding", () { + test("is null without a content-type header", () { + expect(_createMessage().encoding, isNull); + }); + + test("is null without a charset parameter", () { + expect(_createMessage(headers: {'content-type': 'text/plain'}).encoding, + isNull); + }); + + test("is null with an unrecognized charset parameter", () { + expect( + _createMessage( + headers: {'content-type': 'text/plain; charset=fblthp'}).encoding, + isNull); + }); + + test("comes from the content-type charset parameter", () { + expect( + _createMessage( + headers: {'content-type': 'text/plain; charset=iso-8859-1'}) + .encoding, + equals(LATIN1)); + }); + + test("comes from the content-type charset parameter with a different case", + () { + expect( + _createMessage( + headers: {'Content-Type': 'text/plain; charset=iso-8859-1'}) + .encoding, + equals(LATIN1)); + }); + + test("defaults to encoding a String as UTF-8", () { + expect( + _createMessage(body: "è").read().toList(), + completion(equals([ + [195, 168] + ]))); + }); + + test("uses the explicit encoding if available", () { + expect( + _createMessage(body: "è", encoding: LATIN1).read().toList(), + completion(equals([ + [232] + ]))); + }); + + test("adds an explicit encoding to the content-type", () { + var request = _createMessage( + body: "è", encoding: LATIN1, headers: {'content-type': 'text/plain'}); + expect(request.headers, + containsPair('content-type', 'text/plain; charset=iso-8859-1')); + }); + + test("adds an explicit encoding to the content-type with a different case", + () { + var request = _createMessage( + body: "è", encoding: LATIN1, headers: {'Content-Type': 'text/plain'}); + expect(request.headers, + containsPair('Content-Type', 'text/plain; charset=iso-8859-1')); + }); + + test( + "sets an absent content-type to application/octet-stream in order to " + "set the charset", () { + var request = _createMessage(body: "è", encoding: LATIN1); + expect( + request.headers, + containsPair( + 'content-type', 'application/octet-stream; charset=iso-8859-1')); + }); + + test("overwrites an existing charset if given an explicit encoding", () { + var request = _createMessage( + body: "è", + encoding: LATIN1, + headers: {'content-type': 'text/plain; charset=whatever'}); + expect(request.headers, + containsPair('content-type', 'text/plain; charset=iso-8859-1')); + }); + }); +} diff --git a/test/mock_client_test.dart b/test/mock_client_test.dart deleted file mode 100644 index 48e635fee3..0000000000 --- a/test/mock_client_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2012, 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:http/http.dart' as http; -import 'package:http/testing.dart'; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - test('handles a request', () { - var client = new MockClient((request) { - return new Future.value(new http.Response( - JSON.encode(request.bodyFields), 200, - request: request, headers: {'content-type': 'application/json'})); - }); - - expect(client.post("http://example.com/foo", body: { - 'field1': 'value1', - 'field2': 'value2' - }).then((response) => response.body), completion(parse(equals({ - 'field1': 'value1', - 'field2': 'value2' - })))); - }); - - test('handles a streamed request', () { - var client = new MockClient.streaming((request, bodyStream) { - return bodyStream.bytesToString().then((bodyString) { - var controller = new StreamController>(sync: true); - new Future.sync(() { - controller.add('Request body was "$bodyString"'.codeUnits); - controller.close(); - }); - - return new http.StreamedResponse(controller.stream, 200); - }); - }); - - var uri = Uri.parse("http://example.com/foo"); - var request = new http.Request("POST", uri); - request.body = "hello, world"; - var future = client.send(request) - .then(http.Response.fromStream) - .then((response) => response.body); - expect(future, completion(equals('Request body was "hello, world"'))); - }); - - test('handles a request with no body', () { - var client = new MockClient((request) { - return new Future.value(new http.Response('you did it', 200)); - }); - - expect(client.read("http://example.com/foo"), - completion(equals('you did it'))); - }); -} diff --git a/test/multipart_test.dart b/test/multipart_test.dart index ae3c4b320f..bf0d22bbdf 100644 --- a/test/multipart_test.dart +++ b/test/multipart_test.dart @@ -1,40 +1,38 @@ -// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// Copyright (c) 2017, 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:http/http.dart' as http; -import 'package:http/src/boundary_characters.dart'; import 'package:http_parser/http_parser.dart'; -import 'package:unittest/unittest.dart'; +import 'package:test/test.dart'; import 'utils.dart'; void main() { test('empty', () { - var request = new http.MultipartRequest('POST', dummyUrl); - expect(request, bodyMatches(''' + var request = new http.Request.multipart(dummyUrl); + expect(request, multipartBodyMatches(''' --{{boundary}}-- ''')); }); - test('boundary characters', () { - var testBoundary = new String.fromCharCodes(BOUNDARY_CHARACTERS); - var contentType = new MediaType.parse('text/plain; boundary=${testBoundary}'); - var boundary = contentType.parameters['boundary']; - expect(boundary, testBoundary); - }); - test('with fields and files', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.fields['field1'] = 'value1'; - request.fields['field2'] = 'value2'; - request.files.add(new http.MultipartFile.fromString("file1", "contents1", - filename: "filename1.txt")); - request.files.add(new http.MultipartFile.fromString("file2", "contents2")); + var fields = { + 'field1': 'value1', + 'field2': 'value2', + }; + var files = [ + new http.MultipartFile('file1', 'contents1', filename: 'filename1.txt'), + new http.MultipartFile('file2', 'contents2'), + ]; + + var request = + new http.Request.multipart(dummyUrl, fields: fields, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-disposition: form-data; name="field1" @@ -58,10 +56,11 @@ void main() { }); test('with a unicode field name', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.fields['fïēld'] = 'value'; + var fields = {'fïēld': 'value'}; - expect(request, bodyMatches(''' + var request = new http.Request.multipart(dummyUrl, fields: fields); + + expect(request, multipartBodyMatches(''' --{{boundary}} content-disposition: form-data; name="fïēld" @@ -71,10 +70,10 @@ void main() { }); test('with a field name with newlines', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.fields['foo\nbar\rbaz\r\nbang'] = 'value'; + var fields = {'foo\nbar\rbaz\r\nbang': 'value'}; + var request = new http.Request.multipart(dummyUrl, fields: fields); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-disposition: form-data; name="foo%0D%0Abar%0D%0Abaz%0D%0Abang" @@ -84,10 +83,10 @@ void main() { }); test('with a field name with a quote', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.fields['foo"bar'] = 'value'; + var fields = {'foo"bar': 'value'}; + var request = new http.Request.multipart(dummyUrl, fields: fields); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-disposition: form-data; name="foo%22bar" @@ -97,10 +96,10 @@ void main() { }); test('with a unicode field value', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.fields['field'] = 'vⱥlūe'; + var fields = {'field': 'vⱥlūe'}; + var request = new http.Request.multipart(dummyUrl, fields: fields); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-disposition: form-data; name="field" content-type: text/plain; charset=utf-8 @@ -112,11 +111,12 @@ void main() { }); test('with a unicode filename', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.files.add(new http.MultipartFile.fromString('file', 'contents', - filename: 'fïlēname.txt')); + var files = [ + new http.MultipartFile('file', 'contents', filename: 'fïlēname.txt') + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: text/plain; charset=utf-8 content-disposition: form-data; name="file"; filename="fïlēname.txt" @@ -127,11 +127,13 @@ void main() { }); test('with a filename with newlines', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.files.add(new http.MultipartFile.fromString('file', 'contents', - filename: 'foo\nbar\rbaz\r\nbang')); + var files = [ + new http.MultipartFile('file', 'contents', + filename: 'foo\nbar\rbaz\r\nbang') + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: text/plain; charset=utf-8 content-disposition: form-data; name="file"; filename="foo%0D%0Abar%0D%0Abaz%0D%0Abang" @@ -142,11 +144,12 @@ void main() { }); test('with a filename with a quote', () { - var request = new http.MultipartRequest('POST', dummyUrl); - request.files.add(new http.MultipartFile.fromString('file', 'contents', - filename: 'foo"bar')); + var files = [ + new http.MultipartFile('file', 'contents', filename: 'foo"bar') + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: text/plain; charset=utf-8 content-disposition: form-data; name="file"; filename="foo%22bar" @@ -157,12 +160,13 @@ void main() { }); test('with a string file with a content-type but no charset', () { - var request = new http.MultipartRequest('POST', dummyUrl); - var file = new http.MultipartFile.fromString('file', '{"hello": "world"}', - contentType: new MediaType('application', 'json')); - request.files.add(file); + var files = [ + new http.MultipartFile('file', '{"hello": "world"}', + contentType: new MediaType('application', 'json')) + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: application/json; charset=utf-8 content-disposition: form-data; name="file" @@ -173,13 +177,14 @@ void main() { }); test('with a file with a iso-8859-1 body', () { - var request = new http.MultipartRequest('POST', dummyUrl); // "Ã¥" encoded as ISO-8859-1 and then read as UTF-8 results in "å". - var file = new http.MultipartFile.fromString('file', 'non-ascii: "Ã¥"', - contentType: new MediaType('text', 'plain', {'charset': 'iso-8859-1'})); - request.files.add(file); + var files = [ + new http.MultipartFile('file', 'non-ascii: "Ã¥"', + encoding: LATIN1, contentType: new MediaType('text', 'plain')) + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: text/plain; charset=iso-8859-1 content-disposition: form-data; name="file" @@ -190,11 +195,13 @@ void main() { }); test('with a stream file', () { - var request = new http.MultipartRequest('POST', dummyUrl); - var controller = new StreamController(sync: true); - request.files.add(new http.MultipartFile('file', controller.stream, 5)); + var controller = new StreamController>(sync: true); + var files = [ + new http.MultipartFile.fromStream('file', controller.stream, 5) + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: application/octet-stream content-disposition: form-data; name="file" @@ -203,16 +210,19 @@ void main() { --{{boundary}}-- ''')); - controller.add([104, 101, 108, 108, 111]); - controller.close(); + controller + ..add([104, 101, 108, 108, 111]) + ..close(); }); test('with an empty stream file', () { - var request = new http.MultipartRequest('POST', dummyUrl); - var controller = new StreamController(sync: true); - request.files.add(new http.MultipartFile('file', controller.stream, 0)); + var controller = new StreamController>(sync: true); + var files = [ + new http.MultipartFile.fromStream('file', controller.stream, 0) + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: application/octet-stream content-disposition: form-data; name="file" @@ -225,12 +235,12 @@ void main() { }); test('with a byte file', () { - var request = new http.MultipartRequest('POST', dummyUrl); - var file = new http.MultipartFile.fromBytes( - 'file', [104, 101, 108, 108, 111]); - request.files.add(file); + var files = [ + new http.MultipartFile('file', [104, 101, 108, 108, 111]) + ]; + var request = new http.Request.multipart(dummyUrl, files: files); - expect(request, bodyMatches(''' + expect(request, multipartBodyMatches(''' --{{boundary}} content-type: application/octet-stream content-disposition: form-data; name="file" diff --git a/test/pipeline_test.dart b/test/pipeline_test.dart new file mode 100644 index 0000000000..cbde3a62e3 --- /dev/null +++ b/test/pipeline_test.dart @@ -0,0 +1,112 @@ +// Copyright (c) 2017, 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:test/test.dart'; + +import 'package:http/http.dart'; + +void main() { + test('compose middleware with Pipeline', () async { + var accessLocation = 0; + + var middlewareA = createMiddleware(requestHandler: (request) async { + expect(accessLocation, 0); + accessLocation = 1; + return request; + }, responseHandler: (response) async { + expect(accessLocation, 4); + accessLocation = 5; + return response; + }); + + var middlewareB = createMiddleware(requestHandler: (request) async { + expect(accessLocation, 1); + accessLocation = 2; + return request; + }, responseHandler: (response) async { + expect(accessLocation, 3); + accessLocation = 4; + return response; + }); + + var client = const Pipeline() + .addMiddleware(middlewareA) + .addMiddleware(middlewareB) + .addClient(new Client.handler((request) async { + expect(accessLocation, 2); + accessLocation = 3; + return new Response(Uri.parse('dart:http'), 200); + })); + + var response = await client.get(Uri.parse('dart:http')); + + expect(response, isNotNull); + expect(accessLocation, 5); + }); + + test('Pipeline can be used as middleware', () async { + int accessLocation = 0; + + var middlewareA = createMiddleware(requestHandler: (request) async { + expect(accessLocation, 0); + accessLocation = 1; + return request; + }, responseHandler: (response) async { + expect(accessLocation, 4); + accessLocation = 5; + return response; + }); + + var middlewareB = createMiddleware(requestHandler: (request) async { + expect(accessLocation, 1); + accessLocation = 2; + return request; + }, responseHandler: (response) async { + expect(accessLocation, 3); + accessLocation = 4; + return response; + }); + + var innerPipeline = + const Pipeline().addMiddleware(middlewareA).addMiddleware(middlewareB); + + var client = const Pipeline() + .addMiddleware(innerPipeline.middleware) + .addClient(new Client.handler((request) async { + expect(accessLocation, 2); + accessLocation = 3; + return new Response(Uri.parse('dart:http'), 200); + })); + + var response = await client.get(Uri.parse('dart:http')); + + expect(response, isNotNull); + expect(accessLocation, 5); + }); + + test('Pipeline calls close on all middleware', () { + int accessLocation = 0; + + var middlewareA = createMiddleware(onClose: () { + expect(accessLocation, 0); + accessLocation = 1; + }); + + var middlewareB = createMiddleware(onClose: () { + expect(accessLocation, 1); + accessLocation = 2; + }); + + var client = const Pipeline() + .addMiddleware(middlewareA) + .addMiddleware(middlewareB) + .addClient(new Client.handler((request) async => null, onClose: () { + expect(accessLocation, 2); + accessLocation = 3; + })); + + client.close(); + expect(accessLocation, 3); + }); +} diff --git a/test/request_test.dart b/test/request_test.dart index f2f4c133d9..2becbcc4c3 100644 --- a/test/request_test.dart +++ b/test/request_test.dart @@ -1,36 +1,32 @@ -// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file +// Copyright (c) 2017, 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:test/test.dart'; + import 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; import 'utils.dart'; void main() { group('#contentLength', () { test('is computed from bodyBytes', () { - var request = new http.Request('POST', dummyUrl); - request.bodyBytes = [1, 2, 3, 4, 5]; + var request = new http.Request('POST', dummyUrl, body: [1, 2, 3, 4, 5]); expect(request.contentLength, equals(5)); - request.bodyBytes = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + request = new http.Request('POST', dummyUrl, + body: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); expect(request.contentLength, equals(10)); }); test('is computed from body', () { - var request = new http.Request('POST', dummyUrl); - request.body = "hello"; + var request = new http.Request('POST', dummyUrl, body: 'hello'); expect(request.contentLength, equals(5)); - request.body = "hello, world"; + request = new http.Request('POST', dummyUrl, body: 'hello, world'); expect(request.contentLength, equals(12)); }); - - test('is not directly mutable', () { - var request = new http.Request('POST', dummyUrl); - expect(() => request.contentLength = 50, throwsUnsupportedError); - }); }); group('#encoding', () { @@ -40,32 +36,19 @@ void main() { }); test('can be set', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; + var request = new http.Request('POST', dummyUrl, encoding: LATIN1); expect(request.encoding.name, equals(LATIN1.name)); }); test('is based on the content-type charset if it exists', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = 'text/plain; charset=iso-8859-1'; - expect(request.encoding.name, equals(LATIN1.name)); - }); - - test('remains the default if the content-type charset is set and unset', - () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; - request.headers['Content-Type'] = 'text/plain; charset=utf-8'; - expect(request.encoding.name, equals(UTF8.name)); - - request.headers.remove('Content-Type'); + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'text/plain; charset=iso-8859-1'}); expect(request.encoding.name, equals(LATIN1.name)); }); test('throws an error if the content-type charset is unknown', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'text/plain; charset=not-a-real-charset'; + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'text/plain; charset=not-a-real-charset'}); expect(() => request.encoding, throwsFormatException); }); }); @@ -73,111 +56,37 @@ void main() { group('#bodyBytes', () { test('defaults to empty', () { var request = new http.Request('POST', dummyUrl); - expect(request.bodyBytes, isEmpty); - }); - - test('can be set', () { - var request = new http.Request('POST', dummyUrl); - request.bodyBytes = [104, 101, 108, 108, 111]; - expect(request.bodyBytes, equals([104, 101, 108, 108, 111])); - }); - - test('changes when body changes', () { - var request = new http.Request('POST', dummyUrl); - request.body = "hello"; - expect(request.bodyBytes, equals([104, 101, 108, 108, 111])); + expect(collectBytes(request.read()), completion(isEmpty)); }); }); group('#body', () { test('defaults to empty', () { var request = new http.Request('POST', dummyUrl); - expect(request.body, isEmpty); - }); - - test('can be set', () { - var request = new http.Request('POST', dummyUrl); - request.body = "hello"; - expect(request.body, equals("hello")); - }); - - test('changes when bodyBytes changes', () { - var request = new http.Request('POST', dummyUrl); - request.bodyBytes = [104, 101, 108, 108, 111]; - expect(request.body, equals("hello")); + expect(request.readAsString(), completion(isEmpty)); }); test('is encoded according to the given encoding', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; - request.body = "föøbãr"; - expect(request.bodyBytes, equals([102, 246, 248, 98, 227, 114])); + var request = + new http.Request('POST', dummyUrl, encoding: LATIN1, body: "föøbãr"); + expect(collectBytes(request.read()), + completion(equals([102, 246, 248, 98, 227, 114]))); }); test('is decoded according to the given encoding', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; - request.bodyBytes = [102, 246, 248, 98, 227, 114]; - expect(request.body, equals("föøbãr")); + var request = new http.Request('POST', dummyUrl, + encoding: LATIN1, body: [102, 246, 248, 98, 227, 114]); + expect(request.readAsString(), completion(equals("föøbãr"))); }); }); group('#bodyFields', () { - test("can't be read without setting the content-type", () { - var request = new http.Request('POST', dummyUrl); - expect(() => request.bodyFields, throwsStateError); - }); - - test("can't be read with the wrong content-type", () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = 'text/plain'; - expect(() => request.bodyFields, throwsStateError); - }); - - test("can't be set with the wrong content-type", () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = 'text/plain'; - expect(() => request.bodyFields = {}, throwsStateError); - }); - - test('defaults to empty', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/x-www-form-urlencoded'; - expect(request.bodyFields, isEmpty); - }); - - test('can be set with no content-type', () { - var request = new http.Request('POST', dummyUrl); - request.bodyFields = {'hello': 'world'}; - expect(request.bodyFields, equals({'hello': 'world'})); - }); - - test('changes when body changes', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/x-www-form-urlencoded'; - request.body = 'key%201=value&key+2=other%2bvalue'; - expect(request.bodyFields, - equals({'key 1': 'value', 'key 2': 'other+value'})); - }); - test('is encoded according to the given encoding', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/x-www-form-urlencoded'; - request.encoding = LATIN1; - request.bodyFields = {"föø": "bãr"}; - expect(request.body, equals('f%F6%F8=b%E3r')); - }); - - test('is decoded according to the given encoding', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/x-www-form-urlencoded'; - request.encoding = LATIN1; - request.body = 'f%F6%F8=b%E3r'; - expect(request.bodyFields, equals({"föø": "bãr"})); + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + encoding: LATIN1, + body: {"föø": "bãr"}); + expect(request.readAsString(), completion(equals('f%F6%F8=b%E3r'))); }); }); @@ -188,167 +97,125 @@ void main() { }); test('defaults to empty if only encoding is set', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; + var request = new http.Request('POST', dummyUrl, encoding: LATIN1); expect(request.headers['Content-Type'], isNull); }); test('name is case insensitive', () { - var request = new http.Request('POST', dummyUrl); - request.headers['CoNtEnT-tYpE'] = 'application/json'; - expect(request.headers, - containsPair('content-type', 'application/json')); + var request = new http.Request('POST', dummyUrl, + headers: {'CoNtEnT-tYpE': 'application/json'}); + expect(request.headers, containsPair('content-type', 'application/json')); }); - test('is set to application/x-www-form-urlencoded with charset utf-8 if ' + test( + 'is set to application/x-www-form-urlencoded with charset utf-8 if ' 'bodyFields is set', () { - var request = new http.Request('POST', dummyUrl); - request.bodyFields = {'hello': 'world'}; + var request = + new http.Request('POST', dummyUrl, body: {'hello': 'world'}); expect(request.headers['Content-Type'], equals('application/x-www-form-urlencoded; charset=utf-8')); }); - test('is set to application/x-www-form-urlencoded with the given charset ' + test( + 'is set to application/x-www-form-urlencoded with the given charset ' 'if bodyFields and encoding are set', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; - request.bodyFields = {'hello': 'world'}; + var request = new http.Request('POST', dummyUrl, + encoding: LATIN1, body: {'hello': 'world'}); expect(request.headers['Content-Type'], equals('application/x-www-form-urlencoded; charset=iso-8859-1')); }); - test('is set to text/plain and the given encoding if body and encoding are ' + test( + 'is set to text/plain and the given encoding if body and encoding are ' 'both set', () { - var request = new http.Request('POST', dummyUrl); - request.encoding = LATIN1; - request.body = 'hello, world'; + var request = new http.Request('POST', dummyUrl, + encoding: LATIN1, body: 'hello, world'); expect(request.headers['Content-Type'], equals('text/plain; charset=iso-8859-1')); }); test('is modified to include utf-8 if body is set', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = 'application/json'; - request.body = '{"hello": "world"}'; + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'application/json'}, + body: '{"hello": "world"}'); expect(request.headers['Content-Type'], equals('application/json; charset=utf-8')); }); test('is modified to include the given encoding if encoding is set', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = 'application/json'; - request.encoding = LATIN1; + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'application/json'}, encoding: LATIN1); expect(request.headers['Content-Type'], equals('application/json; charset=iso-8859-1')); }); test('has its charset overridden by an explicit encoding', () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/json; charset=utf-8'; - request.encoding = LATIN1; + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'application/json; charset=utf-8'}, + encoding: LATIN1); expect(request.headers['Content-Type'], equals('application/json; charset=iso-8859-1')); }); test("doen't have its charset overridden by setting bodyFields", () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/x-www-form-urlencoded; charset=iso-8859-1'; - request.bodyFields = {'hello': 'world'}; + var request = new http.Request('POST', dummyUrl, headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=iso-8859-1' + }, body: { + 'hello': 'world' + }); expect(request.headers['Content-Type'], equals('application/x-www-form-urlencoded; charset=iso-8859-1')); }); test("doen't have its charset overridden by setting body", () { - var request = new http.Request('POST', dummyUrl); - request.headers['Content-Type'] = - 'application/json; charset=iso-8859-1'; - request.body = '{"hello": "world"}'; + var request = new http.Request('POST', dummyUrl, + headers: {'Content-Type': 'application/json; charset=iso-8859-1'}, + body: '{"hello": "world"}'); expect(request.headers['Content-Type'], equals('application/json; charset=iso-8859-1')); }); }); - group('#finalize', () { - test('returns a stream that emits the request body', () { - var request = new http.Request('POST', dummyUrl); - request.body = "Hello, world!"; - expect(request.finalize().bytesToString(), - completion(equals("Hello, world!"))); - }); - - test('freezes #persistentConnection', () { - var request = new http.Request('POST', dummyUrl); - request.finalize(); - - expect(request.persistentConnection, isTrue); - expect(() => request.persistentConnection = false, throwsStateError); - }); - - test('freezes #followRedirects', () { - var request = new http.Request('POST', dummyUrl); - request.finalize(); - - expect(request.followRedirects, isTrue); - expect(() => request.followRedirects = false, throwsStateError); - }); + group('change', () { + test('with no arguments returns instance with equal values', () { + var request = new http.Request('GET', dummyUrl, + headers: {'header1': 'header value 1'}, + body: 'hello, world', + context: {'context1': 'context value 1'}); - test('freezes #maxRedirects', () { - var request = new http.Request('POST', dummyUrl); - request.finalize(); + var copy = request.change(); - expect(request.maxRedirects, equals(5)); - expect(() => request.maxRedirects = 10, throwsStateError); + expect(copy.method, request.method); + expect(copy.headers, same(request.headers)); + expect(copy.url, request.url); + expect(copy.context, same(request.context)); + expect(copy.readAsString(), completion('hello, world')); }); - test('freezes #encoding', () { - var request = new http.Request('POST', dummyUrl); - request.finalize(); + test("allows the original request to be read", () { + var request = new http.Request('GET', dummyUrl); + var changed = request.change(); - expect(request.encoding.name, equals(UTF8.name)); - expect(() => request.encoding = ASCII, throwsStateError); + expect(request.read().toList(), completion(isEmpty)); + expect(changed.read, throwsStateError); }); - test('freezes #bodyBytes', () { - var request = new http.Request('POST', dummyUrl); - request.bodyBytes = [1, 2, 3]; - request.finalize(); + test("allows the changed request to be read", () { + var request = new http.Request('GET', dummyUrl); + var changed = request.change(); - expect(request.bodyBytes, equals([1, 2, 3])); - expect(() => request.bodyBytes = [4, 5, 6], throwsStateError); + expect(changed.read().toList(), completion(isEmpty)); + expect(request.read, throwsStateError); }); - test('freezes #body', () { - var request = new http.Request('POST', dummyUrl); - request.body = "hello"; - request.finalize(); + test("allows another changed request to be read", () { + var request = new http.Request('GET', dummyUrl); + var changed1 = request.change(); + var changed2 = request.change(); - expect(request.body, equals("hello")); - expect(() => request.body = "goodbye", throwsStateError); - }); - - test('freezes #bodyFields', () { - var request = new http.Request('POST', dummyUrl); - request.bodyFields = {"hello": "world"}; - request.finalize(); - - expect(request.bodyFields, equals({"hello": "world"})); - expect(() => request.bodyFields = {}, throwsStateError); - }); - - test("can't be called twice", () { - var request = new http.Request('POST', dummyUrl); - request.finalize(); - expect(request.finalize, throwsStateError); - }); - }); - - group('#toString()', () { - test('includes the method and URL', () { - var request = new http.Request('POST', dummyUrl); - expect(request.toString(), 'POST $dummyUrl'); + expect(changed2.read().toList(), completion(isEmpty)); + expect(changed1.read, throwsStateError); + expect(request.read, throwsStateError); }); }); } - diff --git a/test/response_test.dart b/test/response_test.dart deleted file mode 100644 index 08c4708c38..0000000000 --- a/test/response_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2012, 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 'package:http/http.dart' as http; -import 'package:unittest/unittest.dart'; - -void main() { - group('()', () { - test('sets body', () { - var response = new http.Response("Hello, world!", 200); - expect(response.body, equals("Hello, world!")); - }); - - test('sets bodyBytes', () { - var response = new http.Response("Hello, world!", 200); - expect(response.bodyBytes, equals( - [72, 101, 108, 108, 111, 44, 32, 119, 111, 114, 108, 100, 33])); - }); - - test('respects the inferred encoding', () { - var response = new http.Response("föøbãr", 200, - headers: {'content-type': 'text/plain; charset=iso-8859-1'}); - expect(response.bodyBytes, equals( - [102, 246, 248, 98, 227, 114])); - }); - }); - - group('.bytes()', () { - test('sets body', () { - var response = new http.Response.bytes([104, 101, 108, 108, 111], 200); - expect(response.body, equals("hello")); - }); - - test('sets bodyBytes', () { - var response = new http.Response.bytes([104, 101, 108, 108, 111], 200); - expect(response.bodyBytes, equals([104, 101, 108, 108, 111])); - }); - - test('respects the inferred encoding', () { - var response = new http.Response.bytes([102, 246, 248, 98, 227, 114], 200, - headers: {'content-type': 'text/plain; charset=iso-8859-1'}); - expect(response.body, equals("föøbãr")); - }); - }); - - group('.fromStream()', () { - test('sets body', () { - var controller = new StreamController(sync: true); - var streamResponse = new http.StreamedResponse( - controller.stream, 200, contentLength: 13); - var future = http.Response.fromStream(streamResponse) - .then((response) => response.body); - expect(future, completion(equals("Hello, world!"))); - - controller.add([72, 101, 108, 108, 111, 44, 32]); - controller.add([119, 111, 114, 108, 100, 33]); - controller.close(); - }); - - test('sets bodyBytes', () { - var controller = new StreamController(sync: true); - var streamResponse = new http.StreamedResponse( - controller.stream, 200, contentLength: 5); - var future = http.Response.fromStream(streamResponse) - .then((response) => response.bodyBytes); - expect(future, completion(equals([104, 101, 108, 108, 111]))); - - controller.add([104, 101, 108, 108, 111]); - controller.close(); - }); - }); -} diff --git a/test/streamed_request_test.dart b/test/streamed_request_test.dart deleted file mode 100644 index c7e56e2c64..0000000000 --- a/test/streamed_request_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) 2012, 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' as http; -import 'package:unittest/unittest.dart'; - -import 'utils.dart'; - -void main() { - group('contentLength', () { - test('defaults to null', () { - var request = new http.StreamedRequest('POST', dummyUrl); - expect(request.contentLength, isNull); - }); - - test('disallows negative values', () { - var request = new http.StreamedRequest('POST', dummyUrl); - expect(() => request.contentLength = -1, throwsArgumentError); - }); - - test('is frozen by finalize()', () { - var request = new http.StreamedRequest('POST', dummyUrl); - request.finalize(); - expect(() => request.contentLength = 10, throwsStateError); - }); - }); -} diff --git a/test/utils.dart b/test/utils.dart index 1bc78f7405..bf8da3a2d4 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -4,9 +4,10 @@ import 'dart:convert'; +import 'package:async/async.dart'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; -import 'package:unittest/unittest.dart'; +import 'package:test/test.dart'; /// A dummy URL for constructing requests that won't be sent. Uri get dummyUrl => Uri.parse('http://dartlang.org/'); @@ -61,32 +62,35 @@ class _Parse extends Matcher { } Description describe(Description description) { - return description.add('parses to a value that ') - .addDescriptionOf(_matcher); + return description + .add('parses to a value that ') + .addDescriptionOf(_matcher); } } /// A matcher that validates the body of a multipart request after finalization. +/// /// The string "{{boundary}}" in [pattern] will be replaced by the boundary /// string for the request, and LF newlines will be replaced with CRLF. /// Indentation will be normalized. -Matcher bodyMatches(String pattern) => new _BodyMatches(pattern); +Matcher multipartBodyMatches(String pattern) => + new _MultipartBodyMatches(pattern); -class _BodyMatches extends Matcher { +class _MultipartBodyMatches extends Matcher { final String _pattern; - _BodyMatches(this._pattern); + _MultipartBodyMatches(this._pattern); bool matches(item, Map matchState) { - if (item is! http.MultipartRequest) return false; + if (item is! http.Request) return false; - var future = item.finalize().toBytes().then((bodyBytes) { + var future = item.readAsBytes().then((bodyBytes) { var body = UTF8.decode(bodyBytes); var contentType = new MediaType.parse(item.headers['content-type']); var boundary = contentType.parameters['boundary']; var expected = cleanUpLiteral(_pattern) - .replaceAll("\n", "\r\n") - .replaceAll("{{boundary}}", boundary); + .replaceAll('\n', '\r\n') + .replaceAll('{{boundary}}', boundary); expect(body, equals(expected)); expect(item.contentLength, equals(bodyBytes.length)); @@ -95,22 +99,23 @@ class _BodyMatches extends Matcher { return completes.matches(future, matchState); } - Description describe(Description description) { - return description.add('has a body that matches "$_pattern"'); - } + Description describe(Description description) => + description.add('has a body that matches "$_pattern"'); } /// A matcher that matches a [http.ClientException] with the given [message]. /// /// [message] can be a String or a [Matcher]. -Matcher isClientException(message) => predicate((error) { - expect(error, new isInstanceOf()); - expect(error.message, message); - return true; -}); +Matcher isClientException([message]) => predicate((error) { + expect(error, new isInstanceOf()); + if (message != null) { + expect(error.message, message); + } + return true; + }); /// A matcher that matches function or future that throws a /// [http.ClientException] with the given [message]. /// /// [message] can be a String or a [Matcher]. -Matcher throwsClientException(message) => throwsA(isClientException(message)); +Matcher throwsClientException([message]) => throwsA(isClientException(message));