Skip to content

Commit

Permalink
Case-sensitive headers (#2008)
Browse files Browse the repository at this point in the history
Fixes #2002 #1102 #788 #641

### New Pull Request Checklist

- [x] I have read the
[Documentation](https://pub.dev/documentation/dio/latest/)
- [x] I have searched for a similar pull request in the
[project](https://github.com/cfug/dio/pulls) and found none
- [x] I have updated this branch with the latest `main` branch to avoid
conflicts (via merge from master or rebase)
- [x] I have added the required tests to prove the fix/feature I'm
adding
- [x] I have updated the documentation (if necessary)
- [x] I have run the tests without failures
- [x] I have updated the `CHANGELOG.md` in the corresponding package

### Additional context and info (if any)

We have `camelCaseContentDisposition` for `FormData` but this is not
related.

---------

Signed-off-by: Alex Li <[email protected]>
  • Loading branch information
AlexV525 authored Nov 25, 2023
1 parent bb6d65b commit 4d61f65
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 48 deletions.
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,
/// 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

0 comments on commit 4d61f65

Please sign in to comment.