Skip to content

Commit 1358a9f

Browse files
Jochum van der Ploegfelangelvuitv
authored
feat(dart_frog): multipart/form-data (#551)
Co-authored-by: Felix Angelov <[email protected]> Co-authored-by: VuiTv <[email protected]>
1 parent eb98fa2 commit 1358a9f

File tree

9 files changed

+562
-41
lines changed

9 files changed

+562
-41
lines changed

docs/docs/basics/routes.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ curl --request POST \
203203

204204
#### Form Data
205205

206-
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>`.
206+
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`.
207207

208208
```dart
209209
import 'package:dart_frog/dart_frog.dart';
@@ -213,9 +213,9 @@ Future<Response> onRequest(RequestContext context) async {
213213
final request = context.request;
214214
215215
// Access the request body form data.
216-
final body = await request.formData();
216+
final formData = await request.formData();
217217
218-
return Response.json(body: {'request_body': body});
218+
return Response.json(body: {'form_data': formData.fields});
219219
}
220220
```
221221

@@ -225,18 +225,53 @@ curl --request POST \
225225
--data hello=world
226226
227227
{
228-
"request_body": {
228+
"form_data": {
229229
"hello": "world"
230230
}
231231
}
232232
```
233233

234+
If the request is a multipart form data request you can also access files that were uploaded.
235+
236+
```dart
237+
import 'package:dart_frog/dart_frog.dart';
238+
239+
Future<Response> onRequest(RequestContext context) async {
240+
// Access the incoming request.
241+
final request = context.request;
242+
243+
// Access the request body form data.
244+
final formData = await request.formData();
245+
246+
// Retrieve an uploaded file.
247+
final photo = formData.files['photo'];
248+
249+
if (photo == null || photo.contentType.mimeType != contentTypePng.mimeType) {
250+
return Response(statusCode: HttpStatus.badRequest);
251+
}
252+
253+
return Response.json(
254+
body: {'message': 'Successfully uploaded ${photo.name}'},
255+
);
256+
}
257+
```
258+
259+
```
260+
curl --request POST \
261+
--url http://localhost:8080/example \
262+
263+
264+
{
265+
"message": "Successfully uploaded photo.png"
266+
}
267+
```
268+
234269
:::info
235-
The `formData` API is supported in `dart_frog >=0.3.1`
270+
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`.
236271
:::
237272

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

242277
## Responses 📤
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'dart:io';
2+
3+
import 'package:dart_frog/dart_frog.dart';
4+
5+
final contentTypePng = ContentType('image', 'png');
6+
7+
Future<Response> onRequest(RequestContext context) async {
8+
final formData = await context.request.formData();
9+
final photo = formData.files['photo'];
10+
11+
if (photo == null || photo.contentType.mimeType != contentTypePng.mimeType) {
12+
return Response(statusCode: HttpStatus.badRequest);
13+
}
14+
15+
return Response.json(
16+
body: {'message': 'Successfully uploaded ${photo.name}'},
17+
);
18+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// ignore_for_file: prefer_const_constructors
2+
3+
import 'dart:io';
4+
5+
import 'package:dart_frog/dart_frog.dart';
6+
import 'package:mocktail/mocktail.dart';
7+
import 'package:test/test.dart';
8+
9+
import '../../../routes/photos/upload.dart' as route;
10+
11+
class _MockRequestContext extends Mock implements RequestContext {}
12+
13+
class _MockRequest extends Mock implements Request {}
14+
15+
void main() {
16+
group('POST /upload', () {
17+
test('responds with a 400 when file extension is not .png', () async {
18+
final context = _MockRequestContext();
19+
final request = _MockRequest();
20+
when(() => context.request).thenReturn(request);
21+
22+
final formData = FormData(
23+
fields: {},
24+
files: {
25+
'photo': UploadedFile(
26+
'file.txt',
27+
ContentType.text,
28+
Stream.fromIterable([[]]),
29+
)
30+
},
31+
);
32+
when(request.formData).thenAnswer((_) async => formData);
33+
34+
final response = await route.onRequest(context);
35+
expect(response.statusCode, equals(HttpStatus.badRequest));
36+
});
37+
38+
test('responds with a 200', () async {
39+
final context = _MockRequestContext();
40+
final request = _MockRequest();
41+
when(() => context.request).thenReturn(request);
42+
43+
final formData = FormData(
44+
fields: {},
45+
files: {
46+
'photo': UploadedFile(
47+
'picture.png',
48+
ContentType('image', 'png'),
49+
Stream.fromIterable([[]]),
50+
)
51+
},
52+
);
53+
when(request.formData).thenAnswer((_) async => formData);
54+
55+
final response = await route.onRequest(context);
56+
expect(response.statusCode, equals(HttpStatus.ok));
57+
expect(
58+
response.json(),
59+
completion(
60+
equals({'message': 'Successfully uploaded picture.png'}),
61+
),
62+
);
63+
});
64+
});
65+
}

packages/dart_frog/lib/dart_frog.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export 'src/_internal.dart'
1313
fromShelfMiddleware,
1414
requestLogger,
1515
serve;
16+
export 'src/body_parsers/body_parsers.dart' show FormData, UploadedFile;
1617
export 'src/create_static_file_handler.dart' show createStaticFileHandler;
1718
export 'src/handler.dart' show Handler;
1819
export 'src/hot_reload.dart' show hotReload;
Lines changed: 165 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,47 @@
1+
import 'dart:collection';
2+
import 'dart:convert';
13
import 'dart:io';
24

5+
import 'package:http_parser/http_parser.dart';
6+
import 'package:mime/mime.dart';
7+
38
/// Content-Type: application/x-www-form-urlencoded
49
final formUrlEncodedContentType = ContentType(
510
'application',
611
'x-www-form-urlencoded',
712
);
813

9-
/// Parses the body as form data and returns a `Future<Map<String, String>>`.
10-
/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded"
14+
/// Content-Type: multipart/form-data
15+
final multipartFormDataContentType = ContentType(
16+
'multipart',
17+
'form-data',
18+
);
19+
20+
/// Parses the body as form data and returns a `Future<Map<String, dynamic>>`.
21+
/// Throws a [StateError] if the MIME type is not "application/x-www-form-urlencoded" or "multipart/form-data".
1122
/// https://fetch.spec.whatwg.org/#ref-for-dom-body-formdata%E2%91%A0
12-
Future<Map<String, String>> parseFormData({
23+
Future<FormData> parseFormData({
1324
required Map<String, String> headers,
1425
required Future<String> Function() body,
26+
required Stream<List<int>> Function() bytes,
1527
}) async {
1628
final contentType = _extractContentType(headers);
17-
if (!_isFormUrlEncoded(contentType)) {
29+
final isFormUrlEncoded = _isFormUrlEncoded(contentType);
30+
final isMultipartFormData = _isMultipartFormData(contentType);
31+
32+
if (!isFormUrlEncoded && !isMultipartFormData) {
1833
throw StateError(
1934
'''
2035
Body could not be parsed as form data due to an invalid MIME type.
21-
Expected MIME type: "${formUrlEncodedContentType.mimeType}"
36+
Expected MIME type: "${formUrlEncodedContentType.mimeType}" OR "${multipartFormDataContentType.mimeType}"
2237
Actual MIME type: "${contentType?.mimeType ?? ''}"
2338
''',
2439
);
2540
}
2641

27-
return Uri.splitQueryString(await body());
42+
return isFormUrlEncoded
43+
? _extractFormUrlEncodedFormData(body: await body())
44+
: await _extractMultipartFormData(headers: headers, bytes: bytes());
2845
}
2946

3047
ContentType? _extractContentType(Map<String, String> headers) {
@@ -37,3 +54,145 @@ bool _isFormUrlEncoded(ContentType? contentType) {
3754
if (contentType == null) return false;
3855
return contentType.mimeType == formUrlEncodedContentType.mimeType;
3956
}
57+
58+
bool _isMultipartFormData(ContentType? contentType) {
59+
if (contentType == null) return false;
60+
return contentType.mimeType == multipartFormDataContentType.mimeType;
61+
}
62+
63+
FormData _extractFormUrlEncodedFormData({required String body}) {
64+
return FormData(fields: Uri.splitQueryString(body), files: {});
65+
}
66+
67+
final _keyValueRegexp = RegExp('(?:(?<key>[a-zA-Z0-9-_]+)="(?<value>.*?)";*)+');
68+
69+
Future<FormData> _extractMultipartFormData({
70+
required Map<String, String> headers,
71+
required Stream<List<int>> bytes,
72+
}) async {
73+
final contentType = headers[HttpHeaders.contentTypeHeader]!;
74+
final mediaType = MediaType.parse(contentType);
75+
final boundary = mediaType.parameters['boundary'];
76+
final transformer = MimeMultipartTransformer(boundary!);
77+
78+
final fields = <String, String>{};
79+
final files = <String, UploadedFile>{};
80+
81+
await for (final part in transformer.bind(bytes)) {
82+
final contentDisposition = part.headers['content-disposition'];
83+
if (contentDisposition == null) continue;
84+
if (!contentDisposition.startsWith('form-data;')) continue;
85+
86+
final values = _keyValueRegexp
87+
.allMatches(contentDisposition)
88+
.fold(<String, String>{}, (map, match) {
89+
return map..[match.namedGroup('key')!] = match.namedGroup('value')!;
90+
});
91+
92+
final name = values['name']!;
93+
final fileName = values['filename'];
94+
95+
if (fileName != null) {
96+
files[name] = UploadedFile(
97+
fileName,
98+
ContentType.parse(part.headers['content-type'] ?? 'text/plain'),
99+
part,
100+
);
101+
} else {
102+
final bytes = (await part.toList()).fold(<int>[], (p, e) => p..addAll(e));
103+
fields[name] = utf8.decode(bytes);
104+
}
105+
}
106+
107+
return FormData(fields: fields, files: files);
108+
}
109+
110+
/// {@template form_data}
111+
/// The fields and files of received form data request.
112+
/// {@endtemplate}
113+
class FormData with MapMixin<String, String> {
114+
/// {@macro form_data}
115+
const FormData({
116+
required Map<String, String> fields,
117+
required Map<String, UploadedFile> files,
118+
}) : _fields = fields,
119+
_files = files;
120+
121+
final Map<String, String> _fields;
122+
123+
final Map<String, UploadedFile> _files;
124+
125+
/// The fields that were submitted in the form.
126+
Map<String, String> get fields => Map.unmodifiable(_fields);
127+
128+
/// The files that were uploaded in the form.
129+
Map<String, UploadedFile> get files => Map.unmodifiable(_files);
130+
131+
@override
132+
@Deprecated('Use `fields[key]` to retrieve values')
133+
String? operator [](Object? key) => _fields[key] ?? _files[key]?.toString();
134+
135+
@override
136+
@Deprecated('Use `fields.keys` to retrieve field keys')
137+
Iterable<String> get keys => _fields.keys;
138+
139+
@override
140+
@Deprecated('Use `fields.values` to retrieve field values')
141+
Iterable<String> get values => _fields.values;
142+
143+
@override
144+
@Deprecated(
145+
'FormData should be immutable, in the future this will thrown an error',
146+
)
147+
void operator []=(String key, String value) => _fields[key] = value;
148+
149+
@override
150+
@Deprecated(
151+
'FormData should be immutable, in the future this will thrown an error',
152+
)
153+
void clear() => _fields.clear();
154+
155+
@override
156+
@Deprecated(
157+
'FormData should be immutable, in the future this will thrown an error',
158+
)
159+
String? remove(Object? key) => _fields.remove(key);
160+
}
161+
162+
/// {@template uploaded_file}
163+
/// The uploaded file of a form data request.
164+
/// {@endtemplate}
165+
class UploadedFile {
166+
/// {@macro uploaded_file}
167+
const UploadedFile(
168+
this.name,
169+
this.contentType,
170+
this._byteStream,
171+
);
172+
173+
/// The name of the uploaded file.
174+
final String name;
175+
176+
/// The type of the uploaded file.
177+
final ContentType contentType;
178+
179+
final Stream<List<int>> _byteStream;
180+
181+
/// Read the content of the file as a list of bytes.
182+
///
183+
/// Can only be called once.
184+
Future<List<int>> readAsBytes() async {
185+
return (await _byteStream.toList())
186+
.fold<List<int>>([], (p, e) => p..addAll(e));
187+
}
188+
189+
/// Open the content of the file as a stream of bytes.
190+
///
191+
/// Can only be called once.
192+
Stream<List<int>> openRead() => _byteStream;
193+
194+
@override
195+
String toString() {
196+
return '{ name: $name, contentType: $contentType }';
197+
}
198+
}

packages/dart_frog/lib/src/request.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ class Request {
139139
}
140140

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

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

packages/dart_frog/lib/src/response.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ class Response {
8181
}
8282

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

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

0 commit comments

Comments
 (0)