diff --git a/lib/src/message.dart b/lib/src/message.dart new file mode 100644 index 0000000000..96608dda66 --- /dev/null +++ b/lib/src/message.dart @@ -0,0 +1,198 @@ +// 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:collection/collection.dart'; +import 'package:http_parser/http_parser.dart'; + +import 'body.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; + if (!headers.containsKey('content-length')) return null; + _contentLengthCache = int.parse(headers['content-length']); + 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 { + var contentType = _contentType; + if (contentType == null) return null; + return 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 { + var contentType = _contentType; + if (contentType == null) return null; + if (!contentType.parameters.containsKey('charset')) return null; + return Encoding.getByName(contentType.parameters['charset']); + } + + /// The parsed version of the Content-Type header in [headers]. + /// + /// This is cached for efficient access. + MediaType get _contentType { + if (_contentTypeCache != null) return _contentTypeCache; + if (!headers.containsKey('content-type')) return null; + _contentTypeCache = new MediaType.parse(headers['content-type']); + return _contentTypeCache; + } + MediaType _contentTypeCache; + + /// Returns the message body as byte chunks. + /// + /// Throws a [StateError] if [read] or [readAsString] has already been called. + Stream> read() => _body.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 [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/request.dart b/lib/src/request.dart index 67b664c08e..1f2be464df 100644 --- a/lib/src/request.dart +++ b/lib/src/request.dart @@ -3,159 +3,154 @@ // 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 'message.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]. - /// - /// 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']); - } - - set encoding(Encoding value) { - _checkFinalized(); - _defaultEncoding = value; - var contentType = _contentType; - if (contentType == null) return; - _contentType = contentType.change(parameters: {'charset': value.name}); - } - - // 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]. +/// Represents an HTTP request to be sent to a server. +class Request extends Message { + /// The HTTP method of the request. /// - /// 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); - } - - /// 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); - } + /// Most commonly "GET" or "POST", less commonly "HEAD", "PUT", or "DELETE". + /// Non-standard method names are also supported. + final String method; - /// 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); - } + /// The URL to which the request will be sent. + final Uri url; - set _contentType(MediaType value) { - headers['content-type'] = value.toString(); - } + /// Creates a new [Request] for [url] using [method]. + /// + /// [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(this.method, this.url, + {body, + Encoding encoding, + Map headers, + Map context}) + : super(body, encoding: encoding, headers: headers, context: context); + + /// Creates a new HEAD [Request] to [url]. + /// + /// [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(Uri url, + {Map headers, Map context}) + : this('HEAD', url, 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 GET [Request] to [url]. + /// + /// [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(Uri url, + {Map headers, Map context}) + : this('GET', url, headers: headers, context: context); + + /// Creates a new POST [Request] to [url]. + /// [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(Uri 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]. + /// + /// [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(Uri 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]. + /// + /// [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(Uri 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]. + /// + /// [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(Uri url, + {Map headers, Map context}) + : this('DELETE', url, headers: headers, context: context); + + /// 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: body ?? getBody(this), + encoding: this.encoding, + headers: updatedHeaders, + context: updatedContext); } } diff --git a/lib/src/response.dart b/lib/src/response.dart index 9fa06ee782..c94cb0b46c 100644 --- a/lib/src/response.dart +++ b/lib/src/response.dart @@ -2,94 +2,87 @@ // 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 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][]. + /// Creates a new HTTP response with the given [statusCode]. /// - /// [RFC 2616]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html - String get body => _encodingForHeaders(headers).decode(bodyBytes); - - /// 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); + /// [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 outer middleware + /// and handlers. + Response(this.statusCode, + {body, + Encoding encoding, + Map headers, + Map context}) + : super(body, encoding: encoding, headers: headers, 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); + /// 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); - /// 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); - }); + return new Response(this.statusCode, + body: body ?? getBody(this), + headers: updatedHeaders, + context: updatedContext); } -} -/// 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']); + /// 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; + } + DateTime _expiresCache; -/// 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"); + /// 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; + } + DateTime _lastModifiedCache; } diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 86a6690fb5..39f1aefac4 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -6,7 +6,24 @@ import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; +import 'package:collection/collection.dart'; + import 'byte_stream.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. /// @@ -139,3 +156,17 @@ class Pair { void chainToCompleter(Future future, Completer completer) { future.then(completer.complete, onError: completer.completeError); } + +/// 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]; + } + return null; +}