Skip to content

Commit

Permalink
feat(dart_frog): multipart/form-data (felangel#551)
Browse files Browse the repository at this point in the history
Co-authored-by: Felix Angelov <felangelov@gmail.com>
Co-authored-by: VuiTv <tangvuicntt@gmail.com>
  • Loading branch information
3 people committed Mar 9, 2023
1 parent eb98fa2 commit 1358a9f
Show file tree
Hide file tree
Showing 9 changed files with 562 additions and 41 deletions.
47 changes: 41 additions & 6 deletions docs/docs/basics/routes.md
Expand Up @@ -203,7 +203,7 @@ curl --request POST \

#### Form Data

When the `Content-Type` is `application/x-www-form-urlencoded`, you can use `context.request.formData()` to read the contents of the request body as a `Map<String, String>`.
When the `Content-Type` is `application/x-www-form-urlencoded` or `multipart/form-data`, you can use `context.request.formData()` to read the contents of the request body as `FormData`.

```dart
import 'package:dart_frog/dart_frog.dart';
Expand All @@ -213,9 +213,9 @@ Future<Response> onRequest(RequestContext context) async {
final request = context.request;
// Access the request body form data.
final body = await request.formData();
final formData = await request.formData();
return Response.json(body: {'request_body': body});
return Response.json(body: {'form_data': formData.fields});
}
```

Expand All @@ -225,18 +225,53 @@ curl --request POST \
--data hello=world
{
"request_body": {
"form_data": {
"hello": "world"
}
}
```

If the request is a multipart form data request you can also access files that were uploaded.

```dart
import 'package:dart_frog/dart_frog.dart';
Future<Response> onRequest(RequestContext context) async {
// Access the incoming request.
final request = context.request;
// Access the request body form data.
final formData = await request.formData();
// Retrieve an uploaded file.
final photo = formData.files['photo'];
if (photo == null || photo.contentType.mimeType != contentTypePng.mimeType) {
return Response(statusCode: HttpStatus.badRequest);
}
return Response.json(
body: {'message': 'Successfully uploaded ${photo.name}'},
);
}
```

```
curl --request POST \
--url http://localhost:8080/example \
--form photo=@photo.png
{
"message": "Successfully uploaded photo.png"
}
```

:::info
The `formData` API is supported in `dart_frog >=0.3.1`
The `formData` API is available since `dart_frog >=0.3.1` and the support for multipart form data was added in `dart_frog >=0.3.4`.
:::

:::caution
`request.formData()` will throw a `StateError` if the MIME type is not `application/x-www-form-urlencoded`.
`request.formData()` will throw a `StateError` if the MIME type is not `application/x-www-form-urlencoded` or `multipart/form-data`.
:::

## Responses 📤
Expand Down
18 changes: 18 additions & 0 deletions examples/kitchen_sink/routes/photos/upload.dart
@@ -0,0 +1,18 @@
import 'dart:io';

import 'package:dart_frog/dart_frog.dart';

final contentTypePng = ContentType('image', 'png');

Future<Response> onRequest(RequestContext context) async {
final formData = await context.request.formData();
final photo = formData.files['photo'];

if (photo == null || photo.contentType.mimeType != contentTypePng.mimeType) {
return Response(statusCode: HttpStatus.badRequest);
}

return Response.json(
body: {'message': 'Successfully uploaded ${photo.name}'},
);
}
65 changes: 65 additions & 0 deletions examples/kitchen_sink/test/routes/photos/upload_test.dart
@@ -0,0 +1,65 @@
// ignore_for_file: prefer_const_constructors

import 'dart:io';

import 'package:dart_frog/dart_frog.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

import '../../../routes/photos/upload.dart' as route;

class _MockRequestContext extends Mock implements RequestContext {}

class _MockRequest extends Mock implements Request {}

void main() {
group('POST /upload', () {
test('responds with a 400 when file extension is not .png', () async {
final context = _MockRequestContext();
final request = _MockRequest();
when(() => context.request).thenReturn(request);

final formData = FormData(
fields: {},
files: {
'photo': UploadedFile(
'file.txt',
ContentType.text,
Stream.fromIterable([[]]),
)
},
);
when(request.formData).thenAnswer((_) async => formData);

final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.badRequest));
});

test('responds with a 200', () async {
final context = _MockRequestContext();
final request = _MockRequest();
when(() => context.request).thenReturn(request);

final formData = FormData(
fields: {},
files: {
'photo': UploadedFile(
'picture.png',
ContentType('image', 'png'),
Stream.fromIterable([[]]),
)
},
);
when(request.formData).thenAnswer((_) async => formData);

final response = await route.onRequest(context);
expect(response.statusCode, equals(HttpStatus.ok));
expect(
response.json(),
completion(
equals({'message': 'Successfully uploaded picture.png'}),
),
);
});
});
}
1 change: 1 addition & 0 deletions packages/dart_frog/lib/dart_frog.dart
Expand Up @@ -13,6 +13,7 @@ export 'src/_internal.dart'
fromShelfMiddleware,
requestLogger,
serve;
export 'src/body_parsers/body_parsers.dart' show FormData, UploadedFile;
export 'src/create_static_file_handler.dart' show createStaticFileHandler;
export 'src/handler.dart' show Handler;
export 'src/hot_reload.dart' show hotReload;
Expand Down
171 changes: 165 additions & 6 deletions packages/dart_frog/lib/src/body_parsers/form_data.dart
@@ -1,30 +1,47 @@
import 'dart:collection';
import 'dart:convert';
import 'dart:io';

import 'package:http_parser/http_parser.dart';
import 'package:mime/mime.dart';

/// Content-Type: application/x-www-form-urlencoded
final formUrlEncodedContentType = ContentType(
'application',
'x-www-form-urlencoded',
);

/// Parses the body as form data and returns a `Future<Map<String, String>>`.
/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded"
/// Content-Type: multipart/form-data
final multipartFormDataContentType = ContentType(
'multipart',
'form-data',
);

/// Parses the body as form data and returns a `Future<Map<String, dynamic>>`.
/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded" or "multipart/form-data".
/// https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata%E2%91%A0
Future<Map<String, String>> parseFormData({
Future<FormData> parseFormData({
required Map<String, String> headers,
required Future<String> Function() body,
required Stream<List<int>> Function() bytes,
}) async {
final contentType = _extractContentType(headers);
if (!_isFormUrlEncoded(contentType)) {
final isFormUrlEncoded = _isFormUrlEncoded(contentType);
final isMultipartFormData = _isMultipartFormData(contentType);

if (!isFormUrlEncoded && !isMultipartFormData) {
throw StateError(
'''
Body could not be parsed as form data due to an invalid MIME type.
Expected MIME type: "${formUrlEncodedContentType.mimeType}"
Expected MIME type: "${formUrlEncodedContentType.mimeType}" OR "${multipartFormDataContentType.mimeType}"
Actual MIME type: "${contentType?.mimeType ?? ''}"
''',
);
}

return Uri.splitQueryString(await body());
return isFormUrlEncoded
? _extractFormUrlEncodedFormData(body: await body())
: await _extractMultipartFormData(headers: headers, bytes: bytes());
}

ContentType? _extractContentType(Map<String, String> headers) {
Expand All @@ -37,3 +54,145 @@ bool _isFormUrlEncoded(ContentType? contentType) {
if (contentType == null) return false;
return contentType.mimeType == formUrlEncodedContentType.mimeType;
}

bool _isMultipartFormData(ContentType? contentType) {
if (contentType == null) return false;
return contentType.mimeType == multipartFormDataContentType.mimeType;
}

FormData _extractFormUrlEncodedFormData({required String body}) {
return FormData(fields: Uri.splitQueryString(body), files: {});
}

final _keyValueRegexp = RegExp('(?:(?<key>[a-zA-Z0-9-_]+)="(?<value>.*?)";*)+');

Future<FormData> _extractMultipartFormData({
required Map<String, String> headers,
required Stream<List<int>> bytes,
}) async {
final contentType = headers[HttpHeaders.contentTypeHeader]!;
final mediaType = MediaType.parse(contentType);
final boundary = mediaType.parameters['boundary'];
final transformer = MimeMultipartTransformer(boundary!);

final fields = <String, String>{};
final files = <String, UploadedFile>{};

await for (final part in transformer.bind(bytes)) {
final contentDisposition = part.headers['content-disposition'];
if (contentDisposition == null) continue;
if (!contentDisposition.startsWith('form-data;')) continue;

final values = _keyValueRegexp
.allMatches(contentDisposition)
.fold(<String, String>{}, (map, match) {
return map..[match.namedGroup('key')!] = match.namedGroup('value')!;
});

final name = values['name']!;
final fileName = values['filename'];

if (fileName != null) {
files[name] = UploadedFile(
fileName,
ContentType.parse(part.headers['content-type'] ?? 'text/plain'),
part,
);
} else {
final bytes = (await part.toList()).fold(<int>[], (p, e) => p..addAll(e));
fields[name] = utf8.decode(bytes);
}
}

return FormData(fields: fields, files: files);
}

/// {@template form_data}
/// The fields and files of received form data request.
/// {@endtemplate}
class FormData with MapMixin<String, String> {
/// {@macro form_data}
const FormData({
required Map<String, String> fields,
required Map<String, UploadedFile> files,
}) : _fields = fields,
_files = files;

final Map<String, String> _fields;

final Map<String, UploadedFile> _files;

/// The fields that were submitted in the form.
Map<String, String> get fields => Map.unmodifiable(_fields);

/// The files that were uploaded in the form.
Map<String, UploadedFile> get files => Map.unmodifiable(_files);

@override
@Deprecated('Use `fields[key]` to retrieve values')
String? operator [](Object? key) => _fields[key] ?? _files[key]?.toString();

@override
@Deprecated('Use `fields.keys` to retrieve field keys')
Iterable<String> get keys => _fields.keys;

@override
@Deprecated('Use `fields.values` to retrieve field values')
Iterable<String> get values => _fields.values;

@override
@Deprecated(
'FormData should be immutable, in the future this will thrown an error',
)
void operator []=(String key, String value) => _fields[key] = value;

@override
@Deprecated(
'FormData should be immutable, in the future this will thrown an error',
)
void clear() => _fields.clear();

@override
@Deprecated(
'FormData should be immutable, in the future this will thrown an error',
)
String? remove(Object? key) => _fields.remove(key);
}

/// {@template uploaded_file}
/// The uploaded file of a form data request.
/// {@endtemplate}
class UploadedFile {
/// {@macro uploaded_file}
const UploadedFile(
this.name,
this.contentType,
this._byteStream,
);

/// The name of the uploaded file.
final String name;

/// The type of the uploaded file.
final ContentType contentType;

final Stream<List<int>> _byteStream;

/// Read the content of the file as a list of bytes.
///
/// Can only be called once.
Future<List<int>> readAsBytes() async {
return (await _byteStream.toList())
.fold<List<int>>([], (p, e) => p..addAll(e));
}

/// Open the content of the file as a stream of bytes.
///
/// Can only be called once.
Stream<List<int>> openRead() => _byteStream;

@override
String toString() {
return '{ name: $name, contentType: $contentType }';
}
}
4 changes: 2 additions & 2 deletions packages/dart_frog/lib/src/request.dart
Expand Up @@ -139,8 +139,8 @@ class Request {
}

/// Returns a [Future] containing the form data as a [Map].
Future<Map<String, String>> formData() {
return parseFormData(headers: headers, body: body);
Future<FormData> formData() {
return parseFormData(headers: headers, body: body, bytes: bytes);
}

/// Returns a [Future] containing the body text parsed as a json object.
Expand Down
4 changes: 2 additions & 2 deletions packages/dart_frog/lib/src/response.dart
Expand Up @@ -81,8 +81,8 @@ class Response {
}

/// Returns a [Future] containing the form data as a [Map].
Future<Map<String, String>> formData() {
return parseFormData(headers: headers, body: body);
Future<FormData> formData() {
return parseFormData(headers: headers, body: body, bytes: bytes);
}

/// Returns a [Future] containing the body text parsed as a json object.
Expand Down

0 comments on commit 1358a9f

Please sign in to comment.