diff --git a/lib/src/body/body.dart b/lib/src/body/body.dart index 11ab6b3..afa0f93 100644 --- a/lib/src/body/body.dart +++ b/lib/src/body/body.dart @@ -99,7 +99,7 @@ class Body { factory Body.fromData( Uint8List body, { Encoding? encoding, - MimeType mimeType = MimeType.binary, + MimeType mimeType = MimeType.octetStream, }) { return Body._( Stream.value(body), diff --git a/lib/src/body/types/body_type.dart b/lib/src/body/types/body_type.dart index e703156..6511dc8 100644 --- a/lib/src/body/types/body_type.dart +++ b/lib/src/body/types/body_type.dart @@ -29,8 +29,8 @@ class BodyType { ); /// A body type for JavaScript. - static const javaScript = BodyType( - mimeType: MimeType.javaScript, + static const javascript = BodyType( + mimeType: MimeType.javascript, encoding: utf8, ); @@ -46,9 +46,9 @@ class BodyType { encoding: utf8, ); - /// A body type for binary data. - static const binary = BodyType( - mimeType: MimeType.binary, + /// A body type for octet stream data. + static const octetStream = BodyType( + mimeType: MimeType.octetStream, ); /// A body type for PDF. @@ -61,6 +61,21 @@ class BodyType { mimeType: MimeType.rtf, ); + /// A body type for multipart form data. + static const multipartFormData = BodyType( + mimeType: MimeType.multipartFormData, + ); + + /// A body type for multipart byteranges. + static const multipartByteranges = BodyType( + mimeType: MimeType.multipartByteranges, + ); + + /// A body type for URL-encoded form data. + static const urlEncoded = BodyType( + mimeType: MimeType.urlEncoded, + ); + /// The mime type of the body. final MimeType mimeType; diff --git a/lib/src/body/types/mime_type.dart b/lib/src/body/types/mime_type.dart index 4f13e78..537d56a 100644 --- a/lib/src/body/types/mime_type.dart +++ b/lib/src/body/types/mime_type.dart @@ -15,7 +15,7 @@ class MimeType { static const csv = MimeType('text', 'csv'); /// JavaScript mime type. - static const javaScript = MimeType('text', 'javascript'); + static const javascript = MimeType('text', 'javascript'); /// JSON mime type. static const json = MimeType('application', 'json'); @@ -25,7 +25,7 @@ class MimeType { /// Binary mime type. - static const binary = MimeType('application', 'octet-stream'); + static const octetStream = MimeType('application', 'octet-stream'); /// PDF mime type. static const pdf = MimeType('application', 'pdf'); @@ -33,6 +33,15 @@ class MimeType { /// RTF mime type. static const rtf = MimeType('application', 'rtf'); + /// Multipart form data mime type. + static const multipartFormData = MimeType('multipart', 'form-data'); + + /// Multipart byteranges mime type. + static const multipartByteranges = MimeType('multipart', 'byteranges'); + + /// URL-encoded form MIME type. + static const urlEncoded = MimeType('application', 'x-www-form-urlencoded'); + /// The primary type of the mime type. final String primaryType; diff --git a/lib/src/extensions/http_response_extension.dart b/lib/src/extensions/http_response_extension.dart new file mode 100644 index 0000000..1aa93ee --- /dev/null +++ b/lib/src/extensions/http_response_extension.dart @@ -0,0 +1,70 @@ +import 'package:relic/relic.dart'; +import 'dart:io' as io; + +/// Extension for [io.HttpResponse] to apply headers and body. +extension HttpResponseExtension on io.HttpResponse { + /// Apply headers and body to the response. + void applyHeaders(Headers headers, Body body) { + var responseHeaders = this.headers; + responseHeaders.clear(); + + // Apply headers + var mappedHeaders = headers.toMap(); + for (var entry in mappedHeaders.entries) { + responseHeaders.set(entry.key, entry.value); + } + + // Set the Content-Type header based on the MIME type of the body. + responseHeaders.contentType = body.getContentType(); + + // If the content length is known, set it and remove the Transfer-Encoding header. + var contentLength = body.contentLength; + if (contentLength != null) { + responseHeaders + ..contentLength = contentLength + ..removeAll(Headers.transferEncodingHeader); + return; + } + + // Check if the content type is multipart/byteranges. + var bodyMimeType = body.contentType?.mimeType; + bool isMultipartByteranges = + bodyMimeType?.primaryType == MimeType.multipartByteranges.primaryType && + bodyMimeType?.subType == MimeType.multipartByteranges.subType; + + // Determine if chunked encoding should be applied. + bool shouldEnableChunkedEncoding = statusCode >= 200 && + // 204 is no content + statusCode != 204 && + // 304 is not modified + statusCode != 304 && + // If the content type is not multipart/byteranges, chunked encoding is applied. + !isMultipartByteranges; + + // Prepare transfer encodings. + var encodings = headers.transferEncoding?.encodings ?? []; + bool isChunked = headers.transferEncoding?.isChunked ?? false; + + if (shouldEnableChunkedEncoding && !isChunked) { + encodings.add(TransferEncoding.chunked); + isChunked = true; + } + + if (!isChunked) { + // Set Content-Length to 0 if chunked encoding is not enabled. + responseHeaders.contentLength = 0; + return; + } + + // Remove conflicting 'identity' encoding if present. + encodings.removeWhere((e) => e.name == TransferEncoding.identity.name); + + // Set Transfer-Encoding header and remove Content-Length as it is not needed for chunked encoding. + responseHeaders + ..set( + Headers.transferEncodingHeader, + encodings.map((e) => e.name).toList(), + ) + ..removeAll(Headers.contentLengthHeader); + } +} diff --git a/lib/src/headers/headers.dart b/lib/src/headers/headers.dart index 14bb64b..db898ca 100644 --- a/lib/src/headers/headers.dart +++ b/lib/src/headers/headers.dart @@ -1,17 +1,13 @@ import 'dart:io' as io; import 'package:http_parser/http_parser.dart'; -import 'package:relic/src/headers/custom/custom_headers.dart'; +import 'package:relic/relic.dart'; import 'package:relic/src/headers/extension/string_list_extensions.dart'; import 'package:relic/src/headers/parser/headers_parser.dart'; import 'package:relic/src/headers/parser/common_types_parser.dart'; import 'package:relic/src/headers/typed/typed_header_interface.dart'; import 'package:relic/src/method/request_method.dart'; -import 'typed/typed_headers.dart'; - -import '../body/body.dart'; - abstract base class Headers { /// Request Headers static const acceptHeader = "accept"; @@ -909,9 +905,6 @@ abstract base class Headers { CrossOriginOpenerPolicyHeader? crossOriginOpenerPolicy, }); - /// Apply headers to the response - void applyHeaders(io.HttpResponse response, Body body); - /// Convert headers to a map Map toMap(); } @@ -1235,87 +1228,6 @@ final class _HeadersImpl extends Headers { ); } - /// Apply headers to the response - @override - void applyHeaders( - io.HttpResponse response, - Body body, - ) { - var headers = response.headers; - headers.clear(); - - // Date-related headers - var dateHeaders = _dateHeadersMap; - for (var entry in dateHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, formatHttpDate(value)); - } - } - - // Number-related headers - var numberHeaders = _numberHeadersMap; - for (var entry in numberHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, value); - } - } - - // String-related headers - var stringHeaders = _stringHeadersMap; - for (var entry in stringHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, value); - } - } - - // List-related headers - var listStringHeaders = _listStringHeadersMap; - for (var entry in listStringHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, value); - } - } - - // Uri-related headers - var uriHeaders = _uriHeadersMap; - for (var entry in uriHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, value.toString()); - } - } - - // TypedHeader-related headers - var typedHeaders = _typedHeadersMap; - for (var entry in typedHeaders.entries) { - var key = entry.key; - var value = entry.value; - if (value != null) { - headers.set(key, value.toHeaderString()); - } - } - - // Set custom headers - for (var entry in custom.entries) { - headers.set(entry.key, entry.value); - } - - // Set the content length from the Body - headers.contentLength = body.contentLength ?? 0; - - // Set the content type from the Body - headers.contentType = body.getContentType(); - } - /// Convert headers to a map @override Map toMap() { diff --git a/lib/src/headers/typed/headers/transfer_encoding_header.dart b/lib/src/headers/typed/headers/transfer_encoding_header.dart index 477f7c8..c0332df 100644 --- a/lib/src/headers/typed/headers/transfer_encoding_header.dart +++ b/lib/src/headers/typed/headers/transfer_encoding_header.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:relic/src/headers/extension/string_list_extensions.dart'; import 'package:relic/src/headers/typed/typed_header_interface.dart'; @@ -10,9 +11,9 @@ class TransferEncodingHeader implements TypedHeader { final List encodings; /// Constructs a [TransferEncodingHeader] instance with the specified transfer encodings. - const TransferEncodingHeader({ - required this.encodings, - }); + TransferEncodingHeader({ + required List encodings, + }) : encodings = _reorderEncodings(encodings); /// Parses the Transfer-Encoding header value and returns a [TransferEncodingHeader] instance. /// @@ -42,6 +43,35 @@ class TransferEncodingHeader implements TypedHeader { String toString() { return 'TransferEncodingHeader(encodings: $encodings)'; } + + /// Ensures that the 'chunked' transfer encoding is always the last in the list. + /// + /// According to the HTTP/1.1 specification (RFC 9112), the 'chunked' transfer + /// encoding must be the final encoding applied to the response body. This is + /// because 'chunked' signals the end of the response message, and any + /// encoding after 'chunked' would cause ambiguity or violate the standard. + /// + /// Example of valid ordering: + /// Transfer-Encoding: gzip, chunked + /// + /// Example of invalid ordering: + /// Transfer-Encoding: chunked, gzip + /// + /// This function reorders the encodings to comply with the standard and + /// ensures compatibility with HTTP clients and intermediaries. + static List _reorderEncodings( + List encodings, + ) { + final TransferEncoding? chunked = encodings.firstWhereOrNull( + (e) => e.name == TransferEncoding.chunked.name, + ); + if (chunked == null) return encodings; + + var reordered = List.from(encodings); + reordered.removeWhere((e) => e.name == TransferEncoding.chunked.name); + reordered.add(chunked); + return reordered; + } } /// A class representing valid transfer encodings. diff --git a/lib/src/message/response.dart b/lib/src/message/response.dart index 8876158..f6d307d 100644 --- a/lib/src/message/response.dart +++ b/lib/src/message/response.dart @@ -1,9 +1,7 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:typed_data'; -import 'package:http_parser/http_parser.dart'; -import 'package:relic/src/headers/typed/headers/transfer_encoding_header.dart'; +import 'package:relic/src/extensions/http_response_extension.dart'; import '../body/body.dart'; import '../headers/headers.dart'; @@ -327,50 +325,14 @@ class Response extends Message { httpResponse.bufferOutput = context['relic_server.buffer_output'] as bool; } + // Set the status code. httpResponse.statusCode = statusCode; - headers.applyHeaders( - httpResponse, - body, - ); - - var mBody = _handleTransferEncoding( - httpResponse, - headers.transferEncoding, - statusCode, - body, - ); + // Apply all headers to the response. + httpResponse.applyHeaders(headers, body); return httpResponse - .addStream(mBody.read()) + .addStream(body.read()) .then((_) => httpResponse.close()); } } - -Body _handleTransferEncoding( - HttpResponse httpResponse, - TransferEncodingHeader? transferEncoding, - int statusCode, - Body body, -) { - if (transferEncoding?.isChunked == true) { - // If the response is already chunked, decode it to avoid double chunking. - body = Body.fromDataStream( - chunkedCoding.decoder.bind(body.read()).cast(), - ); - httpResponse.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); - return body; - } else if (_shouldEnableChunkedEncoding(statusCode, body)) { - // If content length is unknown and chunking is needed, set chunked encoding. - httpResponse.headers.set(HttpHeaders.transferEncodingHeader, 'chunked'); - } - return body; -} - -bool _shouldEnableChunkedEncoding(int statusCode, Body body) { - return statusCode >= 200 && - statusCode != 204 && - statusCode != 304 && - body.contentLength == null && - body.contentType?.mimeType.toString() != 'multipart/byteranges'; -} diff --git a/lib/src/static/static_handler.dart b/lib/src/static/static_handler.dart index e77ed51..8f6d3bf 100644 --- a/lib/src/static/static_handler.dart +++ b/lib/src/static/static_handler.dart @@ -309,7 +309,7 @@ Response? _fileRangeResponse( file.openRead(start, end + 1).cast(), encoding: null, contentLength: (end - start) + 1, - mimeType: MimeType.binary, + mimeType: MimeType.octetStream, ); } diff --git a/test/headers/headers_test_utils.dart b/test/headers/headers_test_utils.dart index 5f89439..4002cf5 100644 --- a/test/headers/headers_test_utils.dart +++ b/test/headers/headers_test_utils.dart @@ -39,13 +39,16 @@ Future createServer({ Future getServerRequestHeaders({ required RelicServer server, required Map headers, + bool echoHeaders = true, }) async { Headers? parsedHeaders; server.mountAndStart( (Request request) { parsedHeaders = request.headers; - return Response.ok(); + return Response.ok( + headers: echoHeaders ? parsedHeaders : null, + ); }, ); diff --git a/test/headers/typed_headers/transfer_encoding_header_test.dart b/test/headers/typed_headers/transfer_encoding_header_test.dart index ef007c1..fcf08cd 100644 --- a/test/headers/typed_headers/transfer_encoding_header_test.dart +++ b/test/headers/typed_headers/transfer_encoding_header_test.dart @@ -53,8 +53,12 @@ void main() { }, ); + /// According to the HTTP/1.1 specification (RFC 9112), the 'chunked' transfer + /// encoding must be the final encoding applied to the response body. test( - 'when a valid Transfer-Encoding header is passed then it should parse the encodings correctly', + 'when a valid Transfer-Encoding header is passed with "chunked" as not the last ' + 'encoding then it should parse the encodings correctly and reorder them sot the ' + 'chunked encoding is the last encoding', () async { Headers headers = await getServerRequestHeaders( server: server, @@ -63,7 +67,22 @@ void main() { expect( headers.transferEncoding?.encodings.map((e) => e.name), - equals(['chunked', 'gzip']), + equals(['gzip', 'chunked']), + ); + }, + ); + + test( + 'when a valid Transfer-Encoding header is passed then it should parse the encodings correctly', + () async { + Headers headers = await getServerRequestHeaders( + server: server, + headers: {'transfer-encoding': 'gzip, chunked'}, + ); + + expect( + headers.transferEncoding?.encodings.map((e) => e.name), + equals(['gzip', 'chunked']), ); }, ); @@ -74,12 +93,12 @@ void main() { () async { Headers headers = await getServerRequestHeaders( server: server, - headers: {'transfer-encoding': 'chunked, gzip, chunked'}, + headers: {'transfer-encoding': 'gzip, chunked, chunked'}, ); expect( headers.transferEncoding?.encodings.map((e) => e.name), - equals(['chunked', 'gzip']), + equals(['gzip', 'chunked']), ); }, ); @@ -89,7 +108,7 @@ void main() { () async { Headers headers = await getServerRequestHeaders( server: server, - headers: {'transfer-encoding': 'chunked, gzip'}, + headers: {'transfer-encoding': 'gzip, chunked'}, ); expect(headers.transferEncoding?.isChunked, isTrue); diff --git a/test/message/body_test.dart b/test/message/body_test.dart new file mode 100644 index 0000000..00953b5 --- /dev/null +++ b/test/message/body_test.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; +import 'package:relic/relic.dart'; +import 'package:relic/src/method/request_method.dart'; +import 'package:relic/src/relic_server_serve.dart' as relic_server; +import 'package:test/test.dart'; + +void main() { + tearDown(() async { + var server = _server; + if (server != null) { + try { + await server.close().timeout(Duration(seconds: 5)); + } catch (e) { + await server.close(force: true); + } finally { + _server = null; + } + } + }); + + group('Given a response', () { + test( + 'with an empty body and "chunked" transfer encoding ' + 'when applying headers to the response then the "chunked" transfer ' + 'encoding is removed', () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.empty(), + headers: Headers.response( + transferEncoding: TransferEncodingHeader( + encodings: [TransferEncoding.chunked], + ), + ), + ), + ); + + var response = await _get(); + expect(response.body, isEmpty); + expect(response.headers['transfer-encoding'], isNull); + }); + + test( + 'with unknown content length when applying headers ' + 'to the response then "chunked" transfer encoding is added', () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromDataStream( + Stream.fromIterable([ + Uint8List.fromList([1, 2, 3, 4]) + ]), + ), + ), + ); + + var response = await _get(); + expect( + response.headers['transfer-encoding'], + contains(TransferEncoding.chunked.name), + ); + expect(response.bodyBytes, equals([1, 2, 3, 4])); + }); + + test( + 'with a known content length when applying headers ' + 'to the response then "chunked" transfer encoding is not added', + () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromData( + Uint8List.fromList([1, 2, 3, 4]), + ), + ), + ); + + var response = await _get(); + expect(response.headers['transfer-encoding'], isNull); + expect(response.headers['content-length'], equals('4')); + expect(response.bodyBytes, equals([1, 2, 3, 4])); + }); + + test( + 'with "identity" transfer encoding and known content length when ' + 'applying headers to the response then "chunked" transfer encoding ' + 'is added and "identity" is removed', () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromDataStream( + Stream.fromIterable([ + Uint8List.fromList([1, 2, 3, 4]) + ]), + ), + headers: Headers.response( + transferEncoding: TransferEncodingHeader( + encodings: [TransferEncoding.identity], + ), + ), + ), + ); + + var response = await _get(); + expect( + response.headers['transfer-encoding'], + contains(TransferEncoding.chunked.name), + ); + expect( + response.headers['transfer-encoding'], + isNot(contains(TransferEncoding.identity.name)), + ); + expect(response.bodyBytes, equals([1, 2, 3, 4])); + }); + + test( + 'with "chunked" transfer encoding already applied when applying headers ' + 'to the response then "chunked" is retained', () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromDataStream( + Stream.fromIterable([ + Uint8List.fromList('5\r\nRelic\r\n0\r\n\r\n'.codeUnits), + ]), + ), + headers: Headers.response( + transferEncoding: TransferEncodingHeader( + encodings: [TransferEncoding.chunked], + ), + ), + ), + ); + + var response = await _get(); + expect( + response.headers['transfer-encoding'], + contains(TransferEncoding.chunked.name), + ); + expect(response.body, equals('5\r\nRelic\r\n0\r\n\r\n')); + }); + + test( + 'with a valid content length when applying headers ' + 'to the response then Content-Length is used instead of chunked encoding', + () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromDataStream( + Stream.fromIterable([ + Uint8List.fromList([1, 2, 3, 4]) + ]), + contentLength: 4, + ), + headers: Headers.response(), + ), + ); + + var response = await _get(); + expect(response.headers['content-length'], equals('4')); + expect(response.headers['transfer-encoding'], isNull); + expect(response.bodyBytes, equals([1, 2, 3, 4])); + }); + + test( + 'with an unknown content length and chunked encoding ' + 'then content-length should be set to 0 and it should throw an HttpException ' + 'with a message that states the content size exceeds the specified contentLength', + () async { + Completer completer = Completer(); + + unawaited(runZonedGuarded( + () async { + await _scheduleServer( + (_) => Response.ok( + body: Body.fromDataStream( + Stream.fromIterable([ + Uint8List.fromList([1, 2, 3, 4]) + ]), + mimeType: MimeType.multipartByteranges, + ), + headers: Headers.response(), + ), + ); + + await _get(); + }, + (error, stackTrace) { + if (completer.isCompleted) return; + completer.complete(error); + }, + )); + + var error = await completer.future; + + expect(error, isA()); + expect( + error.toString(), + contains('Content size exceeds specified contentLength'), + ); + }, + ); + }); +} + +int get _serverPort => _server!.port; + +HttpServer? _server; + +Future _scheduleServer( + Handler handler, { + SecurityContext? securityContext, +}) async { + assert(_server == null); + _server = await relic_server.serve( + handler, + RelicAddress.fromHostname('localhost'), + 0, + securityContext: securityContext, + ); +} + +Future _get({ + Map? headers, + String path = '', +}) async { + var request = http.Request( + RequestMethod.get.value, + Uri.http('localhost:$_serverPort', path), + ); + + if (headers != null) request.headers.addAll(headers); + + var response = await request.send(); + return await http.Response.fromStream(response); +} diff --git a/test/relic_server_serve_test.dart b/test/relic_server_serve_test.dart index 715a5eb..fcee81e 100644 --- a/test/relic_server_serve_test.dart +++ b/test/relic_server_serve_test.dart @@ -458,8 +458,13 @@ void main() { }); var response = await _get(); - expect(response.headers, - containsPair(HttpHeaders.transferEncodingHeader, 'chunked')); + expect( + response.headers, + containsPair( + HttpHeaders.transferEncodingHeader, + TransferEncoding.chunked.name, + ), + ); expect(response.bodyBytes, equals([1, 2, 3, 4])); }); @@ -478,13 +483,19 @@ void main() { }); var response = await _get(); - expect(response.headers, - containsPair(HttpHeaders.transferEncodingHeader, 'chunked')); + expect( + response.headers, + containsPair( + HttpHeaders.transferEncodingHeader, + TransferEncoding.chunked.name, + ), + ); expect(response.bodyBytes, equals([1, 2, 3, 4])); }); }); - test('is preserved when the transfer-encoding header is "chunked"', + test( + 'is preserved and body is not modified when the transfer-encoding header is "chunked"', () async { await _scheduleServer((request) { return Response.ok( @@ -506,9 +517,12 @@ void main() { var response = await _get(); expect( response.headers, - containsPair(HttpHeaders.transferEncodingHeader, 'chunked'), + containsPair( + HttpHeaders.transferEncodingHeader, + TransferEncoding.chunked.name, + ), ); - expect(response.body, equals('hi')); + expect(response.body, equals('2\r\nhi\r\n0\r\n\r\n')); }); group('is not added when', () { @@ -517,11 +531,14 @@ void main() { return Response.ok( body: Body.fromDataStream( Stream.value(Uint8List.fromList([1, 2, 3, 4])), + contentLength: 4, ), ); }); var response = await _get(); + expect(response.headers, + isNot(contains(HttpHeaders.transferEncodingHeader))); expect(response.bodyBytes, equals([1, 2, 3, 4])); });