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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Or add it manually to `pubspec.yaml`:

```yaml
dependencies:
ht: ^0.1.0
ht: ^0.2.0
```

## Scope
Expand Down Expand Up @@ -82,18 +82,54 @@ Future<void> main() async {
```dart
import 'package:ht/ht.dart';

void main() {
Future<void> 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<void> main() async {
final body = block.Block(<Object>['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
Expand Down
115 changes: 56 additions & 59 deletions lib/src/fetch/blob.dart
Original file line number Diff line number Diff line change
@@ -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<Object> parts = const <Object>[], String type = ''])
: _bytes = _concatenate(parts),
type = _normalizeType(type);
: this._fromNormalized(_normalizeParts(parts), _normalizeType(type));

Blob.bytes(List<int> bytes, {String type = ''})
: _bytes = Uint8List.fromList(bytes),
type = _normalizeType(type);
: this._fromNormalized(<Object>[
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(<Object>[
Uint8List.fromList(encoding.encode(text)),
], _normalizeType(type));

Blob._fromNormalized(List<Object> 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<Uint8List> bytes() async => copyBytes();
Future<Uint8List> bytes() async {
return Uint8List.fromList(await _inner.arrayBuffer());
}

/// Synchronous copy helper for internal body assembly.
Uint8List copyBytes() => Uint8List.fromList(_bytes);
@override
Future<Uint8List> arrayBuffer() => bytes();

@override
Future<String> text([Encoding encoding = utf8]) async {
return encoding.decode(_bytes);
if (identical(encoding, utf8)) {
return _inner.text();
}

return encoding.decode(await bytes());
}

Stream<Uint8List> stream({int chunkSize = 16 * 1024}) async* {
@override
Stream<Uint8List> 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<Object> 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<int>) {
builder.add(part);
continue;
}

if (part is String) {
builder.add(utf8.encode(part));
continue;
}
static List<Object> _normalizeParts(Iterable<Object> parts) {
return List<Object>.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<int> 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) {
Expand Down
91 changes: 38 additions & 53 deletions lib/src/fetch/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<int>) {
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<int> 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<List<int>> stream => BodyData.stream(stream),
_ => throw ArgumentError.value(
init,
'init',
'Unsupported body type: ${init.runtimeType}',
),
};
}

if (init is Stream<List<int>>) {
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,
);
}

Expand Down
Loading