Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Case-sensitive headers #2008

Merged
merged 17 commits into from
Nov 25, 2023
1 change: 1 addition & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions dio/README-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,20 @@ String method;
String? baseUrl;

/// HTTP 请求头。
///
/// 请求头的键是否相等的判断大小写不敏感的。
/// 例如:`content-type` 和 `Content-Type` 会视为同样的请求头键。
Map<String, dynamic>? headers;

/// 是否保留请求头的大小写。
///
/// 默认值为 false。
///
/// 该选项在以下场景无效:
/// - XHR 不支持直接处理。
/// - 按照 HTTP/2 的标准,只支持小写请求头键。
bool? preserveHeaderCase;

/// 连接服务器超时时间.
Duration? connectTimeout;

Expand Down
12 changes: 12 additions & 0 deletions dio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>? 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;

Expand Down
10 changes: 8 additions & 2 deletions dio/lib/src/adapters/io_adapter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
10 changes: 8 additions & 2 deletions dio/lib/src/dio_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<dynamic>(
Expand Down Expand Up @@ -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;
}
Expand Down
23 changes: 16 additions & 7 deletions dio/lib/src/headers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ typedef HeaderForEachCallback = void Function(String name, List<String> values);

/// The headers class for requests and responses.
class Headers {
Headers() : _map = caseInsensitiveKeyMap<List<String>>();
Headers({
this.preserveHeaderCase = false,
}) : _map = caseInsensitiveKeyMap<List<String>>();

/// Create the [Headers] from a [Map] instance.
Headers.fromMap(Map<String, List<String>> map)
: _map = caseInsensitiveKeyMap<List<String>>(
map.map((k, v) => MapEntry(k.trim().toLowerCase(), v)),
Headers.fromMap(
Map<String, List<String>> map, {
this.preserveHeaderCase = false,
}) : _map = caseInsensitiveKeyMap<List<String>>(
map.map((k, v) => MapEntry(k.trim(), v)),
);

static const acceptHeader = 'accept';
Expand All @@ -28,14 +32,19 @@ class Headers {

static final jsonMimeType = MediaType.parse(jsonContentType);

/// Whether the header key should be case-sensitive.
///
/// Defaults to false.
final bool preserveHeaderCase;

final Map<String, List<String>> _map;

Map<String, List<String>> get map => _map;

/// Returns the list of values for the header named [name]. If there
/// is no header with the provided name, [:null:] will be returned.
List<String>? operator [](String name) {
return _map[name.trim().toLowerCase()];
return _map[name.trim()];
}

/// Convenience method for the value for a single valued header. If
Expand Down Expand Up @@ -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<String>((e) => e.toString()).toList();
_map[name] = value.map<String>((e) => '$e').toList();
} else {
_map[name] = ['$value'.trim()];
}
Expand Down
29 changes: 27 additions & 2 deletions dio/lib/src/options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin {
Map<String, dynamic>? queryParameters,
Map<String, dynamic>? extra,
Map<String, dynamic>? headers,
bool preserveHeaderCase = false,
ResponseType? responseType = ResponseType.json,
String? contentType,
ValidateStatus? validateStatus,
Expand All @@ -148,6 +149,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin {
sendTimeout: sendTimeout,
extra: extra,
headers: headers,
preserveHeaderCase: preserveHeaderCase,
responseType: responseType,
contentType: contentType,
validateStatus: validateStatus,
Expand Down Expand Up @@ -175,6 +177,7 @@ class BaseOptions extends _RequestConfig with OptionsMixin {
Duration? sendTimeout,
Map<String, Object?>? extra,
Map<String, Object?>? headers,
bool? preserveHeaderCase,
ResponseType? responseType,
String? contentType,
ValidateStatus? validateStatus,
Expand All @@ -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,
Expand All @@ -218,6 +222,7 @@ class Options {
Duration? receiveTimeout,
this.extra,
this.headers,
this.preserveHeaderCase,
this.responseType,
this.contentType,
this.validateStatus,
Expand All @@ -240,6 +245,7 @@ class Options {
Duration? receiveTimeout,
Map<String, Object?>? extra,
Map<String, Object?>? headers,
bool? preserveHeaderCase,
ResponseType? responseType,
String? contentType,
ValidateStatus? validateStatus,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
ueman marked this conversation as resolved.
Show resolved Hide resolved
/// e.g.: `content-type` and `Content-Type` will be treated as the same key.
Map<String, dynamic>? 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
Expand Down Expand Up @@ -474,6 +491,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin {
String? baseUrl,
Map<String, dynamic>? extra,
Map<String, dynamic>? headers,
bool? preserveHeaderCase,
ResponseType? responseType,
String? contentType,
ValidateStatus? validateStatus,
Expand All @@ -493,6 +511,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin {
receiveTimeout: receiveTimeout,
extra: extra,
headers: headers,
preserveHeaderCase: preserveHeaderCase,
responseType: responseType,
contentType: contentType,
validateStatus: validateStatus,
Expand Down Expand Up @@ -525,6 +544,7 @@ class RequestOptions extends _RequestConfig with OptionsMixin {
CancelToken? cancelToken,
Map<String, dynamic>? extra,
Map<String, dynamic>? headers,
bool? preserveHeaderCase,
ResponseType? responseType,
String? contentType,
ValidateStatus? validateStatus,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -638,6 +659,7 @@ class _RequestConfig {
String? method,
Map<String, dynamic>? extra,
Map<String, dynamic>? headers,
bool? preserveHeaderCase,
String? contentType,
ListFormat? listFormat,
bool? followRedirects,
Expand All @@ -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,
Expand Down Expand Up @@ -692,6 +715,8 @@ class _RequestConfig {
}
}

late bool preserveHeaderCase;

Duration? get sendTimeout => _sendTimeout;
Duration? _sendTimeout;

Expand Down
3 changes: 2 additions & 1 deletion dio/lib/src/response.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class Response<T> {
this.redirects = const [],
Map<String, dynamic>? extra,
Headers? headers,
}) : headers = headers ?? Headers(),
}) : headers = headers ??
Headers(preserveHeaderCase: requestOptions.preserveHeaderCase),
extra = extra ?? <String, dynamic>{};

/// The response payload in specific type.
Expand Down
30 changes: 0 additions & 30 deletions dio/test/basic_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
56 changes: 56 additions & 0 deletions dio/test/headers_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading