【Flutter】【解決済み】Multipart/form-dataで送信するときにContent-Typeが変更できない？

# 1. はじめに
当社の開発しているモバイルアプリの中で、ZenDeskと連携しているシステムがあります。
具体的には、ユーザから企業の担当者にメッセージや添付ファイルを送信したり、その逆を行ったりする機能で利用しています。

ZenDeskからユーザに対して送信したメッセージは、それをトリガーとしてWebhookして、Firebase経由でプッシュ通知を送ることにより、ユーザに気づいてもらうイメージです。

既にバックオフィスでZenDeskを利用している企業様には、導入しやすい事例かと思います。

今回は、その開発経験を通して発生した課題・問題点について、ご紹介します。

参考：[ZenDesk API](https://developer.zendesk.com/api-reference/)

# 2. 課題・問題点

下記のパターンのうち、モバイルアプリからZenDeskへ添付ファイルを送信したときに、添付ファイルの送信は成功しましたが、ZenDeskで受信したファイルを開こうとすると、一部壊れているファイルがありました。

| パターン | テキスト |　添付ファイル |
| ---- | ---- | ---- |
| モバイルアプリ　→　ZenDesk | ◯ |△ |
| ZenDesk　→　モバイルアプリ | ◯ |◯ |

## 2.1. 添付ファイル
添付ファイルの形式ごとの結果は、以下のとおりです。

| ファイル形式| 拡張子 |　添付ファイルが開けるか？ |
| ---- | ---- | ---- |
| PDF | pdf |◯ |
| Word(2007以前） | doc |△ 壊れているが開ける|
| Word(2007以降） | doxc |△ 壊れているが開ける |
| Excel(2007以前） | xls |☓ 壊れており開けない|
| Excel(2007以降） | xlsx |☓ 壊れており開けない|
| png | png |☓ 壊れており開けない|
| jpg | jpg |☓ 壊れており開けない|
| webp | webp |☓ 壊れており開けない|


## 2.2 修正前の状態
修正前の状態は、下記のとおりでした。
([retrofit](https://pub.dev/packages/retrofit)を利用しています。）

```dart
  @POST('/uploads')
  Future<String> uploadFile(
    @Query('filename') String filename,
    @Body() FormData formData,
  );
```

# 3. 調査
## 3.1 調査①
原因調査のため、ひとまず、Flutterで通信ログを確認しました。

```
flutter: ╔╣ Request ║ POST
flutter: ║  https://hogehoge.zendesk.com/api/v2/uploads?filename=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Query Parameters
flutter: ╟ filename: image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Headers
flutter: ╟ content-type: multipart/form-data
flutter: ╟ contentType: multipart/form-data
flutter: ╟ responseType: ResponseType.json
flutter: ╟ followRedirects: true
flutter: ╟ connectTimeout: 0:00:10.000000
flutter: ╟ receiveTimeout: 0:00:10.000000
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Form data | --dio-boundary-1324881719
flutter: ╟ formData: Instance of 'MultipartFile'
```

Content-Typeがmultipart/form-dataとなっているようです。

### ソリューション①
最初に、MultipartFile.fromFileのContent-Typeに適切なContent-Typeを指定しました。

```dart
    final formData = FormData.fromMap({
      'file': await MultipartFile.fromFile(
        filePath,
        filename: fileName,
        contentType: MediaType.parse(_getMediaType(filePath)),
      ),
    });

    final result = await _apiService.uploadFile(
        _getMediaType(filePath),
        fileName,
        formData,
    );
```

Content-Typeを取得するメソッドは下記のとおりです。

```dart
  String _getMediaType(String attachmentUrl) {
    final String extension = attachmentUrl.split('.').last;

    switch (extension) {
      case 'pdf':
        return 'application/pdf';

      case 'doc':
        return 'application/msword';

      case 'docx':
        return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';

      case 'xls':
        return 'application/vnd.ms-excel';

      case 'xlsx':
        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';

      //画像全般
      default:
        return 'image/$extension';
    }
  }
  ```

再度、ログを確認しました。
無事、Content-Typeがimage/pngに変わりましたが、結果は変わらずZenDeskにてファイルが開けませんでした。

```
flutter: ╔╣ Request ║ POST
flutter: ║  https://hogehoge.zendesk.com/api/v2/uploads?filename=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Query Parameters
flutter: ╟ filename: image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Headers
flutter: ╟ content-type: image/png
flutter: ╟ contentType: image/png
flutter: ╟ responseType: ResponseType.plain
flutter: ╟ followRedirects: true
flutter: ╟ connectTimeout: 0:00:10.000000
flutter: ╟ receiveTimeout: 0:00:10.000000
flutter: ╚══════════════════════════════════════════════════════════════════════════════════════════╝
flutter: ╔ Form data | --dio-boundary-1324881719
flutter: ╟ formData: Instance of 'MultipartFile'
```

### ソリューション②
次に、ヘッダーにContent-Typeを指定してみましたが、結果は同じでした。

```dart
  @POST('/uploads')
  Future<String> uploadFile(
    @Header('Content-Type') String contentType,
    @Query('filename') String filename,
    @Body() FormData formData,
  );
```

### ソリューション③

次に、staticのヘッダーにContent-Typeを指定してみましたが、結果は同じでした。

```dart
  @POST('/uploads')
  @Headers(<String, dynamic>{
    'Content-Type': 'image/png',
  })  
  Future<String> uploadFile(
    @Query('filename') String filename,
    @Body() FormData formData,
  );
```

## 3.2 調査③
いずれの方法も期待通りに動作出来ませんでした。

実際にネットワークに流れているデータを確認するために、Charlesでログを確認しました。
やはり通信ログをみても、Content-Typeは期待通りに設定されていませんでした。

参考： [charles](https://www.charlesproxy.com/)

![スクリーンショット 2023-11-07 13.50.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71694/cba2b6c1-963f-0250-0087-71b40e0cbe23.png)

## 3.3 調査④

また、ZenDeskAPIの通信ログを確認したところ、content_typeが multipart/form-dataとなっていることが分かりました。

```json
{
	"upload": {
		"token": "HbOu6511fUWlPrLsvJh8F5tJw",
		"expires_at": "2023-11-07T07:23:16Z",
		"attachments": [
			{
				"url": "https://hogehoge.zendesk.com/api/v2/attachments/24815050736537.json",
				"id": 24815050736537,
				"file_name": "image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
				"content_url": "https://hogehoge.zendesk.com/attachments/token/hf7Yp4FjtShZvIiBqmYSbZQEH/?name=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
				"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/hf7Yp4FjtShZvIiBqmYSbZQEH/?name=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
				"content_type": "multipart/form-data", //<---- ここです。
				"size": 173978,
				"width": null,
				"height": null,
				"inline": false,
				"deleted": false,
				"malware_access_override": false,
				"malware_scan_result": "not_scanned",
				"thumbnails": []
			}
		],
		"attachment": {
			"url": "https://hogehoge.zendesk.com/api/v2/attachments/24815050736537.json",
			"id": 24815050736537,
			"file_name": "image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
			"content_url": "https://hogehoge.zendesk.com/attachments/token/hf7Yp4FjtShZvIiBqmYSbZQEH/?name=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
			"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/hf7Yp4FjtShZvIiBqmYSbZQEH/?name=image_picker_37D773DD-9460-4B30-8E58-CC0E74D061E8-14643-00001B510B3B3537.png",
			"content_type": "multipart/form-data",
			"size": 173978,
			"width": null,
			"height": null,
			"inline": false,
			"deleted": false,
			"malware_access_override": false,
			"malware_scan_result": "not_scanned",
			"thumbnails": []
		}
	}
}
```

## 3.4 調査⑤
APIに問題がないことを確認するために、curlで確認しました。

```
curl "https://hogehoge.zendesk.com/api/v2/uploads.json?filename=./sample.png" \
  --data-binary @sample.png\
  -H "Content-Type: image/png" \
  -H "Authorization": "Basic {token}" \
  -X POST
```

レスポンスのログを確認したところ、content_typeがimage/pngとなっており、ZenDeskでファイルを開くことが出来ました。

どうやら、FlutterからMutipartでファイルを送信すると、Content-Typeは、multipart/form-dataになってしまい、ZenDesk側でContent-Typeが不正なため、ファイルが開けないようです。


```json
{
	"upload": {
		"token": "DK0g8cRqU6WEoMHTNpMHHeaKn",
		"expires_at": "2023-11-07T05:47:27Z",
		"attachments": [
			{
				"url": "https://hogehoge.zendesk.com/api/v2/attachments/24812257759513.json",
				"id": 24812257759513,
				"file_name": "./sample.png",
				"content_url": "https://hogehoge.zendesk.com/attachments/token/DJmHFSfXhN1MimnfaPVxQZ0A5/?name=.%2Fsample.png",
				"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/DJmHFSfXhN1MimnfaPVxQZ0A5/?name=.%2Fsample.png",
				"content_type": "image/png",
				"size": 272870,
				"width": 2056,
				"height": 1106,
				"inline": false,
				"deleted": false,
				"malware_access_override": false,
				"malware_scan_result": "not_scanned",
				"thumbnails": [
					{
						"url": "https://hogehoge.zendesk.com/api/v2/attachments/24812288627353.json",
						"id": 24812288627353,
						"file_name": "sample_thumb.png",
						"content_url": "https://hogehige.zendesk.com/attachments/token/UCN4rTJhxFKf58fF7zAblniYW/?name=sample_thumb.png",
						"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/UCN4rTJhxFKf58fF7zAblniYW/?name=sample_thumb.png",
						"content_type": "image/png",
						"size": 1761,
						"width": 80,
						"height": 43,
						"inline": false,
						"deleted": false,
						"malware_access_override": false,
						"malware_scan_result": "not_scanned"
					}
				]
			}
		],
		"attachment": {
			"url": "https://hogehoge.zendesk.com/api/v2/attachments/24812257759513.json",
			"id": 24812257759513,
			"file_name": "./sample.png",
			"content_url": "https://hogehoge.zendesk.com/attachments/token/DJmHFSfXhN1MimnfaPVxQZ0A5/?name=.%2Fsample.png",
			"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/DJmHFSfXhN1MimnfaPVxQZ0A5/?name=.%2Fsample.png",
			"content_type": "image/png",
			"size": 272870,
			"width": 2056,
			"height": 1106,
			"inline": false,
			"deleted": false,
			"malware_access_override": false,
			"malware_scan_result": "not_scanned",
			"thumbnails": [
				{
					"url": "https://hogehoge.zendesk.com/api/v2/attachments/24812288627353.json",
					"id": 24812288627353,
					"file_name": "sample_thumb.png",
					"content_url": "https://hogehoge.zendesk.com/attachments/token/UCN4rTJhxFKf58fF7zAblniYW/?name=sample_thumb.png",
					"mapped_content_url": "https://hogehoge.zendesk.com/attachments/token/UCN4rTJhxFKf58fF7zAblniYW/?name=sample_thumb.png",
					"content_type": "image/png",
					"size": 1761,
					"width": 80,
					"height": 43,
					"inline": false,
					"deleted": false,
					"malware_access_override": false,
					"malware_scan_result": "not_scanned"
				}
			]
		}
	}
}
```

## 3.5 調査⑥

httpパッケージのMultipartの処理を確認してみました。
すると、気になる箇所がありました。

Content-Typeが「multipart/form-data; boundary=$boundary」に書き換えられているように見えました。
つまり、プロダクトコードでContent-Typeを指定しても、パッケージ内部で書き換えられてしまい、multipartを利用した方法では実現できないことが分かりました。


```dart:multipart_request.dart
  /// Freezes all mutable fields and returns a single-subscription [ByteStream]
  /// that will emit the request body.
  @override
  ByteStream finalize() {
    // TODO: freeze fields and files
    final boundary = _boundaryString();
    headers['content-type'] = 'multipart/form-data; boundary=$boundary';
    super.finalize();
    return ByteStream(_finalize(boundary));
  }
```

https://github.com/dart-lang/http/blob/master/pkgs/http/lib/src/multipart_request.dart#L90


### ソリューション④

別の方法がないか検討してみます。

検討中にhttpパッケージ内のソースコードを確認したところ、以下のプロパティがあることが分かりました。

```dart:response.dart
 /// The bytes comprising the body of this response.
  final Uint8List bodyBytes;
```

https://github.com/dart-lang/http/blob/master/pkgs/http/lib/src/response.dart#L18

まずは、httpパッケージのみで検証してみます。

```dart
      final baseUrl = dotenv.get('ZENDESK_BASE_URL');
      final request = http.Request(
         'POST',
         Uri.parse('$baseUrl/uploads?filename=$fileName'),
       );
       request.headers['Authorization'] = authorizationValue;
       request.headers['Content-Type'] = _getMediaType(filePath);

       final file = File(filePath);
       final fileBytes = await file.readAsBytes();
       request.bodyBytes = fileBytes;
       final response = await request.send();
       final body = await response.stream.bytesToString();
```

すると、ようやく、ZenDeskにてContent-Typeが適切に認識されました。

本プロジェクトでは、RetroFitを利用しているため、下記のように変更して検証しました。

```dart:
  @POST('/uploads')
  Future<String> uploadFile(
    @Header('Content-Type') contentType,
    @Query('filename') String filename,
    @Body() List<int> bodyBytes,
  );
  ```

こちらも同様に、期待通りに動作しました。


# 4. まとめ

バックエンドのシステムがすべて自前のシステムの場合は、社内でI/Fを調整することは可能です。
ただし、今回の事例のように一部外部サービスの場合（今回は、ZenDesk）は、APIのI/Fを調整することは難しいです。
（他のシステムでも利用されているため）

そんな中、Flutterのログだけを見ていても、期待通りにサーバーへ送信していると思いがちです。
Flutter内のログは、あくまでのその時点でのログであり、実際にサーバーへ送信しているデータとは違います。

過信しすぎると、フロントエンドは期待通りにデータを送っている、バックエンドは期待通りにデータが届いていないと話が平行線になるケースがよくあります。そのときは、どこのログを確認したか確認することが大切です。

今回は、Flutterからの送信ログをCharlesでパッケットキャプチャしたことにより、フロントエンドの問題であることを認識出来ました。また、curlで検証することによるバックエンド（外部システム）に問題ないことが検証出来ました。

もし、Multipart/form-dataでContent-Typeを変更できる方法をご存知でしたら、メッセージで頂けますと幸いです。

# 5. 仲間募集

弊社では、Flutterを利用したモバイルアプリの開発相談を多数頂いております。

それに対して、エンジニアの数がまだまだ不足している状況です。
弊社にてFlutter開発をしたい方や、インターンシップをしたい学生さんを随時募集しております。

ご興味のある方は、弊社採用アドレス(rec@stv-tech.co.jp )またはhttps://wantedly.com/enterprise/projects… へお気軽にお問い合わせください。