diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2da83..dd9b86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.2.0 + +- BREAKING: Reworked `Blob` to a `block`-backed implementation and removed + synchronous `Blob.copyBytes()`. +- Added direct `Blob` <-> `block.Block` compatibility (`Blob` now implements + `Block`). +- BREAKING: `Blob.slice` now follows Web Blob semantics (negative indexes are + resolved from the end). +- BREAKING: `FormData.encodeMultipart()` now returns a stream-first + `MultipartBody`, and `MultipartBody.bytes` is now async method `bytes()`. +- Added stream-first `MultipartBody` with `stream`, `contentLength`, + `contentType`, and async `bytes()`. +- Added `BodyInit` support for `package:block` `Block` values in `Request` and + `Response`. + ## 0.1.0 - Rebuilt the package as a fetch-first HTTP type layer. diff --git a/README.md b/README.md index a89780a..0fe91fd 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Or add it manually to `pubspec.yaml`: ```yaml dependencies: - ht: ^0.1.0 + ht: ^0.2.0 ``` ## Scope @@ -82,18 +82,54 @@ Future main() async { ```dart import 'package:ht/ht.dart'; -void main() { +Future main() async { final form = FormData() ..append('name', 'alice') ..append('avatar', Blob.text('binary'), filename: 'avatar.txt'); final multipart = form.encodeMultipart(); + final bytes = await multipart.bytes(); print(multipart.contentType); // multipart/form-data; boundary=... print(multipart.contentLength); // body bytes length + print(bytes.length); // same as contentLength } ``` +## Block Interop + +`Blob` implements `package:block` `Block`, and `BodyInit` accepts `Block` +values directly: + +```dart +import 'package:block/block.dart' as block; +import 'package:ht/ht.dart'; + +Future main() async { + final body = block.Block(['hello'], type: 'text/plain'); + final request = Request( + Uri.parse('https://example.com'), + method: 'POST', + body: body, + ); + + print(request.headers.get('content-type')); // text/plain + print(request.headers.get('content-length')); // 5 + print(await request.text()); // hello +} +``` + +## Blob Slice Semantics + +`Blob.slice(start, end)` now follows Web Blob semantics. Negative indexes are +interpreted from the end of the blob: + +```dart +final blob = Blob.text('hello world'); +final tail = blob.slice(-5); +print(await tail.text()); // world +``` + ## Development ```bash diff --git a/lib/src/fetch/blob.dart b/lib/src/fetch/blob.dart index 7510d36..411f6de 100644 --- a/lib/src/fetch/blob.dart +++ b/lib/src/fetch/blob.dart @@ -1,99 +1,96 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:block/block.dart' as block; + /// Binary large object. -class Blob { +class Blob implements block.Block { Blob([Iterable parts = const [], String type = '']) - : _bytes = _concatenate(parts), - type = _normalizeType(type); + : this._fromNormalized(_normalizeParts(parts), _normalizeType(type)); Blob.bytes(List bytes, {String type = ''}) - : _bytes = Uint8List.fromList(bytes), - type = _normalizeType(type); + : this._fromNormalized([ + Uint8List.fromList(bytes), + ], _normalizeType(type)); Blob.text( String text, { String type = 'text/plain;charset=utf-8', Encoding encoding = utf8, - }) : _bytes = Uint8List.fromList(encoding.encode(text)), - type = _normalizeType(type); + }) : this._fromNormalized([ + Uint8List.fromList(encoding.encode(text)), + ], _normalizeType(type)); + + Blob._fromNormalized(List parts, String normalizedType) + : this._fromBlock( + block.Block(parts, type: normalizedType), + type: normalizedType, + ); - final Uint8List _bytes; + Blob._fromBlock(this._inner, {required this.type}); + + final block.Block _inner; /// MIME type hint. + @override final String type; - int get size => _bytes.length; + @override + int get size => _inner.size; /// Returns a copy of underlying bytes. - Future bytes() async => copyBytes(); + Future bytes() async { + return Uint8List.fromList(await _inner.arrayBuffer()); + } - /// Synchronous copy helper for internal body assembly. - Uint8List copyBytes() => Uint8List.fromList(_bytes); + @override + Future arrayBuffer() => bytes(); + @override Future text([Encoding encoding = utf8]) async { - return encoding.decode(_bytes); + if (identical(encoding, utf8)) { + return _inner.text(); + } + + return encoding.decode(await bytes()); } - Stream stream({int chunkSize = 16 * 1024}) async* { + @override + Stream stream({int chunkSize = 16 * 1024}) { if (chunkSize <= 0) { throw ArgumentError.value(chunkSize, 'chunkSize', 'Must be > 0'); } - var offset = 0; - while (offset < _bytes.length) { - final nextOffset = (offset + chunkSize).clamp(0, _bytes.length); - yield Uint8List.sublistView(_bytes, offset, nextOffset); - offset = nextOffset; - } + return _inner.stream(chunkSize: chunkSize); } - Blob slice([int start = 0, int? end, String contentType = '']) { - final safeStart = start.clamp(0, _bytes.length); - final safeEnd = (end ?? _bytes.length).clamp(safeStart, _bytes.length); - return Blob.bytes( - Uint8List.sublistView(_bytes, safeStart, safeEnd), - type: contentType, + @override + Blob slice(int start, [int? end, String? contentType]) { + final normalizedType = _normalizeType(contentType ?? ''); + return Blob._fromBlock( + _inner.slice(start, end, normalizedType), + type: normalizedType, ); } - static Uint8List _concatenate(Iterable parts) { - final builder = BytesBuilder(copy: false); - - for (final part in parts) { - if (part is Blob) { - builder.add(part._bytes); - continue; - } - - if (part is ByteBuffer) { - builder.add(part.asUint8List()); - continue; - } - - if (part is Uint8List) { - builder.add(part); - continue; - } - - if (part is List) { - builder.add(part); - continue; - } - - if (part is String) { - builder.add(utf8.encode(part)); - continue; - } + static List _normalizeParts(Iterable parts) { + return List.unmodifiable(parts.map(_normalizePart)); + } - throw ArgumentError.value( + static Object _normalizePart(Object part) { + return switch (part) { + final Blob blob => blob._inner, + final block.Block blockPart => blockPart, + final ByteBuffer buffer => ByteData.sublistView(buffer.asUint8List()), + final Uint8List bytes => Uint8List.fromList(bytes), + final List bytes => Uint8List.fromList(bytes), + final String text => text, + _ => throw ArgumentError.value( part, 'parts', 'Unsupported blob part type: ${part.runtimeType}', - ); - } - - return builder.takeBytes(); + ), + }; } static String _normalizeType(String input) { diff --git a/lib/src/fetch/body.dart b/lib/src/fetch/body.dart index 251de3b..5565cc9 100644 --- a/lib/src/fetch/body.dart +++ b/lib/src/fetch/body.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:async/async.dart'; +import 'package:block/block.dart' as block; import 'blob.dart'; import 'form_data.dart'; @@ -79,63 +80,47 @@ final class BodyData { _branch = branch; factory BodyData.fromInit(Object? init) { - if (init == null) { - return BodyData.empty(); - } - - if (init is BodyData) { - return init.clone(); - } - - if (init is String) { - return BodyData.bytes( - utf8.encode(init), + return switch (init) { + null => BodyData.empty(), + final BodyData data => data.clone(), + final String text => BodyData.bytes( + utf8.encode(text), defaultContentType: 'text/plain; charset=utf-8', - ); - } - - if (init is Uint8List) { - return BodyData.bytes(init); - } - - if (init is ByteBuffer) { - return BodyData.bytes(init.asUint8List()); - } - - if (init is List) { - return BodyData.bytes(init); - } - - if (init is Blob) { - return BodyData.bytes( - init.copyBytes(), - defaultContentType: init.type.isEmpty ? null : init.type, - ); - } - - if (init is URLSearchParams) { - return BodyData.bytes( - utf8.encode(init.toString()), + ), + final Uint8List bytes => BodyData.bytes(bytes), + final ByteBuffer buffer => BodyData.bytes(buffer.asUint8List()), + final List bytes => BodyData.bytes(bytes), + // Keep Blob ahead of block.Block for explicit fetch-type handling. + final Blob blob => _fromBlock(blob), + final block.Block blockBody => _fromBlock(blockBody), + final URLSearchParams params => BodyData.bytes( + utf8.encode(params.toString()), defaultContentType: 'application/x-www-form-urlencoded; charset=utf-8', - ); - } - - if (init is FormData) { - final payload = init.encodeMultipart(); - return BodyData.bytes( - payload.bytes, - defaultContentType: payload.contentType, - ); - } + ), + final FormData formData => _fromFormData(formData), + final Stream> stream => BodyData.stream(stream), + _ => throw ArgumentError.value( + init, + 'init', + 'Unsupported body type: ${init.runtimeType}', + ), + }; + } - if (init is Stream>) { - return BodyData.stream(init); - } + static BodyData _fromBlock(block.Block value) { + return BodyData.stream( + value.stream(), + defaultContentType: value.type.isEmpty ? null : value.type, + defaultContentLength: value.size, + ); + } - throw ArgumentError.value( - init, - 'init', - 'Unsupported body type: ${init.runtimeType}', + static BodyData _fromFormData(FormData formData) { + final payload = formData.encodeMultipart(); + return BodyData.stream( + payload.stream, + defaultContentType: payload.contentType, + defaultContentLength: payload.contentLength, ); } diff --git a/lib/src/fetch/form_data.dart b/lib/src/fetch/form_data.dart index 1957dfe..c13bf39 100644 --- a/lib/src/fetch/form_data.dart +++ b/lib/src/fetch/form_data.dart @@ -8,13 +8,26 @@ import 'file.dart'; /// Multipart body payload generated from [FormData]. final class MultipartBody { - MultipartBody(this.bytes, this.boundary); + MultipartBody._({ + required Stream Function() streamFactory, + required this.boundary, + required this.contentLength, + }) : _streamFactory = streamFactory; - final Uint8List bytes; + final Stream Function() _streamFactory; final String boundary; + final int contentLength; String get contentType => 'multipart/form-data; boundary=$boundary'; - int get contentLength => bytes.length; + Stream get stream => _streamFactory(); + + Future bytes() async { + final builder = BytesBuilder(copy: false); + await for (final chunk in _streamFactory()) { + builder.add(chunk); + } + return builder.takeBytes(); + } } /// Form-data collection compatible with fetch-style APIs. @@ -62,44 +75,19 @@ class FormData extends IterableBase> { MultipartBody encodeMultipart({String? boundary}) { final safeBoundary = boundary ?? _generateBoundary(); - final builder = BytesBuilder(copy: false); - - for (final entry in _entries) { - builder.add(_utf8('--$safeBoundary\r\n')); - - if (entry.value is File) { - final file = entry.value as File; - builder.add( - _utf8( - 'Content-Disposition: form-data; ' - 'name="${_escapeHeaderValue(entry.key)}"; ' - 'filename="${_escapeHeaderValue(file.name)}"\r\n', - ), - ); - - final type = file.type.isEmpty ? 'application/octet-stream' : file.type; - builder - ..add(_utf8('Content-Type: $type\r\n\r\n')) - ..add(file.copyBytes()) - ..add(_utf8('\r\n')); - continue; - } - - final value = entry.value as String; - builder - ..add( - _utf8( - 'Content-Disposition: form-data; ' - 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', - ), - ) - ..add(_utf8(value)) - ..add(_utf8('\r\n')); - } + final snapshot = List>.unmodifiable( + _entries.map((entry) => MapEntry(entry.key, entry.value)), + ); - builder.add(_utf8('--$safeBoundary--\r\n')); + return MultipartBody._( + streamFactory: () => _encodeMultipart(snapshot, safeBoundary), + boundary: safeBoundary, + contentLength: _calculateMultipartLength(snapshot, safeBoundary), + ); + } - return MultipartBody(builder.takeBytes(), safeBoundary); + MultipartBody encodeMultipartStream({String? boundary}) { + return encodeMultipart(boundary: boundary); } @override @@ -113,7 +101,7 @@ class FormData extends IterableBase> { } return File( - [value.copyBytes()], + [value], filename, type: value.type, lastModified: value.lastModified, @@ -121,11 +109,7 @@ class FormData extends IterableBase> { } if (value is Blob) { - return File( - [value.copyBytes()], - filename ?? 'blob', - type: value.type, - ); + return File([value], filename ?? 'blob', type: value.type); } if (value is String) { @@ -154,6 +138,79 @@ class FormData extends IterableBase> { return '----ht-$suffix'; } + static Stream _encodeMultipart( + List> entries, + String boundary, + ) async* { + for (final entry in entries) { + yield _utf8('--$boundary\r\n'); + + if (entry.value is File) { + final file = entry.value as File; + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(file.name)}"\r\n', + ); + + final type = file.type.isEmpty ? 'application/octet-stream' : file.type; + yield _utf8('Content-Type: $type\r\n\r\n'); + yield* file.stream(); + yield _utf8('\r\n'); + continue; + } + + final value = entry.value as String; + yield _utf8( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + yield _utf8(value); + yield _utf8('\r\n'); + } + + yield _utf8('--$boundary--\r\n'); + } + + static int _calculateMultipartLength( + List> entries, + String boundary, + ) { + var total = 0; + + for (final entry in entries) { + total += _utf8Length('--$boundary\r\n'); + + if (entry.value is File) { + final file = entry.value as File; + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"; ' + 'filename="${_escapeHeaderValue(file.name)}"\r\n', + ); + + final type = file.type.isEmpty ? 'application/octet-stream' : file.type; + total += _utf8Length('Content-Type: $type\r\n\r\n'); + total += file.size; + total += _utf8Length('\r\n'); + continue; + } + + final value = entry.value as String; + total += _utf8Length( + 'Content-Disposition: form-data; ' + 'name="${_escapeHeaderValue(entry.key)}"\r\n\r\n', + ); + total += _utf8Length(value); + total += _utf8Length('\r\n'); + } + + total += _utf8Length('--$boundary--\r\n'); + return total; + } + + static int _utf8Length(String value) => utf8.encode(value).length; + static Uint8List _utf8(String value) => Uint8List.fromList(utf8.encode(value)); } diff --git a/pubspec.lock b/pubspec.lock index cae32d8..740486b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -33,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + block: + dependency: "direct main" + description: + name: block + sha256: "70978fd747d84200617f7a9cb8d1cb7871ee75fcfa70fece7b1004d283a53644" + url: "https://pub.dev" + source: hosted + version: "1.0.0" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e50bc63..3a95795 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: ht description: Fetch-style HTTP types and protocol abstractions for Dart. -version: 0.1.0 +version: 0.2.0 repository: https://github.com/medz/ht homepage: https://github.com/medz/ht issue_tracker: https://github.com/medz/ht/issues @@ -16,6 +16,7 @@ environment: dependencies: async: ^2.13.0 + block: ^1.0.0 http_parser: ^4.1.2 mime: ^2.0.0 diff --git a/test/form_data_test.dart b/test/form_data_test.dart index 50cdff8..e81740b 100644 --- a/test/form_data_test.dart +++ b/test/form_data_test.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -13,6 +14,9 @@ void main() { final slice = blob.slice(6); expect(await slice.text(), 'world'); + + final tailSlice = blob.slice(-5); + expect(await tailSlice.text(), 'world'); }); test('concatenates mixed part types', () async { @@ -41,15 +45,19 @@ void main() { () => Blob.text('x', type: 'text/plain\nfoo'), throwsArgumentError, ); - await expectLater( - Blob.text('x').stream(chunkSize: 0).toList(), - throwsArgumentError, - ); + expect(() => Blob.text('x').stream(chunkSize: 0), throwsArgumentError); }); test('rejects unsupported part types', () { expect(() => Blob([DateTime(2024)]), throwsArgumentError); }); + + test('is compatible with block.Block interface', () async { + final blob = Blob.text('hello'); + final block.Block blockView = blob; + expect(await blockView.text(), 'hello'); + expect(await blockView.slice(-2).text(), 'lo'); + }); }); group('File', () { @@ -62,7 +70,7 @@ void main() { }); group('FormData', () { - test('normalizes values and encodes multipart', () { + test('normalizes values and encodes multipart', () async { final form = FormData() ..append('name', 'alice') ..append('avatar', Blob.text('binary'), filename: 'a.txt'); @@ -71,13 +79,14 @@ void main() { expect(avatar, isA()); final encoded = form.encodeMultipart(boundary: 'test-boundary'); - final bodyText = utf8.decode(encoded.bytes); + final bodyBytes = await encoded.bytes(); + final bodyText = utf8.decode(bodyBytes); expect( encoded.contentType, 'multipart/form-data; boundary=test-boundary', ); - expect(encoded.contentLength, encoded.bytes.length); + expect(encoded.contentLength, bodyBytes.length); expect(bodyText, contains('name="name"')); expect(bodyText, contains('name="avatar"; filename="a.txt"')); expect(bodyText, contains('alice')); @@ -123,15 +132,33 @@ void main() { expect(clone.get('a'), '2'); }); - test('escapes multipart header values', () { + test('escapes multipart header values', () async { final form = FormData() ..append('na"me', Blob.text('x'), filename: 'fi\r\nle.txt'); final encoded = form.encodeMultipart(boundary: 'b'); - final text = utf8.decode(encoded.bytes); + final text = utf8.decode(await encoded.bytes()); expect(text, contains('name="na\\"me"')); expect(text, contains('filename="fi\\r\\nle.txt"')); }); + + test( + 'multipart stream and bytes helper produce identical payload', + () async { + final form = FormData() + ..append('a', '1') + ..append('b', Blob.text('2'), filename: 'b.txt'); + + final encoded = form.encodeMultipart(boundary: 'z'); + final fromBytes = await encoded.bytes(); + final fromStream = BytesBuilder(copy: false); + await for (final chunk in encoded.stream) { + fromStream.add(chunk); + } + + expect(fromStream.takeBytes(), fromBytes); + }, + ); }); } diff --git a/test/public_api_surface_test.dart b/test/public_api_surface_test.dart index a37d1e2..1b123f3 100644 --- a/test/public_api_surface_test.dart +++ b/test/public_api_surface_test.dart @@ -1,3 +1,4 @@ +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -13,6 +14,8 @@ void main() { final blob = Blob.text('hello'); final file = File([blob], 'hello.txt', type: 'text/plain'); final form = FormData()..append('file', file); + final multipart = form.encodeMultipart(boundary: 'api'); + final blockBody = block.Block(['block-body'], type: 'text/plain'); final request = Request.formData( Uri.parse('https://example.com/upload'), @@ -21,7 +24,7 @@ void main() { body: form, ); - final response = Response.bytes([1, 2, 3], status: status); + final response = Response(body: blockBody, status: status); final BodyInit init = 'x'; @@ -32,6 +35,8 @@ void main() { expect(await blob.text(), 'hello'); expect(file.name, 'hello.txt'); expect(request.headers.has('content-type'), isTrue); + expect(await multipart.bytes(), isNotEmpty); + expect(await response.text(), 'block-body'); expect(response.ok, isTrue); expect(init, 'x'); }); diff --git a/test/request_response_test.dart b/test/request_response_test.dart index a4d3072..1a76788 100644 --- a/test/request_response_test.dart +++ b/test/request_response_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:block/block.dart' as block; import 'package:ht/ht.dart'; import 'package:test/test.dart'; @@ -52,6 +53,19 @@ void main() { expect(await request.text(), contains('name="name"')); }); + test('accepts block body and infers content headers', () async { + final body = block.Block(['hello'], type: 'text/custom'); + final request = Request( + Uri.parse('https://example.com'), + method: 'POST', + body: body, + ); + + expect(request.headers.get('content-type'), 'text/custom'); + expect(request.headers.get('content-length'), '5'); + expect(await request.text(), 'hello'); + }); + test('cannot attach body to GET/HEAD/TRACE', () { expect( () => @@ -175,6 +189,15 @@ void main() { expect(response.headers.get('content-length'), isNotNull); }); + test('accepts block body and infers content headers', () async { + final body = block.Block(['payload'], type: 'application/custom'); + final response = Response(body: body); + + expect(response.headers.get('content-type'), 'application/custom'); + expect(response.headers.get('content-length'), '7'); + expect(await response.text(), 'payload'); + }); + test('empty response defaults to 204 and no body', () async { final response = Response.empty();