1. はじめに
当社の開発しているモバイルアプリの中で、ZenDeskと連携しているシステムがあります。
具体的には、ユーザから企業の担当者にメッセージや添付ファイルを送信したり、その逆を行ったりする機能で利用しています。
ZenDeskからユーザに対して送信したメッセージは、それをトリガーとしてWebhookして、Firebase経由でプッシュ通知を送ることにより、ユーザに気づいてもらうイメージです。
既にバックオフィスでZenDeskを利用している企業様には、導入しやすい事例かと思います。
今回は、その開発経験を通して発生した課題・問題点について、ご紹介します。
参考:ZenDesk API
2. 課題・問題点
下記のパターンのうち、モバイルアプリからZenDeskへ添付ファイルを送信したときに、添付ファイルの送信は成功しましたが、ZenDeskで受信したファイルを開こうとすると、一部壊れているファイルがありました。
パターン | テキスト | 添付ファイル |
---|---|---|
モバイルアプリ → ZenDesk | ◯ | △ |
ZenDesk → モバイルアプリ | ◯ | ◯ |
2.1. 添付ファイル
添付ファイルの形式ごとの結果は、以下のとおりです。
ファイル形式 | 拡張子 | 添付ファイルが開けるか? |
---|---|---|
◯ | ||
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
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を利用した方法では実現できないことが分かりました。
/// 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パッケージ内のソースコードを確認したところ、以下のプロパティがあることが分かりました。
/// 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… へお気軽にお問い合わせください。