Repository: dart-lang/http (dart)

Issue:
package:http abstracts request headers as a Map<string, string>; that does not work for repeating headers
_Originally opened as dart-lang/sdk#21802_

_This issue was originally filed by jirkad...&#064;gmail.com_

---

Package http represents request headers as a Map&lt;string, string&gt;, both in requests as well as in responses. That does not work for repeating headers, In that case one needs to fold them into a single string. Which is not always practical or possible.

**What steps will reproduce the problem?**

The most problematic header is Set-Cookie since the concatenation of multiple cookies into one string is tricky to parse.

**Please provide any additional information below.**

Some browsers and libraries e.g. http.cookies in Python can deal with folded Set-Cookie headers.

RFC 6265 says &quot;don't do that&quot; (section 3)

Pull request description:
Adds a extension to `BaseResponse` like:

```dart
extension HeadersWithFieldLists on BaseResponse {
  /// The HTTP headers returned by the server.
  ///
  /// The header names are converted to lowercase and stored with their
  /// associated header values.
  ///
  /// Cookies can be parsed using the dart:io `Cookie` class:
  ///
  /// ```dart
  /// import "dart:io";
  /// import "package:http/http.dart";
  ///
  /// void main() async {
  /// final response = await Client().get(Uri.https('example.com', '/'));
  /// final cookies = [
  ///   for (var value i
  ///       in response.headersFieldValueList['set-cookie'] ?? <String>[])
  ///     Cookie.fromSetCookieValue(value)
  /// ];
  Map<String, List<String>> get headersFieldValueList ...
}
```

Fixes https://github.com/dart-lang/http/issues/24

---

- [X] I’ve reviewed the contributor guide and applied the relevant portions to this PR.

<details>
  <summary>Contribution guidelines:</summary><br>

- See our [contributor guide](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md) for general expectations for PRs.
- Larger or significant changes should be discussed in an issue before creating a PR.
- Contributions to our repos should follow the [Dart style guide](https://dart.dev/guides/language/effective-dart) and use `dart format`.
- Most changes should add an entry to the changelog and may need to [rev the pubspec package version](https://github.com/dart-lang/sdk/wiki/External-Package-Maintenance#making-a-change).
- Changes to packages require [corresponding tests](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md#Testing).

Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.
</details>

Patch:
diff --git a/pkgs/http/CHANGELOG.md b/pkgs/http/CHANGELOG.md
index 7b8dec4fdd..90eb36aa6b 100644
--- a/pkgs/http/CHANGELOG.md
+++ b/pkgs/http/CHANGELOG.md
@@ -1,6 +1,8 @@
-## 1.1.3-wip
+## 1.2.0-wip
 
 * Add `MockClient.pngResponse`, which makes it easier to fake image responses.
+* Add the ability to get headers as a `Map<String, List<String>` to
+  `BaseResponse`.
 
 ## 1.1.2
 
diff --git a/pkgs/http/lib/http.dart b/pkgs/http/lib/http.dart
index 62004240c7..bd039c8519 100644
--- a/pkgs/http/lib/http.dart
+++ b/pkgs/http/lib/http.dart
@@ -16,7 +16,7 @@ import 'src/streamed_request.dart';
 
 export 'src/base_client.dart';
 export 'src/base_request.dart';
-export 'src/base_response.dart';
+export 'src/base_response.dart' show BaseResponse, HeadersWithSplitValues;
 export 'src/byte_stream.dart';
 export 'src/client.dart' hide zoneClient;
 export 'src/exception.dart';
diff --git a/pkgs/http/lib/src/base_response.dart b/pkgs/http/lib/src/base_response.dart
index ed95f6cdb2..e1796e1b36 100644
--- a/pkgs/http/lib/src/base_response.dart
+++ b/pkgs/http/lib/src/base_response.dart
@@ -43,10 +43,12 @@ abstract class BaseResponse {
   /// // values = ['Apple', 'Banana', 'Grape']
   /// ```
   ///
+  /// To retrieve the header values as a `List<String>`, use
+  /// [HeadersWithSplitValues.headersSplitValues].
+  ///
   /// If a header value contains whitespace then that whitespace may be replaced
   /// by a single space. Leading and trailing whitespace in header values are
   /// always removed.
-  // TODO(nweiz): make this a HttpHeaders object.
   final Map<String, String> headers;
 
   final bool isRedirect;
@@ -68,3 +70,68 @@ abstract class BaseResponse {
     }
   }
 }
+
+/// "token" as defined in RFC 2616, 2.2
+/// See https://datatracker.ietf.org/doc/html/rfc2616#section-2.2
+const _tokenChars = r"!#$%&'*+\-.0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ^_`"
+    'abcdefghijklmnopqrstuvwxyz|~';
+
+/// Splits comma-seperated header values.
+var _headerSplitter = RegExp(r'[ \t]*,[ \t]*');
+
+/// Splits comma-seperated "Set-Cookie" header values.
+///
+/// Set-Cookie strings can contain commas. In particular, the following
+/// productions defined in RFC-6265, section 4.1.1:
+/// - <sane-cookie-date> e.g. "Expires=Sun, 06 Nov 1994 08:49:37 GMT"
+/// - <path-value> e.g. "Path=somepath,"
+/// - <extension-av> e.g. "AnyString,Really,"
+///
+/// Some values are ambiguous e.g.
+/// "Set-Cookie: lang=en; Path=/foo/"
+/// "Set-Cookie: SID=x23"
+/// and:
+/// "Set-Cookie: lang=en; Path=/foo/,SID=x23"
+/// would both be result in `response.headers` => "lang=en; Path=/foo/,SID=x23"
+///
+/// The idea behind this regex is that ",<valid token>=" is more likely to
+/// start a new <cookie-pair> then be part of <path-value> or <extension-av>.
+///
+/// See https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1
+var _setCookieSplitter = RegExp(r'[ \t]*,[ \t]*(?=[' + _tokenChars + r']+=)');
+
+extension HeadersWithSplitValues on BaseResponse {
+  /// The HTTP headers returned by the server.
+  ///
+  /// The header names are converted to lowercase and stored with their
+  /// associated header values.
+  ///
+  /// Cookies can be parsed using the dart:io `Cookie` class:
+  ///
+  /// ```dart
+  /// import "dart:io";
+  /// import "package:http/http.dart";
+  ///
+  /// void main() async {
+  /// final response = await Client().get(Uri.https('example.com', '/'));
+  /// final cookies = [
+  ///   for (var value i
+  ///       in response.headersSplitValues['set-cookie'] ?? <String>[])
+  ///     Cookie.fromSetCookieValue(value)
+  /// ];
+  Map<String, List<String>> get headersSplitValues {
+    var headersWithFieldLists = <String, List<String>>{};
+    headers.forEach((key, value) {
+      if (!value.contains(',')) {
+        headersWithFieldLists[key] = [value];
+      } else {
+        if (key == 'set-cookie') {
+          headersWithFieldLists[key] = value.split(_setCookieSplitter);
+        } else {
+          headersWithFieldLists[key] = value.split(_headerSplitter);
+        }
+      }
+    });
+    return headersWithFieldLists;
+  }
+}
diff --git a/pkgs/http/pubspec.yaml b/pkgs/http/pubspec.yaml
index 1645f96048..31746fcb2d 100644
--- a/pkgs/http/pubspec.yaml
+++ b/pkgs/http/pubspec.yaml
@@ -1,5 +1,5 @@
 name: http
-version: 1.1.3-wip
+version: 1.2.0-wip
 description: A composable, multi-platform, Future-based API for HTTP requests.
 repository: https://github.com/dart-lang/http/tree/master/pkgs/http
 


Test patch:
diff --git a/pkgs/http/test/response_test.dart b/pkgs/http/test/response_test.dart
index 38061c1ef4..1bd9fd8e38 100644
--- a/pkgs/http/test/response_test.dart
+++ b/pkgs/http/test/response_test.dart
@@ -70,4 +70,73 @@ void main() {
       expect(response.bodyBytes, equals([104, 101, 108, 108, 111]));
     });
   });
+
+  group('.headersSplitValues', () {
+    test('no headers', () async {
+      var response = http.Response('Hello, world!', 200);
+      expect(response.headersSplitValues, const <String, List<String>>{});
+    });
+
+    test('one header', () async {
+      var response =
+          http.Response('Hello, world!', 200, headers: {'fruit': 'apple'});
+      expect(response.headersSplitValues, const {
+        'fruit': ['apple']
+      });
+    });
+
+    test('two headers', () async {
+      var response = http.Response('Hello, world!', 200,
+          headers: {'fruit': 'apple,banana'});
+      expect(response.headersSplitValues, const {
+        'fruit': ['apple', 'banana']
+      });
+    });
+
+    test('two headers with lots of spaces', () async {
+      var response = http.Response('Hello, world!', 200,
+          headers: {'fruit': 'apple   \t   ,  \tbanana'});
+      expect(response.headersSplitValues, const {
+        'fruit': ['apple', 'banana']
+      });
+    });
+
+    test('one set-cookie', () async {
+      var response = http.Response('Hello, world!', 200, headers: {
+        'set-cookie': 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT'
+      });
+      expect(response.headersSplitValues, const {
+        'set-cookie': ['id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT']
+      });
+    });
+
+    test('two set-cookie, with comma in expires', () async {
+      var response = http.Response('Hello, world!', 200, headers: {
+        // ignore: missing_whitespace_between_adjacent_strings
+        'set-cookie': 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT,'
+            'sessionId=e8bb43229de9; Domain=foo.example.com'
+      });
+      expect(response.headersSplitValues, const {
+        'set-cookie': [
+          'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT',
+          'sessionId=e8bb43229de9; Domain=foo.example.com'
+        ]
+      });
+    });
+
+    test('two set-cookie, with lots of commas', () async {
+      var response = http.Response('Hello, world!', 200, headers: {
+        'set-cookie':
+            // ignore: missing_whitespace_between_adjacent_strings
+            'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/,,HE,=L=LO,'
+                'sessionId=e8bb43229de9; Domain=foo.example.com'
+      });
+      expect(response.headersSplitValues, const {
+        'set-cookie': [
+          'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/,,HE,=L=LO',
+          'sessionId=e8bb43229de9; Domain=foo.example.com'
+        ]
+      });
+    });
+  });
 }