2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FlutterAdvent Calendar 2023

Day 12

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

Last updated at Posted at 2023-12-11

1. はじめに

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

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

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

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

参考:ZenDesk API

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を利用しています。)

  @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を指定しました。

    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を取得するメソッドは下記のとおりです。

  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を指定してみましたが、結果は同じでした。

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

ソリューション③

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

  @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

スクリーンショット 2023-11-07 13.50.39.png

3.3 調査④

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

{
	"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が不正なため、ファイルが開けないようです。

{
	"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を利用した方法では実現できないことが分かりました。

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));
  }

ソリューション④

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

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

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

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

      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を利用しているため、下記のように変更して検証しました。

  @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… へお気軽にお問い合わせください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?