Accept List<int> message bodies. (#61)
This also automatically sets the content-length header when possible,
and works around dart-lang/sdk#27660.
Closes #60
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0be6ff2..3964e15 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,15 @@
## 0.6.6
-* Bump minimum supported Dart SDK to `>=1.13.2`.
+* Allow `List<int>`s to be passed as request or response bodies.
+
+* Requests and responses now automatically set `Content-Length` headers when the
+ body length is known ahead of time.
+
+* Work around [sdk#27660][] by manually setting
+ `HttpResponse.chunkedTransferEncoding` to `false` for requests known to have
+ no content.
+
+[sdk#27660]: https://github.com/dart-lang/sdk/issues/27660
## 0.6.5+3
diff --git a/lib/shelf_io.dart b/lib/shelf_io.dart
index 58fbd74..fa120b3 100644
--- a/lib/shelf_io.dart
+++ b/lib/shelf_io.dart
@@ -149,6 +149,11 @@
httpResponse.headers.date = new DateTime.now().toUtc();
}
+ // Work around sdk#27660.
+ if (response.contentLength == 0) {
+ httpResponse.headers.chunkedTransferEncoding = false;
+ }
+
return httpResponse
.addStream(response.read())
.then((_) => httpResponse.close());
diff --git a/lib/src/body.dart b/lib/src/body.dart
index 6fd0df2..77c3d1b 100644
--- a/lib/src/body.dart
+++ b/lib/src/body.dart
@@ -6,6 +6,7 @@
import 'dart:convert';
import 'package:async/async.dart';
+import 'package:collection/collection.dart';
/// The body of a request or response.
///
@@ -18,23 +19,45 @@
/// This will be `null` after [read] is called.
Stream<List<int>> _stream;
- Body._(this._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 [Stream<List<int>>], or
- /// `null`. If it's a [String], [encoding] will be used to convert it to a
- /// [Stream<List<int>>].
+ /// [body] may be either a [Body], a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] will be
+ /// used to convert it to a [Stream<List<int>>].
factory Body(body, [Encoding encoding]) {
- if (encoding == null) encoding = UTF8;
-
if (body is Body) return body;
Stream<List<int>> stream;
+ int contentLength;
if (body == null) {
+ contentLength = 0;
stream = new Stream.fromIterable([]);
} else if (body is String) {
- stream = new Stream.fromIterable([encoding.encode(body)]);
+ 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 {
@@ -42,7 +65,20 @@
'Stream.');
}
- return new Body._(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<int> 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.
diff --git a/lib/src/message.dart b/lib/src/message.dart
index 16965e1..2059b9a 100644
--- a/lib/src/message.dart
+++ b/lib/src/message.dart
@@ -13,6 +13,11 @@
Body getBody(Message message) => message._body;
+/// The default set of headers for a message created with no body and no
+/// explicit headers.
+final _defaultHeaders = new ShelfUnmodifiableMap<String>(
+ {"content-length": "0"}, ignoreKeyCase: true);
+
/// Represents logic shared between [Request] and [Response].
abstract class Message {
/// The HTTP headers.
@@ -40,7 +45,7 @@
/// Creates a new [Message].
///
- /// [body] is the response body. It may be either a [String], a
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
/// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
/// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
/// UTF-8.
@@ -52,10 +57,13 @@
/// Content-Type header, it will be set to "application/octet-stream".
Message(body, {Encoding encoding, Map<String, String> headers,
Map<String, Object> context})
- : this._body = new Body(body, encoding),
- this.headers = new ShelfUnmodifiableMap<String>(
- _adjustHeaders(headers, encoding), ignoreKeyCase: true),
- this.context = new ShelfUnmodifiableMap<Object>(context,
+ : this._(new Body(body, encoding), headers, context);
+
+ Message._(Body body, Map<String, String> headers, Map<String, Object> context)
+ : _body = body,
+ headers = new ShelfUnmodifiableMap<String>(
+ _adjustHeaders(headers, body), ignoreKeyCase: true),
+ context = new ShelfUnmodifiableMap<Object>(context,
ignoreKeyCase: false);
/// The contents of the content-length field in [headers].
@@ -63,6 +71,7 @@
/// If not set, `null`.
int get contentLength {
if (_contentLengthCache != null) return _contentLengthCache;
+ if (_body.contentLength != null) return _body.contentLength;
if (!headers.containsKey('content-length')) return null;
_contentLengthCache = int.parse(headers['content-length']);
return _contentLengthCache;
@@ -134,17 +143,48 @@
///
/// Returns a new map without modifying [headers].
Map<String, String> _adjustHeaders(
- Map<String, String> headers, Encoding encoding) {
- if (headers == null) headers = const {};
- if (encoding == null) return headers;
-
- headers = new CaseInsensitiveMap.from(headers);
- if (headers['content-type'] == null) {
- return addHeader(headers, 'content-type',
- 'application/octet-stream; charset=${encoding.name}');
+ Map<String, String> headers, Body body) {
+ var sameEncoding = _sameEncoding(headers, body);
+ if (sameEncoding) {
+ if (body.contentLength == null ||
+ getHeader(headers, 'content-length') ==
+ body.contentLength.toString()) {
+ return headers ?? const ShelfUnmodifiableMap.empty();
+ } else if (body.contentLength == 0 &&
+ (headers == null || headers.isEmpty)) {
+ return _defaultHeaders;
+ }
}
- var contentType = new MediaType.parse(headers['content-type']).change(
- parameters: {'charset': encoding.name});
- return addHeader(headers, 'content-type', contentType.toString());
+ var newHeaders = headers == null
+ ? new CaseInsensitiveMap<String>()
+ : new CaseInsensitiveMap<String>.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) {
+ newHeaders['content-length'] = body.contentLength.toString();
+ }
+
+ return newHeaders;
+}
+
+/// Returns whether [headers] declares the same encoding as [body].
+bool _sameEncoding(Map<String, String> 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 1b18418..5d6530d 100644
--- a/lib/src/request.dart
+++ b/lib/src/request.dart
@@ -96,15 +96,18 @@
/// and [url] to `requestedUri.path` without the initial `/`. If only one is
/// passed, the other will be inferred.
///
- /// [body] is the request body. It may be either a [String], a
- /// [Stream<List<int>>], or `null` to indicate no body.
- /// If it's a [String], [encoding] is used to encode it to a
- /// [Stream<List<int>>]. The default encoding is UTF-8.
+ /// [body] is the request body. It may be either a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
+ /// [encoding] is used to encode it to a [Stream<List<int>>]. The default
+ /// encoding is UTF-8.
///
/// 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".
///
+ /// If a non-[Stream] object is passed for the [body], the Content-Length
+ /// header is automatically set to the length of that body.
+ ///
/// The default value for [protocolVersion] is '1.1'.
///
/// ## `onHijack`
@@ -192,8 +195,8 @@
/// [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] or a
- /// [Stream<List<int>>].
+ /// [body] is the request body. It may be either a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null` to indicate no body.
///
/// [path] is used to update both [handlerPath] and [url]. It's designed for
/// routing middleware, and represents the path from the current handler to
diff --git a/lib/src/response.dart b/lib/src/response.dart
index 7945526..b9ab197 100644
--- a/lib/src/response.dart
+++ b/lib/src/response.dart
@@ -43,7 +43,7 @@
///
/// This indicates that the request has succeeded.
///
- /// [body] is the response body. It may be either a [String], a
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
/// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
/// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
/// UTF-8.
@@ -62,7 +62,7 @@
/// URI. [location] is that URI; it can be either a [String] or a [Uri]. It's
/// automatically set as the Location header in [headers].
///
- /// [body] is the response body. It may be either a [String], a
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
/// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
/// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
/// UTF-8.
@@ -81,7 +81,7 @@
/// URI. [location] is that URI; it can be either a [String] or a [Uri]. It's
/// automatically set as the Location header in [headers].
///
- /// [body] is the response body. It may be either a [String], a
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
/// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
/// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
/// UTF-8.
@@ -101,7 +101,7 @@
/// [String] or a [Uri]. It's automatically set as the Location header in
/// [headers].
///
- /// [body] is the response body. It may be either a [String], a
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
/// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
/// [encoding] is used to encode it to a [Stream<List<int>>]. It defaults to
/// UTF-8.
@@ -141,10 +141,10 @@
///
/// This indicates that the server is refusing to fulfill the request.
///
- /// [body] is the response body. It may be a [String], a [Stream<List<int>>],
- /// or `null`. If it's a [String], [encoding] is used to encode it to a
- /// [Stream<List<int>>]. The default encoding is UTF-8. If it's `null` or not
- /// passed, a default error message is used.
+ /// [body] is the response body. It may be a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
+ /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
+ /// `null` or not passed, a default error message is used.
///
/// If [encoding] is passed, the "encoding" field of the Content-Type header
/// in [headers] will be set appropriately. If there is no existing
@@ -161,10 +161,10 @@
/// This indicates that the server didn't find any resource matching the
/// requested URI.
///
- /// [body] is the response body. It may be a [String], a [Stream<List<int>>],
- /// or `null`. If it's a [String], [encoding] is used to encode it to a
- /// [Stream<List<int>>]. The default encoding is UTF-8. If it's `null` or not
- /// passed, a default error message is used.
+ /// [body] is the response body. It may be a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
+ /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
+ /// `null` or not passed, a default error message is used.
///
/// If [encoding] is passed, the "encoding" field of the Content-Type header
/// in [headers] will be set appropriately. If there is no existing
@@ -181,10 +181,10 @@
/// This indicates that the server had an internal error that prevented it
/// from fulfilling the request.
///
- /// [body] is the response body. It may be a [String], a [Stream<List<int>>],
- /// or `null`. If it's a [String], [encoding] is used to encode it to a
- /// [Stream<List<int>>]. The default encoding is UTF-8. If it's `null` or not
- /// passed, a default error message is used.
+ /// [body] is the response body. It may be a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null`. If it's a [String], [encoding] is used to
+ /// encode it to a [Stream<List<int>>]. The default encoding is UTF-8. If it's
+ /// `null` or not passed, a default error message is used.
///
/// If [encoding] is passed, the "encoding" field of the Content-Type header
/// in [headers] will be set appropriately. If there is no existing
@@ -200,14 +200,17 @@
///
/// [statusCode] must be greater than or equal to 100.
///
- /// [body] is the response body. It may be either a [String], a
- /// [Stream<List<int>>], or `null` to indicate no body.
- /// If it's a [String], [encoding] is used to encode it to a
- /// [Stream<List<int>>]. The default encoding is UTF-8.
+ /// [body] is the response body. It may be either a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null` to indicate no body. If it's a [String],
+ /// [encoding] is used to encode it to a [Stream<List<int>>]. The default
+ /// encoding is UTF-8.
///
/// 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".
+ ///
+ /// If a non-[Stream] object is passed for the [body], the Content-Length
+ /// header is automatically set to the length of that body.
Response(this.statusCode, {body, Map<String, String> headers,
Encoding encoding, Map<String, Object> context})
: super(body, encoding: encoding, headers: headers, context: context) {
@@ -229,8 +232,8 @@
/// 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] or a
- /// [Stream<List<int>>].
+ /// [body] is the request body. It may be either a [String], a [List<int>], a
+ /// [Stream<List<int>>], or `null` to indicate no body.
Response change(
{Map<String, String> headers, Map<String, Object> context, body}) {
headers = updateMap(this.headers, headers);
diff --git a/lib/src/shelf_unmodifiable_map.dart b/lib/src/shelf_unmodifiable_map.dart
index 18884e6..20eb20a 100644
--- a/lib/src/shelf_unmodifiable_map.dart
+++ b/lib/src/shelf_unmodifiable_map.dart
@@ -4,6 +4,7 @@
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.
@@ -42,6 +43,9 @@
return new ShelfUnmodifiableMap<V>._(source, ignoreKeyCase);
}
+ /// Returns an empty [ShelfUnmodifiableMap].
+ const factory ShelfUnmodifiableMap.empty() = _EmptyShelfUnmodifiableMap<V>;
+
ShelfUnmodifiableMap._(Map<String, V> source, this._ignoreKeyCase)
: super(source);
}
diff --git a/lib/src/util.dart b/lib/src/util.dart
index 6fd62d6..a59e912 100644
--- a/lib/src/util.dart
+++ b/lib/src/util.dart
@@ -4,6 +4,10 @@
import 'dart:async';
+import 'package:collection/collection.dart';
+
+import 'shelf_unmodifiable_map.dart';
+
/// Like [new Future], but avoids around issue 11911 by using [new Future.value]
/// under the covers.
Future newFuture(callback()) => new Future.value().then((_) => callback());
@@ -44,3 +48,17 @@
headers[name] = value;
return headers;
}
+
+/// 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<String, String> headers, String name) {
+ if (headers == null) return null;
+ if (headers is ShelfUnmodifiableMap) return headers[name];
+
+ for (var key in headers.keys) {
+ if (equalsIgnoreAsciiCase(key, name)) return headers[key];
+ }
+ return null;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 8650a85..0f48cb5 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: shelf
-version: 0.6.6-dev
+version: 0.6.6
author: Dart Team <misc@dartlang.org>
description: Web Server Middleware for Dart
homepage: https://github.com/dart-lang/shelf
@@ -7,6 +7,7 @@
sdk: '>=1.13.2 <2.0.0'
dependencies:
async: '^1.10.0'
+ collection: '^1.5.0'
http_parser: '>=1.0.0 <4.0.0'
path: '^1.0.0'
stack_trace: '^1.0.0'
diff --git a/test/message_change_test.dart b/test/message_change_test.dart
index 20f7740..638f9dc 100644
--- a/test/message_change_test.dart
+++ b/test/message_change_test.dart
@@ -53,7 +53,7 @@
});
});
- test('with empty headers returns indentical instance', () {
+ test('with empty headers returns identical instance', () {
var request = factory(headers: {'header1': 'header value 1'});
var copy = request.change(headers: {});
@@ -71,14 +71,18 @@
var request = factory(headers: {'test': 'test value'});
var copy = request.change(headers: {'test2': 'test2 value'});
- expect(copy.headers, {'test': 'test value', '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'});
+ expect(copy.headers, {'test': 'new test value', 'content-length': '0'});
});
test('new context values are added', () {
diff --git a/test/message_test.dart b/test/message_test.dart
index 51596cb..adc7f63 100644
--- a/test/message_test.dart
+++ b/test/message_test.dart
@@ -36,9 +36,11 @@
expect(message.headers, containsPair('FOO', 'bar'));
});
- test('null header value becomes empty, immutable', () {
+ test('null header value becomes default', () {
var message = _createMessage();
- expect(message.headers, isEmpty);
+ expect(message.headers, equals({'content-length': '0'}));
+ expect(message.headers, containsPair('CoNtEnT-lEnGtH', '0'));
+ expect(message.headers, same(_createMessage().headers));
expect(() => message.headers['h1'] = 'value1', throwsUnsupportedError);
});
@@ -128,6 +130,13 @@
});
});
+ test("supports a List<int> body", () {
+ var controller = new StreamController();
+ var request = _createMessage(body: HELLO_BYTES);
+ expect(request.read().toList(),
+ completion(equals([HELLO_BYTES])));
+ });
+
test("throws when calling read()/readAsString() multiple times", () {
var request;
@@ -149,16 +158,46 @@
});
});
- group("contentLength", () {
- test("is null without a content-length header", () {
+ group("content-length", () {
+ test("is 0 with a default body and without a content-length header", () {
var request = _createMessage();
+ expect(request.contentLength, 0);
+ });
+
+ test("comes from a byte body", () {
+ var request = _createMessage(body: [1, 2, 3]);
+ expect(request.contentLength, 3);
+ });
+
+ test("comes from a string body", () {
+ var request = _createMessage(body: 'foobar');
+ expect(request.contentLength, 6);
+ });
+
+ test("is set based on byte length for a string body", () {
+ var request = _createMessage(body: 'fööbär');
+ expect(request.contentLength, 9);
+
+ request = _createMessage(body: 'fööbär', encoding: LATIN1);
+ expect(request.contentLength, 6);
+ });
+
+ test("is null for a stream body", () {
+ var request = _createMessage(body: new Stream.empty());
expect(request.contentLength, isNull);
});
- test("comes from the content-length header", () {
- var request = _createMessage(headers: {'content-length': '42'});
+ 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);
});
+
+ 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);
+ });
});
group("mimeType", () {