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

Implement multipart forms #113

merged 20 commits into from
Oct 26, 2017

Conversation

donny-dont
Copy link

Implement multipart form handling.

Fixes #97

@donny-dont
Copy link
Author

@nex3 currently work in progress for the implementation. I have tests running locally but won't push that until we figure out if this is what you want.

Another option rather than implementing Body is to move a constructor over there and keep the multipart file handling in there or in a utils sort of file with free functions.

Copy link
Member

@nex3 nex3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overall architecture here looks good!


/// 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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Documentation comments should start with a single-sentence paragraph.

/// 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 BOUNDARY_LENGTH = 70;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Constants should be lower camel case. Same for BOUNDARY_CHARACTERS.

I'd also omit the int here.

@@ -2,6 +2,8 @@
// for details. All rights reserved. Use of this source code is governed by a
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure why this was ever a separate library... I'd just put this stuff in multipart_request.dart.


final Random _random = new Random();

/// Returns a randomly-generated multipart boundary string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Period at the end of the sentence.

@@ -44,20 +40,20 @@ class MultipartFile {
/// future may be inferred from [filename].
MultipartFile(this.field, Stream<List<int>> stream, this.length,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about consolidating these constructors? I'm thinking:

  • new MultipartFile(String field, body, ...) which takes either a List<int> or a String,
  • new MultipartFile.fromStream(String field, stream, int length) which takes either a Stream<List<int>> or a Stream<String>,
  • static Future<MultipartFile> loadStream(String field, stream) which takes either a Stream<List<int>> or a Stream<String> and drains it to determine the length.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've always kinda preferred the explicit constructors rather than using dynamic since there are no union types. I do like the loadStream idea rather than fromPath.

Would fromStream be possible with a Stream<String> though as there would need to be some kind of encoding?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've always kinda preferred the explicit constructors rather than using dynamic since there are no union types. I do like the loadStream idea rather than fromPath.

I disagree. That imposes more writing on the user for minimal static analysis gains. Also, union types will almost certainly happen eventually so it's a good idea to plan APIs around them now where possible.

Would fromStream be possible with a Stream<String> though as there would need to be some kind of encoding?

I suppose we'd need an optional Encoding parameter like we do for the Request constructors.

}
void writeLine() {
controller.add([13, 10]); // \r\n
contentLength += 2;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're only using these for fields now, you might as well just add everything to a single Uint8List rather than adding separate chunks to a controller. The typed_data package can help with that.

header.addAll(UTF8.encode(_headerForFile(file)));
contentLength += header.length + file.length + 2;
fileHeaders[i] = header;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment explaining that we compute the headers ahead of time so we can synchronously access the length.

// used to get the fileHeaders
var i = 0;

Future.forEach(files, (file) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider pulling this into a static method so you can make it async and just use a normal for loop.

// characters). We follow their behavior.
return value.replaceAll(_newlineRegExp, "%0D%0A").replaceAll('"', "%22");
/// Returns the header string for a field.
String _headerForField(String name, String value) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally make private methods that are only used by one class members of that class, even if they don't access its state directly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are accessed from a factory constructor so I put them below. Do you want them as static class methods?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

var boundary = boundaryString();

headers ??= <String, String>{};
headers['content-type'] = 'multipart/form-data; boundary=$boundary';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't destructively modify the user's headers map.

@donny-dont
Copy link
Author

@nex3 https://pub.dartlang.org/packages/mime yay or nay?

@@ -44,20 +40,20 @@ class MultipartFile {
/// future may be inferred from [filename].
MultipartFile(this.field, Stream<List<int>> stream, this.length,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've always kinda preferred the explicit constructors rather than using dynamic since there are no union types. I do like the loadStream idea rather than fromPath.

I disagree. That imposes more writing on the user for minimal static analysis gains. Also, union types will almost certainly happen eventually so it's a good idea to plan APIs around them now where possible.

Would fromStream be possible with a Stream<String> though as there would need to be some kind of encoding?

I suppose we'd need an optional Encoding parameter like we do for the Request constructors.

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

class MultipartBody implements Body {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document this

@@ -0,0 +1,147 @@
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: 2017


/// The length of the stream returned by [read].
///
/// This is calculated from the fields and files passed into the body.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You generally don't have to document inherited members, since they're documented in the parent class.


/// Creates a [MultipartBody] from the given [fields] and [files].
///
/// The [boundary] is used to
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfinished docs

test/utils.dart Outdated
@@ -66,6 +67,40 @@ class _Parse extends Matcher {
}
}

/// A matcher that validates the body of a multipart request after finalization.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: First sentence should be its own paragraph.

test/utils.dart Outdated
/// 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 bodyMatches(String pattern) => new _BodyMatches(pattern);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is pretty generic for something that's specifically designed to work with multipart requests. Maybe multipartBodyMatches()?

test/utils.dart Outdated
bool matches(item, Map matchState) {
if (item is! http.Request) return false;

var future = collectBytes(item.read()).then((bodyBytes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use Request.readAsString() here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of this check. The string length isn't necessarily the same as the contentLength at this point.

expect(item.contentLength, equals(bodyBytes.length));

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... it might be nice to add Request.readAsBytes().

@@ -0,0 +1,249 @@
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2017

--{{boundary}}--
'''));
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these ported directly from the old API?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep they're from the 0.11.x branch.

Copy link
Author

@donny-dont donny-dont left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully this commit resolves everything but the constructors.

mime was just pushed so I'll integrate that when going through that.

test/utils.dart Outdated
bool matches(item, Map matchState) {
if (item is! http.Request) return false;

var future = collectBytes(item.read()).then((bodyBytes) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of this check. The string length isn't necessarily the same as the contentLength at this point.

expect(item.contentLength, equals(bodyBytes.length));

--{{boundary}}--
'''));
});
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep they're from the 0.11.x branch.

pubspec.yaml Outdated
@@ -8,6 +8,7 @@ dependencies:
collection: "^1.5.0"
http_parser: ">=0.0.1 <4.0.0"
path: ">=0.9.0 <2.0.0"
typed_data: "^1.1.5"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for my own reference are we going to want to tighten the restriction for strong mode compliance?

Iterable<MultipartFile> files}) {
fields ??= <String, String>{};
files ??= <MultipartFile>[];
headers ??= <String, String>{};
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will end up getting set when a HttpUnmodifiableMap is created within the Message constructor so no it doesn't need one.

test/utils.dart Outdated
bool matches(item, Map matchState) {
if (item is! http.Request) return false;

var future = collectBytes(item.read()).then((bodyBytes) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... it might be nice to add Request.readAsBytes().

pubspec.yaml Outdated
@@ -8,6 +8,7 @@ dependencies:
collection: "^1.5.0"
http_parser: ">=0.0.1 <4.0.0"
path: ">=0.9.0 <2.0.0"
typed_data: "^1.1.5"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We generally don't do that. This package doesn't depend on the fact that its dependencies are strong-mode compliant; that's a property of the root package, if anything.

controller.add(fileHeaders[i]);
try {
await writeStreamToSink(files[i].read(), controller);
} on Exception catch (exception, stackTrace) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leave off the on clause here. We want to catch anything that gets thrown.

try {
await writeStreamToSink(files[i].read(), controller);
} on Exception catch (exception, stackTrace) {
controller.addError(exception, stackTrace);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document that we're catching here because file.read() can throw synchronously.

bool get isFinalized => _isFinalized;
bool _isFinalized = false;
if (value is String) {
encoding ??= encodingForMediaType(contentType) ?? UTF8;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like with the request body, I'd like the value encoding to come only from the encoding parameter, not from the contentType. This ensures that filling in the defaults flows only one way: contentType always looks at encoding for its default value, not the other way around.

Also, we should only set the encoding to UTF8—and thus add a charset parameter to the content type—if the value isn't plain ASCII.


/// 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]) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: _lookUpMediaType

"Lookup" is a word, but it's only a noun; the verb is always two words.

/// 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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: unnecessary parens.

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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you file an issue against the mime package for this?

Request._(this.method, this.url,
body,
Encoding encoding,
/// Creates a new `multipart/form-data` [Request] to [url], which can be a
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider linking multipart/form-data to an explanation of the format (maybe Wikipedia).

pubspec.yaml Outdated
@@ -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 < 1.0.0"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^0.9.0

With this version constraint, mime could release a totally breaking 0.10.0 release and we'd still declare compatibility with it.

@nex3
Copy link
Member

nex3 commented Oct 26, 2017

Looks good! Thanks for all your hard work on this 😄.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants