add: adding ports from the symfony project
This commit is contained in:
parent
72bfd6a4f0
commit
5b1016cb54
6 changed files with 2061 additions and 0 deletions
87
design.draft
Normal file
87
design.draft
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
fabric-platform/
|
||||||
|
├── .devbox/
|
||||||
|
│ ├
|
||||||
|
├── .github/
|
||||||
|
│ ├
|
||||||
|
├── .vscode/
|
||||||
|
│ ├
|
||||||
|
├── config/
|
||||||
|
│ ├
|
||||||
|
├── docs/
|
||||||
|
│ ├
|
||||||
|
├── example/
|
||||||
|
│ ├
|
||||||
|
├── packages/
|
||||||
|
│ ├── auth/
|
||||||
|
│ ├── base/
|
||||||
|
│ ├── broadcasting/
|
||||||
|
│ ├── bus/
|
||||||
|
│ ├── cache/
|
||||||
|
│ ├── collections/
|
||||||
|
│ ├── conditionable/
|
||||||
|
│ ├── config/
|
||||||
|
│ ├── console/
|
||||||
|
│ │ ├── cli/
|
||||||
|
│ ├── container/
|
||||||
|
│ ├── contracts/
|
||||||
|
│ ├── cookie/
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── orm/
|
||||||
|
│ ├── encryption/
|
||||||
|
│ ├── events/
|
||||||
|
│ ├── extensions/
|
||||||
|
│ │ ├── kafka/
|
||||||
|
│ │ ├── mongodb/
|
||||||
|
│ │ ├── redis/
|
||||||
|
│ │ ├── blockchain/
|
||||||
|
│ │ └── other extensions/
|
||||||
|
│ ├── filesystem/
|
||||||
|
│ ├── foundation/
|
||||||
|
│ ├── hashing/
|
||||||
|
│ ├── http/
|
||||||
|
│ ├── log/
|
||||||
|
│ ├── macroable/
|
||||||
|
│ ├── mail/
|
||||||
|
│ ├── notifications/
|
||||||
|
│ ├── pagination/
|
||||||
|
│ ├── pipeline/
|
||||||
|
│ ├── process/
|
||||||
|
│ ├── queue/
|
||||||
|
│ ├── routing/
|
||||||
|
│ ├── rtc/
|
||||||
|
│ │ ├── websocket/
|
||||||
|
│ │ └── gRPC/
|
||||||
|
│ ├── session/
|
||||||
|
│ ├── support/
|
||||||
|
│ ├── testing/
|
||||||
|
│ ├── translation/
|
||||||
|
│ ├── validation/
|
||||||
|
│ ├── view/
|
||||||
|
│ ├
|
||||||
|
├── scripts/
|
||||||
|
│ ├
|
||||||
|
├── spike/
|
||||||
|
│ ├
|
||||||
|
├── stubs/
|
||||||
|
│ ├
|
||||||
|
├── test/
|
||||||
|
│ ├
|
||||||
|
├── tool/
|
||||||
|
│ ├
|
||||||
|
│
|
||||||
|
├── .editorconfig
|
||||||
|
├── .gitattributes
|
||||||
|
├── .gitignore
|
||||||
|
├── .publishable
|
||||||
|
├── .toolversion
|
||||||
|
├── analysis_options.yaml
|
||||||
|
├── AUTHORS.md
|
||||||
|
├── CHANGELOG.md
|
||||||
|
├── CODE_OF_CONDUCT.md
|
||||||
|
├── CONTRIBUTING.md
|
||||||
|
├── LICENSE
|
||||||
|
├── melos.yaml
|
||||||
|
├── pubspec.yaml
|
||||||
|
├── README.md
|
||||||
|
├── SECURITY.md
|
||||||
|
└── VFP_VERSION
|
255
packages/http/lib/src/foundation/cookie.dart
Normal file
255
packages/http/lib/src/foundation/cookie.dart
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
/*
|
||||||
|
* This file is part of the VieoFabric package.
|
||||||
|
*
|
||||||
|
* (c) Patrick Stewart <patrick@example.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'header_utils.dart';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
/// Represents a cookie.
|
||||||
|
class Cookie {
|
||||||
|
static const String SAMESITE_NONE = 'none';
|
||||||
|
static const String SAMESITE_LAX = 'lax';
|
||||||
|
static const String SAMESITE_STRICT = 'strict';
|
||||||
|
|
||||||
|
late String name;
|
||||||
|
String? value;
|
||||||
|
String? domain;
|
||||||
|
late int expire;
|
||||||
|
late String path;
|
||||||
|
bool? secure;
|
||||||
|
late bool httpOnly;
|
||||||
|
|
||||||
|
late bool raw;
|
||||||
|
String? sameSite;
|
||||||
|
late bool partitioned;
|
||||||
|
late bool secureDefault;
|
||||||
|
|
||||||
|
static const String RESERVED_CHARS_LIST = "=,; \t\r\n\v\f";
|
||||||
|
static const List<String> RESERVED_CHARS_FROM = ['=', ',', ';', ' ', "\t", "\r", "\n", "\v", "\f"];
|
||||||
|
static const List<String> RESERVED_CHARS_TO = ['%3D', '%2C', '%3B', '%20', '%09', '%0D', '%0A', '%0B', '%0C'];
|
||||||
|
|
||||||
|
/// Creates cookie from raw header string.
|
||||||
|
static Cookie fromString(String cookie, {bool decode = false}) {
|
||||||
|
final data = <String, Object?>{
|
||||||
|
'expires': 0,
|
||||||
|
'path': '/',
|
||||||
|
'domain': null,
|
||||||
|
'secure': false,
|
||||||
|
'httponly': false,
|
||||||
|
'raw': !decode,
|
||||||
|
'samesite': null,
|
||||||
|
'partitioned': false,
|
||||||
|
};
|
||||||
|
|
||||||
|
final parts = HeaderUtils.split(cookie, ';=');
|
||||||
|
final part = parts.removeAt(0);
|
||||||
|
|
||||||
|
final name = decode ? Uri.decodeComponent(part[0]) : part[0];
|
||||||
|
final value = part.length > 1 ? (decode ? Uri.decodeComponent(part[1]) : part[1]) : null;
|
||||||
|
|
||||||
|
data.addAll(HeaderUtils.combine(parts));
|
||||||
|
data['expires'] = _expiresTimestamp(data['expires']);
|
||||||
|
|
||||||
|
if (data.containsKey('max-age') && data['max-age'] != null && data['expires'] != null) {
|
||||||
|
if ((data['max-age'] as int) > 0 || (data['expires'] as int) > DateTime.now().millisecondsSinceEpoch) {
|
||||||
|
data['expires'] = DateTime.now().millisecondsSinceEpoch + (data['max-age'] as int);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cookie._internal(
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
data['expires'] as int,
|
||||||
|
data['path'] as String,
|
||||||
|
data['domain'] as String?,
|
||||||
|
data['secure'] as bool,
|
||||||
|
data['httponly'] as bool,
|
||||||
|
data['raw'] as bool,
|
||||||
|
data['samesite'] as String?,
|
||||||
|
data['partitioned'] as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Factory constructor for creating a new cookie.
|
||||||
|
factory Cookie.create(String name, {String? value, dynamic expire = 0, String? path = '/', String? domain, bool? secure, bool httpOnly = true, bool raw = false, String? sameSite = SAMESITE_LAX, bool partitioned = false}) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
Cookie._internal(this.name, this.value, dynamic expire, String? path, this.domain, this.secure, this.httpOnly, this.raw, this.sameSite, this.partitioned) {
|
||||||
|
if (raw && name.contains(RegExp(r'[' + RESERVED_CHARS_LIST + r']'))) {
|
||||||
|
throw ArgumentError('The cookie name "$name" contains invalid characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isEmpty) {
|
||||||
|
throw ArgumentError('The cookie name cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.expire = _expiresTimestamp(expire);
|
||||||
|
this.path = path ?? '/';
|
||||||
|
this.secureDefault = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy with a new value.
|
||||||
|
Cookie withValue(String? value) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy with a new domain that the cookie is available to.
|
||||||
|
Cookie withDomain(String? domain) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy with a new time the cookie expires.
|
||||||
|
Cookie withExpires(dynamic expire) {
|
||||||
|
return Cookie._internal(name, value, _expiresTimestamp(expire), path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts expires formats to a unix timestamp.
|
||||||
|
static int _expiresTimestamp(dynamic expire) {
|
||||||
|
if (expire is DateTime) {
|
||||||
|
return expire.millisecondsSinceEpoch ~/ 1000;
|
||||||
|
} else if (expire is int) {
|
||||||
|
return expire;
|
||||||
|
} else if (expire is String) {
|
||||||
|
return DateTime.parse(expire).millisecondsSinceEpoch ~/ 1000;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError('The cookie expiration time is not valid.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy with a new path on the server in which the cookie will be available on.
|
||||||
|
Cookie withPath(String path) {
|
||||||
|
return Cookie._internal(name, value, expire, path.isEmpty ? '/' : path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy that only be transmitted over a secure HTTPS connection from the client.
|
||||||
|
Cookie withSecure(bool secure) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy that be accessible only through the HTTP protocol.
|
||||||
|
Cookie withHttpOnly(bool httpOnly) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy that uses no url encoding.
|
||||||
|
Cookie withRaw(bool raw) {
|
||||||
|
if (raw && name.contains(RegExp(r'[' + RESERVED_CHARS_LIST + r']'))) {
|
||||||
|
throw ArgumentError('The cookie name "$name" contains invalid characters.');
|
||||||
|
}
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy with SameSite attribute.
|
||||||
|
Cookie withSameSite(String? sameSite) {
|
||||||
|
final validSameSite = [SAMESITE_LAX, SAMESITE_STRICT, SAMESITE_NONE, null];
|
||||||
|
if (!validSameSite.contains(sameSite?.toLowerCase())) {
|
||||||
|
throw ArgumentError('The "sameSite" parameter value is not valid.');
|
||||||
|
}
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite?.toLowerCase(), partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a cookie copy that is tied to the top-level site in cross-site context.
|
||||||
|
Cookie withPartitioned(bool partitioned) {
|
||||||
|
return Cookie._internal(name, value, expire, path, domain, secure, httpOnly, raw, sameSite, partitioned);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the cookie as a string.
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
|
||||||
|
if (raw) {
|
||||||
|
buffer.write(name);
|
||||||
|
} else {
|
||||||
|
buffer.write(Uri.encodeComponent(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.write('=');
|
||||||
|
|
||||||
|
if (value == null || value!.isEmpty) {
|
||||||
|
buffer.write('deleted; expires=${DateTime.fromMillisecondsSinceEpoch(DateTime.now().millisecondsSinceEpoch - 31536001).toUtc().toIso8601String()}; Max-Age=0');
|
||||||
|
} else {
|
||||||
|
buffer.write(raw ? value : Uri.encodeComponent(value!));
|
||||||
|
|
||||||
|
if (expire != 0) {
|
||||||
|
buffer.write('; expires=${DateTime.fromMillisecondsSinceEpoch(expire * 1000).toUtc().toIso8601String()}; Max-Age=${getMaxAge()}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.isNotEmpty) {
|
||||||
|
buffer.write('; path=$path');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain != null && domain!.isNotEmpty) {
|
||||||
|
buffer.write('; domain=$domain');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSecure()) {
|
||||||
|
buffer.write('; secure');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (httpOnly) {
|
||||||
|
buffer.write('; httponly');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sameSite != null) {
|
||||||
|
buffer.write('; samesite=$sameSite');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partitioned) {
|
||||||
|
buffer.write('; partitioned');
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the name of the cookie.
|
||||||
|
String getName() => name;
|
||||||
|
|
||||||
|
/// Gets the value of the cookie.
|
||||||
|
String? getValue() => value;
|
||||||
|
|
||||||
|
/// Gets the domain that the cookie is available to.
|
||||||
|
String? getDomain() => domain;
|
||||||
|
|
||||||
|
/// Gets the time the cookie expires.
|
||||||
|
int getExpiresTime() => expire;
|
||||||
|
|
||||||
|
/// Gets the max-age attribute.
|
||||||
|
int getMaxAge() {
|
||||||
|
final maxAge = expire - (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||||
|
return max(0, maxAge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the path on the server in which the cookie will be available on.
|
||||||
|
String getPath() => path;
|
||||||
|
|
||||||
|
/// Checks whether the cookie should only be transmitted over a secure HTTPS connection from the client.
|
||||||
|
bool isSecure() => secure ?? secureDefault;
|
||||||
|
|
||||||
|
/// Checks whether the cookie will be made accessible only through the HTTP protocol.
|
||||||
|
bool isHttpOnly() => httpOnly;
|
||||||
|
|
||||||
|
/// Whether this cookie is about to be cleared.
|
||||||
|
bool isCleared() => expire != 0 && expire < (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||||
|
|
||||||
|
/// Checks if the cookie value should be sent with no url encoding.
|
||||||
|
bool isRaw() => raw;
|
||||||
|
|
||||||
|
/// Checks whether the cookie should be tied to the top-level site in cross-site context.
|
||||||
|
bool isPartitioned() => partitioned;
|
||||||
|
|
||||||
|
/// Gets the SameSite attribute of the cookie.
|
||||||
|
String? getSameSite() => sameSite;
|
||||||
|
|
||||||
|
/// Sets the default value of the "secure" flag when it is set to null.
|
||||||
|
void setSecureDefault(bool defaultSecure) {
|
||||||
|
secureDefault = defaultSecure;
|
||||||
|
}
|
||||||
|
}
|
204
packages/http/lib/src/foundation/header_bag.dart
Normal file
204
packages/http/lib/src/foundation/header_bag.dart
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
/// HeaderBag is a container for HTTP headers.
|
||||||
|
///
|
||||||
|
/// Author: Fabien Potencier <fabien@symfony.com>
|
||||||
|
/// This file is part of the Symfony package.
|
||||||
|
class HeaderBag extends IterableBase<MapEntry<String, List<String?>>> {
|
||||||
|
static const String upper = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||||
|
static const String lower = '-abcdefghijklmnopqrstuvwxyz';
|
||||||
|
|
||||||
|
/// A map to store the headers.
|
||||||
|
final Map<String, List<String?>> _headers = {};
|
||||||
|
|
||||||
|
/// A map to store cache control directives.
|
||||||
|
final Map<String, dynamic> _cacheControl = {};
|
||||||
|
|
||||||
|
/// Constructor for HeaderBag
|
||||||
|
HeaderBag([Map<String, List<String?>> headers = const {}]) {
|
||||||
|
headers.forEach((key, values) {
|
||||||
|
set(key, values);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the headers as a string.
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
if (_headers.isEmpty) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedHeaders = SplayTreeMap<String, List<String?>>.from(_headers);
|
||||||
|
var max = sortedHeaders.keys.map((k) => k.length).reduce((a, b) => a > b ? a : b) + 1;
|
||||||
|
var content = StringBuffer();
|
||||||
|
|
||||||
|
for (var entry in sortedHeaders.entries) {
|
||||||
|
var name = entry.key.replaceAllMapped(RegExp(r'-([a-z])'), (match) => '-${match.group(1)!.toUpperCase()}');
|
||||||
|
for (var value in entry.value) {
|
||||||
|
content.write('${name.padRight(max)}: $value\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the headers.
|
||||||
|
///
|
||||||
|
/// @param key The name of the headers to return or null to get them all
|
||||||
|
///
|
||||||
|
/// @return A map of headers.
|
||||||
|
Map<String, List<String?>> all([String? key]) {
|
||||||
|
if (key != null) {
|
||||||
|
return {key.toLowerCase(): _headers[key.toLowerCase()] ?? []};
|
||||||
|
}
|
||||||
|
return _headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the parameter keys.
|
||||||
|
///
|
||||||
|
/// @return A list of keys.
|
||||||
|
List<String> keys() {
|
||||||
|
return _headers.keys.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the current HTTP headers by a new set.
|
||||||
|
void replace(Map<String, List<String?>> headers) {
|
||||||
|
_headers.clear();
|
||||||
|
add(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds new headers to the current HTTP headers set.
|
||||||
|
void add(Map<String, List<String?>> headers) {
|
||||||
|
headers.forEach((key, values) {
|
||||||
|
set(key, values);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the first header by name or the default one.
|
||||||
|
String? get(String key, [String? defaultValue]) {
|
||||||
|
var headers = all(key)[key.toLowerCase()];
|
||||||
|
if (headers == null || headers.isEmpty) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return headers[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a header by name.
|
||||||
|
///
|
||||||
|
/// @param values The value or an array of values
|
||||||
|
/// @param replace Whether to replace the actual value or not (true by default)
|
||||||
|
void set(String key, dynamic values, [bool replace = true]) {
|
||||||
|
key = key.toLowerCase();
|
||||||
|
List<String?> valueList;
|
||||||
|
|
||||||
|
if (values is List) {
|
||||||
|
valueList = List<String?>.from(values);
|
||||||
|
if (replace || !_headers.containsKey(key)) {
|
||||||
|
_headers[key] = valueList;
|
||||||
|
} else {
|
||||||
|
_headers[key] = List<String?>.from(_headers[key]!)..addAll(valueList);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (replace || !_headers.containsKey(key)) {
|
||||||
|
_headers[key] = [values];
|
||||||
|
} else {
|
||||||
|
_headers[key]!.add(values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key == 'cache-control') {
|
||||||
|
_cacheControl.addAll(_parseCacheControl(_headers[key]!.join(', ')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the HTTP header is defined.
|
||||||
|
bool hasHeader(String key) {
|
||||||
|
return _headers.containsKey(key.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the given HTTP header contains the given value.
|
||||||
|
bool containsHeaderValue(String key, String value) {
|
||||||
|
return _headers[key.toLowerCase()]?.contains(value) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a header.
|
||||||
|
void remove(String key) {
|
||||||
|
key = key.toLowerCase();
|
||||||
|
_headers.remove(key);
|
||||||
|
if (key == 'cache-control') {
|
||||||
|
_cacheControl.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HTTP header value converted to a date.
|
||||||
|
///
|
||||||
|
/// Throws an exception when the HTTP header is not parseable.
|
||||||
|
DateTime? getDate(String key, [DateTime? defaultValue]) {
|
||||||
|
var value = get(key);
|
||||||
|
if (value == null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DateTime.parse(value);
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('The "$key" HTTP header is not parseable ($value).');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a custom Cache-Control directive.
|
||||||
|
void addCacheControlDirective(String key, [dynamic value = true]) {
|
||||||
|
_cacheControl[key] = value;
|
||||||
|
set('Cache-Control', getCacheControlHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the Cache-Control directive is defined.
|
||||||
|
bool hasCacheControlDirective(String key) {
|
||||||
|
return _cacheControl.containsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a Cache-Control directive value by name.
|
||||||
|
dynamic getCacheControlDirective(String key) {
|
||||||
|
return _cacheControl[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a Cache-Control directive.
|
||||||
|
void removeCacheControlDirective(String key) {
|
||||||
|
_cacheControl.remove(key);
|
||||||
|
set('Cache-Control', getCacheControlHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an iterator for headers.
|
||||||
|
///
|
||||||
|
/// @return An iterator of MapEntry.
|
||||||
|
@override
|
||||||
|
Iterator<MapEntry<String, List<String?>>> get iterator {
|
||||||
|
return _headers.entries.iterator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of headers.
|
||||||
|
@override
|
||||||
|
int get length {
|
||||||
|
return _headers.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates the Cache-Control header value.
|
||||||
|
///
|
||||||
|
/// @return A string representation of the Cache-Control header.
|
||||||
|
String getCacheControlHeader() {
|
||||||
|
var sortedCacheControl = SplayTreeMap<String, dynamic>.from(_cacheControl);
|
||||||
|
return sortedCacheControl.entries.map((e) => '${e.key}=${e.value}').join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a Cache-Control HTTP header.
|
||||||
|
///
|
||||||
|
/// @return A map of Cache-Control directives.
|
||||||
|
Map<String, dynamic> _parseCacheControl(String header) {
|
||||||
|
var parts = header.split(',').map((e) => e.split('=')).toList();
|
||||||
|
var map = <String, dynamic>{};
|
||||||
|
for (var part in parts) {
|
||||||
|
map[part[0].trim()] = part.length > 1 ? part[1].trim() : true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
264
packages/http/lib/src/foundation/header_utils.dart
Normal file
264
packages/http/lib/src/foundation/header_utils.dart
Normal file
|
@ -0,0 +1,264 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class HeaderUtils {
|
||||||
|
static const String DISPOSITION_ATTACHMENT = 'attachment';
|
||||||
|
static const String DISPOSITION_INLINE = 'inline';
|
||||||
|
|
||||||
|
// This class should not be instantiated.
|
||||||
|
HeaderUtils._();
|
||||||
|
|
||||||
|
/// Splits an HTTP header by one or more separators.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// HeaderUtils.split('da, en-gb;q=0.8', ',;')
|
||||||
|
/// // => [['da'], ['en-gb', 'q=0.8']]
|
||||||
|
///
|
||||||
|
/// @param String separators List of characters to split on, ordered by
|
||||||
|
/// precedence, e.g. ',', ';=', or ',;='
|
||||||
|
///
|
||||||
|
/// @return List<List<String>> Nested array with as many levels as there are characters in
|
||||||
|
/// separators
|
||||||
|
static List<List<String>> split(String header, String separators) {
|
||||||
|
if (separators.isEmpty) {
|
||||||
|
throw ArgumentError('At least one separator must be specified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final quotedSeparators = RegExp.escape(separators);
|
||||||
|
|
||||||
|
final pattern = '''
|
||||||
|
(?!\\s)
|
||||||
|
(?:
|
||||||
|
# quoted-string
|
||||||
|
"(?:[^"\\\\]|\\\\.)*(?:"|\\\\|)"
|
||||||
|
|
|
||||||
|
# token
|
||||||
|
[^"$quotedSeparators]+
|
||||||
|
)+
|
||||||
|
(?<!\\s)
|
||||||
|
|
|
||||||
|
# separator
|
||||||
|
\\s*
|
||||||
|
(?<separator>[$quotedSeparators])
|
||||||
|
\\s*
|
||||||
|
''';
|
||||||
|
|
||||||
|
final matches = RegExp(pattern, multiLine: true, dotAll: true, caseSensitive: false)
|
||||||
|
.allMatches(header.trim())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return _groupParts(matches, separators);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Combines an array of arrays into one associative array.
|
||||||
|
///
|
||||||
|
/// Each of the nested arrays should have one or two elements. The first
|
||||||
|
/// value will be used as the keys in the associative array, and the second
|
||||||
|
/// will be used as the values, or true if the nested array only contains one
|
||||||
|
/// element. Array keys are lowercased.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// HeaderUtils.combine([['foo', 'abc'], ['bar']])
|
||||||
|
/// // => {'foo': 'abc', 'bar': true}
|
||||||
|
static Map<String, dynamic> combine(List<List<String>> parts) {
|
||||||
|
final assoc = <String, dynamic>{};
|
||||||
|
for (var part in parts) {
|
||||||
|
final name = part[0].toLowerCase();
|
||||||
|
final value = part.length > 1 ? part[1] : true;
|
||||||
|
assoc[name] = value;
|
||||||
|
}
|
||||||
|
return assoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins an associative array into a string for use in an HTTP header.
|
||||||
|
///
|
||||||
|
/// The key and value of each entry are joined with '=', and all entries
|
||||||
|
/// are joined with the specified separator and an additional space (for
|
||||||
|
/// readability). Values are quoted if necessary.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
///
|
||||||
|
/// HeaderUtils.headerToString({'foo': 'abc', 'bar': true, 'baz': 'a b c'}, ',')
|
||||||
|
/// // => 'foo=abc, bar, baz="a b c"'
|
||||||
|
static String headerToString(Map<String, dynamic> assoc, String separator) {
|
||||||
|
final parts = <String>[];
|
||||||
|
assoc.forEach((name, value) {
|
||||||
|
if (value == true) {
|
||||||
|
parts.add(name);
|
||||||
|
} else {
|
||||||
|
parts.add('$name=${quote(value.toString())}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return parts.join('$separator ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes a string as a quoted string, if necessary.
|
||||||
|
///
|
||||||
|
/// If a string contains characters not allowed by the "token" construct in
|
||||||
|
/// the HTTP specification, it is backslash-escaped and enclosed in quotes
|
||||||
|
/// to match the "quoted-string" construct.
|
||||||
|
static String quote(String? s) {
|
||||||
|
if (s == null) {
|
||||||
|
throw ArgumentError('Input string cannot be null');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.isEmpty) {
|
||||||
|
return '""';
|
||||||
|
}
|
||||||
|
|
||||||
|
final isQuotingAllowed = _isQuotingAllowed(s);
|
||||||
|
|
||||||
|
if (!isQuotingAllowed) {
|
||||||
|
return '"${s.replaceAll('"', '\\"')}"';
|
||||||
|
}
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _isQuotingAllowed(String s) {
|
||||||
|
final pattern = RegExp('^[a-zA-Z0-9!#\$%&\'*+\\-\\.^_`|~]+\$');
|
||||||
|
return pattern.hasMatch(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a quoted string.
|
||||||
|
///
|
||||||
|
/// If passed an unquoted string that matches the "token" construct (as
|
||||||
|
/// defined in the HTTP specification), it is passed through verbatim.
|
||||||
|
static String unquote(String s) {
|
||||||
|
return s.replaceAllMapped(RegExp(r'\\(.)|\"'), (match) => match[1] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates an HTTP Content-Disposition field-value.
|
||||||
|
///
|
||||||
|
/// @param String disposition One of "inline" or "attachment"
|
||||||
|
/// @param String filename A unicode string
|
||||||
|
/// @param String filenameFallback A string containing only ASCII characters that
|
||||||
|
/// is semantically equivalent to filename. If the filename is already ASCII,
|
||||||
|
/// it can be omitted, or just copied from filename
|
||||||
|
///
|
||||||
|
/// @throws ArgumentError
|
||||||
|
///
|
||||||
|
/// @see RFC 6266
|
||||||
|
static String makeDisposition(String disposition, String filename, [String filenameFallback = '']) {
|
||||||
|
if (![DISPOSITION_ATTACHMENT, DISPOSITION_INLINE].contains(disposition)) {
|
||||||
|
throw ArgumentError('The disposition must be either "$DISPOSITION_ATTACHMENT" or "$DISPOSITION_INLINE".');
|
||||||
|
}
|
||||||
|
|
||||||
|
filenameFallback = filenameFallback.isEmpty ? filename : filenameFallback;
|
||||||
|
|
||||||
|
if (!RegExp(r'^[\x20-\x7e]*$').hasMatch(filenameFallback)) {
|
||||||
|
throw ArgumentError('The filename fallback must only contain ASCII characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filenameFallback.contains('%')) {
|
||||||
|
throw ArgumentError('The filename fallback cannot contain the "%" character.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename.contains('/') || filename.contains('\\') || filenameFallback.contains('/') || filenameFallback.contains('\\')) {
|
||||||
|
throw ArgumentError('The filename and the fallback cannot contain the "/" and "\\" characters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final params = {'filename': filenameFallback};
|
||||||
|
if (filename != filenameFallback) {
|
||||||
|
params['filename*'] = "utf-8''${Uri.encodeComponent(filename)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return '$disposition; ${headerToString(params, ';')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like parse_str(), but preserves dots in variable names.
|
||||||
|
static Map<String, dynamic> parseQuery(String query, [bool ignoreBrackets = false, String separator = '&']) {
|
||||||
|
final result = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (ignoreBrackets) {
|
||||||
|
for (var item in query.split(separator)) {
|
||||||
|
var parts = item.split('=');
|
||||||
|
result[parts[0]] = Uri.decodeComponent(parts[1]);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var v in query.split(separator)) {
|
||||||
|
var i = v.indexOf('0');
|
||||||
|
if (i != -1) {
|
||||||
|
v = v.substring(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
i = v.indexOf('=');
|
||||||
|
String k;
|
||||||
|
if (i == -1) {
|
||||||
|
k = Uri.decodeComponent(v);
|
||||||
|
v = '';
|
||||||
|
} else {
|
||||||
|
k = Uri.decodeComponent(v.substring(0, i));
|
||||||
|
v = v.substring(i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
i = k.indexOf('0');
|
||||||
|
if (i != -1) {
|
||||||
|
k = k.substring(0, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
k = k.trimLeft();
|
||||||
|
|
||||||
|
i = k.indexOf('[');
|
||||||
|
if (i == -1) {
|
||||||
|
result[utf8.decode(base64.decode(k))] = Uri.decodeComponent(v);
|
||||||
|
} else {
|
||||||
|
result['${utf8.decode(base64.decode(k.substring(0, i)))}[${Uri.decodeComponent(k.substring(i + 1))}]'] = Uri.decodeComponent(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static List<List<String>> _groupParts(List<RegExpMatch> matches, String separators, [bool first = true]) {
|
||||||
|
final separator = separators[0];
|
||||||
|
separators = separators.substring(1);
|
||||||
|
var i = 0;
|
||||||
|
|
||||||
|
if (separators.isEmpty && !first) {
|
||||||
|
final parts = <String>[''];
|
||||||
|
|
||||||
|
for (var match in matches) {
|
||||||
|
if (i == 0 && match.namedGroup('separator') != null) {
|
||||||
|
i = 1;
|
||||||
|
parts.add('');
|
||||||
|
} else {
|
||||||
|
parts[i] += unquote(match[0]!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [parts];
|
||||||
|
}
|
||||||
|
|
||||||
|
final parts = <List<String>>[];
|
||||||
|
final partMatches = <int, List<RegExpMatch>>{};
|
||||||
|
|
||||||
|
for (var match in matches) {
|
||||||
|
if (match.namedGroup('separator') == separator) {
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
partMatches.putIfAbsent(i, () => []).add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var subMatches in partMatches.values) {
|
||||||
|
if (separators.isEmpty) {
|
||||||
|
final unquoted = unquote(subMatches[0][0]!);
|
||||||
|
if (unquoted.isNotEmpty) {
|
||||||
|
parts.add([unquoted]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final groupedParts = _groupParts(subMatches, separators, false);
|
||||||
|
if (groupedParts.isNotEmpty) {
|
||||||
|
parts.add(groupedParts.expand((element) => element).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
}
|
984
packages/http/lib/src/foundation/response.dart
Normal file
984
packages/http/lib/src/foundation/response.dart
Normal file
|
@ -0,0 +1,984 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'response_header_bag.dart';
|
||||||
|
|
||||||
|
class Response {
|
||||||
|
// HTTP status codes
|
||||||
|
static const int HTTP_CONTINUE = 100;
|
||||||
|
static const int HTTP_SWITCHING_PROTOCOLS = 101;
|
||||||
|
static const int HTTP_PROCESSING = 102; // RFC2518
|
||||||
|
static const int HTTP_EARLY_HINTS = 103; // RFC8297
|
||||||
|
static const int HTTP_OK = 200;
|
||||||
|
static const int HTTP_CREATED = 201;
|
||||||
|
static const int HTTP_ACCEPTED = 202;
|
||||||
|
static const int HTTP_NON_AUTHORITATIVE_INFORMATION = 203;
|
||||||
|
static const int HTTP_NO_CONTENT = 204;
|
||||||
|
static const int HTTP_RESET_CONTENT = 205;
|
||||||
|
static const int HTTP_PARTIAL_CONTENT = 206;
|
||||||
|
static const int HTTP_MULTI_STATUS = 207; // RFC4918
|
||||||
|
static const int HTTP_ALREADY_REPORTED = 208; // RFC5842
|
||||||
|
static const int HTTP_IM_USED = 226; // RFC3229
|
||||||
|
static const int HTTP_MULTIPLE_CHOICES = 300;
|
||||||
|
static const int HTTP_MOVED_PERMANENTLY = 301;
|
||||||
|
static const int HTTP_FOUND = 302;
|
||||||
|
static const int HTTP_SEE_OTHER = 303;
|
||||||
|
static const int HTTP_NOT_MODIFIED = 304;
|
||||||
|
static const int HTTP_USE_PROXY = 305;
|
||||||
|
static const int HTTP_RESERVED = 306;
|
||||||
|
static const int HTTP_TEMPORARY_REDIRECT = 307;
|
||||||
|
static const int HTTP_PERMANENTLY_REDIRECT = 308; // RFC7238
|
||||||
|
static const int HTTP_BAD_REQUEST = 400;
|
||||||
|
static const int HTTP_UNAUTHORIZED = 401;
|
||||||
|
static const int HTTP_PAYMENT_REQUIRED = 402;
|
||||||
|
static const int HTTP_FORBIDDEN = 403;
|
||||||
|
static const int HTTP_NOT_FOUND = 404;
|
||||||
|
static const int HTTP_METHOD_NOT_ALLOWED = 405;
|
||||||
|
static const int HTTP_NOT_ACCEPTABLE = 406;
|
||||||
|
static const int HTTP_PROXY_AUTHENTICATION_REQUIRED = 407;
|
||||||
|
static const int HTTP_REQUEST_TIMEOUT = 408;
|
||||||
|
static const int HTTP_CONFLICT = 409;
|
||||||
|
static const int HTTP_GONE = 410;
|
||||||
|
static const int HTTP_LENGTH_REQUIRED = 411;
|
||||||
|
static const int HTTP_PRECONDITION_FAILED = 412;
|
||||||
|
static const int HTTP_REQUEST_ENTITY_TOO_LARGE = 413;
|
||||||
|
static const int HTTP_REQUEST_URI_TOO_LONG = 414;
|
||||||
|
static const int HTTP_UNSUPPORTED_MEDIA_TYPE = 415;
|
||||||
|
static const int HTTP_REQUESTED_RANGE_NOT_SATISFIABLE = 416;
|
||||||
|
static const int HTTP_EXPECTATION_FAILED = 417;
|
||||||
|
static const int HTTP_I_AM_A_TEAPOT = 418; // RFC2324
|
||||||
|
static const int HTTP_MISDIRECTED_REQUEST = 421; // RFC7540
|
||||||
|
static const int HTTP_UNPROCESSABLE_ENTITY = 422; // RFC4918
|
||||||
|
static const int HTTP_LOCKED = 423; // RFC4918
|
||||||
|
static const int HTTP_FAILED_DEPENDENCY = 424; // RFC4918
|
||||||
|
static const int HTTP_TOO_EARLY = 425; // RFC-ietf-httpbis-replay-04
|
||||||
|
static const int HTTP_UPGRADE_REQUIRED = 426; // RFC2817
|
||||||
|
static const int HTTP_PRECONDITION_REQUIRED = 428; // RFC6585
|
||||||
|
static const int HTTP_TOO_MANY_REQUESTS = 429; // RFC6585
|
||||||
|
static const int HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE = 431; // RFC6585
|
||||||
|
static const int HTTP_UNAVAILABLE_FOR_LEGAL_REASONS = 451; // RFC7725
|
||||||
|
static const int HTTP_INTERNAL_SERVER_ERROR = 500;
|
||||||
|
static const int HTTP_NOT_IMPLEMENTED = 501;
|
||||||
|
static const int HTTP_BAD_GATEWAY = 502;
|
||||||
|
static const int HTTP_SERVICE_UNAVAILABLE = 503;
|
||||||
|
static const int HTTP_GATEWAY_TIMEOUT = 504;
|
||||||
|
static const int HTTP_VERSION_NOT_SUPPORTED = 505;
|
||||||
|
static const int HTTP_VARIANT_ALSO_NEGOTIATES_EXPERIMENTAL = 506; // RFC2295
|
||||||
|
static const int HTTP_INSUFFICIENT_STORAGE = 507; // RFC4918
|
||||||
|
static const int HTTP_LOOP_DETECTED = 508; // RFC5842
|
||||||
|
static const int HTTP_NOT_EXTENDED = 510; // RFC2774
|
||||||
|
static const int HTTP_NETWORK_AUTHENTICATION_REQUIRED = 511; // RFC6585
|
||||||
|
|
||||||
|
/// Status codes translation table.
|
||||||
|
///
|
||||||
|
/// The list of codes is complete according to the
|
||||||
|
/// {@link https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml Hypertext Transfer Protocol (HTTP) Status Code Registry}
|
||||||
|
/// (last updated 2021-10-01).
|
||||||
|
///
|
||||||
|
/// Unless otherwise noted, the status code is defined in RFC2616.
|
||||||
|
static const Map<int, String> statusTexts = {
|
||||||
|
100: 'Continue',
|
||||||
|
101: 'Switching Protocols',
|
||||||
|
102: 'Processing', // RFC2518
|
||||||
|
103: 'Early Hints',
|
||||||
|
200: 'OK',
|
||||||
|
201: 'Created',
|
||||||
|
202: 'Accepted',
|
||||||
|
203: 'Non-Authoritative Information',
|
||||||
|
204: 'No Content',
|
||||||
|
205: 'Reset Content',
|
||||||
|
206: 'Partial Content',
|
||||||
|
207: 'Multi-Status', // RFC4918
|
||||||
|
208: 'Already Reported', // RFC5842
|
||||||
|
226: 'IM Used', // RFC3229
|
||||||
|
300: 'Multiple Choices',
|
||||||
|
301: 'Moved Permanently',
|
||||||
|
302: 'Found',
|
||||||
|
303: 'See Other',
|
||||||
|
304: 'Not Modified',
|
||||||
|
305: 'Use Proxy',
|
||||||
|
307: 'Temporary Redirect',
|
||||||
|
308: 'Permanent Redirect', // RFC7238
|
||||||
|
400: 'Bad Request',
|
||||||
|
401: 'Unauthorized',
|
||||||
|
402: 'Payment Required',
|
||||||
|
403: 'Forbidden',
|
||||||
|
404: 'Not Found',
|
||||||
|
405: 'Method Not Allowed',
|
||||||
|
406: 'Not Acceptable',
|
||||||
|
407: 'Proxy Authentication Required',
|
||||||
|
408: 'Request Timeout',
|
||||||
|
409: 'Conflict',
|
||||||
|
410: 'Gone',
|
||||||
|
411: 'Length Required',
|
||||||
|
412: 'Precondition Failed',
|
||||||
|
413: 'Content Too Large', // RFC-ietf-httpbis-semantics
|
||||||
|
414: 'URI Too Long',
|
||||||
|
415: 'Unsupported Media Type',
|
||||||
|
416: 'Range Not Satisfiable',
|
||||||
|
417: 'Expectation Failed',
|
||||||
|
418: "I'm a teapot", // RFC2324
|
||||||
|
421: 'Misdirected Request', // RFC7540
|
||||||
|
422: 'Unprocessable Content', // RFC-ietf-httpbis-semantics
|
||||||
|
423: 'Locked', // RFC4918
|
||||||
|
424: 'Failed Dependency', // RFC4918
|
||||||
|
425: 'Too Early', // RFC-ietf-httpbis-replay-04
|
||||||
|
426: 'Upgrade Required', // RFC2817
|
||||||
|
428: 'Precondition Required', // RFC6585
|
||||||
|
429: 'Too Many Requests', // RFC6585
|
||||||
|
431: 'Request Header Fields Too Large', // RFC6585
|
||||||
|
451: 'Unavailable For Legal Reasons', // RFC7725
|
||||||
|
500: 'Internal Server Error',
|
||||||
|
501: 'Not Implemented',
|
||||||
|
502: 'Bad Gateway',
|
||||||
|
503: 'Service Unavailable',
|
||||||
|
504: 'Gateway Timeout',
|
||||||
|
505: 'HTTP Version Not Supported',
|
||||||
|
506: 'Variant Also Negotiates', // RFC2295
|
||||||
|
507: 'Insufficient Storage', // RFC4918
|
||||||
|
508: 'Loop Detected', // RFC5842
|
||||||
|
510: 'Not Extended', // RFC2774
|
||||||
|
511: 'Network Authentication Required', // RFC6585
|
||||||
|
};
|
||||||
|
|
||||||
|
/// HTTP response cache control directives
|
||||||
|
///
|
||||||
|
/// @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||||
|
static const Map<String, bool> httpResponseCacheControlDirectives = {
|
||||||
|
'must_revalidate': false,
|
||||||
|
'no_cache': false,
|
||||||
|
'no_store': false,
|
||||||
|
'no_transform': false,
|
||||||
|
'public': false,
|
||||||
|
'private': false,
|
||||||
|
'proxy_revalidate': false,
|
||||||
|
'max_age': true,
|
||||||
|
's_maxage': true,
|
||||||
|
'stale_if_error': true, // RFC5861
|
||||||
|
'stale_while_revalidate': true, // RFC5861
|
||||||
|
'immutable': false,
|
||||||
|
'last_modified': true,
|
||||||
|
'etag': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
ResponseHeaderBag headers;
|
||||||
|
String content;
|
||||||
|
String version;
|
||||||
|
int statusCode;
|
||||||
|
String statusText;
|
||||||
|
String? charset;
|
||||||
|
|
||||||
|
Map<String, List<String>> sentHeaders = {};
|
||||||
|
|
||||||
|
/// @param int $status The HTTP status code (200 "OK" by default)
|
||||||
|
///
|
||||||
|
/// @throws \InvalidArgumentException When the HTTP status code is not valid
|
||||||
|
Response({String? content = '', int status = HTTP_OK, Map<String, List<String?>>? headers})
|
||||||
|
: headers = ResponseHeaderBag(headers ?? {}),
|
||||||
|
content = content ?? '',
|
||||||
|
version = '1.0',
|
||||||
|
statusCode = status,
|
||||||
|
statusText = statusTexts[status] ?? 'unknown status';
|
||||||
|
|
||||||
|
/// Returns the Response as an HTTP string.
|
||||||
|
///
|
||||||
|
/// The string representation of the Response is the same as the
|
||||||
|
/// one that will be sent to the client only if the prepare() method
|
||||||
|
/// has been called before.
|
||||||
|
///
|
||||||
|
/// @see prepare()
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'HTTP/$version $statusCode $statusText\r\n$headers\r\n$content';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clones the current Response instance.
|
||||||
|
Response clone() {
|
||||||
|
return Response(content: content, status: statusCode, headers: headers.all());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares the Response before it is sent to the client.
|
||||||
|
///
|
||||||
|
/// This method tweaks the Response to ensure that it is
|
||||||
|
/// compliant with RFC 2616. Most of the changes are based on
|
||||||
|
/// the Request that is "associated" with this Response.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void prepare(HttpRequest request) {
|
||||||
|
if (isInformational() || isEmpty()) {
|
||||||
|
content = '';
|
||||||
|
headers.remove('Content-Type');
|
||||||
|
headers.remove('Content-Length');
|
||||||
|
} else {
|
||||||
|
if (!headers.containsKey('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'text/html; charset=${charset ?? 'UTF-8'}');
|
||||||
|
} else if (headers.value('Content-Type')!.startsWith('text/') && !headers.value('Content-Type')!.contains('charset')) {
|
||||||
|
headers.set('Content-Type', '${headers.value('Content-Type')}; charset=${charset ?? 'UTF-8'}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers.containsKey('Transfer-Encoding')) {
|
||||||
|
headers.remove('Content-Length');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method == 'HEAD') {
|
||||||
|
var length = headers.value('Content-Length');
|
||||||
|
content = '';
|
||||||
|
if (length != null) {
|
||||||
|
headers.set('Content-Length', length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.protocolVersion != 'HTTP/1.0') {
|
||||||
|
version = '1.1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version == '1.0' && headers.value('Cache-Control')!.contains('no-cache')) {
|
||||||
|
headers.set('pragma', 'no-cache');
|
||||||
|
headers.set('expires', '-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureIEOverSSLCompatibility(request);
|
||||||
|
|
||||||
|
if (request.uri.scheme == 'https') {
|
||||||
|
headers.all('Set-Cookie')['set-cookie']?.forEach((cookie) {
|
||||||
|
if (cookie.contains('; Secure')) return;
|
||||||
|
headers.set('Set-Cookie', '$cookie; Secure', false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends HTTP headers.
|
||||||
|
///
|
||||||
|
/// @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void sendHeaders([int? statusCode]) {
|
||||||
|
if (HttpHeaders.headersSent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var informationalResponse = statusCode != null && statusCode >= 100 && statusCode < 200;
|
||||||
|
|
||||||
|
headers.allPreserveCaseWithoutCookies().forEach((name, values) {
|
||||||
|
var previousValues = sentHeaders[name];
|
||||||
|
if (previousValues != null && previousValues == values) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var replace = name.toLowerCase() == 'content-type';
|
||||||
|
if (previousValues != null && !previousValues.every(values.contains)) {
|
||||||
|
HttpHeaders.removeHeader(name);
|
||||||
|
previousValues = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newValues = previousValues == null ? values : values.where((v) => !previousValues!.contains(v)).toList();
|
||||||
|
|
||||||
|
for (var value in newValues) {
|
||||||
|
HttpHeaders.setHeader(name, value, replace: replace, statusCode: statusCode ?? this.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (informationalResponse) {
|
||||||
|
sentHeaders[name] = values;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (informationalResponse) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode ??= this.statusCode;
|
||||||
|
HttpHeaders.setHeader('Status', '$version $statusCode $statusText', replace: true, statusCode: statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends content for the current web response.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void sendContent() {
|
||||||
|
print(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends HTTP headers and content.
|
||||||
|
///
|
||||||
|
/// @param bool $flush Whether output buffers should be flushed
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void send([bool flush = true]) {
|
||||||
|
sendHeaders();
|
||||||
|
sendContent();
|
||||||
|
|
||||||
|
if (flush) {
|
||||||
|
stdout.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response content.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void setContent(String? content) {
|
||||||
|
this.content = content ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the current response content.
|
||||||
|
String getContent() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the HTTP protocol version (1.0 or 1.1).
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setProtocolVersion(String version) {
|
||||||
|
this.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the HTTP protocol version.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
String getProtocolVersion() {
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response status code.
|
||||||
|
///
|
||||||
|
/// If the status text is null it will be automatically populated for the known
|
||||||
|
/// status codes and left empty otherwise.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @throws \InvalidArgumentException When the HTTP status code is not valid
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setStatusCode(int code, [String? text]) {
|
||||||
|
statusCode = code;
|
||||||
|
if (isInvalid()) {
|
||||||
|
throw ArgumentError('The HTTP status code "$code" is not valid.');
|
||||||
|
}
|
||||||
|
|
||||||
|
statusText = text ?? statusTexts[code] ?? 'unknown status';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the status code for the current web response.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
int getStatusCode() {
|
||||||
|
return statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response charset.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setCharset(String charset) {
|
||||||
|
this.charset = charset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the response charset.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
String? getCharset() {
|
||||||
|
return charset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response may safely be kept in a shared (surrogate) cache.
|
||||||
|
///
|
||||||
|
/// Responses marked "private" with an explicit Cache-Control directive are
|
||||||
|
/// considered uncacheable.
|
||||||
|
///
|
||||||
|
/// Responses with neither a freshness lifetime (Expires, max-age) nor cache
|
||||||
|
/// validator (Last-Modified, ETag) are considered uncacheable because there is
|
||||||
|
/// no way to tell when or how to remove them from the cache.
|
||||||
|
///
|
||||||
|
/// Note that RFC 7231 and RFC 7234 possibly allow for a more permissive implementation,
|
||||||
|
/// for example "status codes that are defined as cacheable by default [...]
|
||||||
|
/// can be reused by a cache with heuristic expiration unless otherwise indicated"
|
||||||
|
/// (https://tools.ietf.org/html/rfc7231#section-6.1)
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isCacheable() {
|
||||||
|
if (![200, 203, 300, 301, 302, 404, 410].contains(statusCode)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers.hasCacheControlDirective('no-store') || headers.hasCacheControlDirective('private')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValidateable() || isFresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response is "fresh".
|
||||||
|
///
|
||||||
|
/// Fresh responses may be served from cache without any interaction with the
|
||||||
|
/// origin. A response is considered fresh when it includes a Cache-Control/max-age
|
||||||
|
/// indicator or Expires header and the calculated age is less than the freshness lifetime.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isFresh() {
|
||||||
|
return getTtl() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response includes headers that can be used to validate
|
||||||
|
/// the response with the origin server using a conditional GET request.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isValidateable() {
|
||||||
|
return headers.value('Last-Modified') != null || headers.value('ETag') != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the response as "private".
|
||||||
|
///
|
||||||
|
/// It makes the response ineligible for serving other clients.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setPrivate() {
|
||||||
|
headers.remove('Cache-Control');
|
||||||
|
headers.set('Cache-Control', 'private');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the response as "public".
|
||||||
|
///
|
||||||
|
/// It makes the response eligible for serving other clients.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setPublic() {
|
||||||
|
headers.remove('Cache-Control');
|
||||||
|
headers.set('Cache-Control', 'public');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the response as "immutable".
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setImmutable(bool immutable) {
|
||||||
|
if (immutable) {
|
||||||
|
headers.set('Cache-Control', 'immutable');
|
||||||
|
} else {
|
||||||
|
headers.remove('Cache-Control');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response is marked as "immutable".
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isImmutable() {
|
||||||
|
return headers.hasCacheControlDirective('immutable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response must be revalidated by shared caches once it has become stale.
|
||||||
|
///
|
||||||
|
/// This method indicates that the response must not be served stale by a
|
||||||
|
/// cache in any circumstance without first revalidating with the origin.
|
||||||
|
/// When present, the TTL of the response should not be overridden to be
|
||||||
|
/// greater than the value provided by the origin.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool mustRevalidate() {
|
||||||
|
return headers.hasCacheControlDirective('must-revalidate') || headers.hasCacheControlDirective('proxy-revalidate');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Date header as a DateTime instance.
|
||||||
|
///
|
||||||
|
/// @throws \RuntimeException When the header is not parseable
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
DateTime? getDate() {
|
||||||
|
var date = headers.value('Date');
|
||||||
|
if (date == null) return null;
|
||||||
|
return HttpDate.parse(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the Date header.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setDate(DateTime date) {
|
||||||
|
headers.set('Date', HttpDate.format(date.toUtc()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the age of the response in seconds.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
int getAge() {
|
||||||
|
var age = headers.value('Age');
|
||||||
|
if (age != null) return int.parse(age);
|
||||||
|
var date = getDate();
|
||||||
|
if (date == null) return 0;
|
||||||
|
return DateTime.now().toUtc().difference(date).inSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks the response stale by setting the Age header to be equal to the maximum age of the response.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void expire() {
|
||||||
|
if (isFresh()) {
|
||||||
|
headers.set('Age', getMaxAge().toString());
|
||||||
|
headers.remove('Expires');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the value of the Expires header as a DateTime instance.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
DateTime? getExpires() {
|
||||||
|
var expires = headers.value('Expires');
|
||||||
|
if (expires == null) return null;
|
||||||
|
try {
|
||||||
|
return HttpDate.parse(expires);
|
||||||
|
} catch (_) {
|
||||||
|
return DateTime.now().subtract(Duration(days: 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the Expires HTTP header with a DateTime instance.
|
||||||
|
///
|
||||||
|
/// Passing null as value will remove the header.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setExpires(DateTime? date) {
|
||||||
|
if (date == null) {
|
||||||
|
headers.remove('Expires');
|
||||||
|
} else {
|
||||||
|
headers.set('Expires', HttpDate.format(date.toUtc()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the number of seconds after the time specified in the response's Date
|
||||||
|
/// header when the response should no longer be considered fresh.
|
||||||
|
///
|
||||||
|
/// First, it checks for a s-maxage directive, then a max-age directive, and then it falls
|
||||||
|
/// back on an expires header. It returns null when no maximum age can be established.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
int? getMaxAge() {
|
||||||
|
if (headers.hasCacheControlDirective('s-maxage')) {
|
||||||
|
return int.parse(headers.getCacheControlDirective('s-maxage'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headers.hasCacheControlDirective('max-age')) {
|
||||||
|
return int.parse(headers.getCacheControlDirective('max-age'));
|
||||||
|
}
|
||||||
|
|
||||||
|
var expires = getExpires();
|
||||||
|
if (expires != null) {
|
||||||
|
return DateTime.now().difference(expires).inSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of seconds after which the response should no longer be considered fresh.
|
||||||
|
///
|
||||||
|
/// This method sets the Cache-Control max-age directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setMaxAge(int value) {
|
||||||
|
headers.set('Cache-Control', 'max-age=$value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of seconds after which the response should no longer be returned by shared caches when backend is down.
|
||||||
|
///
|
||||||
|
/// This method sets the Cache-Control stale-if-error directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setStaleIfError(int value) {
|
||||||
|
headers.set('Cache-Control', 'stale-if-error=$value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of seconds after which the response should no longer return stale content by shared caches.
|
||||||
|
///
|
||||||
|
/// This method sets the Cache-Control stale-while-revalidate directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setStaleWhileRevalidate(int value) {
|
||||||
|
headers.set('Cache-Control', 'stale-while-revalidate=$value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the number of seconds after which the response should no longer be considered fresh by shared caches.
|
||||||
|
///
|
||||||
|
/// This method sets the Cache-Control s-maxage directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setSharedMaxAge(int value) {
|
||||||
|
setPublic();
|
||||||
|
headers.set('Cache-Control', 's-maxage=$value');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the response's time-to-live in seconds.
|
||||||
|
///
|
||||||
|
/// It returns null when no freshness information is present in the response.
|
||||||
|
///
|
||||||
|
/// When the response's TTL is 0, the response may not be served from cache without first
|
||||||
|
/// revalidating with the origin.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
int? getTtl() {
|
||||||
|
var maxAge = getMaxAge();
|
||||||
|
return maxAge != null ? maxAge - getAge() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response's time-to-live for shared caches in seconds.
|
||||||
|
///
|
||||||
|
/// This method adjusts the Cache-Control/s-maxage directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setTtl(int seconds) {
|
||||||
|
setSharedMaxAge(getAge() + seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response's time-to-live for private/client caches in seconds.
|
||||||
|
///
|
||||||
|
/// This method adjusts the Cache-Control/max-age directive.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setClientTtl(int seconds) {
|
||||||
|
setMaxAge(getAge() + seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the Last-Modified HTTP header as a DateTime instance.
|
||||||
|
///
|
||||||
|
/// @throws \RuntimeException When the HTTP header is not parseable
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
DateTime? getLastModified() {
|
||||||
|
var lastModified = headers.value('Last-Modified');
|
||||||
|
if (lastModified == null) return null;
|
||||||
|
return HttpDate.parse(lastModified);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the Last-Modified HTTP header with a DateTime instance.
|
||||||
|
///
|
||||||
|
/// Passing null as value will remove the header.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setLastModified(DateTime? date) {
|
||||||
|
if (date == null) {
|
||||||
|
headers.remove('Last-Modified');
|
||||||
|
} else {
|
||||||
|
headers.set('Last-Modified', HttpDate.format(date.toUtc()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the literal value of the ETag HTTP header.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
String? getEtag() {
|
||||||
|
return headers.value('ETag');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the ETag value.
|
||||||
|
///
|
||||||
|
/// @param string|null $etag The ETag unique identifier or null to remove the header
|
||||||
|
/// @param bool $weak Whether you want a weak ETag or not
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setEtag(String? etag, {bool weak = false}) {
|
||||||
|
if (etag == null) {
|
||||||
|
headers.remove('ETag');
|
||||||
|
} else {
|
||||||
|
if (!etag.startsWith('"')) {
|
||||||
|
etag = '"$etag"';
|
||||||
|
}
|
||||||
|
headers.set('ETag', '${weak ? 'W/' : ''}$etag');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the response's cache headers (validation and/or expiration).
|
||||||
|
///
|
||||||
|
/// Available options are: must_revalidate, no_cache, no_store, no_transform, public, private, proxy_revalidate, max_age, s_maxage, immutable, last_modified and etag.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @throws \InvalidArgumentException
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setCache(Map<String, dynamic> options) {
|
||||||
|
var diff = options.keys.toSet().difference(httpResponseCacheControlDirectives.keys.toSet());
|
||||||
|
if (diff.isNotEmpty) {
|
||||||
|
throw ArgumentError('Response does not support the following options: "${diff.join(', ')}".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('etag')) {
|
||||||
|
setEtag(options['etag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('last_modified')) {
|
||||||
|
setLastModified(options['last_modified']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('max_age')) {
|
||||||
|
setMaxAge(options['max_age']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('s_maxage')) {
|
||||||
|
setSharedMaxAge(options['s_maxage']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('stale_while_revalidate')) {
|
||||||
|
setStaleWhileRevalidate(options['stale_while_revalidate']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('stale_if_error')) {
|
||||||
|
setStaleIfError(options['stale_if_error']);
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResponseCacheControlDirectives.forEach((directive, hasValue) {
|
||||||
|
if (!hasValue && options.containsKey(directive)) {
|
||||||
|
if (options[directive]) {
|
||||||
|
headers.set('Cache-Control', directive.replaceAll('_', '-'));
|
||||||
|
} else {
|
||||||
|
headers.remove(directive.replaceAll('_', '-'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.containsKey('public')) {
|
||||||
|
if (options['public']) {
|
||||||
|
setPublic();
|
||||||
|
} else {
|
||||||
|
setPrivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.containsKey('private')) {
|
||||||
|
if (options['private']) {
|
||||||
|
setPrivate();
|
||||||
|
} else {
|
||||||
|
setPublic();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifies the response so that it conforms to the rules defined for a 304 status code.
|
||||||
|
///
|
||||||
|
/// This sets the status, removes the body, and discards any headers
|
||||||
|
/// that MUST NOT be included in 304 responses.
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
void setNotModified() {
|
||||||
|
setStatusCode(304);
|
||||||
|
content = '';
|
||||||
|
|
||||||
|
['Allow', 'Content-Encoding', 'Content-Language', 'Content-Length', 'Content-MD5', 'Content-Type', 'Last-Modified'].forEach(headers.remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if the response includes a Vary header.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool hasVary() {
|
||||||
|
return headers.value('Vary') != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an array of header names given in the Vary header.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
List<String> getVary() {
|
||||||
|
var vary = headers.all('Vary')['Vary'];
|
||||||
|
if (vary == null) return [];
|
||||||
|
return vary.expand((v) => v.split(RegExp(r'[\s,]+'))).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the Vary header.
|
||||||
|
///
|
||||||
|
/// @param bool $replace Whether to replace the actual value or not (true by default)
|
||||||
|
///
|
||||||
|
/// @return $this
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void setVary(List<String> headers, [bool replace = true]) {
|
||||||
|
this.headers.set('Vary', headers.join(', '), replace: replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if the Response validators (ETag, Last-Modified) match
|
||||||
|
/// a conditional value specified in the Request.
|
||||||
|
///
|
||||||
|
/// If the Response is not modified, it sets the status code to 304 and
|
||||||
|
/// removes the actual content by calling the setNotModified() method.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isNotModified(HttpRequest request) {
|
||||||
|
if (!['GET', 'HEAD'].contains(request.method)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var notModified = false;
|
||||||
|
var lastModified = headers.value('Last-Modified');
|
||||||
|
var modifiedSince = request.headers.value(HttpHeaders.ifModifiedSinceHeader);
|
||||||
|
|
||||||
|
var ifNoneMatchEtags = request.headers[HttpHeaders.ifNoneMatchHeader];
|
||||||
|
var etag = getEtag();
|
||||||
|
|
||||||
|
if (ifNoneMatchEtags != null && etag != null) {
|
||||||
|
if (etag.startsWith('W/')) {
|
||||||
|
etag = etag.substring(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var ifNoneMatchEtag in ifNoneMatchEtags) {
|
||||||
|
if (ifNoneMatchEtag.startsWith('W/')) {
|
||||||
|
ifNoneMatchEtag = ifNoneMatchEtag.substring(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifNoneMatchEtag == etag || ifNoneMatchEtag == '*') {
|
||||||
|
notModified = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (modifiedSince != null && lastModified != null) {
|
||||||
|
notModified = DateTime.parse(modifiedSince).isAfter(DateTime.parse(lastModified));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notModified) {
|
||||||
|
setNotModified();
|
||||||
|
}
|
||||||
|
|
||||||
|
return notModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is response invalid?
|
||||||
|
///
|
||||||
|
/// @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isInvalid() {
|
||||||
|
return statusCode < 100 || statusCode >= 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is response informative?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isInformational() {
|
||||||
|
return statusCode >= 100 && statusCode < 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is response successful?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isSuccessful() {
|
||||||
|
return statusCode >= 200 && statusCode < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response a redirect?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isRedirection() {
|
||||||
|
return statusCode >= 300 && statusCode < 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is there a client error?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isClientError() {
|
||||||
|
return statusCode >= 400 && statusCode < 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was there a server side error?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isServerError() {
|
||||||
|
return statusCode >= 500 && statusCode < 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response OK?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isOk() {
|
||||||
|
return statusCode == HTTP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response forbidden?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isForbidden() {
|
||||||
|
return statusCode == HTTP_FORBIDDEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response a not found error?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isNotFound() {
|
||||||
|
return statusCode == HTTP_NOT_FOUND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response a redirect of some form?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isRedirect([String? location]) {
|
||||||
|
return [HTTP_CREATED, HTTP_MOVED_PERMANENTLY, HTTP_FOUND, HTTP_SEE_OTHER, HTTP_TEMPORARY_REDIRECT, HTTP_PERMANENTLY_REDIRECT].contains(statusCode) &&
|
||||||
|
(location == null || location == headers.value('Location'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the response empty?
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
bool isEmpty() {
|
||||||
|
return [HTTP_NO_CONTENT, HTTP_NOT_MODIFIED].contains(statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cleans or flushes output buffers up to target level.
|
||||||
|
///
|
||||||
|
/// Resulting level can be greater than target level if a non-removable buffer has been encountered.
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
static void closeOutputBuffers(int targetLevel, bool flush) {
|
||||||
|
while (stdout.hasTerminal && stdout.terminalOutputMode != null && targetLevel > 0) {
|
||||||
|
if (flush) {
|
||||||
|
stdout.flush();
|
||||||
|
} else {
|
||||||
|
stdout.clear();
|
||||||
|
}
|
||||||
|
targetLevel--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks a response as safe according to RFC8674.
|
||||||
|
///
|
||||||
|
/// @see https://tools.ietf.org/html/rfc8674
|
||||||
|
void setContentSafe(bool safe) {
|
||||||
|
if (safe) {
|
||||||
|
headers.set('Preference-Applied', 'safe');
|
||||||
|
} else if (headers.value('Preference-Applied') == 'safe') {
|
||||||
|
headers.remove('Preference-Applied');
|
||||||
|
}
|
||||||
|
setVary(['Prefer'], false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if we need to remove Cache-Control for SSL encrypted downloads when using IE < 9.
|
||||||
|
///
|
||||||
|
/// @see http://support.microsoft.com/kb/323308
|
||||||
|
///
|
||||||
|
/// @final
|
||||||
|
void ensureIEOverSSLCompatibility(HttpRequest request) {
|
||||||
|
if (headers.value('Content-Disposition')?.contains('attachment') == true &&
|
||||||
|
request.headers.value(HttpHeaders.userAgentHeader)?.contains(RegExp(r'MSIE (\d+)')) == true &&
|
||||||
|
request.uri.scheme == 'https') {
|
||||||
|
var match = RegExp(r'MSIE (\d+)').firstMatch(request.headers.value(HttpHeaders.userAgentHeader) ?? '');
|
||||||
|
if (match != null && int.parse(match.group(1)!) < 9) {
|
||||||
|
headers.remove('Cache-Control');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
267
packages/http/lib/src/foundation/response_header_bag.dart
Normal file
267
packages/http/lib/src/foundation/response_header_bag.dart
Normal file
|
@ -0,0 +1,267 @@
|
||||||
|
/*
|
||||||
|
* This file is part of the Symfony package.
|
||||||
|
*
|
||||||
|
* (c) Fabien Potencier <fabien@symfony.com>
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'header_bag.dart';
|
||||||
|
import 'cookie.dart';
|
||||||
|
import 'header_utils.dart';
|
||||||
|
|
||||||
|
/// ResponseHeaderBag is a container for Response HTTP headers.
|
||||||
|
///
|
||||||
|
/// Author: Fabien Potencier <fabien@symfony.com>
|
||||||
|
class ResponseHeaderBag extends HeaderBag {
|
||||||
|
static const String COOKIES_FLAT = 'flat';
|
||||||
|
static const String COOKIES_ARRAY = 'array';
|
||||||
|
|
||||||
|
static const String DISPOSITION_ATTACHMENT = 'attachment';
|
||||||
|
static const String DISPOSITION_INLINE = 'inline';
|
||||||
|
|
||||||
|
Map<String, String> computedCacheControl = {};
|
||||||
|
Map<String, Map<String, Map<String, Cookie>>> cookies = {};
|
||||||
|
Map<String, String> headerNames = {};
|
||||||
|
|
||||||
|
/// Constructor for the ResponseHeaderBag class.
|
||||||
|
ResponseHeaderBag([Map<String, List<String?>>? headers]) : super(headers ?? {}) {
|
||||||
|
if (!headers!.containsKey('cache-control')) {
|
||||||
|
set('Cache-Control', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC2616 - 14.18 says all Responses need to have a Date
|
||||||
|
if (!headers.containsKey('date')) {
|
||||||
|
initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the headers, with original capitalizations.
|
||||||
|
Map<String, List<String?>> allPreserveCase() {
|
||||||
|
final headers = <String, List<String?>>{};
|
||||||
|
super.all().forEach((name, value) {
|
||||||
|
headers[headerNames[name] ?? name] = value;
|
||||||
|
});
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the headers with original capitalizations, excluding cookies.
|
||||||
|
Map<String, List<String?>> allPreserveCaseWithoutCookies() {
|
||||||
|
final headers = allPreserveCase();
|
||||||
|
if (headerNames.containsKey('set-cookie')) {
|
||||||
|
headers.remove(headerNames['set-cookie']);
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the current headers with new headers.
|
||||||
|
@override
|
||||||
|
void replace([Map<String, List<String?>>? headers]) {
|
||||||
|
headerNames = {};
|
||||||
|
super.replace(headers ?? {});
|
||||||
|
if (!headers!.containsKey('cache-control')) {
|
||||||
|
set('Cache-Control', '');
|
||||||
|
}
|
||||||
|
if (!headers.containsKey('date')) {
|
||||||
|
initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns all headers, optionally filtered by a key.
|
||||||
|
@override
|
||||||
|
Map<String, List<String?>> all([String? key]) {
|
||||||
|
final headers = super.all();
|
||||||
|
if (key != null) {
|
||||||
|
final uniqueKey = key.toLowerCase();
|
||||||
|
if (uniqueKey != 'set-cookie') {
|
||||||
|
return {uniqueKey: headers[uniqueKey] ?? []};
|
||||||
|
} else {
|
||||||
|
return {'set-cookie': cookies.values.expand((path) => path.values.expand((cookie) => cookie.values)).map((cookie) => cookie.toString()).toList()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var path in cookies.values) {
|
||||||
|
for (var cookie in path.values) {
|
||||||
|
headers['set-cookie'] ??= [];
|
||||||
|
headers['set-cookie']!.add(cookie.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a header value.
|
||||||
|
@override
|
||||||
|
void set(String key, dynamic values, [bool replace = true]) {
|
||||||
|
final uniqueKey = key.toLowerCase();
|
||||||
|
if (uniqueKey == 'set-cookie') {
|
||||||
|
if (replace) {
|
||||||
|
cookies = {};
|
||||||
|
}
|
||||||
|
for (var cookie in List<String>.from(values)) {
|
||||||
|
setCookie(Cookie.fromString(cookie));
|
||||||
|
}
|
||||||
|
headerNames[uniqueKey] = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
headerNames[uniqueKey] = key;
|
||||||
|
super.set(key, values, replace);
|
||||||
|
|
||||||
|
// Ensure the cache-control header has sensible defaults
|
||||||
|
if (['cache-control', 'etag', 'last-modified', 'expires'].contains(uniqueKey)) {
|
||||||
|
final computedValue = computeCacheControlValue();
|
||||||
|
super.set('Cache-Control', computedValue, replace);
|
||||||
|
headerNames['cache-control'] = 'Cache-Control';
|
||||||
|
computedCacheControl = parseCacheControl(computedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a header.
|
||||||
|
@override
|
||||||
|
void remove(String key) {
|
||||||
|
final uniqueKey = key.toLowerCase();
|
||||||
|
headerNames.remove(uniqueKey);
|
||||||
|
if (uniqueKey == 'set-cookie') {
|
||||||
|
cookies = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.remove(key);
|
||||||
|
if (uniqueKey == 'cache-control') {
|
||||||
|
computedCacheControl = {};
|
||||||
|
}
|
||||||
|
if (uniqueKey == 'date') {
|
||||||
|
initDate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if the cache-control directive exists.
|
||||||
|
@override
|
||||||
|
bool hasCacheControlDirective(String key) {
|
||||||
|
return computedCacheControl.containsKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the value of a cache-control directive.
|
||||||
|
@override
|
||||||
|
dynamic getCacheControlDirective(String key) {
|
||||||
|
return computedCacheControl[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets a cookie.
|
||||||
|
void setCookie(Cookie cookie) {
|
||||||
|
cookies.putIfAbsent(cookie.domain ?? '', () => {})
|
||||||
|
.putIfAbsent(cookie.path, () => {})[cookie.name] = cookie;
|
||||||
|
headerNames['set-cookie'] = 'Set-Cookie';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a cookie from the array, but does not unset it in the browser.
|
||||||
|
void removeCookie(String name, [String? path = '/', String? domain]) {
|
||||||
|
path ??= '/';
|
||||||
|
final domainCookies = cookies[domain] ?? {};
|
||||||
|
final pathCookies = domainCookies[path] ?? {};
|
||||||
|
pathCookies.remove(name);
|
||||||
|
|
||||||
|
if (pathCookies.isEmpty) {
|
||||||
|
domainCookies.remove(path);
|
||||||
|
if (domainCookies.isEmpty) {
|
||||||
|
cookies.remove(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cookies.isEmpty) {
|
||||||
|
headerNames.remove('set-cookie');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an array with all cookies.
|
||||||
|
///
|
||||||
|
/// @return List<Cookie>
|
||||||
|
///
|
||||||
|
/// @throws ArgumentError When the format is invalid
|
||||||
|
List<Cookie> getCookies([String format = COOKIES_FLAT]) {
|
||||||
|
if (!([COOKIES_FLAT, COOKIES_ARRAY].contains(format))) {
|
||||||
|
throw ArgumentError('Format "$format" invalid (${[COOKIES_FLAT, COOKIES_ARRAY].join(', ')}).');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format == COOKIES_ARRAY) {
|
||||||
|
return cookies.values.expand((path) => path.values.expand((cookie) => cookie.values)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final flattenedCookies = <Cookie>[];
|
||||||
|
for (var path in cookies.values) {
|
||||||
|
for (var domainCookies in path.values) {
|
||||||
|
for (var cookie in domainCookies.values) {
|
||||||
|
flattenedCookies.add(cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattenedCookies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears a cookie in the browser.
|
||||||
|
///
|
||||||
|
/// @param bool partitioned
|
||||||
|
void clearCookie(String name,
|
||||||
|
[String? path = '/', String? domain, bool secure = false, bool httpOnly = true, String? sameSite, bool partitioned = false]) {
|
||||||
|
final cookieString = '$name=null; Expires=${DateTime.fromMillisecondsSinceEpoch(1).toUtc().toIso8601String()}; Path=${path ?? "/"}; Domain=$domain${secure ? "; Secure" : ""}${httpOnly ? "; HttpOnly" : ""}${sameSite != null ? "; SameSite=$sameSite" : ""}${partitioned ? "; Partitioned" : ""}';
|
||||||
|
setCookie(Cookie.fromString(cookieString));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes a disposition header.
|
||||||
|
///
|
||||||
|
/// @see HeaderUtils::makeDisposition()
|
||||||
|
String makeDisposition(String disposition, String filename, [String filenameFallback = '']) {
|
||||||
|
return HeaderUtils.makeDisposition(disposition, filename, filenameFallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the calculated value of the cache-control header.
|
||||||
|
///
|
||||||
|
/// This considers several other headers and calculates or modifies the
|
||||||
|
/// cache-control header to a sensible, conservative value.
|
||||||
|
String computeCacheControlValue() {
|
||||||
|
if (computedCacheControl.isEmpty) {
|
||||||
|
if (hasHeader('Last-Modified') || hasHeader('Expires')) {
|
||||||
|
return 'private, must-revalidate'; // allows for heuristic expiration (RFC 7234 Section 4.2.2) in the case of "Last-Modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conservative by default
|
||||||
|
return 'no-cache, private';
|
||||||
|
}
|
||||||
|
|
||||||
|
final header = getCacheControlHeader();
|
||||||
|
if (computedCacheControl.containsKey('public') || computedCacheControl.containsKey('private')) {
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public if s-maxage is defined, private otherwise
|
||||||
|
if (!computedCacheControl.containsKey('s-maxage')) {
|
||||||
|
return '$header, private';
|
||||||
|
}
|
||||||
|
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the cache-control header value into a map.
|
||||||
|
Map<String, String> parseCacheControl(String header) {
|
||||||
|
final directives = <String, String>{};
|
||||||
|
for (var directive in header.split(',')) {
|
||||||
|
final parts = directive.trim().split('=');
|
||||||
|
directives[parts[0]] = parts.length > 1 ? parts[1] : '';
|
||||||
|
}
|
||||||
|
return directives;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the Date header to the current date and time.
|
||||||
|
void initDate() {
|
||||||
|
set('Date', DateTime.now().toUtc().toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a header exists.
|
||||||
|
bool containsKey(String key) {
|
||||||
|
return super.all().containsKey(key.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the value of a header.
|
||||||
|
String? value(String key) {
|
||||||
|
return super.all()[key.toLowerCase()]?.join(', ');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue