diff --git a/dio/CHANGELOG.md b/dio/CHANGELOG.md index 306a2b79f..786414884 100644 --- a/dio/CHANGELOG.md +++ b/dio/CHANGELOG.md @@ -6,6 +6,7 @@ See the [Migration Guide][] for the complete breaking changes list.** ## Unreleased - Improve `SyncTransformer`'s stream transform. +- Allow case-sensitive header keys with the `preserveHeaderCase` flag through options. ## 5.3.4 diff --git a/dio/README-ZH.md b/dio/README-ZH.md index 6ccca968a..a3819f000 100644 --- a/dio/README-ZH.md +++ b/dio/README-ZH.md @@ -291,8 +291,20 @@ String method; String? baseUrl; /// HTTP 请求头。 +/// +/// 请求头的键是否相等的判断大小写不敏感的。 +/// 例如:`content-type` 和 `Content-Type` 会视为同样的请求头键。 Map? headers; +/// 是否保留请求头的大小写。 +/// +/// 默认值为 false。 +/// +/// 该选项在以下场景无效: +/// - XHR 不支持直接处理。 +/// - 按照 HTTP/2 的标准,只支持小写请求头键。 +bool? preserveHeaderCase; + /// 连接服务器超时时间. Duration? connectTimeout; diff --git a/dio/README.md b/dio/README.md index c415d8f72..4a268d1c2 100644 --- a/dio/README.md +++ b/dio/README.md @@ -275,8 +275,20 @@ String method; String? baseUrl; /// Http request headers. +/// +/// The equality of the header keys is case-insensitive, +/// e.g.: `content-type` and `Content-Type` will be treated as the same key. Map? headers; +/// Whether the case of header keys should be preserved. +/// +/// Defaults to false. +/// +/// This option WILL NOT take effect on these circumstances: +/// - XHR ([HttpRequest]) does not support handling this explicitly. +/// - The HTTP/2 standard only supports lowercase header keys. +bool? preserveHeaderCase; + /// Timeout for opening url. Duration? connectTimeout; diff --git a/dio/lib/src/adapters/io_adapter.dart b/dio/lib/src/adapters/io_adapter.dart index 35a1b781b..5c7d94ece 100644 --- a/dio/lib/src/adapters/io_adapter.dart +++ b/dio/lib/src/adapters/io_adapter.dart @@ -111,8 +111,14 @@ class IOHttpClientAdapter implements HttpClientAdapter { } // Set Headers - options.headers.forEach((k, v) { - if (v != null) request.headers.set(k, v); + options.headers.forEach((key, value) { + if (value != null) { + request.headers.set( + key, + value, + preserveHeaderCase: options.preserveHeaderCase, + ); + } }); } on SocketException catch (e) { if (e.message.contains('timed out')) { diff --git a/dio/lib/src/dio_mixin.dart b/dio/lib/src/dio_mixin.dart index 0387540a4..66973d5ba 100644 --- a/dio/lib/src/dio_mixin.dart +++ b/dio/lib/src/dio_mixin.dart @@ -517,7 +517,10 @@ abstract class DioMixin implements Dio { stream, cancelToken?.whenCancel, ); - final headers = Headers.fromMap(responseBody.headers); + final headers = Headers.fromMap( + responseBody.headers, + preserveHeaderCase: reqOpt.preserveHeaderCase, + ); // Make sure headers and [ResponseBody.headers] are the same instance. responseBody.headers = headers.map; final ret = Response( @@ -713,7 +716,10 @@ abstract class DioMixin implements Dio { final T? data = response.data as T?; final Headers headers; if (data is ResponseBody) { - headers = Headers.fromMap(data.headers); + headers = Headers.fromMap( + data.headers, + preserveHeaderCase: requestOptions.preserveHeaderCase, + ); } else { headers = response.headers; } diff --git a/dio/lib/src/headers.dart b/dio/lib/src/headers.dart index 42854c3e2..d0d9524a8 100644 --- a/dio/lib/src/headers.dart +++ b/dio/lib/src/headers.dart @@ -7,12 +7,16 @@ typedef HeaderForEachCallback = void Function(String name, List values); /// The headers class for requests and responses. class Headers { - Headers() : _map = caseInsensitiveKeyMap>(); + Headers({ + this.preserveHeaderCase = false, + }) : _map = caseInsensitiveKeyMap>(); /// Create the [Headers] from a [Map] instance. - Headers.fromMap(Map> map) - : _map = caseInsensitiveKeyMap>( - map.map((k, v) => MapEntry(k.trim().toLowerCase(), v)), + Headers.fromMap( + Map> map, { + this.preserveHeaderCase = false, + }) : _map = caseInsensitiveKeyMap>( + map.map((k, v) => MapEntry(k.trim(), v)), ); static const acceptHeader = 'accept'; @@ -28,6 +32,11 @@ class Headers { static final jsonMimeType = MediaType.parse(jsonContentType); + /// Whether the header key should be case-sensitive. + /// + /// Defaults to false. + final bool preserveHeaderCase; + final Map> _map; Map> get map => _map; @@ -35,7 +44,7 @@ class Headers { /// Returns the list of values for the header named [name]. If there /// is no header with the provided name, [:null:] will be returned. List? operator [](String name) { - return _map[name.trim().toLowerCase()]; + return _map[name.trim()]; } /// Convenience method for the value for a single valued header. If @@ -63,9 +72,9 @@ class Headers { /// cleared before the value [value] is added as its value. void set(String name, dynamic value) { if (value == null) return; - name = name.trim().toLowerCase(); + name = name.trim(); if (value is List) { - _map[name] = value.map((e) => e.toString()).toList(); + _map[name] = value.map((e) => '$e').toList(); } else { _map[name] = ['$value'.trim()]; } diff --git a/dio/lib/src/options.dart b/dio/lib/src/options.dart index 44038d380..b95994f7c 100644 --- a/dio/lib/src/options.dart +++ b/dio/lib/src/options.dart @@ -130,6 +130,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { Map? queryParameters, Map? extra, Map? headers, + bool preserveHeaderCase = false, ResponseType? responseType = ResponseType.json, String? contentType, ValidateStatus? validateStatus, @@ -148,6 +149,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { sendTimeout: sendTimeout, extra: extra, headers: headers, + preserveHeaderCase: preserveHeaderCase, responseType: responseType, contentType: contentType, validateStatus: validateStatus, @@ -175,6 +177,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { Duration? sendTimeout, Map? extra, Map? headers, + bool? preserveHeaderCase, ResponseType? responseType, String? contentType, ValidateStatus? validateStatus, @@ -195,6 +198,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin { sendTimeout: sendTimeout ?? this.sendTimeout, extra: extra ?? Map.from(this.extra), headers: headers ?? Map.from(this.headers), + preserveHeaderCase: preserveHeaderCase ?? this.preserveHeaderCase, responseType: responseType ?? this.responseType, contentType: contentType ?? this.contentType, validateStatus: validateStatus ?? this.validateStatus, @@ -218,6 +222,7 @@ class Options { Duration? receiveTimeout, this.extra, this.headers, + this.preserveHeaderCase, this.responseType, this.contentType, this.validateStatus, @@ -240,6 +245,7 @@ class Options { Duration? receiveTimeout, Map? extra, Map? headers, + bool? preserveHeaderCase, ResponseType? responseType, String? contentType, ValidateStatus? validateStatus, @@ -276,6 +282,7 @@ class Options { receiveTimeout: receiveTimeout ?? this.receiveTimeout, extra: extra ?? effectiveExtra, headers: headers ?? effectiveHeaders, + preserveHeaderCase: preserveHeaderCase ?? this.preserveHeaderCase, responseType: responseType ?? this.responseType, contentType: contentType ?? this.contentType, validateStatus: validateStatus ?? this.validateStatus, @@ -324,6 +331,7 @@ class Options { baseUrl: baseOpt.baseUrl, path: path, data: data, + preserveHeaderCase: preserveHeaderCase ?? baseOpt.preserveHeaderCase, sourceStackTrace: sourceStackTrace ?? StackTrace.current, connectTimeout: baseOpt.connectTimeout, sendTimeout: sendTimeout ?? baseOpt.sendTimeout, @@ -354,10 +362,19 @@ class Options { /// HTTP request headers. /// - /// The keys of the header are case-insensitive, - /// e.g.: content-type and Content-Type will be treated as the same key. + /// The equality of the header keys is case-insensitive, + /// e.g.: `content-type` and `Content-Type` will be treated as the same key. Map? headers; + /// Whether the case of header keys should be preserved. + /// + /// Defaults to false. + /// + /// This option WILL NOT take effect on these circumstances: + /// - XHR ([HttpRequest]) does not support handling this explicitly. + /// - The HTTP/2 standard only supports lowercase header keys. + bool? preserveHeaderCase; + /// Timeout when sending data. /// /// [Dio] will throw the [DioException] with @@ -474,6 +491,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { String? baseUrl, Map? extra, Map? headers, + bool? preserveHeaderCase, ResponseType? responseType, String? contentType, ValidateStatus? validateStatus, @@ -493,6 +511,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { receiveTimeout: receiveTimeout, extra: extra, headers: headers, + preserveHeaderCase: preserveHeaderCase, responseType: responseType, contentType: contentType, validateStatus: validateStatus, @@ -525,6 +544,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { CancelToken? cancelToken, Map? extra, Map? headers, + bool? preserveHeaderCase, ResponseType? responseType, String? contentType, ValidateStatus? validateStatus, @@ -561,6 +581,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin { cancelToken: cancelToken ?? this.cancelToken, extra: extra ?? Map.from(this.extra), headers: headers ?? Map.from(this.headers), + preserveHeaderCase: preserveHeaderCase ?? this.preserveHeaderCase, responseType: responseType ?? this.responseType, validateStatus: validateStatus ?? this.validateStatus, receiveDataWhenStatusError: @@ -638,6 +659,7 @@ class _RequestConfig { String? method, Map? extra, Map? headers, + bool? preserveHeaderCase, String? contentType, ListFormat? listFormat, bool? followRedirects, @@ -653,6 +675,7 @@ class _RequestConfig { assert(sendTimeout == null || !sendTimeout.isNegative), _sendTimeout = sendTimeout, method = method ?? 'GET', + preserveHeaderCase = preserveHeaderCase ?? false, listFormat = listFormat ?? ListFormat.multi, extra = extra ?? {}, followRedirects = followRedirects ?? true, @@ -692,6 +715,8 @@ class _RequestConfig { } } + late bool preserveHeaderCase; + Duration? get sendTimeout => _sendTimeout; Duration? _sendTimeout; diff --git a/dio/lib/src/response.dart b/dio/lib/src/response.dart index b6ff998de..4713330f2 100644 --- a/dio/lib/src/response.dart +++ b/dio/lib/src/response.dart @@ -19,7 +19,8 @@ class Response { this.redirects = const [], Map? extra, Headers? headers, - }) : headers = headers ?? Headers(), + }) : headers = headers ?? + Headers(preserveHeaderCase: requestOptions.preserveHeaderCase), extra = extra ?? {}; /// The response payload in specific type. diff --git a/dio/test/basic_test.dart b/dio/test/basic_test.dart index e52faeae6..272d4f997 100644 --- a/dio/test/basic_test.dart +++ b/dio/test/basic_test.dart @@ -8,36 +8,6 @@ import 'mock/adapters.dart'; import 'utils.dart'; void main() { - test('test headers', () { - final headers = Headers.fromMap({ - 'set-cookie': ['k=v', 'k1=v1'], - 'content-length': ['200'], - 'test': ['1', '2'], - }); - headers.add('SET-COOKIE', 'k2=v2'); - expect(headers.value('content-length'), '200'); - expect(Future(() => headers.value('test')), throwsException); - expect(headers['set-cookie']?.length, 3); - headers.remove('set-cookie', 'k=v'); - expect(headers['set-cookie']?.length, 2); - headers.removeAll('set-cookie'); - expect(headers['set-cookie'], isNull); - final ls = []; - headers.forEach((k, list) => ls.addAll(list)); - expect(ls.length, 3); - expect(headers.toString(), 'content-length: 200\ntest: 1\ntest: 2\n'); - headers.set('content-length', '300'); - expect(headers.value('content-length'), '300'); - headers.set('content-length', ['400']); - expect(headers.value('content-length'), '400'); - - final headers1 = Headers(); - headers1.set('xx', 'v'); - expect(headers1.value('xx'), 'v'); - headers1.clear(); - expect(headers1.map.isEmpty, isTrue); - }); - test('send with an invalid URL', () async { await expectLater( Dio().get('http://http.invalid'), diff --git a/dio/test/headers_test.dart b/dio/test/headers_test.dart new file mode 100644 index 000000000..4d71553fe --- /dev/null +++ b/dio/test/headers_test.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:test/test.dart'; + +void main() { + group(Headers, () { + test('set', () { + final headers = Headers.fromMap({ + 'set-cookie': ['k=v', 'k1=v1'], + 'content-length': ['200'], + 'test': ['1', '2'], + }); + headers.add('SET-COOKIE', 'k2=v2'); + expect(headers.value('content-length'), '200'); + expect(Future(() => headers.value('test')), throwsException); + expect(headers['set-cookie']?.length, 3); + headers.remove('set-cookie', 'k=v'); + expect(headers['set-cookie']?.length, 2); + headers.removeAll('set-cookie'); + expect(headers['set-cookie'], isNull); + final ls = []; + headers.forEach((k, list) => ls.addAll(list)); + expect(ls.length, 3); + expect(headers.toString(), 'content-length: 200\ntest: 1\ntest: 2\n'); + headers.set('content-length', '300'); + expect(headers.value('content-length'), '300'); + headers.set('content-length', ['400']); + expect(headers.value('content-length'), '400'); + }); + + test('clear', () { + final headers1 = Headers(); + headers1.set('xx', 'v'); + expect(headers1.value('xx'), 'v'); + headers1.clear(); + expect(headers1.map.isEmpty, isTrue); + }); + + test('case-sensitive', () { + final headers = Headers.fromMap( + { + 'SET-COOKIE': ['k=v', 'k1=v1'], + 'content-length': ['200'], + 'Test': ['1', '2'], + }, + preserveHeaderCase: true, + ); + expect(headers['SET-COOKIE']?.length, 2); + // Although it's case-sensitive, we still use case-insensitive map. + expect(headers['set-cookie']?.length, 2); + expect(headers['content-length']?.length, 1); + expect(headers['Test']?.length, 2); + }); + }); +} diff --git a/dio/test/options_test.dart b/dio/test/options_test.dart index c31229bf9..086fe8b34 100644 --- a/dio/test/options_test.dart +++ b/dio/test/options_test.dart @@ -1,10 +1,15 @@ @TestOn('vm') import 'dart:convert'; +import 'dart:typed_data'; import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:dio/src/utils.dart'; +import 'package:mockito/mockito.dart'; import 'package:test/test.dart'; import 'mock/adapters.dart'; +import 'mock/http_mock.mocks.dart'; import 'utils.dart'; void main() { @@ -523,4 +528,62 @@ void main() { expect(response.data, 'test'); } }); + + test('Headers can be case-sensitive', () async { + final dio = Dio(); + final client = MockHttpClient(); + dio.httpClientAdapter = IOHttpClientAdapter(createHttpClient: () => client); + + final headerMap = caseInsensitiveKeyMap(); + late MockHttpHeaders mockRequestHeaders; + when(client.openUrl(any, any)).thenAnswer((_) async { + final request = MockHttpClientRequest(); + final response = MockHttpClientResponse(); + mockRequestHeaders = MockHttpHeaders(); + when( + mockRequestHeaders.set( + any, + any, + preserveHeaderCase: anyNamed('preserveHeaderCase'), + ), + ).thenAnswer((invocation) { + final args = invocation.positionalArguments.cast(); + final preserveHeaderCase = + invocation.namedArguments[#preserveHeaderCase] as bool; + headerMap[preserveHeaderCase ? args[0] : args[0].toLowerCase()] = + args[1]; + }); + when(request.headers).thenAnswer((_) => mockRequestHeaders); + when(request.close()).thenAnswer((_) => Future.value(response)); + when(request.addStream(any)).thenAnswer((_) async => null); + when(response.headers).thenReturn(MockHttpHeaders()); + when(response.statusCode).thenReturn(200); + when(response.reasonPhrase).thenReturn('OK'); + when(response.isRedirect).thenReturn(false); + when(response.redirects).thenReturn([]); + when(response.transform(any)) + .thenAnswer((_) => Stream.empty()); + return Future.value(request); + }); + + await dio.get( + '', + options: Options( + preserveHeaderCase: true, + headers: {'Sensitive': 'test', 'insensitive': 'test'}, + ), + ); + expect(headerMap['Sensitive'], 'test'); + expect(headerMap['insensitive'], 'test'); + headerMap.clear(); + + await dio.get( + '', + options: Options( + headers: {'Sensitive': 'test', 'insensitive': 'test'}, + ), + ); + expect(headerMap['sensitive'], 'test'); + expect(headerMap['insensitive'], 'test'); + }); } diff --git a/plugins/native_dio_adapter/CHANGELOG.md b/plugins/native_dio_adapter/CHANGELOG.md index 7e9bdaf7e..53d3386b3 100644 --- a/plugins/native_dio_adapter/CHANGELOG.md +++ b/plugins/native_dio_adapter/CHANGELOG.md @@ -15,6 +15,7 @@ - Bump `cronet_http` version. - Minimal required Dart version is now 3.1. - Minimal required Flutter version is now 3.13.0. +- Allow case-sensitive header keys with the `preserveHeaderCase` flag through options. ## 1.0.0+2 diff --git a/plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart b/plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart index a035f2c45..0e1904f0b 100644 --- a/plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart +++ b/plugins/native_dio_adapter/lib/src/conversion_layer_adapter.dart @@ -24,7 +24,7 @@ class ConversionLayerAdapter implements HttpClientAdapter { ) async { final request = await _fromOptionsAndStream(options, requestStream); final response = await client.send(request); - return response.toDioResponseBody(); + return response.toDioResponseBody(options); } @override @@ -41,7 +41,12 @@ class ConversionLayerAdapter implements HttpClientAdapter { request.headers.addAll( Map.fromEntries( - options.headers.entries.map((e) => MapEntry(e.key, e.value.toString())), + options.headers.entries.map( + (e) => MapEntry( + options.preserveHeaderCase ? e.key : e.key.toLowerCase(), + e.value.toString(), + ), + ), ), ); @@ -67,8 +72,13 @@ class ConversionLayerAdapter implements HttpClientAdapter { } extension on StreamedResponse { - ResponseBody toDioResponseBody() { - final dioHeaders = headers.entries.map((e) => MapEntry(e.key, [e.value])); + ResponseBody toDioResponseBody(RequestOptions options) { + final dioHeaders = headers.entries.map( + (e) => MapEntry( + options.preserveHeaderCase ? e.key : e.key.toLowerCase(), + [e.value], + ), + ); return ResponseBody( stream.cast(), statusCode,