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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
instead of reading the previous `body.stream` getter.
- Added `Body.size` for exposing known body byte lengths without consuming the
body.
- Added a public generative `Body` constructor so downstream wrappers can extend
`Body` without reimplementing `BodyInit` normalization.
- Fixed `Blob` byte snapshot semantics so byte-backed parts and read buffers are
copied consistently across native, `dart:io`, and js wrappers.

Expand Down
89 changes: 57 additions & 32 deletions lib/src/fetch/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,30 +40,18 @@ const _defaultBlobChunkSize = 16 * 1024;
///
/// This is the shared body baseline that web/io implementations align to.
class Body extends Blob with Stream<Uint8List> implements Stream<Uint8List> {
Body._({
Iterable<BlobPart> blobParts = const <BlobPart>[],
Stream<Uint8List>? streamHost,
int? streamSize,
String type = '',
this.contentType,
}) : assert(streamSize == null || streamSize >= 0),
_streamHost = streamHost,
_streamSize = streamSize,
super(blobParts, type);

factory Body([BodyInit? init]) {
return switch (init) {
final Body body => body.clone(),
final FormData formData => Body._fromFormData(formData),
final Stream<List<int>> stream => Body._fromStream(stream),
final String text => Body._fromBlobInit(text, _textPlainUtf8),
final URLSearchParams params => Body._fromBlobInit(
params.toString(),
_urlEncodedUtf8,
),
_ => Body._fromBlobInit(init, _blobInitType(init)),
};
}
/// Creates a body from [init].
///
/// This constructor is generative so downstream wrappers can extend [Body]
/// and call `super(init)` without reimplementing [BodyInit] normalization.
Body([BodyInit? init]) : this._fromState(_BodyState.from(init));

Body._fromState(_BodyState state)
: assert(state.streamSize == null || state.streamSize! >= 0),
_streamHost = state.streamHost,
_streamSize = state.streamSize,
contentType = state.contentType,
super(state.blobParts, state.type);

Stream<Uint8List>? _streamHost;
int? _streamSize;
Expand Down Expand Up @@ -146,13 +134,17 @@ class Body extends Blob with Stream<Uint8List> implements Stream<Uint8List> {
}

Body clone() {
return Body._fromState(_cloneState());
}

_BodyState _cloneState() {
if (_used) {
throw StateError('Body has already been consumed.');
}

final streamHost = _streamHost;
if (streamHost == null) {
return Body._(
return _BodyState(
blobParts: <BlobPart>[super.slice(0, null, type)],
type: type,
contentType: contentType,
Expand All @@ -161,7 +153,7 @@ class Body extends Blob with Stream<Uint8List> implements Stream<Uint8List> {

final (left, right) = streamTee(streamHost);
_streamHost = left;
return Body._(
return _BodyState(
streamHost: right,
streamSize: _streamSize,
type: type,
Expand Down Expand Up @@ -204,26 +196,26 @@ class Body extends Blob with Stream<Uint8List> implements Stream<Uint8List> {
_used = true;
}

static Body _fromBlobInit(BodyInit init, String type) {
return Body._(
static _BodyState _fromBlobInit(BodyInit init, String type) {
return _BodyState(
blobParts: init == null ? const <BlobPart>[] : <BlobPart>[init],
type: type,
contentType: _contentType(type),
);
}

static Body _fromFormData(FormData formData) {
static _BodyState _fromFormData(FormData formData) {
final encoded = formData.encodeMultipart();
return Body._(
return _BodyState(
streamHost: encoded.stream,
streamSize: encoded.contentLength,
type: encoded.contentType,
contentType: encoded.contentType,
);
}

static Body _fromStream(Stream<List<int>> stream) {
return Body._(streamHost: stream.map(Uint8List.fromList));
static _BodyState _fromStream(Stream<List<int>> stream) {
return _BodyState(streamHost: stream.map(Uint8List.fromList));
}

static Future<Uint8List> _readStream(Stream<Uint8List> stream) async {
Expand All @@ -247,3 +239,36 @@ class Body extends Blob with Stream<Uint8List> implements Stream<Uint8List> {

static String? _contentType(String type) => type.isEmpty ? null : type;
}

final class _BodyState {
const _BodyState({
this.blobParts = const <BlobPart>[],
this.streamHost,
this.streamSize,
this.type = '',
this.contentType,
}) : assert(streamSize == null || streamSize >= 0);

final Iterable<BlobPart> blobParts;
final Stream<Uint8List>? streamHost;
final int? streamSize;
final String type;
final String? contentType;

static _BodyState from(BodyInit? init) {
return switch (init) {
// Constructor-copy path for subclasses calling super(init). Dispatching
// through clone() here would recurse for clone() methods that rebuild
// the subclass from the current instance.
final Body body => body._cloneState(),
Comment thread
medz marked this conversation as resolved.
final FormData formData => Body._fromFormData(formData),
final Stream<List<int>> stream => Body._fromStream(stream),
final String text => Body._fromBlobInit(text, _textPlainUtf8),
final URLSearchParams params => Body._fromBlobInit(
params.toString(),
_urlEncodedUtf8,
),
_ => Body._fromBlobInit(init, Body._blobInitType(init)),
};
}
}
5 changes: 4 additions & 1 deletion lib/src/fetch/request.native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,10 @@ class Request {
) {
if (init != null) {
_validateRequestBodyMethod(method);
return Body(init);
return switch (init) {
final Body body => body.clone(),
_ => Body(init),
};
}

final body = switch (input) {
Expand Down
1 change: 1 addition & 0 deletions lib/src/fetch/response.native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ class Response {
'body',
'Response status $status cannot have a body.',
),
final Body body => body.clone(),
_ => Body(init),
};
}
Expand Down
39 changes: 39 additions & 0 deletions test/public_api_surface_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ import 'package:block/block.dart' as block;
import 'package:ht/ht.dart';
import 'package:test/test.dart';

final class _RequestBody extends Body {
_RequestBody(super.init, {required this.replayable});

final bool replayable;

@override
_RequestBody clone() {
return _RequestBody(this, replayable: replayable);
}
}

void main() {
test('public API symbols are importable and usable', () async {
const method = 'POST';
Expand All @@ -25,6 +36,18 @@ void main() {
final form = FormData()..append('file', Multipart.blob(file));
final multipart = form.encodeMultipart(boundary: 'api');
final body = Body('public');
final requestBody = _RequestBody(
Stream<List<int>>.fromIterable(<List<int>>[
Uint8List.fromList(<int>[119, 114, 97, 112]),
]),
replayable: true,
);
final requestBodyClone = requestBody.clone();
final requestBodyForRequest = _RequestBody(
'request-body',
replayable: false,
);
final responseBody = _RequestBody('response-body', replayable: true);
final blockBody = block.Block(<Object>['block-body'], type: 'text/plain');

final request = Request(
Expand All @@ -33,6 +56,11 @@ void main() {
);

final response = Response(blockBody, responseInit);
final requestWithSubclassBody = Request(
Uri.parse('https://example.com/subclass'),
RequestInit(method: method, body: requestBodyForRequest),
);
final responseWithSubclassBody = Response(responseBody);

final Object init = 'x';

Expand All @@ -48,9 +76,20 @@ void main() {
expect(body, isA<Blob>());
expect(body, isA<Stream<Uint8List>>());
expect(body.size, 6);
expect(requestBody, isA<Body>());
expect(requestBodyClone, isA<_RequestBody>());
expect(requestBodyClone.replayable, isTrue);
expect(await requestBody.text(), 'wrap');
expect(await requestBodyClone.text(), 'wrap');
expect(requestWithSubclassBody.body, isA<_RequestBody>());
expect((requestWithSubclassBody.body! as _RequestBody).replayable, isFalse);
expect(await requestWithSubclassBody.text(), 'request-body');
expect(request.headers.has('content-type'), isTrue);
expect(await multipart.bytes(), isNotEmpty);
expect(await response.text(), 'block-body');
expect(responseWithSubclassBody.body, isA<_RequestBody>());
expect((responseWithSubclassBody.body! as _RequestBody).replayable, isTrue);
expect(await responseWithSubclassBody.text(), 'response-body');
expect(response.ok, isTrue);
expect(init, 'x');
});
Expand Down