プログラムを中心とした個人的なメモ用のブログです。 タイトルは迷走中。
内容の保証はできませんのであしからずご了承ください。

2022/06/02

WebAPI で multipart/form-data を使う

event_note2022/06/02 8:20

AI を使った画像解析システムなどでは、画像をリクエストで投げて推論結果をレスポンスでもらう、というのをよくやるかと思います。

そういった API で、最初は画像を Base64 に変換して JSON で渡していたのですが、エンコード/デコードの処理時間があるため、バイナリでデータを送りたいと考えました。
また、1度のリクエストで複数枚の画像を送る必要があるのと、画像の対する付加情報(カメラのIDや画像の取得日時など)も一緒に渡す必要があるということで、multipart/form-data を使って送ってみることにしました。

multipart/form-data についての詳細は割愛しますが、バウンダリ文字列で区切って複数のデータを送信するやつで、HTML の form 文とかで使われるやつです。

今まで WebAPI では json と yaml しか扱ったことありませんでしたが、調べた感じ、WebAPI でも multipart/form-data を使ってテキストとバイナリを一緒に送るというのはよくある手法っぽいです。

リクエスト内容

例として、POST で以下のような内容を送ります。

ヘッダー(抜粋)

Content-Type multipart/form-data; boundary=cc874efaa4e6a1e85c14280c96a7b237

サンプルコードを後に載せていますが、バウンダリ文字列は自動生成でリクエスト毎に異なります。
基本的に意識しなくても大丈夫でした。

ペイロード

テキストについては一つずつ text/plain で送ろうかとも思いましたが、今後データが増えた時にサーバー側の処理が面倒かなと思い、JSON にまとめることにしました。
画像データ以外は JSON でまとめて送り、画像データはバイナリで送ります。
これをカメラ台数分送ります。 カメラ毎に JSON と画像をマッピングする必要がありますが、試行錯誤した結果、name にカメラを識別するためのIDを設定することにしました。 つまり、name の値を見てどのカメラの JSON データ、画像データかを識別する想定です。
後述するサンプルコードで試した結果、ペイロードは以下のような形になりました。

--cc874efaa4e6a1e85c14280c96a7b237
Content-Disposition: form-data; name="カメラのID"

{
  "camera_id": "1",
  "datetime": "2021-08-05T17:07:01.388437",
}
--cc874efaa4e6a1e85c14280c96a7b237
Content-Disposition: form-data; name="カメラのID"; filename="ファイル名"
Content-Type: image/jpeg

<ここに画像のバイナリデータが入る>
--cc874efaa4e6a1e85c14280c96a7b237--

複数カメラ分のデータを送信した場合、並びは以下ようになりました。

  • 1台目のカメラの JSON データ
  • 2台目のカメラの JSON データ
  • 1台目のカメラの画像データ
  • 2台目のカメラの画像データ

name でどのカメラかを判別できるので、基本的にこの並びは意識しなくても大丈夫でした。

サンプルコード

Python での実装例です。

クライアント側

上記のリクエストを requests モジュールを使って送信するサンプルです。

import requests
import json
from datetime import datetime

if __name__ == '__main__':
    data = {}
    files = {}
    # 複数カメラを想定して送信
    for camera_id in ['hoge', 'piyo']: # 各カメラのID
        # 適当なテキストデータ
        json_data = {
            'camera_id': camera_id,
            'datetime': datetime.now().isoformat()
        }
        # 適当な画像ファイルを読み込む
        image = None
        filename = 'send_image.jpg'
        with open(filename, 'rb') as f:
            image = f.read()

        # 送信
        # data,files は dict 型でなければならないので、カメラIDをキーとして渡すことにした。このキーが name に設定される
        data[camera_id] = json.dumps(json_data), # dict -> str への変換
        # filename や Content-Type を指定して送りたい場合
        files[camera_id] = (filename, image, 'image/jpeg')
        # 画像のみで送りたい場合。filename は name と同じになり、Content-Type は付与されなかった
        #files[camera_id] = image
    res = requests.post(f'http://localhost:8000', files=files, data=data)
    print(res)

サーバー側

上記のリクエストを Flask で受信するサンプルです。

import json
from flask import Flask, request

if __name__ == '__main__':
    app = Flask(__name__)

    @app.route('/', methods=['POST'])
    def index():
        print(request.headers)
        #print(request.get_data()) # 生データ
        # JSON データの受け取り
        for key, value in request.form.items():
            json_data = json.loads(value) # str から dict への変換
            print(f'json, key={key}:, value={json_data}, type={type(json_data)}')
        # 画像データの受け取り
        for key, value in request.files.items():
            print(f'image, key={key}, type={type(value)}') # FileStorage 型
            #print(value.headers)
            # ファイルに保存して確認
            with open(f'{key}.jpg', 'wb') as f:
                f.write(value.read())
            # ファイルの保存だけなら以下でも可
            #value.save(f'{key}.jpg')
        return {}, 200

    app.run(port=8000, debug=True)

OpenAPI で multipart/form-data を記述する

OpenAPI で API の仕様書を作成しているのですが、multipart/form-data は以下のように記述するようです。
ただ、上述したような name をカメラのIDとするような記述はできないようです。
ここでは json_data image という値で記述しています。

また、Content-Type を記述したい場合は encoding を使うそうですが、Swagger で試しに送信しても付与されていませんでした。 あと、Redoc ではリクエストボディの内容が表示されませんでした。
``multipart/form-data` 自体に対応していないのかもしれません。

/endpoint:
  post:
    summary: サンプル
    description: 説明
    requestBody:
      content:
        multipart/form-data:
          schema:
            type: object
            required:
              - json_data
              - image
            properties:
              data:
                type: object
                required:
                  - camera_id
                  - datetime
                description: 画像以外のデータ(JSON)
                properties:
                  camera_id:
                    type: string
                    description: カメラの識別子
                    example: 'hoge'
                  datetime:
                    type: string
                    format: date-time
                    description: 画像の取得日時(ISO8601)
                    example: '2021-08-05T17:07:01.388437'
              image:
                type: string
                format: binary
                description: 画像データ(バイナリ)
          encoding:
            image:
              contentType: image/jpeg;