Skip to content

Implement multipart forms #113

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Oct 26, 2017
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
1 change: 1 addition & 0 deletions lib/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export 'src/exception.dart';
export 'src/handler.dart';
export 'src/io_client.dart';
export 'src/middleware.dart';
export 'src/multipart_file.dart';
export 'src/pipeline.dart';
export 'src/request.dart';
export 'src/response.dart';
Expand Down
41 changes: 41 additions & 0 deletions lib/src/boundary.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:math';

/// All character codes that are valid in multipart boundaries.
///
/// This is the intersection of the characters allowed in the `bcharsnospace`
/// production defined in [RFC 2046][] and those allowed in the `token`
/// production defined in [RFC 1521][].
///
/// [RFC 2046]: http://tools.ietf.org/html/rfc2046#section-5.1.1.
/// [RFC 1521]: https://tools.ietf.org/html/rfc1521#section-4
const List<int> _boundaryCharacters = const <int>[
43, 95, 45, 46, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 65, 66, 67, 68, //
69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86,
87, 88, 89, 90, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107,
108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121,
122
];

/// The total length of the multipart boundaries used when building the
/// request body.
///
/// According to http://tools.ietf.org/html/rfc1341.html, this can't be longer
/// than 70.
const int _boundaryLength = 70;

final Random _random = new Random();

/// Returns a randomly-generated multipart boundary string
String boundaryString() {
var prefix = 'dart-http-boundary-';
var list = new List<int>.generate(
_boundaryLength - prefix.length,
(index) =>
_boundaryCharacters[_random.nextInt(_boundaryCharacters.length)],
growable: false);
return '$prefix${new String.fromCharCodes(list)}';
}
18 changes: 0 additions & 18 deletions lib/src/boundary_characters.dart

This file was deleted.

13 changes: 11 additions & 2 deletions lib/src/message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import 'dart:async';
import 'dart:convert';

import 'package:async/async.dart';
import 'package:collection/collection.dart';
import 'package:http_parser/http_parser.dart';

Expand Down Expand Up @@ -121,16 +122,24 @@ abstract class Message {

/// Returns the message body as byte chunks.
///
/// Throws a [StateError] if [read] or [readAsString] has already been called.
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
/// already been called.
Stream<List<int>> read() => _body.read();

/// Returns the message body as a list of bytes.
///
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
/// already been called.
Future<List<int>> readAsBytes() => collectBytes(read());

/// Returns the message body as a string.
///
/// If [encoding] is passed, that's used to decode the body. Otherwise the
/// encoding is taken from the Content-Type header. If that doesn't exist or
/// doesn't have a "charset" parameter, UTF-8 is used.
///
/// Throws a [StateError] if [read] or [readAsString] has already been called.
/// Throws a [StateError] if [read] or [readAsBytes] or [readAsString] has
/// already been called.
Future<String> readAsString([Encoding encoding]) {
encoding ??= this.encoding ?? UTF8;
return encoding.decodeStream(read());
Expand Down
159 changes: 159 additions & 0 deletions lib/src/multipart_body.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';

import 'package:typed_data/typed_buffers.dart';

import 'body.dart';
import 'multipart_file.dart';
import 'utils.dart';

/// A `multipart/form-data` request [Body].
///
/// Such a request has both string fields, which function as normal form
/// fields, and (potentially streamed) binary files.
class MultipartBody implements Body {
/// The contents of the message body.
///
/// This will be `null` after [read] is called.
Stream<List<int>> _stream;

final int contentLength;

/// Multipart forms do not have an encoding.
Encoding get encoding => null;

/// Creates a [MultipartBody] from the given [fields] and [files].
///
/// The [boundary] is used to separate key value pairs within the body.
factory MultipartBody(Map<String, String> fields,
Iterable<MultipartFile> files, String boundary) {
var controller = new StreamController<List<int>>(sync: true);
var buffer = new Uint8Buffer();

void writeAscii(String string) {
buffer.addAll(string.codeUnits);
}

void writeUtf8(String string) {
buffer.addAll(UTF8.encode(string));
}

void writeLine() {
buffer..add(13)..add(10); // \r\n
}

// Write the fields to the buffer.
fields.forEach((name, value) {
writeAscii('--$boundary\r\n');
writeUtf8(_headerForField(name, value));
writeUtf8(value);
writeLine();
});

controller.add(buffer);

// Iterate over the files to get the length and compute the headers ahead of
// time so the length can be synchronously accessed.
var fileList = files.toList();
var fileHeaders = <List<int>>[];
var fileContentsLength = 0;

for (var file in fileList) {
var header = <int>[]
..addAll('--$boundary\r\n'.codeUnits)
..addAll(UTF8.encode(_headerForFile(file)));

fileContentsLength += header.length + file.length + 2;
fileHeaders.add(header);
}

// Ending characters.
var ending = '--$boundary--\r\n'.codeUnits;
fileContentsLength += ending.length;

// Write the files to the stream asynchronously.
_writeFilesToStream(controller, fileList, fileHeaders, ending);

return new MultipartBody._(
controller.stream, buffer.length + fileContentsLength);
}

MultipartBody._(this._stream, this.contentLength);

/// Returns a [Stream] representing the body.
///
/// Can only be called once.
Stream<List<int>> read() {
if (_stream == null) {
throw new StateError("The 'read' method can only be called once on a "
'http.Request/http.Response object.');
}
var stream = _stream;
_stream = null;
return stream;
}

/// Writes the [files] to the [controller].
static Future _writeFilesToStream(
StreamController<List<int>> controller,
List<MultipartFile> files,
List<List<int>> fileHeaders,
List<int> ending) async {
for (var i = 0; i < files.length; ++i) {
controller.add(fileHeaders[i]);

// file.read() can throw synchronously
try {
await writeStreamToSink(files[i].read(), controller);
} catch (exception, stackTrace) {
controller.addError(exception, stackTrace);
}

controller.add([13, 10]);
}

controller
..add(ending)
..close();
}

/// Returns the header string for a field.
static String _headerForField(String name, String value) {
var header =
'content-disposition: form-data; name="${_browserEncode(name)}"';
if (!isPlainAscii(value)) {
header = '$header\r\n'
'content-type: text/plain; charset=utf-8\r\n'
'content-transfer-encoding: binary';
}
return '$header\r\n\r\n';
}

/// Returns the header string for a file.
///
/// The return value is guaranteed to contain only ASCII characters.
static String _headerForFile(MultipartFile file) {
var header = 'content-type: ${file.contentType}\r\n'
'content-disposition: form-data; name="${_browserEncode(file.field)}"';

if (file.filename != null) {
header = '$header; filename="${_browserEncode(file.filename)}"';
}
return '$header\r\n\r\n';
}

static final _newlineRegExp = new RegExp(r'\r\n|\r|\n');

/// Encode [value] in the same way browsers do.
static 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.
value.replaceAll(_newlineRegExp, '%0D%0A').replaceAll('"', '%22');
}
Loading