diff --git a/lib/http.dart b/lib/http.dart
index 2728e1994b..4736abf2da 100644
--- a/lib/http.dart
+++ b/lib/http.dart
@@ -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';
diff --git a/lib/src/boundary.dart b/lib/src/boundary.dart
new file mode 100644
index 0000000000..6dd352daad
--- /dev/null
+++ b/lib/src/boundary.dart
@@ -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)}';
+}
diff --git a/lib/src/boundary_characters.dart b/lib/src/boundary_characters.dart
deleted file mode 100644
index cc5742a30a..0000000000
--- a/lib/src/boundary_characters.dart
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) 2013, 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.
-
-/// 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> BOUNDARY_CHARACTERS = 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
-];
diff --git a/lib/src/message.dart b/lib/src/message.dart
index ae16e24963..6111027948 100644
--- a/lib/src/message.dart
+++ b/lib/src/message.dart
@@ -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';
 
@@ -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());
diff --git a/lib/src/multipart_body.dart b/lib/src/multipart_body.dart
new file mode 100644
index 0000000000..961db27973
--- /dev/null
+++ b/lib/src/multipart_body.dart
@@ -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');
+}
diff --git a/lib/src/multipart_file.dart b/lib/src/multipart_file.dart
index da4bface78..de75371c01 100644
--- a/lib/src/multipart_file.dart
+++ b/lib/src/multipart_file.dart
@@ -4,108 +4,132 @@
 
 import 'dart:async';
 import 'dart:convert';
-import 'dart:io';
 
 import 'package:async/async.dart';
 import 'package:http_parser/http_parser.dart';
-import 'package:path/path.dart' as path;
+import 'package:mime/mime.dart';
 
-import 'byte_stream.dart';
-import 'utils.dart';
+import 'content_type.dart';
 
-/// A file to be uploaded as part of a [MultipartRequest]. This doesn't need to
-/// correspond to a physical file.
+/// A file to be uploaded as part of a `multipart/form-data` Request.
+///
+/// This doesn't need to correspond to a physical file.
 class MultipartFile {
+  /// The stream that will emit the file's contents.
+  Stream<List<int>> _stream;
+
   /// The name of the form field for the file.
   final String field;
 
-  /// The size of the file in bytes. This must be known in advance, even if this
-  /// file is created from a [ByteStream].
+  /// The size of the file in bytes.
+  ///
+  /// This must be known in advance, even if this file is created from a
+  /// [Stream].
   final int length;
 
   /// The basename of the file. May be null.
   final String filename;
 
-  /// The content-type of the file. Defaults to `application/octet-stream`.
+  /// The content-type of the file.
+  ///
+  /// Defaults to `application/octet-stream`.
   final MediaType contentType;
 
-  /// The stream that will emit the file's contents.
-  final ByteStream _stream;
-
-  /// Whether [finalize] has been called.
-  bool get isFinalized => _isFinalized;
-  bool _isFinalized = false;
-
-  /// Creates a new [MultipartFile] from a chunked [Stream] of bytes. The length
-  /// of the file in bytes must be known in advance. If it's not, read the data
-  /// from the stream and use [MultipartFile.fromBytes] instead.
+  /// Creates a [MultipartFile] from the [value].
+  ///
+  /// [value] can be either a [String] or a [List<int>].
   ///
-  /// [contentType] currently defaults to `application/octet-stream`, but in the
-  /// future may be inferred from [filename].
-  MultipartFile(this.field, Stream<List<int>> stream, this.length,
-      {this.filename, MediaType contentType})
-    : this._stream = toByteStream(stream),
-      this.contentType = contentType != null ? contentType :
-          new MediaType("application", "octet-stream");
-
-  /// Creates a new [MultipartFile] from a byte array.
+  /// For a String [value] the content will be encoded using [encoding] which
+  /// defaults to [UTF8]. The `charset` from [contentType] is ignored when
+  /// encoding the String.
   ///
-  /// [contentType] currently defaults to `application/octet-stream`, but in the
-  /// future may be inferred from [filename].
-  factory MultipartFile.fromBytes(String field, List<int> value,
-      {String filename, MediaType contentType}) {
-    var stream = new ByteStream.fromBytes(value);
-    return new MultipartFile(field, stream, value.length,
-        filename: filename,
-        contentType: contentType);
+  /// [contentType] if not specified will attempt to be looked up from the
+  /// bytes contained within the [stream] and the [filename] if provided. It
+  /// will default to `plain/text` for [String]s and `application/octet-stream`
+  /// for [List<int>].
+  factory MultipartFile(String field, value,
+      {String filename, MediaType contentType, Encoding encoding}) {
+    List<int> bytes;
+    var defaultMediaType;
+
+    if (value is String) {
+      encoding ??= UTF8;
+      bytes = encoding.encode(value);
+      defaultMediaType = new MediaType('text', 'plain');
+    } else if (value is List<int>) {
+      bytes = value;
+      defaultMediaType = new MediaType('application', 'octet-stream');
+    } else {
+      throw new ArgumentError.value(
+          value, 'value', 'value must be either a String or a List<int>');
+    }
+
+    contentType ??= _lookUpMediaType(filename, bytes) ?? defaultMediaType;
+
+    if (encoding != null) {
+      contentType = contentType.change(parameters: {'charset': encoding.name});
+    }
+
+    return new MultipartFile.fromStream(
+        field, new Stream.fromIterable([bytes]), bytes.length,
+        filename: filename, contentType: contentType);
   }
 
-  /// Creates a new [MultipartFile] from a string.
+  /// Creates a new [MultipartFile] from a chunked [stream] of bytes.
   ///
-  /// The encoding to use when translating [value] into bytes is taken from
-  /// [contentType] if it has a charset set. Otherwise, it defaults to UTF-8.
-  /// [contentType] currently defaults to `text/plain; charset=utf-8`, but in
-  /// the future may be inferred from [filename].
-  factory MultipartFile.fromString(String field, String value,
-      {String filename, MediaType contentType}) {
-    contentType = contentType == null ? new MediaType("text", "plain")
-                                      : contentType;
-    var encoding = encodingForCharset(contentType.parameters['charset'], UTF8);
-    contentType = contentType.change(parameters: {'charset': encoding.name});
-
-    return new MultipartFile.fromBytes(field, encoding.encode(value),
-        filename: filename,
-        contentType: contentType);
-  }
+  /// The [length] of the file in bytes must be known in advance. If it's not
+  /// then use [loadStream] to create the [MultipartFile] instance.
+  ///
+  /// [contentType] if not specified will attempt to be looked up from the
+  /// [filename] if provided. It will default to `application/octet-stream`.
+  MultipartFile.fromStream(this.field, Stream<List<int>> stream, this.length,
+      {String filename, MediaType contentType})
+      : _stream = stream,
+        filename = filename,
+        contentType = contentType ??
+            _lookUpMediaType(filename) ??
+            new MediaType('application', 'octet-stream');
 
-  // TODO(nweiz): Infer the content-type from the filename.
-  /// Creates a new [MultipartFile] from a path to a file on disk.
+  /// Creates a new [MultipartFile] from the [stream].
   ///
-  /// [filename] defaults to the basename of [filePath]. [contentType] currently
-  /// defaults to `application/octet-stream`, but in the future may be inferred
-  /// from [filename].
+  /// This method should be used when the length of [stream] in bytes is not
+  /// known ahead of time.
   ///
-  /// Throws an [UnsupportedError] if `dart:io` isn't supported in this
-  /// environment.
-  static Future<MultipartFile> fromPath(String field, String filePath,
+  /// [contentType] if not specified will attempt to be looked up from the
+  /// bytes contained within the [stream] and the [filename] if provided. It
+  /// will default to `application/octet-stream`.
+  static Future<MultipartFile> loadStream(
+      String field, Stream<List<int>> stream,
       {String filename, MediaType contentType}) async {
-    if (filename == null) filename = path.basename(filePath);
-    var file = new File(filePath);
-    var length = await file.length();
-    var stream = new ByteStream(DelegatingStream.typed(file.openRead()));
-    return new MultipartFile(field, stream, length,
-        filename: filename,
-        contentType: contentType);
+    var bytes = await collectBytes(stream);
+
+    return new MultipartFile(field, bytes,
+        filename: filename, contentType: contentType);
   }
 
-  // Finalizes the file in preparation for it being sent as part of a
-  // [MultipartRequest]. This returns a [ByteStream] that should emit the body
-  // of the file. The stream may be closed to indicate an empty file.
-  ByteStream finalize() {
-    if (isFinalized) {
-      throw new StateError("Can't finalize a finalized MultipartFile.");
+  /// Returns a [Stream] representing the contents of the file.
+  ///
+  /// 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.MultipartFile object.');
     }
-    _isFinalized = true;
-    return _stream;
+    var stream = _stream;
+    _stream = null;
+    return stream;
+  }
+
+  /// Looks up the [MediaType] from the [filename]'s extension or from
+  /// magic numbers contained within a file header's [bytes].
+  static MediaType _lookUpMediaType(String filename, [List<int> bytes]) {
+    if (filename == null && bytes == null) return null;
+
+    // lookupMimeType expects filename to be non-null but its possible that
+    // this can be called with bytes but no filename.
+    // FIXME: https://github.com/dart-lang/mime/issues/11
+    var mimeType = lookupMimeType(filename ?? '', headerBytes: bytes);
+
+    return mimeType != null ? new MediaType.parse(mimeType) : null;
   }
 }
diff --git a/lib/src/multipart_request.dart b/lib/src/multipart_request.dart
deleted file mode 100644
index 8132f80924..0000000000
--- a/lib/src/multipart_request.dart
+++ /dev/null
@@ -1,165 +0,0 @@
-// Copyright (c) 2013, 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 'dart:math';
-
-import 'base_request.dart';
-import 'boundary_characters.dart';
-import 'byte_stream.dart';
-import 'multipart_file.dart';
-import 'utils.dart';
-
-final _newlineRegExp = new RegExp(r"\r\n|\r|\n");
-
-/// A `multipart/form-data` request. Such a request has both string [fields],
-/// which function as normal form fields, and (potentially streamed) binary
-/// [files].
-///
-/// This request automatically sets the Content-Type header to
-/// `multipart/form-data`. This value will override any value set by the user.
-///
-///     var uri = Uri.parse("http://pub.dartlang.org/packages/create");
-///     var request = new http.MultipartRequest("POST", url);
-///     request.fields['user'] = 'nweiz@google.com';
-///     request.files.add(new http.MultipartFile.fromFile(
-///         'package',
-///         new File('build/package.tar.gz'),
-///         contentType: new MediaType('application', 'x-tar'));
-///     request.send().then((response) {
-///       if (response.statusCode == 200) print("Uploaded!");
-///     });
-class MultipartRequest extends BaseRequest {
-  /// 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.
-  static const int _BOUNDARY_LENGTH = 70;
-
-  static final Random _random = new Random();
-
-  /// The form fields to send for this request.
-  final Map<String, String> fields;
-
-  /// The private version of [files].
-  final List<MultipartFile> _files;
-
-  /// Creates a new [MultipartRequest].
-  MultipartRequest(String method, Uri url)
-    : fields = {},
-      _files = <MultipartFile>[],
-      super(method, url);
-
-  /// The list of files to upload for this request.
-  List<MultipartFile> get files => _files;
-
-  /// The total length of the request body, in bytes. This is calculated from
-  /// [fields] and [files] and cannot be set manually.
-  int get contentLength {
-    var length = 0;
-
-    fields.forEach((name, value) {
-      length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
-          UTF8.encode(_headerForField(name, value)).length +
-          UTF8.encode(value).length + "\r\n".length;
-    });
-
-    for (var file in _files) {
-      length += "--".length + _BOUNDARY_LENGTH + "\r\n".length +
-          UTF8.encode(_headerForFile(file)).length +
-          file.length + "\r\n".length;
-    }
-
-    return length + "--".length + _BOUNDARY_LENGTH + "--\r\n".length;
-  }
-
-  void set contentLength(int value) {
-    throw new UnsupportedError("Cannot set the contentLength property of "
-        "multipart requests.");
-  }
-
-  /// Freezes all mutable fields and returns a single-subscription [ByteStream]
-  /// that will emit the request body.
-  ByteStream finalize() {
-    // TODO(nweiz): freeze fields and files
-    var boundary = _boundaryString();
-    headers['content-type'] = 'multipart/form-data; boundary=$boundary';
-    super.finalize();
-
-    var controller = new StreamController<List<int>>(sync: true);
-
-    void writeAscii(String string) {
-      controller.add(UTF8.encode(string));
-    }
-
-    writeUtf8(String string) => controller.add(UTF8.encode(string));
-    writeLine() => controller.add([13, 10]); // \r\n
-
-    fields.forEach((name, value) {
-      writeAscii('--$boundary\r\n');
-      writeAscii(_headerForField(name, value));
-      writeUtf8(value);
-      writeLine();
-    });
-
-    Future.forEach(_files, (file) {
-      writeAscii('--$boundary\r\n');
-      writeAscii(_headerForFile(file));
-      return writeStreamToSink(file.finalize(), controller)
-        .then((_) => writeLine());
-    }).then((_) {
-      // TODO(nweiz): pass any errors propagated through this future on to
-      // the stream. See issue 3657.
-      writeAscii('--$boundary--\r\n');
-      controller.close();
-    });
-
-    return new ByteStream(controller.stream);
-  }
-
-  /// Returns the header string for a field. The return value is guaranteed to
-  /// contain only ASCII characters.
-  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.
-  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';
-  }
-
-  /// Encode [value] in the same way browsers do.
-  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.
-    return value.replaceAll(_newlineRegExp, "%0D%0A").replaceAll('"', "%22");
-  }
-
-  /// Returns a randomly-generated multipart boundary string
-  String _boundaryString() {
-    var prefix = "dart-http-boundary-";
-    var list = new List<int>.generate(_BOUNDARY_LENGTH - prefix.length,
-        (index) =>
-            BOUNDARY_CHARACTERS[_random.nextInt(BOUNDARY_CHARACTERS.length)],
-        growable: false);
-    return "$prefix${new String.fromCharCodes(list)}";
-  }
-}
diff --git a/lib/src/request.dart b/lib/src/request.dart
index f49c5b9f16..c50f018648 100644
--- a/lib/src/request.dart
+++ b/lib/src/request.dart
@@ -4,7 +4,10 @@
 
 import 'dart:convert';
 
+import 'boundary.dart';
 import 'message.dart';
+import 'multipart_body.dart';
+import 'multipart_file.dart';
 import 'utils.dart';
 
 /// Represents an HTTP request to be sent to a server.
@@ -45,8 +48,7 @@ class Request extends Message {
   ///
   /// Extra [context] can be used to pass information between inner middleware
   /// and handlers.
-  Request.head(url,
-      {Map<String, String> headers, Map<String, Object> context})
+  Request.head(url, {Map<String, String> headers, Map<String, Object> context})
       : this('HEAD', url, headers: headers, context: context);
 
   /// Creates a new GET [Request] to [url], which can be a [Uri] or a [String].
@@ -56,8 +58,7 @@ class Request extends Message {
   ///
   /// Extra [context] can be used to pass information between inner middleware
   /// and handlers.
-  Request.get(url,
-      {Map<String, String> headers, Map<String, Object> context})
+  Request.get(url, {Map<String, String> headers, Map<String, Object> context})
       : this('GET', url, headers: headers, context: context);
 
   /// Creates a new POST [Request] to [url], which can be a [Uri] or a [String].
@@ -77,7 +78,7 @@ class Request extends Message {
       Map<String, String> headers,
       Map<String, Object> context})
       : this('POST', url,
-      body: body, encoding: encoding, headers: headers, context: context);
+            body: body, encoding: encoding, headers: headers, context: context);
 
   /// Creates a new PUT [Request] to [url], which can be a [Uri] or a [String].
   ///
@@ -96,7 +97,7 @@ class Request extends Message {
       Map<String, String> headers,
       Map<String, Object> context})
       : this('PUT', url,
-      body: body, encoding: encoding, headers: headers, context: context);
+            body: body, encoding: encoding, headers: headers, context: context);
 
   /// Creates a new PATCH [Request] to [url], which can be a [Uri] or a
   /// [String].
@@ -116,7 +117,7 @@ class Request extends Message {
       Map<String, String> headers,
       Map<String, Object> context})
       : this('PATCH', url,
-      body: body, encoding: encoding, headers: headers, context: context);
+            body: body, encoding: encoding, headers: headers, context: context);
 
   /// Creates a new DELETE [Request] to [url], which can be a [Uri] or a
   /// [String].
@@ -130,11 +131,44 @@ class Request extends Message {
       {Map<String, String> headers, Map<String, Object> context})
       : this('DELETE', url, headers: headers, context: context);
 
-  Request._(this.method, this.url,
-      body,
-      Encoding encoding,
+  /// Creates a new
+  /// [`multipart/form-data`](https://en.wikipedia.org/wiki/MIME#Multipart_messages)
+  /// [Request] to [url], which can be a [Uri] or a [String].
+  ///
+  /// The content of the body is specified through the values of [fields] and
+  /// [files].
+  ///
+  /// If [method] is not specified it defaults to POST.
+  ///
+  /// [headers] are the HTTP headers for the request. If [headers] is `null`,
+  /// it is treated as empty.
+  ///
+  /// Extra [context] can be used to pass information between inner middleware
+  /// and handlers.
+  factory Request.multipart(url,
+      {String method,
       Map<String, String> headers,
-      Map<String, Object> context)
+      Map<String, Object> context,
+      Map<String, String> fields,
+      Iterable<MultipartFile> files}) {
+    fields ??= const {};
+    files ??= const [];
+    headers ??= {};
+
+    var boundary = boundaryString();
+
+    return new Request._(
+        method ?? 'POST',
+        getUrl(url),
+        new MultipartBody(fields, files, boundary),
+        null,
+        updateMap(headers,
+            {'content-type': 'multipart/form-data; boundary=$boundary'}),
+        context);
+  }
+
+  Request._(this.method, this.url, body, Encoding encoding,
+      Map<String, String> headers, Map<String, Object> context)
       : super(body, encoding: encoding, headers: headers, context: context);
 
   /// Creates a new [Request] by copying existing values and applying specified
@@ -149,18 +183,11 @@ class Request extends Message {
   /// [body] is the request body. It may be either a [String], a [List<int>], a
   /// [Stream<List<int>>], or `null` to indicate no body.
   Request change(
-      {Map<String, String> headers,
-      Map<String, Object> context,
-      body}) {
+      {Map<String, String> headers, Map<String, Object> context, body}) {
     var updatedHeaders = updateMap(this.headers, headers);
     var updatedContext = updateMap(this.context, context);
 
-    return new Request._(
-        this.method,
-        this.url,
-        body ?? getBody(this),
-        this.encoding,
-        updatedHeaders,
-        updatedContext);
+    return new Request._(this.method, this.url, body ?? getBody(this),
+        this.encoding, updatedHeaders, updatedContext);
   }
 }
diff --git a/pubspec.yaml b/pubspec.yaml
index 958b2babf8..fbe50ba65e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -7,7 +7,8 @@ dependencies:
   async: "^1.13.0"
   collection: "^1.5.0"
   http_parser: ">=0.0.1 <4.0.0"
-  path: ">=0.9.0 <2.0.0"
+  mime: "^0.9.0"
+  typed_data: "^1.0.0"
 dev_dependencies:
   test: "^0.12.18"
 # Override dependency on package_resolver to enable test
diff --git a/test/multipart_test.dart b/test/multipart_test.dart
new file mode 100644
index 0000000000..7e539f5da0
--- /dev/null
+++ b/test/multipart_test.dart
@@ -0,0 +1,254 @@
+// 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:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  test('empty', () {
+    var request = new http.Request.multipart(dummyUrl);
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with fields and files', () {
+    var fields = <String, String>{
+      'field1': 'value1',
+      'field2': 'value2',
+    };
+    var files = [
+      new http.MultipartFile('file1', 'contents1', filename: 'filename1.txt'),
+      new http.MultipartFile('file2', 'contents2'),
+    ];
+
+    var request =
+        new http.Request.multipart(dummyUrl, fields: fields, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-disposition: form-data; name="field1"
+
+        value1
+        --{{boundary}}
+        content-disposition: form-data; name="field2"
+
+        value2
+        --{{boundary}}
+        content-type: text/plain; charset=utf-8
+        content-disposition: form-data; name="file1"; filename="filename1.txt"
+
+        contents1
+        --{{boundary}}
+        content-type: text/plain; charset=utf-8
+        content-disposition: form-data; name="file2"
+
+        contents2
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a unicode field name', () {
+    var fields = {'fïēld': 'value'};
+
+    var request = new http.Request.multipart(dummyUrl, fields: fields);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-disposition: form-data; name="fïēld"
+
+        value
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a field name with newlines', () {
+    var fields = {'foo\nbar\rbaz\r\nbang': 'value'};
+    var request = new http.Request.multipart(dummyUrl, fields: fields);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-disposition: form-data; name="foo%0D%0Abar%0D%0Abaz%0D%0Abang"
+
+        value
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a field name with a quote', () {
+    var fields = {'foo"bar': 'value'};
+    var request = new http.Request.multipart(dummyUrl, fields: fields);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-disposition: form-data; name="foo%22bar"
+
+        value
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a unicode field value', () {
+    var fields = {'field': 'vⱥlūe'};
+    var request = new http.Request.multipart(dummyUrl, fields: fields);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-disposition: form-data; name="field"
+        content-type: text/plain; charset=utf-8
+        content-transfer-encoding: binary
+
+        vⱥlūe
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a unicode filename', () {
+    var files = [
+      new http.MultipartFile('file', 'contents', filename: 'fïlēname.txt')
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: text/plain; charset=utf-8
+        content-disposition: form-data; name="file"; filename="fïlēname.txt"
+
+        contents
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a filename with newlines', () {
+    var files = [
+      new http.MultipartFile('file', 'contents',
+          filename: 'foo\nbar\rbaz\r\nbang')
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: text/plain; charset=utf-8
+        content-disposition: form-data; name="file"; filename="foo%0D%0Abar%0D%0Abaz%0D%0Abang"
+
+        contents
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a filename with a quote', () {
+    var files = [
+      new http.MultipartFile('file', 'contents', filename: 'foo"bar')
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: text/plain; charset=utf-8
+        content-disposition: form-data; name="file"; filename="foo%22bar"
+
+        contents
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a string file with a content-type but no charset', () {
+    var files = [
+      new http.MultipartFile('file', '{"hello": "world"}',
+          contentType: new MediaType('application', 'json'))
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: application/json; charset=utf-8
+        content-disposition: form-data; name="file"
+
+        {"hello": "world"}
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a file with a iso-8859-1 body', () {
+    // "Ã¥" encoded as ISO-8859-1 and then read as UTF-8 results in "å".
+    var files = [
+      new http.MultipartFile('file', 'non-ascii: "Ã¥"',
+          encoding: LATIN1,
+          contentType:
+              new MediaType('text', 'plain'))
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: text/plain; charset=iso-8859-1
+        content-disposition: form-data; name="file"
+
+        non-ascii: "å"
+        --{{boundary}}--
+        '''));
+  });
+
+  test('with a stream file', () {
+    var controller = new StreamController<List<int>>(sync: true);
+    var files = [
+      new http.MultipartFile.fromStream('file', controller.stream, 5)
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: application/octet-stream
+        content-disposition: form-data; name="file"
+
+        hello
+        --{{boundary}}--
+        '''));
+
+    controller
+      ..add([104, 101, 108, 108, 111])
+      ..close();
+  });
+
+  test('with an empty stream file', () {
+    var controller = new StreamController<List<int>>(sync: true);
+    var files = [
+      new http.MultipartFile.fromStream('file', controller.stream, 0)
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: application/octet-stream
+        content-disposition: form-data; name="file"
+
+
+        --{{boundary}}--
+        '''));
+
+    controller.close();
+  });
+
+  test('with a byte file', () {
+    var files = [
+      new http.MultipartFile('file', [104, 101, 108, 108, 111])
+    ];
+    var request = new http.Request.multipart(dummyUrl, files: files);
+
+    expect(request, multipartBodyMatches('''
+        --{{boundary}}
+        content-type: application/octet-stream
+        content-disposition: form-data; name="file"
+
+        hello
+        --{{boundary}}--
+        '''));
+  });
+}
diff --git a/test/utils.dart b/test/utils.dart
index 505ee5fda3..bf8da3a2d4 100644
--- a/test/utils.dart
+++ b/test/utils.dart
@@ -4,9 +4,10 @@
 
 import 'dart:convert';
 
-import 'package:test/test.dart';
-
+import 'package:async/async.dart';
 import 'package:http/http.dart' as http;
+import 'package:http_parser/http_parser.dart';
+import 'package:test/test.dart';
 
 /// A dummy URL for constructing requests that won't be sent.
 Uri get dummyUrl => Uri.parse('http://dartlang.org/');
@@ -61,21 +62,57 @@ class _Parse extends Matcher {
   }
 
   Description describe(Description description) {
-    return description.add('parses to a value that ')
-      .addDescriptionOf(_matcher);
+    return description
+        .add('parses to a value that ')
+        .addDescriptionOf(_matcher);
+  }
+}
+
+/// A matcher that validates the body of a multipart request after finalization.
+///
+/// The string "{{boundary}}" in [pattern] will be replaced by the boundary
+/// string for the request, and LF newlines will be replaced with CRLF.
+/// Indentation will be normalized.
+Matcher multipartBodyMatches(String pattern) =>
+    new _MultipartBodyMatches(pattern);
+
+class _MultipartBodyMatches extends Matcher {
+  final String _pattern;
+
+  _MultipartBodyMatches(this._pattern);
+
+  bool matches(item, Map matchState) {
+    if (item is! http.Request) return false;
+
+    var future = item.readAsBytes().then((bodyBytes) {
+      var body = UTF8.decode(bodyBytes);
+      var contentType = new MediaType.parse(item.headers['content-type']);
+      var boundary = contentType.parameters['boundary'];
+      var expected = cleanUpLiteral(_pattern)
+          .replaceAll('\n', '\r\n')
+          .replaceAll('{{boundary}}', boundary);
+
+      expect(body, equals(expected));
+      expect(item.contentLength, equals(bodyBytes.length));
+    });
+
+    return completes.matches(future, matchState);
   }
+
+  Description describe(Description description) =>
+      description.add('has a body that matches "$_pattern"');
 }
 
 /// A matcher that matches a [http.ClientException] with the given [message].
 ///
 /// [message] can be a String or a [Matcher].
 Matcher isClientException([message]) => predicate((error) {
-  expect(error, new isInstanceOf<http.ClientException>());
-  if (message != null) {
-    expect(error.message, message);
-  }
-  return true;
-});
+      expect(error, new isInstanceOf<http.ClientException>());
+      if (message != null) {
+        expect(error.message, message);
+      }
+      return true;
+    });
 
 /// A matcher that matches function or future that throws a
 /// [http.ClientException] with the given [message].