diff --git a/pkgs/http_profile/lib/http_profile.dart b/pkgs/http_profile/lib/http_profile.dart index 768d161853..0a1c283f4d 100644 --- a/pkgs/http_profile/lib/http_profile.dart +++ b/pkgs/http_profile/lib/http_profile.dart @@ -1,540 +1 @@ -// Copyright (c) 2023, 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' show StreamController, StreamSink, unawaited; -import 'dart:developer' show Service, addHttpClientProfilingData; -import 'dart:io' show HttpClient, HttpClientResponseCompressionState; -import 'dart:isolate' show Isolate; - -/// "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-separated header values. -var _headerSplitter = RegExp(r'[ \t]*,[ \t]*'); - -/// Splits comma-separated "Set-Cookie" header values. -/// -/// Set-Cookie strings can contain commas. In particular, the following -/// productions defined in RFC-6265, section 4.1.1: -/// - e.g. "Expires=Sun, 06 Nov 1994 08:49:37 GMT" -/// - e.g. "Path=somepath," -/// - 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 ",=" is more likely to -/// start a new then be part of or . -/// -/// See https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 -var _setCookieSplitter = RegExp(r'[ \t]*,[ \t]*(?=[' + _tokenChars + r']+=)'); - -/// Splits comma-separated header values into a [List]. -/// -/// Copied from `package:http`. -Map> _splitHeaderValues(Map headers) { - var headersWithFieldLists = >{}; - 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; -} - -/// Describes an event related to an HTTP request. -final class HttpProfileRequestEvent { - final int _timestamp; - final String _name; - - HttpProfileRequestEvent({required DateTime timestamp, required String name}) - : _timestamp = timestamp.microsecondsSinceEpoch, - _name = name; - - Map _toJson() => { - 'timestamp': _timestamp, - 'event': _name, - }; -} - -/// Describes proxy authentication details associated with an HTTP request. -final class HttpProfileProxyData { - final String? _host; - final String? _username; - final bool? _isDirect; - final int? _port; - - HttpProfileProxyData({ - String? host, - String? username, - bool? isDirect, - int? port, - }) : _host = host, - _username = username, - _isDirect = isDirect, - _port = port; - - Map _toJson() => { - if (_host != null) 'host': _host, - if (_username != null) 'username': _username, - if (_isDirect != null) 'isDirect': _isDirect, - if (_port != null) 'port': _port, - }; -} - -/// Describes a redirect that an HTTP connection went through. -class HttpProfileRedirectData { - final int _statusCode; - final String _method; - final String _location; - - HttpProfileRedirectData({ - required int statusCode, - required String method, - required String location, - }) : _statusCode = statusCode, - _method = method, - _location = location; - - Map _toJson() => { - 'statusCode': _statusCode, - 'method': _method, - 'location': _location, - }; -} - -/// Describes details about an HTTP request. -final class HttpProfileRequestData { - final Map _data; - final StreamController> _body = StreamController>(); - bool _isClosed = false; - final void Function() _updated; - - Map get _requestData => - _data['requestData'] as Map; - - /// The body of the request. - StreamSink> get bodySink => _body.sink; - - /// Information about the networking connection used in the HTTP request. - /// - /// This information is meant to be used for debugging. - /// - /// It can contain any arbitrary data as long as the values are of type - /// [String] or [int]. For example: - /// { 'localPort': 1285, 'remotePort': 443, 'connectionPoolId': '21x23' } - set connectionInfo(Map value) { - _checkAndUpdate(); - for (final v in value.values) { - if (!(v is String || v is int)) { - throw ArgumentError( - 'The values in connectionInfo must be of type String or int.', - ); - } - } - _requestData['connectionInfo'] = {...value}; - } - - /// The content length of the request, in bytes. - set contentLength(int? value) { - _checkAndUpdate(); - if (value == null) { - _requestData.remove('contentLength'); - } else { - _requestData['contentLength'] = value; - } - } - - /// Whether automatic redirect following was enabled for the request. - set followRedirects(bool value) { - _checkAndUpdate(); - _requestData['followRedirects'] = value; - } - - /// The request headers where duplicate headers are represented using a list - /// of values. - /// - /// For example: - /// - /// ```dart - /// // Foo: Bar - /// // Foo: Baz - /// - /// profile?.requestData.headersListValues({'Foo', ['Bar', 'Baz']}); - /// ``` - set headersListValues(Map>? value) { - _checkAndUpdate(); - if (value == null) { - _requestData.remove('headers'); - return; - } - _requestData['headers'] = {...value}; - } - - /// The request headers where duplicate headers are represented using a - /// comma-separated list of values. - /// - /// For example: - /// - /// ```dart - /// // Foo: Bar - /// // Foo: Baz - /// - /// profile?.requestData.headersCommaValues({'Foo', 'Bar, Baz']}); - /// ``` - set headersCommaValues(Map? value) { - _checkAndUpdate(); - if (value == null) { - _requestData.remove('headers'); - return; - } - _requestData['headers'] = _splitHeaderValues(value); - } - - /// The maximum number of redirects allowed during the request. - set maxRedirects(int value) { - _checkAndUpdate(); - _requestData['maxRedirects'] = value; - } - - /// The requested persistent connection state. - set persistentConnection(bool value) { - _checkAndUpdate(); - _requestData['persistentConnection'] = value; - } - - /// Proxy authentication details for the request. - set proxyDetails(HttpProfileProxyData value) { - _checkAndUpdate(); - _requestData['proxyDetails'] = value._toJson(); - } - - HttpProfileRequestData._( - this._data, - this._updated, - ); - - void _checkAndUpdate() { - if (_isClosed) { - throw StateError('HttpProfileResponseData has been closed, no further ' - 'updates are allowed'); - } - _updated(); - } - - /// Signal that the request, including the entire request body, has been - /// sent. - /// - /// [bodySink] will be closed and the fields of [HttpProfileRequestData] will - /// no longer be changeable. - /// - /// [endTime] is the time when the request was fully sent. It defaults to the - /// current time. - void close([DateTime? endTime]) { - _checkAndUpdate(); - _isClosed = true; - unawaited(bodySink.close()); - _data['requestEndTimestamp'] = - (endTime ?? DateTime.now()).microsecondsSinceEpoch; - } - - /// Signal that sending the request has failed with an error. - /// - /// [bodySink] will be closed and the fields of [HttpProfileRequestData] will - /// no longer be changeable. - /// - /// [value] is a textual description of the error e.g. 'host does not exist'. - /// - /// [endTime] is the time when the error occurred. It defaults to the current - /// time. - void closeWithError(String value, [DateTime? endTime]) { - _checkAndUpdate(); - _isClosed = true; - unawaited(bodySink.close()); - _requestData['error'] = value; - _data['requestEndTimestamp'] = - (endTime ?? DateTime.now()).microsecondsSinceEpoch; - } -} - -/// Describes details about a response to an HTTP request. -final class HttpProfileResponseData { - bool _isClosed = false; - final Map _data; - final void Function() _updated; - final StreamController> _body = StreamController>(); - - /// Records a redirect that the connection went through. - void addRedirect(HttpProfileRedirectData redirect) { - _checkAndUpdate(); - (_data['redirects'] as List>).add(redirect._toJson()); - } - - /// The body of the response. - StreamSink> get bodySink => _body.sink; - - /// Information about the networking connection used in the HTTP response. - /// - /// This information is meant to be used for debugging. - /// - /// It can contain any arbitrary data as long as the values are of type - /// [String] or [int]. For example: - /// { 'localPort': 1285, 'remotePort': 443, 'connectionPoolId': '21x23' } - set connectionInfo(Map value) { - _checkAndUpdate(); - for (final v in value.values) { - if (!(v is String || v is int)) { - throw ArgumentError( - 'The values in connectionInfo must be of type String or int.', - ); - } - } - _data['connectionInfo'] = {...value}; - } - - /// The reponse headers where duplicate headers are represented using a list - /// of values. - /// - /// For example: - /// - /// ```dart - /// // Foo: Bar - /// // Foo: Baz - /// - /// profile?.requestData.headersListValues({'Foo', ['Bar', 'Baz']}); - /// ``` - set headersListValues(Map>? value) { - _checkAndUpdate(); - if (value == null) { - _data.remove('headers'); - return; - } - _data['headers'] = {...value}; - } - - /// The response headers where duplicate headers are represented using a - /// comma-separated list of values. - /// - /// For example: - /// - /// ```dart - /// // Foo: Bar - /// // Foo: Baz - /// - /// profile?.responseData.headersCommaValues({'Foo', 'Bar, Baz']}); - /// ``` - set headersCommaValues(Map? value) { - _checkAndUpdate(); - if (value == null) { - _data.remove('headers'); - return; - } - _data['headers'] = _splitHeaderValues(value); - } - - // The compression state of the response. - // - // This specifies whether the response bytes were compressed when they were - // received across the wire and whether callers will receive compressed or - // uncompressed bytes when they listen to the response body byte stream. - set compressionState(HttpClientResponseCompressionState value) { - _checkAndUpdate(); - _data['compressionState'] = value.name; - } - - // The reason phrase associated with the response e.g. "OK". - set reasonPhrase(String? value) { - _checkAndUpdate(); - if (value == null) { - _data.remove('reasonPhrase'); - } else { - _data['reasonPhrase'] = value; - } - } - - /// Whether the status code was one of the normal redirect codes. - set isRedirect(bool value) { - _checkAndUpdate(); - _data['isRedirect'] = value; - } - - /// The persistent connection state returned by the server. - set persistentConnection(bool value) { - _checkAndUpdate(); - _data['persistentConnection'] = value; - } - - /// The content length of the response body, in bytes. - set contentLength(int? value) { - _checkAndUpdate(); - if (value == null) { - _data.remove('contentLength'); - } else { - _data['contentLength'] = value; - } - } - - set statusCode(int value) { - _checkAndUpdate(); - _data['statusCode'] = value; - } - - /// The time at which the initial response was received. - set startTime(DateTime value) { - _checkAndUpdate(); - _data['startTime'] = value.microsecondsSinceEpoch; - } - - HttpProfileResponseData._( - this._data, - this._updated, - ) { - _data['redirects'] = >[]; - } - - void _checkAndUpdate() { - if (_isClosed) { - throw StateError('HttpProfileResponseData has been closed, no further ' - 'updates are allowed'); - } - _updated(); - } - - /// Signal that the response, including the entire response body, has been - /// received. - /// - /// [bodySink] will be closed and the fields of [HttpProfileResponseData] will - /// no longer be changeable. - /// - /// [endTime] is the time when the response was fully received. It defaults - /// to the current time. - void close([DateTime? endTime]) { - _checkAndUpdate(); - _isClosed = true; - unawaited(bodySink.close()); - _data['endTime'] = (endTime ?? DateTime.now()).microsecondsSinceEpoch; - } - - /// Signal that receiving the response has failed with an error. - /// - /// [bodySink] will be closed and the fields of [HttpProfileResponseData] will - /// no longer be changeable. - /// - /// [value] is a textual description of the error e.g. 'host does not exist'. - /// - /// [endTime] is the time when the error occurred. It defaults to the current - /// time. - void closeWithError(String value, [DateTime? endTime]) { - _checkAndUpdate(); - _isClosed = true; - unawaited(bodySink.close()); - _data['error'] = value; - _data['endTime'] = (endTime ?? DateTime.now()).microsecondsSinceEpoch; - } -} - -/// A record of debugging information about an HTTP request. -final class HttpClientRequestProfile { - /// Whether HTTP profiling is enabled or not. - /// - /// The value can be changed programmatically or through the DevTools Network - /// UX. - static bool get profilingEnabled => HttpClient.enableTimelineLogging; - static set profilingEnabled(bool enabled) => - HttpClient.enableTimelineLogging = enabled; - - final _data = {}; - - /// Records an event related to the request. - /// - /// Usage example: - /// - /// ```dart - /// profile.addEvent( - /// HttpProfileRequestEvent( - /// timestamp: DateTime.now(), - /// name: "Connection Established", - /// ), - /// ); - /// profile.addEvent( - /// HttpProfileRequestEvent( - /// timestamp: DateTime.now(), - /// name: "Remote Disconnected", - /// ), - /// ); - /// ``` - void addEvent(HttpProfileRequestEvent event) { - (_data['events'] as List>).add(event._toJson()); - _updated(); - } - - /// Details about the request. - late final HttpProfileRequestData requestData; - - /// Details about the response. - late final HttpProfileResponseData responseData; - - void _updated() => - _data['_lastUpdateTime'] = DateTime.now().microsecondsSinceEpoch; - - HttpClientRequestProfile._({ - required DateTime requestStartTime, - required String requestMethod, - required String requestUri, - }) { - _data['isolateId'] = Service.getIsolateId(Isolate.current)!; - _data['requestStartTimestamp'] = requestStartTime.microsecondsSinceEpoch; - _data['requestMethod'] = requestMethod; - _data['requestUri'] = requestUri; - _data['events'] = >[]; - _data['requestData'] = {}; - requestData = HttpProfileRequestData._(_data, _updated); - _data['responseData'] = {}; - responseData = HttpProfileResponseData._( - _data['responseData'] as Map, _updated); - _data['_requestBodyStream'] = requestData._body.stream; - _data['_responseBodyStream'] = responseData._body.stream; - // This entry is needed to support the updatedSince parameter of - // ext.dart.io.getHttpProfile. - _updated(); - } - - /// If HTTP profiling is enabled, returns an [HttpClientRequestProfile], - /// otherwise returns `null`. - static HttpClientRequestProfile? profile({ - /// The time at which the request was initiated. - required DateTime requestStartTime, - - /// The HTTP request method associated with the request. - required String requestMethod, - - /// The URI to which the request was sent. - required String requestUri, - }) { - // Always return `null` in product mode so that the profiling code can be - // tree shaken away. - if (const bool.fromEnvironment('dart.vm.product') || !profilingEnabled) { - return null; - } - final requestProfile = HttpClientRequestProfile._( - requestStartTime: requestStartTime, - requestMethod: requestMethod, - requestUri: requestUri, - ); - addHttpClientProfilingData(requestProfile._data); - return requestProfile; - } -} +export 'src/http_profile.dart'; diff --git a/pkgs/http_profile/lib/src/http_client_request_profile.dart b/pkgs/http_profile/lib/src/http_client_request_profile.dart new file mode 100644 index 0000000000..ffa8ac5ebb --- /dev/null +++ b/pkgs/http_profile/lib/src/http_client_request_profile.dart @@ -0,0 +1,113 @@ +// Copyright (c) 2023, 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. + +part of 'http_profile.dart'; + +/// Describes an event related to an HTTP request. +final class HttpProfileRequestEvent { + final int _timestamp; + final String _name; + + HttpProfileRequestEvent({required DateTime timestamp, required String name}) + : _timestamp = timestamp.microsecondsSinceEpoch, + _name = name; + + Map _toJson() => { + 'timestamp': _timestamp, + 'event': _name, + }; +} + +/// A record of debugging information about an HTTP request. +final class HttpClientRequestProfile { + /// Whether HTTP profiling is enabled or not. + /// + /// The value can be changed programmatically or through the DevTools Network + /// UX. + static bool get profilingEnabled => HttpClient.enableTimelineLogging; + static set profilingEnabled(bool enabled) => + HttpClient.enableTimelineLogging = enabled; + + final _data = {}; + + /// Records an event related to the request. + /// + /// Usage example: + /// + /// ```dart + /// profile.addEvent( + /// HttpProfileRequestEvent( + /// timestamp: DateTime.now(), + /// name: "Connection Established", + /// ), + /// ); + /// profile.addEvent( + /// HttpProfileRequestEvent( + /// timestamp: DateTime.now(), + /// name: "Remote Disconnected", + /// ), + /// ); + /// ``` + void addEvent(HttpProfileRequestEvent event) { + (_data['events'] as List>).add(event._toJson()); + _updated(); + } + + /// Details about the request. + late final HttpProfileRequestData requestData; + + /// Details about the response. + late final HttpProfileResponseData responseData; + + void _updated() => + _data['_lastUpdateTime'] = DateTime.now().microsecondsSinceEpoch; + + HttpClientRequestProfile._({ + required DateTime requestStartTime, + required String requestMethod, + required String requestUri, + }) { + _data['isolateId'] = Service.getIsolateId(Isolate.current)!; + _data['requestStartTimestamp'] = requestStartTime.microsecondsSinceEpoch; + _data['requestMethod'] = requestMethod; + _data['requestUri'] = requestUri; + _data['events'] = >[]; + _data['requestData'] = {}; + requestData = HttpProfileRequestData._(_data, _updated); + _data['responseData'] = {}; + responseData = HttpProfileResponseData._( + _data['responseData'] as Map, _updated); + _data['_requestBodyStream'] = requestData._body.stream; + _data['_responseBodyStream'] = responseData._body.stream; + // This entry is needed to support the updatedSince parameter of + // ext.dart.io.getHttpProfile. + _updated(); + } + + /// If HTTP profiling is enabled, returns an [HttpClientRequestProfile], + /// otherwise returns `null`. + static HttpClientRequestProfile? profile({ + /// The time at which the request was initiated. + required DateTime requestStartTime, + + /// The HTTP request method associated with the request. + required String requestMethod, + + /// The URI to which the request was sent. + required String requestUri, + }) { + // Always return `null` in product mode so that the profiling code can be + // tree shaken away. + if (const bool.fromEnvironment('dart.vm.product') || !profilingEnabled) { + return null; + } + final requestProfile = HttpClientRequestProfile._( + requestStartTime: requestStartTime, + requestMethod: requestMethod, + requestUri: requestUri, + ); + addHttpClientProfilingData(requestProfile._data); + return requestProfile; + } +} diff --git a/pkgs/http_profile/lib/src/http_profile.dart b/pkgs/http_profile/lib/src/http_profile.dart new file mode 100644 index 0000000000..bca3bfd310 --- /dev/null +++ b/pkgs/http_profile/lib/src/http_profile.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2024, 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:developer' show Service, addHttpClientProfilingData; +import 'dart:io' show HttpClient, HttpClientResponseCompressionState; +import 'dart:isolate' show Isolate; + +import 'utils.dart'; + +part 'http_client_request_profile.dart'; +part 'http_profile_request_data.dart'; +part 'http_profile_response_data.dart'; diff --git a/pkgs/http_profile/lib/src/http_profile_request_data.dart b/pkgs/http_profile/lib/src/http_profile_request_data.dart new file mode 100644 index 0000000000..6c5aeb19a9 --- /dev/null +++ b/pkgs/http_profile/lib/src/http_profile_request_data.dart @@ -0,0 +1,183 @@ +// Copyright (c) 2023, 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. + +part of 'http_profile.dart'; + +final class HttpProfileProxyData { + final String? _host; + final String? _username; + final bool? _isDirect; + final int? _port; + + HttpProfileProxyData({ + String? host, + String? username, + bool? isDirect, + int? port, + }) : _host = host, + _username = username, + _isDirect = isDirect, + _port = port; + + Map _toJson() => { + if (_host != null) 'host': _host, + if (_username != null) 'username': _username, + if (_isDirect != null) 'isDirect': _isDirect, + if (_port != null) 'port': _port, + }; +} + +/// Describes details about an HTTP request. +final class HttpProfileRequestData { + final Map _data; + final StreamController> _body = StreamController>(); + bool _isClosed = false; + final void Function() _updated; + + Map get _requestData => + _data['requestData'] as Map; + + /// The body of the request. + StreamSink> get bodySink => _body.sink; + + /// Information about the networking connection used in the HTTP request. + /// + /// This information is meant to be used for debugging. + /// + /// It can contain any arbitrary data as long as the values are of type + /// [String] or [int]. For example: + /// { 'localPort': 1285, 'remotePort': 443, 'connectionPoolId': '21x23' } + set connectionInfo(Map value) { + _checkAndUpdate(); + for (final v in value.values) { + if (!(v is String || v is int)) { + throw ArgumentError( + 'The values in connectionInfo must be of type String or int.', + ); + } + } + _requestData['connectionInfo'] = {...value}; + } + + /// The content length of the request, in bytes. + set contentLength(int? value) { + _checkAndUpdate(); + if (value == null) { + _requestData.remove('contentLength'); + } else { + _requestData['contentLength'] = value; + } + } + + /// Whether automatic redirect following was enabled for the request. + set followRedirects(bool value) { + _checkAndUpdate(); + _requestData['followRedirects'] = value; + } + + /// The request headers where duplicate headers are represented using a list + /// of values. + /// + /// For example: + /// + /// ```dart + /// // Foo: Bar + /// // Foo: Baz + /// + /// profile?.requestData.headersListValues({'Foo', ['Bar', 'Baz']}); + /// ``` + set headersListValues(Map>? value) { + _checkAndUpdate(); + if (value == null) { + _requestData.remove('headers'); + return; + } + _requestData['headers'] = {...value}; + } + + /// The request headers where duplicate headers are represented using a + /// comma-separated list of values. + /// + /// For example: + /// + /// ```dart + /// // Foo: Bar + /// // Foo: Baz + /// + /// profile?.requestData.headersCommaValues({'Foo', 'Bar, Baz']}); + /// ``` + set headersCommaValues(Map? value) { + _checkAndUpdate(); + if (value == null) { + _requestData.remove('headers'); + return; + } + _requestData['headers'] = splitHeaderValues(value); + } + + /// The maximum number of redirects allowed during the request. + set maxRedirects(int value) { + _checkAndUpdate(); + _requestData['maxRedirects'] = value; + } + + /// The requested persistent connection state. + set persistentConnection(bool value) { + _checkAndUpdate(); + _requestData['persistentConnection'] = value; + } + + /// Proxy authentication details for the request. + set proxyDetails(HttpProfileProxyData value) { + _checkAndUpdate(); + _requestData['proxyDetails'] = value._toJson(); + } + + HttpProfileRequestData._( + this._data, + this._updated, + ); + + void _checkAndUpdate() { + if (_isClosed) { + throw StateError('HttpProfileResponseData has been closed, no further ' + 'updates are allowed'); + } + _updated(); + } + + /// Signal that the request, including the entire request body, has been + /// sent. + /// + /// [bodySink] will be closed and the fields of [HttpProfileRequestData] will + /// no longer be changeable. + /// + /// [endTime] is the time when the request was fully sent. It defaults to the + /// current time. + void close([DateTime? endTime]) { + _checkAndUpdate(); + _isClosed = true; + unawaited(bodySink.close()); + _data['requestEndTimestamp'] = + (endTime ?? DateTime.now()).microsecondsSinceEpoch; + } + + /// Signal that sending the request has failed with an error. + /// + /// [bodySink] will be closed and the fields of [HttpProfileRequestData] will + /// no longer be changeable. + /// + /// [value] is a textual description of the error e.g. 'host does not exist'. + /// + /// [endTime] is the time when the error occurred. It defaults to the current + /// time. + void closeWithError(String value, [DateTime? endTime]) { + _checkAndUpdate(); + _isClosed = true; + unawaited(bodySink.close()); + _requestData['error'] = value; + _data['requestEndTimestamp'] = + (endTime ?? DateTime.now()).microsecondsSinceEpoch; + } +} diff --git a/pkgs/http_profile/lib/src/http_profile_response_data.dart b/pkgs/http_profile/lib/src/http_profile_response_data.dart new file mode 100644 index 0000000000..ee0720cfa2 --- /dev/null +++ b/pkgs/http_profile/lib/src/http_profile_response_data.dart @@ -0,0 +1,202 @@ +// Copyright (c) 2023, 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. + +part of 'http_profile.dart'; + +/// Describes a redirect that an HTTP connection went through. +class HttpProfileRedirectData { + final int _statusCode; + final String _method; + final String _location; + + HttpProfileRedirectData({ + required int statusCode, + required String method, + required String location, + }) : _statusCode = statusCode, + _method = method, + _location = location; + + Map _toJson() => { + 'statusCode': _statusCode, + 'method': _method, + 'location': _location, + }; +} + +/// Describes details about a response to an HTTP request. +final class HttpProfileResponseData { + bool _isClosed = false; + final Map _data; + final void Function() _updated; + final StreamController> _body = StreamController>(); + + /// Records a redirect that the connection went through. + void addRedirect(HttpProfileRedirectData redirect) { + _checkAndUpdate(); + (_data['redirects'] as List>).add(redirect._toJson()); + } + + /// The body of the response. + StreamSink> get bodySink => _body.sink; + + /// Information about the networking connection used in the HTTP response. + /// + /// This information is meant to be used for debugging. + /// + /// It can contain any arbitrary data as long as the values are of type + /// [String] or [int]. For example: + /// { 'localPort': 1285, 'remotePort': 443, 'connectionPoolId': '21x23' } + set connectionInfo(Map value) { + _checkAndUpdate(); + for (final v in value.values) { + if (!(v is String || v is int)) { + throw ArgumentError( + 'The values in connectionInfo must be of type String or int.', + ); + } + } + _data['connectionInfo'] = {...value}; + } + + /// The reponse headers where duplicate headers are represented using a list + /// of values. + /// + /// For example: + /// + /// ```dart + /// // Foo: Bar + /// // Foo: Baz + /// + /// profile?.requestData.headersListValues({'Foo', ['Bar', 'Baz']}); + /// ``` + set headersListValues(Map>? value) { + _checkAndUpdate(); + if (value == null) { + _data.remove('headers'); + return; + } + _data['headers'] = {...value}; + } + + /// The response headers where duplicate headers are represented using a + /// comma-separated list of values. + /// + /// For example: + /// + /// ```dart + /// // Foo: Bar + /// // Foo: Baz + /// + /// profile?.responseData.headersCommaValues({'Foo', 'Bar, Baz']}); + /// ``` + set headersCommaValues(Map? value) { + _checkAndUpdate(); + if (value == null) { + _data.remove('headers'); + return; + } + _data['headers'] = splitHeaderValues(value); + } + + // The compression state of the response. + // + // This specifies whether the response bytes were compressed when they were + // received across the wire and whether callers will receive compressed or + // uncompressed bytes when they listen to the response body byte stream. + set compressionState(HttpClientResponseCompressionState value) { + _checkAndUpdate(); + _data['compressionState'] = value.name; + } + + // The reason phrase associated with the response e.g. "OK". + set reasonPhrase(String? value) { + _checkAndUpdate(); + if (value == null) { + _data.remove('reasonPhrase'); + } else { + _data['reasonPhrase'] = value; + } + } + + /// Whether the status code was one of the normal redirect codes. + set isRedirect(bool value) { + _checkAndUpdate(); + _data['isRedirect'] = value; + } + + /// The persistent connection state returned by the server. + set persistentConnection(bool value) { + _checkAndUpdate(); + _data['persistentConnection'] = value; + } + + /// The content length of the response body, in bytes. + set contentLength(int? value) { + _checkAndUpdate(); + if (value == null) { + _data.remove('contentLength'); + } else { + _data['contentLength'] = value; + } + } + + set statusCode(int value) { + _checkAndUpdate(); + _data['statusCode'] = value; + } + + /// The time at which the initial response was received. + set startTime(DateTime value) { + _checkAndUpdate(); + _data['startTime'] = value.microsecondsSinceEpoch; + } + + HttpProfileResponseData._( + this._data, + this._updated, + ) { + _data['redirects'] = >[]; + } + + void _checkAndUpdate() { + if (_isClosed) { + throw StateError('HttpProfileResponseData has been closed, no further ' + 'updates are allowed'); + } + _updated(); + } + + /// Signal that the response, including the entire response body, has been + /// received. + /// + /// [bodySink] will be closed and the fields of [HttpProfileResponseData] will + /// no longer be changeable. + /// + /// [endTime] is the time when the response was fully received. It defaults + /// to the current time. + void close([DateTime? endTime]) { + _checkAndUpdate(); + _isClosed = true; + unawaited(bodySink.close()); + _data['endTime'] = (endTime ?? DateTime.now()).microsecondsSinceEpoch; + } + + /// Signal that receiving the response has failed with an error. + /// + /// [bodySink] will be closed and the fields of [HttpProfileResponseData] will + /// no longer be changeable. + /// + /// [value] is a textual description of the error e.g. 'host does not exist'. + /// + /// [endTime] is the time when the error occurred. It defaults to the current + /// time. + void closeWithError(String value, [DateTime? endTime]) { + _checkAndUpdate(); + _isClosed = true; + unawaited(bodySink.close()); + _data['error'] = value; + _data['endTime'] = (endTime ?? DateTime.now()).microsecondsSinceEpoch; + } +} diff --git a/pkgs/http_profile/lib/src/utils.dart b/pkgs/http_profile/lib/src/utils.dart new file mode 100644 index 0000000000..0225f87f39 --- /dev/null +++ b/pkgs/http_profile/lib/src/utils.dart @@ -0,0 +1,51 @@ +// Copyright (c) 2023, 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. + +/// "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-separated header values. +var _headerSplitter = RegExp(r'[ \t]*,[ \t]*'); + +/// Splits comma-separated "Set-Cookie" header values. +/// +/// Set-Cookie strings can contain commas. In particular, the following +/// productions defined in RFC-6265, section 4.1.1: +/// - e.g. "Expires=Sun, 06 Nov 1994 08:49:37 GMT" +/// - e.g. "Path=somepath," +/// - 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 ",=" is more likely to +/// start a new then be part of or . +/// +/// See https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1 +var _setCookieSplitter = RegExp(r'[ \t]*,[ \t]*(?=[' + _tokenChars + r']+=)'); + +/// Splits comma-separated header values into a [List]. +/// +/// Copied from `package:http`. +Map> splitHeaderValues(Map headers) { + var headersWithFieldLists = >{}; + 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_profile/test/http_client_request_profile_test.dart b/pkgs/http_profile/test/http_client_request_profile_test.dart new file mode 100644 index 0000000000..6776db8a29 --- /dev/null +++ b/pkgs/http_profile/test/http_client_request_profile_test.dart @@ -0,0 +1,72 @@ +// Copyright (c) 2023, 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:developer'; +import 'dart:io'; + +import 'package:http_profile/src/http_profile.dart'; +import 'package:test/test.dart'; + +void main() { + late HttpClientRequestProfile profile; + late Map backingMap; + + setUp(() { + HttpClientRequestProfile.profilingEnabled = true; + + profile = HttpClientRequestProfile.profile( + requestStartTime: DateTime.parse('2024-03-21'), + requestMethod: 'GET', + requestUri: 'https://www.example.com', + )!; + + final profileBackingMaps = getHttpClientProfilingData(); + expect(profileBackingMaps.length, isPositive); + backingMap = profileBackingMaps.lastOrNull!; + }); + + test('profiling enabled', () async { + HttpClientRequestProfile.profilingEnabled = true; + expect(HttpClient.enableTimelineLogging, true); + expect( + HttpClientRequestProfile.profile( + requestStartTime: DateTime.parse('2024-03-21'), + requestMethod: 'GET', + requestUri: 'https://www.example.com', + ), + isNotNull, + ); + }); + + test('profiling disabled', () async { + HttpClientRequestProfile.profilingEnabled = false; + expect(HttpClient.enableTimelineLogging, false); + expect( + HttpClientRequestProfile.profile( + requestStartTime: DateTime.parse('2024-03-21'), + requestMethod: 'GET', + requestUri: 'https://www.example.com', + ), + isNull, + ); + }); + + test('calling HttpClientRequestProfile.addEvent', () async { + final events = backingMap['events'] as List>; + expect(events, isEmpty); + + profile.addEvent(HttpProfileRequestEvent( + timestamp: DateTime.parse('2024-03-22'), + name: 'an event', + )); + + expect(events.length, 1); + final event = events.last; + expect( + event['timestamp'], + DateTime.parse('2024-03-22').microsecondsSinceEpoch, + ); + expect(event['event'], 'an event'); + }); +} diff --git a/pkgs/http_profile/test/http_profile_request_data_test.dart b/pkgs/http_profile/test/http_profile_request_data_test.dart new file mode 100644 index 0000000000..4a0c13e9b4 --- /dev/null +++ b/pkgs/http_profile/test/http_profile_request_data_test.dart @@ -0,0 +1,188 @@ +// Copyright (c) 2023, 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:developer' show Service, getHttpClientProfilingData; +import 'dart:isolate' show Isolate; + +import 'package:http_profile/http_profile.dart'; +import 'package:test/test.dart'; + +void main() { + late HttpClientRequestProfile profile; + late Map backingMap; + + setUp(() { + HttpClientRequestProfile.profilingEnabled = true; + + profile = HttpClientRequestProfile.profile( + requestStartTime: DateTime.parse('2024-03-21'), + requestMethod: 'GET', + requestUri: 'https://www.example.com', + )!; + + final profileBackingMaps = getHttpClientProfilingData(); + expect(profileBackingMaps.length, isPositive); + backingMap = profileBackingMaps.lastOrNull!; + }); + + test( + 'mandatory fields are populated when an HttpClientRequestProfile is ' + 'constructed', () async { + expect(backingMap['id'], isNotNull); + expect(backingMap['isolateId'], Service.getIsolateId(Isolate.current)!); + expect( + backingMap['requestStartTimestamp'], + DateTime.parse('2024-03-21').microsecondsSinceEpoch, + ); + expect(backingMap['requestMethod'], 'GET'); + expect(backingMap['requestUri'], 'https://www.example.com'); + }); + + test('populating HttpClientRequestProfile.requestEndTimestamp', () async { + expect(backingMap['requestEndTimestamp'], isNull); + profile.requestData.close(DateTime.parse('2024-03-23')); + + expect( + backingMap['requestEndTimestamp'], + DateTime.parse('2024-03-23').microsecondsSinceEpoch, + ); + }); + + test('populating HttpClientRequestProfile.requestData.connectionInfo', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['connectionInfo'], isNull); + + profile.requestData.connectionInfo = { + 'localPort': 1285, + 'remotePort': 443, + 'connectionPoolId': '21x23' + }; + + final connectionInfo = + requestData['connectionInfo'] as Map; + expect(connectionInfo['localPort'], 1285); + expect(connectionInfo['remotePort'], 443); + expect(connectionInfo['connectionPoolId'], '21x23'); + }); + + test('populating HttpClientRequestProfile.requestData.contentLength', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['contentLength'], isNull); + + profile.requestData.contentLength = 1200; + + expect(requestData['contentLength'], 1200); + }); + + test('HttpClientRequestProfile.requestData.contentLength = nil', () async { + final requestData = backingMap['requestData'] as Map; + + profile.requestData.contentLength = 1200; + expect(requestData['contentLength'], 1200); + + profile.requestData.contentLength = null; + expect(requestData['contentLength'], isNull); + }); + + test('populating HttpClientRequestProfile.requestData.error', () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['error'], isNull); + + profile.requestData.closeWithError('failed'); + + expect(requestData['error'], 'failed'); + }); + + test('populating HttpClientRequestProfile.requestData.followRedirects', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['followRedirects'], isNull); + + profile.requestData.followRedirects = true; + + expect(requestData['followRedirects'], true); + }); + + test('populating HttpClientRequestProfile.requestData.headersListValues', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['headers'], isNull); + + profile.requestData.headersListValues = { + 'fruit': ['apple', 'banana', 'grape'], + 'content-length': ['0'], + }; + + final headers = requestData['headers'] as Map>; + expect(headers, { + 'fruit': ['apple', 'banana', 'grape'], + 'content-length': ['0'], + }); + }); + + test('populating HttpClientRequestProfile.requestData.headersCommaValues', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['headers'], isNull); + + profile.requestData.headersCommaValues = { + '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' + }; + + final headers = requestData['headers'] as Map>; + expect(headers, { + 'set-cookie': [ + 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/,,HE,=L=LO', + 'sessionId=e8bb43229de9; Domain=foo.example.com' + ] + }); + }); + + test('populating HttpClientRequestProfile.requestData.maxRedirects', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['maxRedirects'], isNull); + + profile.requestData.maxRedirects = 5; + + expect(requestData['maxRedirects'], 5); + }); + + test('populating HttpClientRequestProfile.requestData.persistentConnection', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['persistentConnection'], isNull); + + profile.requestData.persistentConnection = true; + + expect(requestData['persistentConnection'], true); + }); + + test('populating HttpClientRequestProfile.requestData.proxyDetails', + () async { + final requestData = backingMap['requestData'] as Map; + expect(requestData['proxyDetails'], isNull); + + profile.requestData.proxyDetails = HttpProfileProxyData( + host: 'https://www.example.com', + username: 'abc123', + isDirect: true, + port: 4321, + ); + + final proxyDetails = requestData['proxyDetails'] as Map; + expect( + proxyDetails['host'], + 'https://www.example.com', + ); + expect(proxyDetails['username'], 'abc123'); + expect(proxyDetails['isDirect'], true); + expect(proxyDetails['port'], 4321); + }); +} diff --git a/pkgs/http_profile/test/populating_profiles_test.dart b/pkgs/http_profile/test/http_profile_response_data_test.dart similarity index 56% rename from pkgs/http_profile/test/populating_profiles_test.dart rename to pkgs/http_profile/test/http_profile_response_data_test.dart index 63a92f1f25..625c097664 100644 --- a/pkgs/http_profile/test/populating_profiles_test.dart +++ b/pkgs/http_profile/test/http_profile_response_data_test.dart @@ -3,9 +3,8 @@ // BSD-style license that can be found in the LICENSE file. import 'dart:async'; -import 'dart:developer' show Service, getHttpClientProfilingData; +import 'dart:developer' show getHttpClientProfilingData; import 'dart:io'; -import 'dart:isolate' show Isolate; import 'package:http_profile/http_profile.dart'; import 'package:test/test.dart'; @@ -28,184 +27,6 @@ void main() { backingMap = profileBackingMaps.lastOrNull!; }); - test( - 'mandatory fields are populated when an HttpClientRequestProfile is ' - 'constructed', () async { - expect(backingMap['id'], isNotNull); - expect(backingMap['isolateId'], Service.getIsolateId(Isolate.current)!); - expect( - backingMap['requestStartTimestamp'], - DateTime.parse('2024-03-21').microsecondsSinceEpoch, - ); - expect(backingMap['requestMethod'], 'GET'); - expect(backingMap['requestUri'], 'https://www.example.com'); - }); - - test('calling HttpClientRequestProfile.addEvent', () async { - final events = backingMap['events'] as List>; - expect(events, isEmpty); - - profile.addEvent(HttpProfileRequestEvent( - timestamp: DateTime.parse('2024-03-22'), - name: 'an event', - )); - - expect(events.length, 1); - final event = events.last; - expect( - event['timestamp'], - DateTime.parse('2024-03-22').microsecondsSinceEpoch, - ); - expect(event['event'], 'an event'); - }); - - test('populating HttpClientRequestProfile.requestEndTimestamp', () async { - expect(backingMap['requestEndTimestamp'], isNull); - profile.requestData.close(DateTime.parse('2024-03-23')); - - expect( - backingMap['requestEndTimestamp'], - DateTime.parse('2024-03-23').microsecondsSinceEpoch, - ); - }); - - test('populating HttpClientRequestProfile.requestData.connectionInfo', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['connectionInfo'], isNull); - - profile.requestData.connectionInfo = { - 'localPort': 1285, - 'remotePort': 443, - 'connectionPoolId': '21x23' - }; - - final connectionInfo = - requestData['connectionInfo'] as Map; - expect(connectionInfo['localPort'], 1285); - expect(connectionInfo['remotePort'], 443); - expect(connectionInfo['connectionPoolId'], '21x23'); - }); - - test('populating HttpClientRequestProfile.requestData.contentLength', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['contentLength'], isNull); - - profile.requestData.contentLength = 1200; - - expect(requestData['contentLength'], 1200); - }); - - test('HttpClientRequestProfile.requestData.contentLength = nil', () async { - final requestData = backingMap['requestData'] as Map; - - profile.requestData.contentLength = 1200; - expect(requestData['contentLength'], 1200); - - profile.requestData.contentLength = null; - expect(requestData['contentLength'], isNull); - }); - - test('populating HttpClientRequestProfile.requestData.error', () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['error'], isNull); - - profile.requestData.closeWithError('failed'); - - expect(requestData['error'], 'failed'); - }); - - test('populating HttpClientRequestProfile.requestData.followRedirects', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['followRedirects'], isNull); - - profile.requestData.followRedirects = true; - - expect(requestData['followRedirects'], true); - }); - - test('populating HttpClientRequestProfile.requestData.headersListValues', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['headers'], isNull); - - profile.requestData.headersListValues = { - 'fruit': ['apple', 'banana', 'grape'], - 'content-length': ['0'], - }; - - final headers = requestData['headers'] as Map>; - expect(headers, { - 'fruit': ['apple', 'banana', 'grape'], - 'content-length': ['0'], - }); - }); - - test('populating HttpClientRequestProfile.requestData.headersCommaValues', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['headers'], isNull); - - profile.requestData.headersCommaValues = { - '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' - }; - - final headers = requestData['headers'] as Map>; - expect(headers, { - 'set-cookie': [ - 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/,,HE,=L=LO', - 'sessionId=e8bb43229de9; Domain=foo.example.com' - ] - }); - }); - - test('populating HttpClientRequestProfile.requestData.maxRedirects', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['maxRedirects'], isNull); - - profile.requestData.maxRedirects = 5; - - expect(requestData['maxRedirects'], 5); - }); - - test('populating HttpClientRequestProfile.requestData.persistentConnection', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['persistentConnection'], isNull); - - profile.requestData.persistentConnection = true; - - expect(requestData['persistentConnection'], true); - }); - - test('populating HttpClientRequestProfile.requestData.proxyDetails', - () async { - final requestData = backingMap['requestData'] as Map; - expect(requestData['proxyDetails'], isNull); - - profile.requestData.proxyDetails = HttpProfileProxyData( - host: 'https://www.example.com', - username: 'abc123', - isDirect: true, - port: 4321, - ); - - final proxyDetails = requestData['proxyDetails'] as Map; - expect( - proxyDetails['host'], - 'https://www.example.com', - ); - expect(proxyDetails['username'], 'abc123'); - expect(proxyDetails['isDirect'], true); - expect(proxyDetails['port'], 4321); - }); - test('calling HttpClientRequestProfile.responseData.addRedirect', () async { final responseData = backingMap['responseData'] as Map; final redirects = responseData['redirects'] as List>; diff --git a/pkgs/http_profile/test/profiling_enabled_test.dart b/pkgs/http_profile/test/profiling_enabled_test.dart deleted file mode 100644 index 7d9b63410f..0000000000 --- a/pkgs/http_profile/test/profiling_enabled_test.dart +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) 2023, 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:io'; - -import 'package:http_profile/http_profile.dart'; -import 'package:test/test.dart'; - -void main() { - test('profiling enabled', () async { - HttpClientRequestProfile.profilingEnabled = true; - expect(HttpClient.enableTimelineLogging, true); - expect( - HttpClientRequestProfile.profile( - requestStartTime: DateTime.parse('2024-03-21'), - requestMethod: 'GET', - requestUri: 'https://www.example.com', - ), - isNotNull, - ); - }); - - test('profiling disabled', () async { - HttpClientRequestProfile.profilingEnabled = false; - expect(HttpClient.enableTimelineLogging, false); - expect( - HttpClientRequestProfile.profile( - requestStartTime: DateTime.parse('2024-03-21'), - requestMethod: 'GET', - requestUri: 'https://www.example.com', - ), - isNull, - ); - }); -} diff --git a/pkgs/http_profile/test/utils_test.dart b/pkgs/http_profile/test/utils_test.dart new file mode 100644 index 0000000000..a15700ec8d --- /dev/null +++ b/pkgs/http_profile/test/utils_test.dart @@ -0,0 +1,74 @@ +// Copyright (c) 2024, 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 'package:http_profile/src/utils.dart'; +import 'package:test/test.dart'; + +void main() { + group('splitHeaderValues', () { + test('no headers', () async { + expect(splitHeaderValues({}), const >{}); + }); + + test('one header', () async { + expect(splitHeaderValues({'fruit': 'apple'}), const { + 'fruit': ['apple'] + }); + }); + + test('two header', () async { + expect(splitHeaderValues({'fruit': 'apple,banana'}), const { + 'fruit': ['apple', 'banana'] + }); + }); + + test('two headers with lots of spaces', () async { + expect(splitHeaderValues({'fruit': 'apple \t , \tbanana'}), const { + 'fruit': ['apple', 'banana'] + }); + }); + + test('one set-cookie', () async { + expect( + splitHeaderValues({ + 'set-cookie': 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT' + }), + { + 'set-cookie': ['id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT'] + }); + }); + + test('two set-cookie, with comma in expires', () async { + expect( + splitHeaderValues({ + // 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' + }), + { + '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 { + expect( + splitHeaderValues({ + // ignore: missing_whitespace_between_adjacent_strings + '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' + }), + { + 'set-cookie': [ + 'id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/,,HE,=L=LO', + 'sessionId=e8bb43229de9; Domain=foo.example.com' + ] + }); + }); + }); +}