Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions lib/src/body/body.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';

import 'package:mime/mime.dart';

import 'types/body_type.dart';
import 'types/mime_type.dart';

Expand Down Expand Up @@ -51,12 +54,18 @@ class Body {
factory Body.empty() => Body._(const Stream.empty(), 0);

/// Creates a body from a string.
///
/// If [mimeType] is not provided, it will be inferred from the string.
/// It is more performant to set it explicitly.
factory Body.fromString(
final String body, {
final Encoding encoding = utf8,
final MimeType mimeType = MimeType.plainText,
MimeType? mimeType,
}) {
final Uint8List encoded = Uint8List.fromList(encoding.encode(body));

mimeType ??= _tryInferTextMimeTypeFrom(body) ?? MimeType.plainText;

return Body._(
Stream.value(encoded),
encoded.length,
Expand All @@ -65,6 +74,39 @@ class Body {
);
}

static MimeType? _tryInferTextMimeTypeFrom(final String content) {
var firstNonWhiteSpace = 0;
final end = content.length;
while (firstNonWhiteSpace < end &&
_isWhitespace(content[firstNonWhiteSpace])) {
firstNonWhiteSpace++;
}

final prefix = content.substring(firstNonWhiteSpace,
min(end, firstNonWhiteSpace + 14)); // 14 max length needed

if (prefix.startsWith('{') || prefix.startsWith('[')) {
return MimeType.json;
}

if (prefix.startsWith('<?xml')) {
return MimeType.xml;
}

if (prefix.startsWith('<!DOCTYPE html') ||
prefix.startsWith('<!doctype html') ||
prefix.startsWith('<html')) {
return MimeType.html;
}

return null; // give up
}

/// Checks if a character is whitespace.
static bool _isWhitespace(final String char) {
return char == ' ' || char == '\t' || char == '\n' || char == '\r';
}

/// Creates a body from a [Stream] of [Uint8List].
factory Body.fromDataStream(
final Stream<Uint8List> body, {
Expand All @@ -80,17 +122,27 @@ class Body {
);
}

static final _resolver = MimeTypeResolver();

/// Creates a body from a [Uint8List].
///
/// Will try to infer the [mimeType] if it is not provided,
/// This will only work for some binary formats, and falls
/// back to [MimeType.octetStream].
factory Body.fromData(
final Uint8List body, {
final Encoding? encoding,
final MimeType mimeType = MimeType.octetStream,
MimeType? mimeType,
}) {
if (mimeType == null) {
final mimeString = _resolver.lookup('', headerBytes: body);
mimeType = mimeString == null ? null : MimeType.parse(mimeString);
}
return Body._(
Stream.value(body),
body.length,
encoding: encoding ?? (mimeType.isText == true ? utf8 : null),
mimeType: mimeType,
encoding: encoding ?? (mimeType?.isText == true ? utf8 : null),
mimeType: mimeType ?? MimeType.octetStream,
);
}

Expand Down
72 changes: 0 additions & 72 deletions lib/src/body/types/body_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,78 +4,6 @@ import 'mime_type.dart';

/// A body type.
class BodyType {
/// A body type for plain text.
static const plainText = BodyType(
mimeType: MimeType.plainText,
encoding: utf8,
);

/// A body type for HTML.
static const html = BodyType(
mimeType: MimeType.html,
encoding: utf8,
);

/// A body type for CSS.
static const css = BodyType(
mimeType: MimeType.css,
encoding: utf8,
);

/// A body type for CSV.
static const csv = BodyType(
mimeType: MimeType.csv,
encoding: utf8,
);

/// A body type for JavaScript.
static const javascript = BodyType(
mimeType: MimeType.javascript,
encoding: utf8,
);

/// A body type for JSON.
static const json = BodyType(
mimeType: MimeType.json,
encoding: utf8,
);

/// A body type for XML.
static const xml = BodyType(
mimeType: MimeType.xml,
encoding: utf8,
);

/// A body type for octet stream data.
static const octetStream = BodyType(
mimeType: MimeType.octetStream,
);

/// A body type for PDF.
static const pdf = BodyType(
mimeType: MimeType.pdf,
);

/// A body type for RTF.
static const rtf = 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
7 changes: 4 additions & 3 deletions lib/src/body/types/mime_type.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class MimeType {
static const xml = MimeType('application', 'xml');

/// Binary mime type.

static const octetStream = MimeType('application', 'octet-stream');

/// PDF mime type.
Expand All @@ -46,6 +45,7 @@ class MimeType {
/// The sub type of the mime type.
final String subType;

/// Creates a new mime type.
const MimeType(this.primaryType, this.subType);

/// Parses a mime type from a string.
Expand All @@ -58,8 +58,8 @@ class MimeType {
throw FormatException('Invalid mime type $type');
}

final primaryType = parts[0];
final subType = parts[1];
final primaryType = parts[0].trim();
final subType = parts[1].trim();

if (primaryType.isEmpty || subType.isEmpty) {
throw FormatException('Invalid mime type $type');
Expand All @@ -68,6 +68,7 @@ class MimeType {
return MimeType(primaryType, subType);
}

/// Returns `true` if the mime type is text.
bool get isText {
if (primaryType == 'text') return true;
if (primaryType == 'application') {
Expand Down
168 changes: 168 additions & 0 deletions test/body/body_infer_mime_type_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import 'dart:convert';
import 'dart:typed_data';

import 'package:relic/src/body/body.dart';
import 'package:relic/src/body/types/mime_type.dart';
import 'package:test/test.dart';

void main() {
test(
'Given JSON content without explicit mimeType, '
'when Body.fromString is called, '
'then it infers application/json', () {
const jsonContent = '{"key": "value"}';
final body = Body.fromString(jsonContent);
expect(body.bodyType?.mimeType, MimeType.json);
});

test(
'Given HTML content without explicit mimeType, '
'when Body.fromString is called, '
'then it infers text/html', () {
const htmlContent = '<!DOCTYPE html><html><body>Hello</body></html>';
final body = Body.fromString(htmlContent);
expect(body.bodyType?.mimeType, MimeType.html);
});

test(
'Given HTML content with whitespace prefix, '
'when Body.fromString is called, '
'then it infers text/html', () {
const htmlContentWithWhitespace = ' \t \n \r ' // some whitespace
'<!DOCTYPE html><html><body>Hello</body></html>'; // followed by html
final body = Body.fromString(htmlContentWithWhitespace);
expect(body.bodyType?.mimeType, MimeType.html);
});

test(
'Given XML content without explicit mimeType, '
'when Body.fromString is called, '
'then it infers application/xml', () {
const xmlContent = '<?xml version="1.0"?><root></root>';
final body = Body.fromString(xmlContent);
expect(body.bodyType?.mimeType, MimeType.xml);
});

test(
'Given plain text content without explicit mimeType, '
'when Body.fromString is called, '
'then it defaults to text/plain', () {
const plainTextContent = 'Just some plain text';
final body = Body.fromString(plainTextContent);
expect(body.bodyType?.mimeType, MimeType.plainText);
});

test(
'Given empty string without explicit mimeType, '
'when Body.fromString is called, '
'then it defaults to text/plain', () {
const emptyContent = '';
final body = Body.fromString(emptyContent);
expect(body.bodyType?.mimeType, MimeType.plainText);
});

test(
'Given JSON content with explicit mimeType, '
'when Body.fromString is called, '
'then it uses the explicit mimeType', () {
const jsonContent = '{"key": "value"}';
final body = Body.fromString(
jsonContent,
mimeType: MimeType.plainText,
);
expect(body.bodyType?.mimeType, MimeType.plainText);
});

test(
'Given content with custom encoding, '
'when Body.fromString is called, '
'then it preserves the encoding', () {
const content = 'Héllo world';
final body = Body.fromString(
content,
encoding: latin1,
);
expect(body.bodyType?.encoding, latin1);
});

test(
'Given inferred MIME type, '
'when Body.fromString is called without encoding, '
'then it uses utf8 encoding by default', () {
const jsonContent = '{"key": "value"}';
final body = Body.fromString(jsonContent);
expect(body.bodyType?.encoding, utf8);
});

test(
'Given PNG binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it infers image/png', () {
final pngBytes = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
]);
final body = Body.fromData(pngBytes);
expect(body.bodyType?.mimeType.primaryType, 'image');
expect(body.bodyType?.mimeType.subType, 'png');
});

test(
'Given JPEG binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it infers image/jpeg', () {
final jpegBytes = Uint8List.fromList([
0xFF, 0xD8, 0xFF, 0xE0, // JPEG signature
]);
final body = Body.fromData(jpegBytes);
expect(body.bodyType?.mimeType.primaryType, 'image');
expect(body.bodyType?.mimeType.subType, 'jpeg');
});

test(
'Given GIF binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it infers image/gif', () {
final gifBytes = Uint8List.fromList(utf8.encode('GIF89a'));
final body = Body.fromData(gifBytes);
expect(body.bodyType?.mimeType.primaryType, 'image');
expect(body.bodyType?.mimeType.subType, 'gif');
});

test(
'Given PDF binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it infers application/pdf', () {
final pdfBytes = Uint8List.fromList(utf8.encode('%PDF-1.4'));
final body = Body.fromData(pdfBytes);
expect(body.bodyType?.mimeType, MimeType.pdf);
});

test(
'Given unrecognizable binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it defaults to application/octet-stream', () {
final unknownBytes = Uint8List.fromList([0x00, 0x01, 0x02, 0x03]);
final body = Body.fromData(unknownBytes);
expect(body.bodyType?.mimeType, MimeType.octetStream);
});

test(
'Given binary data with explicit mimeType, '
'when Body.fromData is called, '
'then it uses the explicit mimeType', () {
final pngBytes = Uint8List.fromList(
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // PNG signature
);
final body = Body.fromData(pngBytes, mimeType: MimeType.json);
expect(body.bodyType?.mimeType, MimeType.json);
});

test(
'Given empty binary data without explicit mimeType, '
'when Body.fromData is called, '
'then it defaults to application/octet-stream', () {
final emptyBytes = Uint8List(0);
final body = Body.fromData(emptyBytes);
expect(body.bodyType?.mimeType, MimeType.octetStream);
});
}