Skip to content

Commit

Permalink
⚡️ Improve FormData (#2156)
Browse files Browse the repository at this point in the history
- Allows to define `FormData.boundaryName` instead of the default
`--dio-boundary-`.
- Change the internal `Future.forEach` to iterated await and always
close the controller instead of closing only when futures succeed.
- Writing more comments, improving readability, and making more TODOs.

### Additional context and info (if any)

We barely touch this ground, because its code is less readable and
contains complex computations. Hope this is a good start to exploring
it.
  • Loading branch information
AlexV525 authored Apr 12, 2024
1 parent 6656bd5 commit ed67eb1
Show file tree
Hide file tree
Showing 5 changed files with 136 additions and 73 deletions.
1 change: 1 addition & 0 deletions dio/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ See the [Migration Guide][] for the complete breaking changes list.**
## Unreleased

- Remove sockets detach in `IOHttpClientAdapter`.
- Allows to define `FormData.boundaryName` instead of the default `--dio-boundary-`.

## 5.4.2+1

Expand Down
9 changes: 9 additions & 0 deletions dio/README-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,15 @@ final formData = FormData.fromMap({
final response = await dio.post('/info', data: formData);
```

你也可以指定封边 (boundary) 的名称,
封边名称会与额外的前缀和后缀一并组装成 `FormData` 的封边。

```dart
final formDataWithBoundaryName = FormData(
boundaryName: 'my-boundary-name',
);
```

> 通常情况下只有 POST 方法支持发送 FormData。
这里有一个完整的 [示例](../example/lib/formdata.dart)
Expand Down
9 changes: 9 additions & 0 deletions dio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,15 @@ final formData = FormData.fromMap({
final response = await dio.post('/info', data: formData);
```

You can also specify your desired boundary name which will be used
to construct boundaries of every `FormData` with additional prefix and suffix.

```dart
final formDataWithBoundaryName = FormData(
boundaryName: 'my-boundary-name',
);
```

> `FormData` is supported with the POST method typically.
There is a complete example [here](../example/lib/formdata.dart).
Expand Down
173 changes: 100 additions & 73 deletions dio/lib/src/form_data.dart
Original file line number Diff line number Diff line change
@@ -1,58 +1,80 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'dart:typed_data';

import 'multipart_file.dart';
import 'options.dart';
import 'utils.dart';

const _boundaryName = '--dio-boundary';
const _rn = '\r\n';
final _rnU8 = Uint8List.fromList([13, 10]);

const _secureRandomSeedBound = 4294967296;
final _random = math.Random();

String get _nextRandomId =>
_random.nextInt(_secureRandomSeedBound).toString().padLeft(10, '0');

/// A class to create readable "multipart/form-data" streams.
/// It can be used to submit forms and file uploads to http server.
class FormData {
FormData({this.camelCaseContentDisposition = false}) {
FormData({
this.boundaryName = _boundaryName,
this.camelCaseContentDisposition = false,
}) {
_init();
}

/// Create FormData instance with a Map.
/// Create [FormData] from a [Map].
FormData.fromMap(
Map<String, dynamic> map, [
ListFormat collectionFormat = ListFormat.multi,
ListFormat listFormat = ListFormat.multi,
this.camelCaseContentDisposition = false,
this.boundaryName = _boundaryName,
]) {
_init();
encodeMap(
map,
(key, value) {
if (value is MultipartFile) {
files.add(MapEntry(key, value));
} else {
fields.add(MapEntry(key, value?.toString() ?? ''));
}
return null;
},
listFormat: collectionFormat,
encode: false,
);
_init(fromMap: map, listFormat: listFormat);
}

void _init() {
// Assure the boundary unpredictable and unique
final random = math.Random();
_boundary = _boundaryPrefix +
random.nextInt(4294967296).toString().padLeft(10, '0');
}
/// Provides the boundary name which will be used to construct boundaries
/// in the [FormData] with additional prefix and suffix.
final String boundaryName;

static const String _boundaryPrefix = '--dio-boundary-';
static const int _boundaryLength = _boundaryPrefix.length + 10;
/// Whether the 'content-disposition' header can be 'Content-Disposition'.
final bool camelCaseContentDisposition;

late String _boundary;
void _init({
Map<String, dynamic>? fromMap,
ListFormat listFormat = ListFormat.multi,
}) {
// Get an unique boundary for the instance.
_boundary = '$boundaryName-$_nextRandomId';
if (fromMap != null) {
// Use [encodeMap] to recursively add fields and files.
// TODO(Alex): Write a proper/elegant implementation.
encodeMap(
fromMap,
(key, value) {
if (value is MultipartFile) {
files.add(MapEntry(key, value));
} else {
fields.add(MapEntry(key, value?.toString() ?? ''));
}
return null;
},
listFormat: listFormat,
encode: false,
);
}
}

/// The boundary of FormData, it consists of a constant prefix and a random
/// Postfix to assure the boundary is unpredictable and unique for each FormData
/// instance will be different.
/// The Content-Type field for multipart entities requires one parameter,
/// "boundary", which is used to specify the encapsulation boundary.
///
/// See also: https://www.w3.org/Protocols/rfc1341/7_2_Multipart.html
String get boundary => _boundary;

final _newlineRegExp = RegExp(r'\r\n|\r|\n');
late final String _boundary;

/// The form fields to send for this request.
final fields = <MapEntry<String, String>>[];
Expand All @@ -63,50 +85,54 @@ class FormData {
/// Whether [finalize] has been called.
bool get isFinalized => _isFinalized;
bool _isFinalized = false;
final bool camelCaseContentDisposition;

String get _contentDispositionKey => camelCaseContentDisposition
? 'Content-Disposition'
: 'content-disposition';

/// Returns the header string for a field.
String _headerForField(String name, String value) {
return '${camelCaseContentDisposition ? 'Content-Disposition' : 'content-disposition'}'
return '$_contentDispositionKey'
': form-data; name="${_browserEncode(name)}"'
'\r\n\r\n';
'$_rn$_rn';
}

/// Returns the header string for a file. The return value is guaranteed to
/// contain only ASCII characters.
String _headerForFile(MapEntry<String, MultipartFile> entry) {
final file = entry.value;
String header =
'${camelCaseContentDisposition ? 'Content-Disposition' : 'content-disposition'}'
String header = '$_contentDispositionKey'
': form-data; name="${_browserEncode(entry.key)}"';
if (file.filename != null) {
header = '$header; filename="${_browserEncode(file.filename)}"';
}
header = '$header\r\n'
header = '$header$_rn'
'content-type: ${file.contentType}';
if (file.headers != null) {
// append additional headers
file.headers!.forEach((key, values) {
for (final value in values) {
header = '$header\r\n'
header = '$header$_rn'
'$key: $value';
}
});
}
return '$header\r\n\r\n';
return '$header$_rn$_rn';
}

/// Encode [value] in the same way browsers do.
/// Encode [value] that follows
/// [RFC 2388](http://tools.ietf.org/html/rfc2388).
///
/// The standard mandates some complex encodings for field and file names,
/// but in practice user agents seem not to follow this at all.
/// Instead, they URL-encode `\r`, `\n`, and `\r\n` as `\r\n`;
/// URL-encode `"`; and do nothing else
/// (even for `%` or non-ASCII characters).
/// Here we follow their behavior.
String? _browserEncode(String? value) {
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
// field names and file names, but in practice user agents seem not to
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
// characters). We follow their behavior.
if (value == null) {
return null;
}
return value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
return value
?.replaceAll(RegExp(r'\r\n|\r|\n'), '%0D%0A')
.replaceAll('"', '%22');
}

/// The total length of the request body, in bytes. This is calculated from
Expand All @@ -115,26 +141,27 @@ class FormData {
int length = 0;
for (final entry in fields) {
length += '--'.length +
_boundaryLength +
'\r\n'.length +
_boundary.length +
_rn.length +
utf8.encode(_headerForField(entry.key, entry.value)).length +
utf8.encode(entry.value).length +
'\r\n'.length;
_rn.length;
}

for (final file in files) {
length += '--'.length +
_boundaryLength +
'\r\n'.length +
_boundary.length +
_rn.length +
utf8.encode(_headerForFile(file)).length +
file.value.length +
'\r\n'.length;
_rn.length;
}

return length + '--'.length + _boundaryLength + '--\r\n'.length;
return length + '--'.length + _boundary.length + '--$_rn'.length;
}

Stream<List<int>> finalize() {
/// Commits all fields and files into a stream for the final sending.
Stream<Uint8List> finalize() {
if (isFinalized) {
throw StateError(
'The FormData has already been finalized. '
Expand All @@ -143,38 +170,38 @@ class FormData {
);
}
_isFinalized = true;
final controller = StreamController<List<int>>(sync: false);
void writeAscii(String string) {
controller.add(utf8.encode(string));
}

final controller = StreamController<Uint8List>(sync: false);
void writeAscii(String s) => controller.add(utf8.encode(s));
void writeUtf8(String string) => controller.add(utf8.encode(string));
void writeLine() => controller.add([13, 10]); // \r\n
void writeLine() => controller.add(_rnU8); // \r\n

for (final entry in fields) {
writeAscii('--$boundary\r\n');
writeAscii('--$boundary$_rn');
writeAscii(_headerForField(entry.key, entry.value));
writeUtf8(entry.value);
writeLine();
}

Future.forEach<MapEntry<String, MultipartFile>>(files, (file) {
writeAscii('--$boundary\r\n');
writeAscii(_headerForFile(file));
return writeStreamToSink(
file.value.finalize(),
controller,
).then((_) => writeLine());
Future<void>(() async {
for (final file in files) {
writeAscii('--$boundary$_rn');
writeAscii(_headerForFile(file));
await writeStreamToSink(file.value.finalize(), controller);
writeLine();
}
}).then((_) {
writeAscii('--$boundary--\r\n');
writeAscii('--$boundary--$_rn');
}).whenComplete(() {
controller.close();
});

return controller.stream;
}

/// Transform the entire FormData contents as a list of bytes asynchronously.
Future<List<int>> readAsBytes() {
return Future(() => finalize().reduce((a, b) => [...a, ...b]));
Future<Uint8List> readAsBytes() {
return finalize().reduce((a, b) => Uint8List.fromList([...a, ...b]));
}

// Convenience method to clone finalized FormData when retrying requests.
Expand Down
17 changes: 17 additions & 0 deletions dio/test/formdata_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -285,5 +285,22 @@ void main() async {
expect(result, contains('name="items[2][name]"'));
expect(result, contains('name="items[2][value]"'));
});

test('has the correct boundary', () async {
final fd1 = FormData();
expect(fd1.boundary, matches(RegExp(r'dio-boundary-\d{10}')));
const name = 'test-boundary';
final fd2 = FormData(boundaryName: name);
expect(fd2.boundary, matches(RegExp('$name-\\d{10}')));
expect(fd2.boundary.length, name.length + 11);
final fd3 = FormData.fromMap(
{'test-key': 'test-value'},
ListFormat.multi,
false,
name,
);
final fd3Data = utf8.decode(await fd3.readAsBytes()).trim();
expect(fd3Data, matches(RegExp('.*--$name-\\d{10}--\\s?\$')));
});
});
}

0 comments on commit ed67eb1

Please sign in to comment.