Skip to content

Commit 5983d3f

Browse files
donny-dontnex3
authored andcommitted
Implement multipart requests (#113)
1 parent f940c48 commit 5983d3f

11 files changed

+662
-292
lines changed

lib/http.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export 'src/exception.dart';
1616
export 'src/handler.dart';
1717
export 'src/io_client.dart';
1818
export 'src/middleware.dart';
19+
export 'src/multipart_file.dart';
1920
export 'src/pipeline.dart';
2021
export 'src/request.dart';
2122
export 'src/response.dart';

lib/src/boundary.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:math';
6+
7+
/// All character codes that are valid in multipart boundaries.
8+
///
9+
/// This is the intersection of the characters allowed in the `bcharsnospace`
10+
/// production defined in [RFC 2046][] and those allowed in the `token`
11+
/// production defined in [RFC 1521][].
12+
///
13+
/// [RFC 2046]: http://tools.ietf.org/html/rfc2046#section-5.1.1.
14+
/// [RFC 1521]: https://tools.ietf.org/html/rfc1521#section-4
15+
const List<int> _boundaryCharacters = const <int>[
16+
43, 95, 45, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, //
17+
69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86,
18+
87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107,
19+
108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121,
20+
122
21+
];
22+
23+
/// The total length of the multipart boundaries used when building the
24+
/// request body.
25+
///
26+
/// According to http://tools.ietf.org/html/rfc1341.html, this can't be longer
27+
/// than 70.
28+
const int _boundaryLength = 70;
29+
30+
final Random _random = new Random();
31+
32+
/// Returns a randomly-generated multipart boundary string
33+
String boundaryString() {
34+
var prefix = 'dart-http-boundary-';
35+
var list = new List<int>.generate(
36+
_boundaryLength - prefix.length,
37+
(index) =>
38+
_boundaryCharacters[_random.nextInt(_boundaryCharacters.length)],
39+
growable: false);
40+
return '$prefix${new String.fromCharCodes(list)}';
41+
}

lib/src/boundary_characters.dart

Lines changed: 0 additions & 18 deletions
This file was deleted.

lib/src/message.dart

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import 'dart:async';
66
import 'dart:convert';
77

8+
import 'package:async/async.dart';
89
import 'package:collection/collection.dart';
910
import 'package:http_parser/http_parser.dart';
1011

@@ -121,16 +122,24 @@ abstract class Message {
121122

122123
/// Returns the message body as byte chunks.
123124
///
124-
/// Throws a [StateError] if [read] or [readAsString] has already been called.
125+
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
126+
/// already been called.
125127
Stream<List<int>> read() => _body.read();
126128

129+
/// Returns the message body as a list of bytes.
130+
///
131+
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
132+
/// already been called.
133+
Future<List<int>> readAsBytes() => collectBytes(read());
134+
127135
/// Returns the message body as a string.
128136
///
129137
/// If [encoding] is passed, that's used to decode the body. Otherwise the
130138
/// encoding is taken from the Content-Type header. If that doesn't exist or
131139
/// doesn't have a "charset" parameter, UTF-8 is used.
132140
///
133-
/// Throws a [StateError] if [read] or [readAsString] has already been called.
141+
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
142+
/// already been called.
134143
Future<String> readAsString([Encoding encoding]) {
135144
encoding ??= this.encoding ?? UTF8;
136145
return encoding.decodeStream(read());

lib/src/multipart_body.dart

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'dart:async';
6+
import 'dart:convert';
7+
8+
import 'package:typed_data/typed_buffers.dart';
9+
10+
import 'body.dart';
11+
import 'multipart_file.dart';
12+
import 'utils.dart';
13+
14+
/// A `multipart/form-data` request [Body].
15+
///
16+
/// Such a request has both string fields, which function as normal form
17+
/// fields, and (potentially streamed) binary files.
18+
class MultipartBody implements Body {
19+
/// The contents of the message body.
20+
///
21+
/// This will be `null` after [read] is called.
22+
Stream<List<int>> _stream;
23+
24+
final int contentLength;
25+
26+
/// Multipart forms do not have an encoding.
27+
Encoding get encoding => null;
28+
29+
/// Creates a [MultipartBody] from the given [fields] and [files].
30+
///
31+
/// The [boundary] is used to separate key value pairs within the body.
32+
factory MultipartBody(Map<String, String> fields,
33+
Iterable<MultipartFile> files, String boundary) {
34+
var controller = new StreamController<List<int>>(sync: true);
35+
var buffer = new Uint8Buffer();
36+
37+
void writeAscii(String string) {
38+
buffer.addAll(string.codeUnits);
39+
}
40+
41+
void writeUtf8(String string) {
42+
buffer.addAll(UTF8.encode(string));
43+
}
44+
45+
void writeLine() {
46+
buffer..add(13)..add(10); // \r\n
47+
}
48+
49+
// Write the fields to the buffer.
50+
fields.forEach((name, value) {
51+
writeAscii('--$boundary\r\n');
52+
writeUtf8(_headerForField(name, value));
53+
writeUtf8(value);
54+
writeLine();
55+
});
56+
57+
controller.add(buffer);
58+
59+
// Iterate over the files to get the length and compute the headers ahead of
60+
// time so the length can be synchronously accessed.
61+
var fileList = files.toList();
62+
var fileHeaders = <List<int>>[];
63+
var fileContentsLength = 0;
64+
65+
for (var file in fileList) {
66+
var header = <int>[]
67+
..addAll('--$boundary\r\n'.codeUnits)
68+
..addAll(UTF8.encode(_headerForFile(file)));
69+
70+
fileContentsLength += header.length + file.length + 2;
71+
fileHeaders.add(header);
72+
}
73+
74+
// Ending characters.
75+
var ending = '--$boundary--\r\n'.codeUnits;
76+
fileContentsLength += ending.length;
77+
78+
// Write the files to the stream asynchronously.
79+
_writeFilesToStream(controller, fileList, fileHeaders, ending);
80+
81+
return new MultipartBody._(
82+
controller.stream, buffer.length + fileContentsLength);
83+
}
84+
85+
MultipartBody._(this._stream, this.contentLength);
86+
87+
/// Returns a [Stream] representing the body.
88+
///
89+
/// Can only be called once.
90+
Stream<List<int>> read() {
91+
if (_stream == null) {
92+
throw new StateError("The 'read' method can only be called once on a "
93+
'http.Request/http.Response object.');
94+
}
95+
var stream = _stream;
96+
_stream = null;
97+
return stream;
98+
}
99+
100+
/// Writes the [files] to the [controller].
101+
static Future _writeFilesToStream(
102+
StreamController<List<int>> controller,
103+
List<MultipartFile> files,
104+
List<List<int>> fileHeaders,
105+
List<int> ending) async {
106+
for (var i = 0; i < files.length; ++i) {
107+
controller.add(fileHeaders[i]);
108+
109+
// file.read() can throw synchronously
110+
try {
111+
await writeStreamToSink(files[i].read(), controller);
112+
} catch (exception, stackTrace) {
113+
controller.addError(exception, stackTrace);
114+
}
115+
116+
controller.add([13, 10]);
117+
}
118+
119+
controller
120+
..add(ending)
121+
..close();
122+
}
123+
124+
/// Returns the header string for a field.
125+
static String _headerForField(String name, String value) {
126+
var header =
127+
'content-disposition: form-data; name="${_browserEncode(name)}"';
128+
if (!isPlainAscii(value)) {
129+
header = '$header\r\n'
130+
'content-type: text/plain; charset=utf-8\r\n'
131+
'content-transfer-encoding: binary';
132+
}
133+
return '$header\r\n\r\n';
134+
}
135+
136+
/// Returns the header string for a file.
137+
///
138+
/// The return value is guaranteed to contain only ASCII characters.
139+
static String _headerForFile(MultipartFile file) {
140+
var header = 'content-type: ${file.contentType}\r\n'
141+
'content-disposition: form-data; name="${_browserEncode(file.field)}"';
142+
143+
if (file.filename != null) {
144+
header = '$header; filename="${_browserEncode(file.filename)}"';
145+
}
146+
return '$header\r\n\r\n';
147+
}
148+
149+
static final _newlineRegExp = new RegExp(r'\r\n|\r|\n');
150+
151+
/// Encode [value] in the same way browsers do.
152+
static String _browserEncode(String value) =>
153+
// http://tools.ietf.org/html/rfc2388 mandates some complex encodings for
154+
// field names and file names, but in practice user agents seem not to
155+
// follow this at all. Instead, they URL-encode `\r`, `\n`, and `\r\n` as
156+
// `\r\n`; URL-encode `"`; and do nothing else (even for `%` or non-ASCII
157+
// characters). We follow their behavior.
158+
value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
159+
}

0 commit comments

Comments
 (0)