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
3 changes: 2 additions & 1 deletion dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ See the [Migration Guide][] for the complete breaking changes list.**
## Unreleased

- Raise warning for `Map`s other than `Map<String, dynamic>` when encoding request data.
- Improve exception messages
- Improve exception messages.
- Allow case-sensitive header keys with the `preserveHeaderCase` flag through options.

## 5.3.3

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 @@ -707,7 +710,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 @@ -128,6 +128,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 @@ -146,6 +147,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 @@ -173,6 +175,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 @@ -193,6 +196,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 @@ -216,6 +220,7 @@ class Options {
Duration? receiveTimeout,
this.extra,
this.headers,
this.preserveHeaderCase,
this.responseType,
this.contentType,
this.validateStatus,
Expand All @@ -238,6 +243,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 @@ -274,6 +280,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 @@ -322,6 +329,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 @@ -352,10 +360,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 @@ -472,6 +489,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 @@ -491,6 +509,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 @@ -523,6 +542,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 @@ -559,6 +579,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 @@ -636,6 +657,7 @@ class _RequestConfig {
String? method,
Map<String, dynamic>? extra,
Map<String, dynamic>? headers,
bool? preserveHeaderCase,
String? contentType,
ListFormat? listFormat,
bool? followRedirects,
Expand All @@ -651,6 +673,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 @@ -690,6 +713,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);
});
});
}
35 changes: 35 additions & 0 deletions dio/test/options_test.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
@TestOn('vm')
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:dio/io.dart';
import 'package:test/test.dart';

import 'mock/adapters.dart';
Expand Down Expand Up @@ -477,4 +480,36 @@ void main() {
expect(response.data, 'test');
}
});

test('Headers can be case-sensitive', () async {
final dio = Dio()..options.baseUrl = 'https://httpbun.com/';
dio.httpClientAdapter = IOHttpClientAdapter(
createHttpClient: () {
return HttpClient()..findProxy = (_) => 'PROXY 192.168.0.10:8764';
},
);
final sensitiveResponse = await dio.get<Map<String, dynamic>>(
'/headers',
options: Options(
preserveHeaderCase: true,
headers: {
'Sensitive': 'test',
'insensitive': 'test',
},
),
);
expect(sensitiveResponse.data!['Sensitive'], 'test');
expect(sensitiveResponse.data!['insensitive'], 'test');
final inSensitiveResponse = await dio.get<Map<String, dynamic>>(
'/headers',
options: Options(
headers: {
'Sensitive': 'test',
'insensitive': 'test',
},
),
);
expect(inSensitiveResponse.data!['sensitive'], 'test');
expect(inSensitiveResponse.data!['insensitive'], 'test');
});
}
Loading
Loading