Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

fix: Resolve Content-Length header conflict with Transfer-Encoding: chunked #22

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 1 addition & 1 deletion lib/src/body/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
25 changes: 20 additions & 5 deletions lib/src/body/types/body_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand All @@ -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.
Expand All @@ -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;

Expand Down
13 changes: 11 additions & 2 deletions lib/src/body/types/mime_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -25,14 +25,23 @@ 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');

/// 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');
SandPod marked this conversation as resolved.
Show resolved Hide resolved

/// URL-encoded form MIME type.
static const urlEncoded = MimeType('application', 'x-www-form-urlencoded');

/// The primary type of the mime type.
final String primaryType;

Expand Down
70 changes: 70 additions & 0 deletions lib/src/extensions/http_response_extension.dart
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What is the difference between body.contentType and body.getContentType?

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussion: This still looks a bit funky to me. Let's discuss it.

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);
}
}
90 changes: 1 addition & 89 deletions lib/src/headers/headers.dart
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<String, Object> toMap();
}
Expand Down Expand Up @@ -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<String>-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<String, Object> toMap() {
Expand Down
36 changes: 33 additions & 3 deletions lib/src/headers/typed/headers/transfer_encoding_header.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,9 +11,9 @@ class TransferEncodingHeader implements TypedHeader {
final List<TransferEncoding> encodings;

/// Constructs a [TransferEncodingHeader] instance with the specified transfer encodings.
const TransferEncodingHeader({
required this.encodings,
});
TransferEncodingHeader({
required List<TransferEncoding> encodings,
}) : encodings = _reorderEncodings(encodings);

/// Parses the Transfer-Encoding header value and returns a [TransferEncodingHeader] instance.
///
Expand Down Expand Up @@ -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<TransferEncoding> _reorderEncodings(
List<TransferEncoding> encodings,
) {
final TransferEncoding? chunked = encodings.firstWhereOrNull(
(e) => e.name == TransferEncoding.chunked.name,
);
if (chunked == null) return encodings;
klkucaj marked this conversation as resolved.
Show resolved Hide resolved

var reordered = List<TransferEncoding>.from(encodings);
reordered.removeWhere((e) => e.name == TransferEncoding.chunked.name);
reordered.add(chunked);
return reordered;
}
}

/// A class representing valid transfer encodings.
Expand Down
48 changes: 5 additions & 43 deletions lib/src/message/response.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<Uint8List>(),
);
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';
}
Loading
Loading