diff --git a/lib/src/body.dart b/lib/src/body.dart index e3589d0326..89f968579c 100644 --- a/lib/src/body.dart +++ b/lib/src/body.dart @@ -8,6 +8,9 @@ import 'dart:convert'; import 'package:async/async.dart'; import 'package:collection/collection.dart'; +import 'empty_body.dart'; +import 'utils.dart'; + /// The body of a request or response. /// /// This tracks whether the body has been read. It's separate from [Message] @@ -27,6 +30,9 @@ class Body { /// determined efficiently. final int contentLength; + /// An empty stream for use with empty bodies. + static const _emptyStream = const Stream.empty(); + Body._(this._stream, this.encoding, this.contentLength); /// Converts [body] to a byte stream and wraps it in a [Body]. @@ -36,25 +42,23 @@ class Body { /// used to convert it to a [Stream>]. factory Body(body, [Encoding encoding]) { if (body is Body) return body; + if (body == null) { + return const EmptyBody(); + } 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]); - } + + encoding ??= UTF8; + + if (body is Map) { + body = mapToQuery(body, encoding: encoding); + } + + if (body is String) { + 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)]); @@ -68,19 +72,6 @@ class Body { 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. @@ -94,3 +85,12 @@ class Body { return stream; } } + +class _EmptyBody implements Body { + const _EmptyBody(); + + Encoding get encoding => UTF8; + int get contentLength => 0; + + Stream> read() => const Stream>.empty(); +} diff --git a/lib/src/empty_body.dart b/lib/src/empty_body.dart new file mode 100644 index 0000000000..d811b78276 --- /dev/null +++ b/lib/src/empty_body.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:async'; +import 'dart:convert'; + +import 'body.dart'; + +/// An empty request body. +/// +/// Used as an optimization when a `null` body is used. +class EmptyBody implements Body { + /// An empty body is not encoded. + Encoding get encoding => null; + + /// An empty body has no length. + int get contentLength => 0; + + /// Creates an instance of [EmptyBody]. + const EmptyBody(); + + /// Returns an empty [Stream] representing the body. + Stream> read() => const Stream>.empty(); +} diff --git a/lib/src/message.dart b/lib/src/message.dart index 96608dda66..d78d22eeb1 100644 --- a/lib/src/message.dart +++ b/lib/src/message.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:collection/collection.dart'; import 'package:http_parser/http_parser.dart'; import 'body.dart'; @@ -61,12 +60,27 @@ abstract class Message { {Encoding encoding, Map headers, Map context}) - : this._(new Body(body, encoding), headers, context); + : this._forwardToContentType(body, encoding, + _determineMediaType(body, encoding, headers), headers, context); - Message._(Body body, Map headers, Map context) + Message.withContentType(Body body, MediaType contentType, + Map headers, Map context) + : this._fromValues( + body, _adjustHeaders(headers, body, contentType), context); + + Message._forwardToContentType(body, Encoding encoding, MediaType contentType, + Map headers, Map context) + : this.withContentType( + new Body(body, encoding), contentType, headers, context); + + /// Creates a new [Message]. + /// + /// This constructor should be used when no computation is required for the + /// [body], [headers] or [context]. + Message._fromValues( + Body body, Map headers, Map context) : _body = body, - headers = new HttpUnmodifiableMap(_adjustHeaders(headers, body), - ignoreKeyCase: true), + headers = new HttpUnmodifiableMap(headers, ignoreKeyCase: true), context = new HttpUnmodifiableMap(context, ignoreKeyCase: false); @@ -144,55 +158,119 @@ abstract class Message { /// 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(); + /// Determines the media type based on the [headers], [encoding] and [body]. + static MediaType _determineMediaType( + body, Encoding encoding, Map headers) => + _headerMediaType(headers, encoding) ?? _defaultMediaType(body, encoding); + + static MediaType _defaultMediaType(body, Encoding encoding) { + //if (body == null) return null; + + var parameters = {'charset': encoding?.name ?? UTF8.name}; + + if (body is String) { + return new MediaType('text', 'plain', parameters); + } else if (body is Map) { + return new MediaType('application', 'x-www-form-urlencoded', parameters); + } else if (encoding != null) { + return new MediaType('application', 'octet-stream', parameters); } + + return null; } - 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(); - } + static MediaType _headerMediaType( + Map headers, Encoding encoding) { + var contentTypeHeader = getHeader(headers, 'content-type'); + if (contentTypeHeader == null) return null; + + var contentType = new MediaType.parse(contentTypeHeader); + var parameters = { + 'charset': + encoding?.name ?? contentType.parameters['charset'] ?? UTF8.name + }; + + return contentType.change(parameters: parameters); } - if (body.contentLength != null) { - var coding = newHeaders['transfer-encoding']; - if (coding == null || equalsIgnoreAsciiCase(coding, 'identity')) { - newHeaders['content-length'] = body.contentLength.toString(); + /// Adjusts the [headers] to include information from the [body]. + /// + /// Returns a new map without modifying [headers]. + /// + /// The following headers could be added or modified. + /// * content-length + /// * content-type + static Map _adjustHeaders( + Map headers, Body body, MediaType contentType) { + var modified = {}; + + var contentLengthHeader = _adjustContentLengthHeader(headers, body); + if (contentLengthHeader.isNotEmpty) { + modified['content-length'] = contentLengthHeader; + } + + var contentTypeHeader = _adjustContentTypeHeader(headers, contentType); + if (contentTypeHeader.isNotEmpty) { + modified['content-type'] = contentTypeHeader; } + + if (modified.isEmpty) { + return headers ?? const HttpUnmodifiableMap.empty(); + } + + var newHeaders = headers == null + ? new CaseInsensitiveMap() + : new CaseInsensitiveMap.from(headers); + + newHeaders.addAll(modified); + + return newHeaders; } - return newHeaders; -} + /// Checks the `content-length` header to see if it requires modification. + /// + /// Returns an empty string when no modification is required, otherwise it + /// returns the value to set. + /// + /// If there is a contentLength specified within the [body] and it does not + /// match what is specified in the [headers] it will be modified to the body's + /// value. + static String _adjustContentLengthHeader( + Map headers, Body body) { + var bodyContentLength = body.contentLength ?? -1; -/// Returns whether [headers] declares the same encoding as [body]. -bool _sameEncoding(Map headers, Body body) { - if (body.encoding == null) return true; + if (bodyContentLength >= 0) { + var bodyContentHeader = bodyContentLength.toString(); - var contentType = getHeader(headers, 'content-type'); - if (contentType == null) return false; + if (getHeader(headers, 'content-length') != bodyContentHeader) { + return bodyContentHeader; + } + } + + return ''; + } - var charset = new MediaType.parse(contentType).parameters['charset']; - return Encoding.getByName(charset) == body.encoding; + /// Checks the `content-type` header to see if it requires modification. + /// + /// Returns an empty string when no modification is required, otherwise it + /// returns the value to set. + /// + /// If the contentType within [body] is different than the one specified in the + /// [headers] then body's value will be used. The [headers] were already used + /// when creating the body's contentType so this will only actually change + /// things when headers did not contain a `content-type`. + static String _adjustContentTypeHeader( + Map headers, MediaType contentType) { + var headerContentType = getHeader(headers, 'content-type'); + var bodyContentType = contentType?.toString(); + + // Neither are set so don't modify it + if ((headerContentType == null) && (bodyContentType == null)) { + return ''; + } + + // The value of bodyContentType will have the overridden values so use that + return headerContentType != bodyContentType ? bodyContentType : ''; + } }