Add Message.isEmpty. (#62)

This also fixes some logic bugs with the previous release. In
particular, it doesn't send Content-Length headers along with chunked
transfer encodings, and it allows users to override the automatic
Content-Length.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3964e15..a980cf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,14 @@
+## 0.6.7
+
+* Add `Request.isEmpty` and `Response.isEmpty` getters which indicate whether a
+  message has an empty body.
+
+* Don't automatically generate `Content-Length` headers on messages where they
+  may not be allowed.
+
+* User-specified `Content-Length` headers now always take precedence over
+  automatically-generated headers.
+
 ## 0.6.6
 
 * Allow `List<int>`s to be passed as request or response bodies.
diff --git a/lib/shelf_io.dart b/lib/shelf_io.dart
index fa120b3..7019d2e 100644
--- a/lib/shelf_io.dart
+++ b/lib/shelf_io.dart
@@ -150,9 +150,7 @@
   }
 
   // Work around sdk#27660.
-  if (response.contentLength == 0) {
-    httpResponse.headers.chunkedTransferEncoding = false;
-  }
+  if (response.isEmpty) httpResponse.headers.chunkedTransferEncoding = false;
 
   return httpResponse
       .addStream(response.read())
diff --git a/lib/src/body.dart b/lib/src/body.dart
index 77c3d1b..448cab8 100644
--- a/lib/src/body.dart
+++ b/lib/src/body.dart
@@ -23,11 +23,12 @@
   /// 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;
+  /// If `true`, the stream returned by [read] will be empty.
+  ///
+  /// This may have false negatives, but it won't have false positives.
+  final bool isEmpty;
 
-  Body._(this._stream, this.encoding, this.contentLength);
+  Body._(this._stream, this.encoding, this.isEmpty);
 
   /// Converts [body] to a byte stream and wraps it in a [Body].
   ///
@@ -38,25 +39,23 @@
     if (body is Body) return body;
 
     Stream<List<int>> stream;
-    int contentLength;
+    var isEmpty = false;
     if (body == null) {
-      contentLength = 0;
+      isEmpty = true;
       stream = new Stream.fromIterable([]);
     } else if (body is String) {
+      isEmpty = body.isEmpty;
       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.
+        // that an encoding of "text/plain" without a charset 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]);
+        stream = new Stream.fromIterable([encoding.encode(body)]);
       }
     } else if (body is List) {
-      contentLength = body.length;
+      isEmpty = body.isEmpty;
       stream = new Stream.fromIterable([DelegatingList.typed(body)]);
     } else if (body is Stream) {
       stream = DelegatingStream.typed(body);
@@ -65,7 +64,7 @@
           'Stream.');
     }
 
-    return new Body._(stream, encoding, contentLength);
+    return new Body._(stream, encoding, isEmpty);
   }
 
   /// Returns whether [bytes] is plain ASCII.
diff --git a/lib/src/message.dart b/lib/src/message.dart
index 2059b9a..df770fe 100644
--- a/lib/src/message.dart
+++ b/lib/src/message.dart
@@ -43,6 +43,11 @@
   /// This can be read via [read] or [readAsString].
   final Body _body;
 
+  /// 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.isEmpty;
+
   /// Creates a new [Message].
   ///
   /// [body] is the response body. It may be either a [String], a [List<int>], a
@@ -71,7 +76,6 @@
   /// 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;
@@ -146,12 +150,9 @@
     Map<String, String> headers, Body body) {
   var sameEncoding = _sameEncoding(headers, body);
   if (sameEncoding) {
-    if (body.contentLength == null ||
-        getHeader(headers, 'content-length') ==
-            body.contentLength.toString()) {
+    if (!body.isEmpty || hasHeader(headers, 'content-length')) {
       return headers ?? const ShelfUnmodifiableMap.empty();
-    } else if (body.contentLength == 0 &&
-        (headers == null || headers.isEmpty)) {
+    } else if (body.isEmpty && (headers == null || headers.isEmpty)) {
       return _defaultHeaders;
     }
   }
@@ -171,8 +172,14 @@
     }
   }
 
-  if (body.contentLength != null) {
-    newHeaders['content-length'] = body.contentLength.toString();
+  // Only add a content-length header if the length is 0 and there isn't already
+  // a content-length set. Content-length may not be sent with a chunked
+  // transfer encoding, and HEAD requests may send a non-zero content-length
+  // along with an empty body.
+  //
+  // See https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.4.
+  if (body.isEmpty) {
+    newHeaders.putIfAbsent('content-length', () => '0');
   }
 
   return newHeaders;
diff --git a/lib/src/util.dart b/lib/src/util.dart
index a59e912..fcf66d8 100644
--- a/lib/src/util.dart
+++ b/lib/src/util.dart
@@ -62,3 +62,17 @@
   }
   return null;
 }
+
+/// Returns whether [headers] contains a header with the given [name].
+///
+/// This works even if [headers] is `null`, or if it's not yet a
+/// case-insensitive map.
+bool hasHeader(Map<String, String> headers, String name) {
+  if (headers == null) return false;
+  if (headers is ShelfUnmodifiableMap) return headers.containsKey(name);
+
+  for (var key in headers.keys) {
+    if (equalsIgnoreAsciiCase(key, name)) return true;
+  }
+  return false;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index 0f48cb5..72b0877 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: shelf
-version: 0.6.6
+version: 0.6.7
 author: Dart Team <misc@dartlang.org>
 description: Web Server Middleware for Dart
 homepage: https://github.com/dart-lang/shelf
diff --git a/test/message_test.dart b/test/message_test.dart
index adc7f63..4175455 100644
--- a/test/message_test.dart
+++ b/test/message_test.dart
@@ -164,39 +164,40 @@
       expect(request.contentLength, 0);
     });
 
-    test("comes from a byte body", () {
-      var request = _createMessage(body: [1, 2, 3]);
-      expect(request.contentLength, 3);
+    test("is 0 with an empty byte body", () {
+      var request = _createMessage(body: []);
+      expect(request.contentLength, 0);
     });
 
-    test("comes from a string body", () {
-      var request = _createMessage(body: 'foobar');
-      expect(request.contentLength, 6);
+    test("is 0 with an empty string body", () {
+      var request = _createMessage(body: '');
+      expect(request.contentLength, 0);
     });
 
-    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", () {
+    test("is null for an empty stream body", () {
       var request = _createMessage(body: new Stream.empty());
       expect(request.contentLength, isNull);
     });
 
+    test("is null for a non-empty byte body", () {
+      var request = _createMessage(body: [1, 2, 3]);
+      expect(request.contentLength, isNull);
+    });
+
+    test("is null for a non-empty string body", () {
+      var request = _createMessage(body: "foo");
+      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);
     });
 
-    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);
+    test("content-length header takes precedence over an empty body", () {
+      var request = _createMessage(headers: {'content-length': '42'});
+      expect(request.contentLength, 42);
     });
   });
 
diff --git a/test/shelf_io_test.dart b/test/shelf_io_test.dart
index 3e644e5..c555b19 100644
--- a/test/shelf_io_test.dart
+++ b/test/shelf_io_test.dart
@@ -335,6 +335,29 @@
     });
   });
 
+  group("doesn't use a chunked transfer-encoding", () {
+    test("for a response with an empty body", () {
+      _scheduleServer((request) => new Response.notModified());
+
+      return _scheduleGet().then((response) {
+        expect(response.headers, isNot(contains('transfer-encoding')));
+        expect(response.headers, containsPair('content-length', '0'));
+      });
+    });
+
+    test("for a response with an empty body and a non-empty content-length",
+        () {
+      _scheduleServer((request) {
+        return new Response.ok(null, headers: {'content-length': '42'});
+      });
+
+      return _scheduleHead().then((response) {
+        expect(response.headers, isNot(contains('transfer-encoding')));
+        expect(response.headers, containsPair('content-length', '42'));
+      });
+    });
+  });
+
   test('respects the "shelf.io.buffer_output" context parameter', () {
     var controller = new StreamController<String>();
     _scheduleServer((request) {
@@ -385,6 +408,13 @@
       () => http.get('http://localhost:$_serverPort/', headers: headers));
 }
 
+Future<http.Response> _scheduleHead({Map<String, String> headers}) {
+  if (headers == null) headers = {};
+
+  return schedule/*<Future<http.Response>>*/(
+      () => http.head('http://localhost:$_serverPort/', headers: headers));
+}
+
 Future<http.StreamedResponse> _schedulePost(
     {Map<String, String> headers, String body}) {
   return schedule/*<Future<http.StreamedResponse>>*/(() {