Skip to content

Commit f73acc7

Browse files
committed
feat: support multiple files per form field
1 parent eb5c3a0 commit f73acc7

2 files changed

Lines changed: 113 additions & 48 deletions

File tree

packages/dart_frog/lib/src/body_parsers/form_data.dart

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ bool _isMultipartFormData(ContentType? contentType) {
6161
}
6262

6363
FormData _extractFormUrlEncodedFormData({required String body}) {
64-
return FormData(fields: Uri.splitQueryString(body), files: {});
64+
final query = Uri.splitQueryString(body);
65+
final fields = <String, List<String>>{};
66+
67+
query.forEach((k, v) => fields.putIfAbsent(k, () => []).add(v));
68+
return FormData(fields: fields, files: {});
6569
}
6670

6771
final _keyValueRegexp = RegExp('(?:(?<key>[a-zA-Z0-9-_]+)="(?<value>.*?)";*)+');
@@ -75,8 +79,8 @@ Future<FormData> _extractMultipartFormData({
7579
final boundary = mediaType.parameters['boundary'];
7680
final transformer = MimeMultipartTransformer(boundary!);
7781

78-
final fields = <String, String>{};
79-
final files = <String, UploadedFile>{};
82+
final fields = <String, List<String>>{};
83+
final files = <String, List<UploadedFile>>{};
8084

8185
await for (final part in transformer.bind(bytes)) {
8286
final contentDisposition = part.headers['content-disposition'];
@@ -93,14 +97,16 @@ Future<FormData> _extractMultipartFormData({
9397
final fileName = values['filename'];
9498

9599
if (fileName != null) {
96-
files[name] = UploadedFile(
97-
fileName,
98-
ContentType.parse(part.headers['content-type'] ?? 'text/plain'),
99-
part,
100-
);
100+
files.putIfAbsent(name, () => []).add(
101+
UploadedFile(
102+
fileName,
103+
ContentType.parse(part.headers['content-type'] ?? 'text/plain'),
104+
part,
105+
),
106+
);
101107
} else {
102108
final bytes = (await part.toList()).fold(<int>[], (p, e) => p..addAll(e));
103-
fields[name] = utf8.decode(bytes);
109+
fields.putIfAbsent(name, () => []).add(utf8.decode(bytes));
104110
}
105111
}
106112

@@ -110,41 +116,41 @@ Future<FormData> _extractMultipartFormData({
110116
/// {@template form_data}
111117
/// The fields and files of received form data request.
112118
/// {@endtemplate}
113-
class FormData with MapMixin<String, String> {
119+
class FormData with MapMixin<String, List<String>> {
114120
/// {@macro form_data}
115121
const FormData({
116-
required Map<String, String> fields,
117-
required Map<String, UploadedFile> files,
122+
required Map<String, List<String>> fields,
123+
required Map<String, List<UploadedFile>> files,
118124
}) : _fields = fields,
119125
_files = files;
120126

121-
final Map<String, String> _fields;
127+
final Map<String, List<String>> _fields;
122128

123-
final Map<String, UploadedFile> _files;
129+
final Map<String, List<UploadedFile>> _files;
124130

125131
/// The fields that were submitted in the form.
126-
Map<String, String> get fields => Map.unmodifiable(_fields);
132+
Map<String, List<String>> get fields => Map.unmodifiable(_fields);
127133

128134
/// The files that were uploaded in the form.
129-
Map<String, UploadedFile> get files => Map.unmodifiable(_files);
135+
Map<String, List<UploadedFile>> get files => Map.unmodifiable(_files);
130136

131137
@override
132138
@Deprecated('Use `fields[key]` to retrieve values')
133-
String? operator [](Object? key) => _fields[key] ?? _files[key]?.toString();
139+
List<String>? operator [](Object? key) => _fields[key];
134140

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

139145
@override
140146
@Deprecated('Use `fields.values` to retrieve field values')
141-
Iterable<String> get values => _fields.values;
147+
Iterable<List<String>> get values => _fields.values;
142148

143149
@override
144150
@Deprecated(
145151
'FormData should be immutable, in the future this will thrown an error',
146152
)
147-
void operator []=(String key, String value) => _fields[key] = value;
153+
void operator []=(String key, List<String> value) => _fields[key] = value;
148154

149155
@override
150156
@Deprecated(
@@ -156,7 +162,7 @@ class FormData with MapMixin<String, String> {
156162
@Deprecated(
157163
'FormData should be immutable, in the future this will thrown an error',
158164
)
159-
String? remove(Object? key) => _fields.remove(key);
165+
List<String>? remove(Object? key) => _fields.remove(key);
160166
}
161167

162168
/// {@template uploaded_file}

packages/dart_frog/test/src/body_parsers/form_data_test.dart

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ Actual MIME type: "application/json"
6666
bytes: () async* {},
6767
);
6868

69-
expect(formData.fields, equals({'foo': 'bar'}));
69+
expect(
70+
formData.fields,
71+
equals({
72+
'foo': ['bar'],
73+
}),
74+
);
7075
expect(formData.files, isEmpty);
7176
});
7277

@@ -81,7 +86,13 @@ Actual MIME type: "application/json"
8186
bytes: () async* {},
8287
);
8388

84-
expect(formData.fields, equals({'foo': 'bar', 'bar': 'baz'}));
89+
expect(
90+
formData.fields,
91+
equals({
92+
'foo': ['bar'],
93+
'bar': ['baz'],
94+
}),
95+
);
8596
expect(formData.files, isEmpty);
8697
});
8798

@@ -101,7 +112,12 @@ Actual MIME type: "application/json"
101112
},
102113
);
103114

104-
expect(formData.fields, equals({'foo': 'bar'}));
115+
expect(
116+
formData.fields,
117+
equals({
118+
'foo': ['bar'],
119+
}),
120+
);
105121
expect(formData.files, isEmpty);
106122
});
107123

@@ -130,11 +146,13 @@ Actual MIME type: "application/json"
130146
expect(
131147
formData.files,
132148
equals({
133-
'my_file': isUploadedFile(
134-
'my_file.txt',
135-
ContentType.text,
136-
'file content',
137-
),
149+
'my_file': [
150+
isUploadedFile(
151+
'my_file.txt',
152+
ContentType.text,
153+
'file content',
154+
),
155+
],
138156
}),
139157
);
140158
});
@@ -168,44 +186,85 @@ Actual MIME type: "application/json"
168186
},
169187
);
170188

171-
expect(formData.fields, equals({'foo': 'bar', 'bar': 'baz'}));
189+
expect(
190+
formData.fields,
191+
equals({
192+
'foo': ['bar'],
193+
'bar': ['baz'],
194+
}),
195+
);
172196
expect(
173197
formData.files,
174198
equals({
175-
'my_file': isUploadedFile(
176-
'my_file.txt',
177-
ContentType.text,
178-
'file content',
179-
),
180-
'my_other_file': isUploadedFile(
181-
'my_other_file.txt',
182-
ContentType.text,
183-
'file content',
184-
),
199+
'my_file': [
200+
isUploadedFile(
201+
'my_file.txt',
202+
ContentType.text,
203+
'file content',
204+
),
205+
],
206+
'my_other_file': [
207+
isUploadedFile(
208+
'my_other_file.txt',
209+
ContentType.text,
210+
'file content',
211+
),
212+
],
185213
}),
186214
);
187215
});
188216
});
189217
});
190218

191219
group('$FormData', () {
192-
test('is backwards compatible with a Map<String, String>', () {
220+
test('supports Map<String, List<String>> access', () {
193221
final formData = FormData(
194-
fields: {'foo': 'bar', 'bar': 'baz'},
222+
fields: {
223+
'foo': ['bar'],
224+
'bar': ['baz'],
225+
},
195226
files: {},
196227
);
197228

198-
expect(formData['foo'], equals('bar'));
229+
expect(formData['foo'], equals(['bar']));
199230
expect(formData.keys, equals(['foo', 'bar']));
200-
expect(formData.values, equals(['bar', 'baz']));
231+
expect(
232+
formData.values,
233+
equals([
234+
['bar'],
235+
['baz'],
236+
]),
237+
);
201238

202239
formData.remove('bar');
203-
expect(formData, equals({'foo': 'bar'}));
204-
expect(formData.fields, equals({'foo': 'bar'}));
240+
expect(
241+
formData,
242+
equals({
243+
'foo': ['bar'],
244+
}),
245+
);
246+
expect(
247+
formData.fields,
248+
equals({
249+
'foo': ['bar'],
250+
}),
251+
);
205252

206-
formData['bar'] = 'baz';
207-
expect(formData, equals({'foo': 'bar', 'bar': 'baz'}));
208-
expect(formData.fields, equals({'foo': 'bar', 'bar': 'baz'}));
253+
formData['bar'] = ['baz'];
254+
expect(
255+
formData,
256+
equals({
257+
'foo': ['bar'],
258+
'bar': ['baz'],
259+
}),
260+
);
261+
expect(
262+
formData.fields,
263+
equals({
264+
'foo': ['bar'],
265+
'bar': ['baz'],
266+
}),
267+
);
209268

210269
formData.clear();
211270
expect(formData, equals(isEmpty));

0 commit comments

Comments
 (0)