【AWS-Chalice】multipart/form-data の形式でアップロードされたバイナリファイルの扱い方 （有名人識別サービスをChaliceで作るハンズオン付き！）

# はじめに

[AWS Chalice](https://aws.github.io/chalice/) を用いてAPIを開発した際、multipart/form-data の形式でアップロードされた画像ファイルの取り扱いにつまずきました。
[先駆者様の記事](https://qiita.com/saiva/items/5d281ed7ccc678c3d750)を見つけたものの、2021年3月現在ではうまく動きません:cry:
他の解決策を探した結果、[GitHubの issue](https://github.com/aws/chalice/issues/796#issuecomment-686796755) を参考に解決できたため、備忘も兼ねて手法を記載します。

# コード例
Chalice の基本は理解しているものとして、主要部分のみを記載します。
Chalice については、[こちらのAWS公式ハンズオン記事](https://feature-ai-service.dma9ecr5ksxts.amplifyapp.com/chalice/) がファーストステップとしておすすめです。

**▼Chalice - app.py**

```python:app.py
import cgi
from chalice import Chalice
from io import BytesIO

# Chalice設定
app = Chalice(app_name='APP_NAME')

# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定 （ここがポイント！！）
app.api.binary_types.append('multipart/form-data')

@app.route('/upload', methods=['POST'], content_types=['multipart/form-data'], cors=True)
def upload():
    # 受け取った情報をバイナリデータとして保存
    body_binary = BytesIO(app.current_request.raw_body)

    # cgi.FieldStorageクラスを用いて、フォームの内容を解析
    environ = {'REQUEST_METHOD': 'POST'}
    headers = {'content-type': app.current_request.headers['content-type']}
    form = cgi.FieldStorage(fp=body_binary, environ=environ, headers=headers)
    
    # 指定したファイルをバイナリ形式で取得（'uploadfile'は送信時に設定した当該ファイルのnameの値）
    file_binary = form.getvalue('uploadfile')
    
    # 以降、任意の処理を記述
```


**▼index.html （アップロード元の例）**
コード内、`** API Gateway URL をここに貼り付け! **`に作成した API の URL を上書きしてください。
「/」以下のパスまで忘れずに記載ください。
（例：`https://XXXXXXX.execute-api.ap-northeast-1.amazonaws.com/api/upload`） ← 「/upload」まで忘れずに！

```html:index.html
<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="UTF-8">
        <title>TITLE</title>
    </head>
    <body>
        <form action="** API Gateway URL をここに貼り付け! **"
                enctype="multipart/form-data"
                method="POST">
            <input type="file" name="uploadfile" />
            <input type="submit"/>
        </form>
    </body>
</html>
```


# コードのポイント
2021年3月現在、[先駆者様の記事](https://qiita.com/saiva/items/5d281ed7ccc678c3d750)を参考にしても、Chaliceでは画像ファイルなどのバイナリファイルをうまく扱えません。
これは、API Gateway のデフォルト設定が、 multipart/formdata 形式を**バイナリとして扱わない設定**になっているためです。この設定のため、API Gateway は、http の body の部分を UTF-8 で再エンコーディングしてしまい、アップロードされたファイルを壊してしまいます。
参照：[multipart/form-data support in AWS Chalice · Issue #796 · aws/chalice](https://github.com/aws/chalice/issues/796#issuecomment-686796755)

解決方法としては、上記 issue に記載の通り、`app.api.binary_types.append()`メソッドを使います。
これにより、 multipart/form-data 形式で送られる body 部分をバイナリとして扱うように API Gateway に明示的に設定することができます。
参照：[Chalice — Python Serverless Microframework for AWS 1.15.1 documentation](https://chalice.readthedocs.io/en/stable/api.html#APIGateway.binary_types)
上記の app.py の中では以下の箇所でその設定をしています。

```python:app.py(edited)
# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定 （ここがポイント！！）
app.api.binary_types.append('multipart/form-data')
```

# おまけ： 【ハンズオン】有名人識別サービスを Chalice で作ろう！
以前、[【AWSハンズオン】サーバレスアーキテクチャで、有名人識別サービスを作ろう！ - Qiita](https://qiita.com/hayate_h/items/2091dda98bb07f758f06) という記事を投稿させていただきました。
せっかくなので、今回の記事の内容を活用して、Chalice版で上記サービスを作り直してみましょう。
フロントには Vue.js も使用していますが、わからなくても動かせる内容になっています。ご安心ください。
[私の GitHub リポジトリ](https://github.com/cloud8high/rekognition-handson-chalice-v2) からクローンする方法もあります。この場合の手順はリポジトリの README を参照してください。

## 作るもの（動作イメージ）
有名人の画像（例えばガッキー）の画像をアップロードすると、`Yui Aragaki 100%`と表示されるシンプルな有名人の識別サービスです。
![gakkii_rekognition_long2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341404/da0df532-1490-afdd-7579-1d45447daa95.gif)


## 作るもの（アーキテクチャ）
1. クライアントPCから API Gateway 経由で有名人の画像を Lambda にアップロードします。
2. 画像ファイルの受信をトリガーに、Lambda 関数が起動します。
3. Lambda 関数内で Amazon Rekognition の recognize_celebrities 機能に画像ファイルを送り、画像内の有名人を識別します。
4. 取得した有名人の情報を出力用に整形して、API Gateway を通して返します。
5. 呼び出し元のブラウザ上で、識別結果が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341404/dbe42b3a-57c2-a90e-e16a-172490710406.png)


## ソースコード
`$ chalice new-project rekognition-handson-chalice-v2` をした状態を仮定します。
以下、主要ファイルについてのみ記載します。　詳細なコードは私の [GitHub リポジトリ](https://github.com/cloud8high/rekognition-handson-chalice-v2) を参照してください、

**▼Lambda側：Chalice - app.py**

```python:app.py
import logging
import traceback
import boto3
import cgi
from chalice import Chalice, BadRequestError
from io import BytesIO

# logger設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Chalice設定
app = Chalice(app_name='rekognition-handson-chalice-v2')

# API Gateway に multipart/form-data をバイナリとして扱うように指示する設定
app.api.binary_types.append('multipart/form-data')

# rekognition インスタンスの作成
rekognition = boto3.client('rekognition')

@app.route('/upload', methods=['POST'], content_types=['multipart/form-data'], cors=True)
def upload():
    # 受け取った情報をバイナリデータとして保存
    body_binary = BytesIO(app.current_request.raw_body)

    # cgi.FieldStorageクラスを用いて、フォームの内容を解析
    environ = {'REQUEST_METHOD': 'POST'}
    headers = {'content-type': app.current_request.headers['content-type']}
    form = cgi.FieldStorage(fp=body_binary, environ=environ, headers=headers)
    
    # ファイルをバイナリ形式で取得（'uploadfile'は送信時に設定した当該ファイルのnameの値）
    file_binary = form.getvalue('uploadfile')

    # 取得した画像ファイルを、Rekognitionに渡して有名人の識別をする
    response = rekognition.recognize_celebrities(
        Image={'Bytes': file_binary}
    )
    logger.info(f'Rekognition response = {response}')

    try:
        # Rekognition のレスポンスから有名人の名前と確度を取り出し、APIのコール元へレスポンスする。
        label   = response['CelebrityFaces'][0]
        name    = label['Name']
        conf    = round(label['Face']['Confidence'])
        output  = { 'name': name, 'confidence': conf }
        logger.info(f'API response = {output}')
        return output

    except IndexError as e:
        # Rekognition のレスポンスから有名人情報を取得出来なかった場合、他の写真で試すように伝える。
        logger.warning(f"Coudn't detect celebrities in the Photo. Exception = {e}")
        logger.warning(traceback.format_exc())
        raise BadRequestError("Couldn't detect celebrities in the uploaded photo. Please upload another photo.")
```

**▼フロント側：index.html**

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Rekognition-Handson</title>
</head>
<body>
    <div id="app">
        <h2>有名人識別ハンズオン</h2>
        <input type="file" @change="onChange">
        <div v-if="imagePreview">
            <hr/>
            <button @click="onSubmit">画像解析を実行</button><br/><br/>
            <img :src="imagePreview" width="300"/>
        </div>
        <div v-if="isLoading">
            <p>画像を送信中...</p>
        </div>
        <div v-if="celebrityInfo.name">
            <p>名前：{{ celebrityInfo.name }}</p>
            <p>確度：{{ celebrityInfo.confidence }} %</p>
        </div>
        <div v-if="celebrityInfo.errorMessage">
            <p>{{ celebrityInfo.errorMessage }}</p>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@0.21.1/dist/axios.min.js"></script>
    <script src="main.js"></script>
</body>
</html>
```

**▼フロント側：main.js**
コード内、`** API Gateway URL をここに貼り付け! **`に作成した API の URL を上書きしてください。
「/」以下のパスまで忘れずに記載ください。
（例：`https://XXXXXXX.execute-api.ap-northeast-1.amazonaws.com/api/upload`） ← 「/upload」まで忘れずに！

```javascript:main.js
let app = new Vue({
    el: '#app',
    data: {
        imageFile: '',
        imagePreview: '',
        celebrityInfo: '',
        isLoading: false,
    },
    methods: {
        onChange: function(event) {
            this.celebrityInfo = '';
            this.imageFile = event.target.files[0];
            if (this.imageFile && this.imageFile.type.match(/^image\/(png|jpeg)$/)) {
                this.imagePreview = window.URL.createObjectURL(this.imageFile);
            }
        },
        onSubmit: function(event) {
            this.isLoading = true;
            this.celebrityInfo = '';
            const targetUrl = '** API Gateway URL をここに貼り付け! **';
            const formData = new FormData();
            formData.append('uploadfile', this.imageFile);
            var config = {
                headers: {
                    'content-type': 'multipart/form-data',
                }
            };
            const that = this;
            axios
                .post(targetUrl, formData, config)
                .then(response => {
                    that.celebrityInfo = response.data;
                })
                .catch(error => {
                    console.error(error);
                    that.celebrityInfo = {errorMessage: '画像から有名人を認識できませんでした。他の画像に変更してください。'}
                })
                .finally(() => {
                    that.isLoading = false;
                });
        }
    }
})
```

## HTMLファイルの開き方
今回、JavaScript を利用していることもあり、index.html の開き方について**注意点が３つ**あります。

1. index.html と main.js は同じディレクトリに配置してください。
2. Internet Explorer では動作しません。（ES2018の文法を一部使用しているため、Chrome などを推奨します。）
3. index.html は http スキーマでブラウザから読み込んでください。　**← 重要！**

３点目について説明をします。
index.html ファイルをブラウザにドラッグ&ドロップして開いた場合、アップロード処理が**正常に動作しません**。
これは、index.html ファイルを file スキーマの形式でブラウザから読み込んだ場合、Same Origin Policy のセキュリティー制限によって、画像アップロードの JavaScript 処理が正常に動かないためです。
そのため、以下の方法で**ローカルサーバーを立て、http スキーマで index.html ファイルにアクセスする必要があります。**
Pythonを用いて簡単にサーバーを起動できます。index.html と main.js が存在するディレクトリで、ターミナルから以下のコマンドを実行してください。

```shell:TERMINAL
$ ls #ファイルの確認
index.html  main.js
$ python -m http.server 8080
```
サーバー起動後に、ブラウザから`http://0.0.0.0:8080/`へアクセスしてください。作成したHTMLファイルが開けるはずです。
サーバーを停止する場合は `Ctrl + C` で止めてください。
なお、index.html や main.js を更新した際、サーバーを再起動しても更新後のファイルがロードされないことがあります。
その際はターミナルを一度停止し、開き直してサーバーも再起動することで解決します。

## 動作デモ
[YouTube](https://youtu.be/E35YKHVjWJA) に動作画面をアップロードしました。
思い当たる有名人の画像を数枚集めてテストしてみたところ、一通り認識できているようです。（さすがAmazon Rekognition！）
ただし、キムタクは認識できませんでした。　個人的にはガッキーよりも有名な気がしますが...（注：諸説あります）
![kimutaku_rekognition_long.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/341404/4502c388-9bfb-c724-1671-7dc5f8fe108f.gif)


# まとめ
以上、AWS Chalice にて、 multipart/form-data 形式でアップロードされたバイナリファイルを扱う方法についてでした。
おまけのハンズオンが本編よりも長く脱線してしまいましたが、お役に立てれば幸いです！