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 _boundaryCharacters = const [ + 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.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 BOUNDARY_CHARACTERS = const [ - 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> 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> 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 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> _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 fields, + Iterable files, String boundary) { + var controller = new StreamController>(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 = >[]; + var fileContentsLength = 0; + + for (var file in fileList) { + var header = [] + ..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> 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> controller, + List files, + List> fileHeaders, + List 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> _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]. /// - /// [contentType] currently defaults to `application/octet-stream`, but in the - /// future may be inferred from [filename]. - MultipartFile(this.field, Stream> 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 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]. + factory MultipartFile(String field, value, + {String filename, MediaType contentType, Encoding encoding}) { + List bytes; + var defaultMediaType; + + if (value is String) { + encoding ??= UTF8; + bytes = encoding.encode(value); + defaultMediaType = new MediaType('text', 'plain'); + } else if (value is List) { + bytes = value; + defaultMediaType = new MediaType('application', 'octet-stream'); + } else { + throw new ArgumentError.value( + value, 'value', 'value must be either a String or a List'); + } + + 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> 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 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 loadStream( + String field, Stream> 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> 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 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 fields; - - /// The private version of [files]. - final List _files; - - /// Creates a new [MultipartRequest]. - MultipartRequest(String method, Uri url) - : fields = {}, - _files = [], - super(method, url); - - /// The list of files to upload for this request. - List 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>(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.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 headers, Map context}) + Request.head(url, {Map headers, Map 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 headers, Map context}) + Request.get(url, {Map headers, Map 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 headers, Map 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 headers, Map 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 headers, Map 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 headers, Map 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 headers, - Map context) + Map context, + Map fields, + Iterable 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 headers, Map 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], a /// [Stream>], or `null` to indicate no body. Request change( - {Map headers, - Map context, - body}) { + {Map headers, Map 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 = { + '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>(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>(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()); - if (message != null) { - expect(error.message, message); - } - return true; -}); + expect(error, new isInstanceOf()); + 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].