Skip to content

Commit 7aa5a81

Browse files
Copilottrevorwang
andauthored
Add support for Map<String, File> in @part annotation for dynamic multipart field names (#848)
* Initial plan * Add support for Map<String, File> in @part annotation for dynamic field names Co-authored-by: trevorwang <121966+trevorwang@users.noreply.github.com> * Add example for Map<String, File> usage in multipart uploads Co-authored-by: trevorwang <121966+trevorwang@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: trevorwang <121966+trevorwang@users.noreply.github.com>
1 parent 31204a1 commit 7aa5a81

4 files changed

Lines changed: 181 additions & 10 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,33 @@ The `@PartMap()` annotation accepts a `Map<String, dynamic>` with keys in the fo
246246
- `fileName` defaults to the file's actual name (extracted from file path)
247247
- `contentType` defaults to `null` (Dio will auto-detect based on file extension)
248248

249+
#### Dynamic Field Names for Multiple Files
250+
251+
Use `@Part()` with `Map<String, File>` to upload multiple files with dynamic field names:
252+
253+
```dart
254+
@POST('/api/files')
255+
@MultiPart()
256+
Future<void> uploadFiles(@Part() Map<String, File> files);
257+
258+
// Usage - Upload multiple files with custom field names
259+
await client.uploadFiles({
260+
'image[0]': File('/path/to/photo1.jpg'),
261+
'image[1]': File('/path/to/photo2.jpg'),
262+
'document': File('/path/to/report.pdf'),
263+
});
264+
```
265+
266+
This feature also supports:
267+
- `Map<String, MultipartFile>` - For files already wrapped in MultipartFile
268+
- `Map<String, List<int>>` - For raw byte data
269+
- Nullable maps: `Map<String, File>?`
270+
271+
**Use cases:**
272+
- Uploading arrays of files where each file needs a unique indexed name (e.g., `image[0]`, `image[1]`)
273+
- Uploading files to endpoints that require specific field names determined at runtime
274+
- Sending multiple files of different types in a single request
275+
249276
### Get original HTTP response
250277

251278
```dart

example/lib/example.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ abstract class RestClient {
138138
@PartMap() Map<String, dynamic>? metadata,
139139
});
140140

141+
/// Example of uploading multiple files with dynamic field names.
142+
///
143+
/// This is useful when you need to send files with indexed names like
144+
/// `image[0]`, `image[1]`, etc., or when field names are determined at runtime.
145+
///
146+
/// Usage:
147+
/// ```dart
148+
/// await client.uploadMultipleFiles({
149+
/// 'image[0]': File('/path/to/photo1.jpg'),
150+
/// 'image[1]': File('/path/to/photo2.jpg'),
151+
/// 'document': File('/path/to/report.pdf'),
152+
/// });
153+
/// ```
154+
@POST('http://httpbin.org/post')
155+
@MultiPart()
156+
Future<void> uploadMultipleFiles(@Part() Map<String, File> files);
157+
141158
@Headers(<String, String>{'accept': 'image/jpeg'})
142159
@GET('http://httpbin.org/image/jpeg')
143160
@DioResponseType(ResponseType.bytes)

generator/lib/src/generator.dart

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2548,16 +2548,80 @@ if (T != dynamic &&
25482548
final parts = _getAnnotations(m, retrofit.Part);
25492549
if (parts.isNotEmpty) {
25502550
if (parts.length == 1 && parts.keys.first.type.isDartCoreMap) {
2551-
blocks.add(
2552-
declareFinal(dataVar)
2553-
.assign(
2554-
refer('FormData').newInstanceNamed('fromMap', [
2555-
CodeExpression(Code(parts.keys.first.displayName)),
2556-
]),
2557-
)
2558-
.statement,
2559-
);
2560-
return;
2551+
final mapParam = parts.keys.first;
2552+
final mapValueType = _genericListOf(mapParam.type)?[1];
2553+
2554+
// Check if Map value type is File, MultipartFile, or List<int>
2555+
final isFileMap = mapValueType != null && _isAssignable(io.File, mapValueType);
2556+
final isMultipartFileMap = mapValueType != null && _isMultipartFile(mapValueType);
2557+
final isByteListMap = mapValueType != null && _displayString(mapValueType) == 'List<int>';
2558+
2559+
if (isFileMap || isMultipartFileMap || isByteListMap) {
2560+
// Handle Map<String, File>, Map<String, MultipartFile>, or Map<String, List<int>>
2561+
blocks.add(
2562+
declareFinal(dataVar).assign(refer('FormData').newInstance([])).statement,
2563+
);
2564+
2565+
final nullableCheck = mapParam.type.nullabilitySuffix == NullabilitySuffix.question;
2566+
2567+
if (nullableCheck) {
2568+
blocks.add(Code('if (${mapParam.displayName} != null) {'));
2569+
}
2570+
2571+
if (isFileMap) {
2572+
// Generate code for Map<String, File>
2573+
blocks.add(Code('''
2574+
${mapParam.displayName}.forEach((key, value) {
2575+
$dataVar.files.add(
2576+
MapEntry(
2577+
key,
2578+
MultipartFile.fromFileSync(
2579+
value.path,
2580+
filename: value.path.split(Platform.pathSeparator).last,
2581+
),
2582+
),
2583+
);
2584+
});
2585+
'''));
2586+
} else if (isMultipartFileMap) {
2587+
// Generate code for Map<String, MultipartFile>
2588+
blocks.add(Code('''
2589+
${mapParam.displayName}.forEach((key, value) {
2590+
$dataVar.files.add(MapEntry(key, value));
2591+
});
2592+
'''));
2593+
} else if (isByteListMap) {
2594+
// Generate code for Map<String, List<int>>
2595+
blocks.add(Code('''
2596+
${mapParam.displayName}.forEach((key, value) {
2597+
$dataVar.files.add(
2598+
MapEntry(
2599+
key,
2600+
MultipartFile.fromBytes(value),
2601+
),
2602+
);
2603+
});
2604+
'''));
2605+
}
2606+
2607+
if (nullableCheck) {
2608+
blocks.add(const Code('}'));
2609+
}
2610+
2611+
return;
2612+
} else {
2613+
// Default behavior for Map<String, dynamic> - use FormData.fromMap
2614+
blocks.add(
2615+
declareFinal(dataVar)
2616+
.assign(
2617+
refer('FormData').newInstanceNamed('fromMap', [
2618+
CodeExpression(Code(mapParam.displayName)),
2619+
]),
2620+
)
2621+
.statement,
2622+
);
2623+
return;
2624+
}
25612625
}
25622626

25632627
blocks.add(

generator/test/src/generator_test_src.dart

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3017,6 +3017,69 @@ abstract class PartMapWithListIntTest {
30173017
});
30183018
}
30193019

3020+
@ShouldGenerate(
3021+
r'''
3022+
files.forEach((key, value) {
3023+
_data.files.add(
3024+
MapEntry(
3025+
key,
3026+
MultipartFile.fromFileSync(
3027+
value.path,
3028+
filename: value.path.split(Platform.pathSeparator).last,
3029+
),
3030+
),
3031+
);
3032+
});
3033+
''',
3034+
contains: true,
3035+
)
3036+
@RestApi(baseUrl: 'https://httpbin.org/')
3037+
abstract class PartWithMapStringFileTest {
3038+
@POST('/upload')
3039+
@MultiPart()
3040+
Future<String> uploadFiles(@Part() Map<String, File> files);
3041+
}
3042+
3043+
@ShouldGenerate(
3044+
r'''
3045+
if (files != null) {
3046+
files.forEach((key, value) {
3047+
_data.files.add(
3048+
MapEntry(
3049+
key,
3050+
MultipartFile.fromFileSync(
3051+
value.path,
3052+
filename: value.path.split(Platform.pathSeparator).last,
3053+
),
3054+
),
3055+
);
3056+
});
3057+
}
3058+
''',
3059+
contains: true,
3060+
)
3061+
@RestApi(baseUrl: 'https://httpbin.org/')
3062+
abstract class PartWithNullableMapStringFileTest {
3063+
@POST('/upload')
3064+
@MultiPart()
3065+
Future<String> uploadFiles(@Part() Map<String, File>? files);
3066+
}
3067+
3068+
@ShouldGenerate(
3069+
r'''
3070+
files.forEach((key, value) {
3071+
_data.files.add(MapEntry(key, value));
3072+
});
3073+
''',
3074+
contains: true,
3075+
)
3076+
@RestApi(baseUrl: 'https://httpbin.org/')
3077+
abstract class PartWithMapStringMultipartFileTest {
3078+
@POST('/upload')
3079+
@MultiPart()
3080+
Future<String> uploadFiles(@Part() Map<String, MultipartFile> files);
3081+
}
3082+
30203083
@ShouldGenerate(
30213084
'''
30223085
@override

0 commit comments

Comments
 (0)