【Python】POSTされたmultipart/form-dataをmultipartモジュールでパースする

# 記載の経緯

multipart/form-dataでPOSTされたデータをPythonでパースする方法として、Python標準モジュールのcgi.FieldStorageでパースする記事をいくつか見かけましたが、**cgiモジュールは Python3.11で非推奨、Python3.13で削除されました**。

FieldStorageの代替方法として、公式ドキュメントにてmultipartモジュールが上げられており、multipartを使った記事があまりなかったため、記載します。

> <font color="red">Deprecated since version 3.11, will be removed in version 3.13:</font>
 The cgi module is deprecated (see PEP 594 for details and alternatives).
>
>The FieldStorage class can typically be replaced with urllib.parse.parse_qsl() for GET and HEAD requests, and the email.message module or multipart for POST and PUT. Most utility functions have replacements.

※公式ドキュメントから引用：https://docs.python.org/ja/3.12/library/cgi.html

# multipartモジュール

確認したバージョンは、記載時最新の`v1.1.0`です。

https://pypi.org/project/multipart/

|||
|---|---|
|ライセンス|MIT License|
|依存するモジュール|なし|
|Python バージョン|3.5以上|

参考までに、snyk Adviserの結果は 82点 でした（2024/11/3時点）。
https://snyk.io/advisor/python/multipart

multipartモジュールは、multipart/form-dataでPOSTされたボディを直にパースする関数に加えて、WSGIアプリケーション向けの高レベルのヘルパー関数も用意されています。

本記事で紹介するのは前者のmultipart/form-dataのボディを直にパースする関数のサンプルです。
後者のWSGI向けのサンプルはモジュールのreadmeに記載があるのでそちらを参照ください。

# サンプルコード

``` python
import io
from multipart import MultipartParser

multipart_body_data = """\
----------------------------892683388101143002038604
Content-Disposition: form-data; name="jsonfile"; filename="test.json"
Content-Type: application/json

{
    "testStr": "testValue",
    "testInt": 1
}
----------------------------892683388101143002038604
Content-Disposition: form-data; name="test_text"

This is test.
----------------------------892683388101143002038604--
""".replace("\n", "\r\n").encode("utf-8")


def parse_multipart(body):
    stream = io.BytesIO(body)
    boundary = "--------------------------892683388101143002038604"

    # MultipartParserクラスにパースした各データを返却する__iter__が実装されているので、ループで回せる。
    for part in MultipartParser(stream, boundary):
        print(
            {
                "size": part.size,
                "name": part.name,
                "filename": part.filename,
                "headerlist": part.headerlist,
                "payload": part.value,  # 文字デコードされたデータ
                "raw": part.raw,  # 生データ
            }
        )


parse_multipart(multipart_body_data)
```
※実行環境：python3.11.9

## 実行結果
``` python
{'size': 51, 'name': 'jsonfile', 'filename': 'test.json', 'headerlist': [('Content-Disposition', 'form-data; name="jsonfile"; filename="test.json"'), ('Content-Type', 'application/json')], 'payload': '{\r\n    "testStr": "testValue",\r\n    "testInt": 1\r\n}', 'raw': b'{\r\n    "testStr": "testValue",\r\n    "testInt": 1\r\n}'}
{'size': 13, 'name': 'test_text', 'filename': None, 'headerlist': [('Content-Disposition', 'form-data; name="test_text"')], 'payload': 'This is test.', 'raw': b'This is test.'}
```

## 解説
*  `multipart_body_data`：  
multipart/form-dataで送信されたボディデータです。サンプルのためコード上に記載しています。   
実際のボディデータの改行コードはCRLFのため改行コードを変換しています。

* `parse_multipart()`：     
    * `MultipartParser`クラスを使用してパースを行います。コンストラクタの引数にボディデータのストリーム[^1]とバウンダリを指定します。
    * `MultipartParser`クラスにはパースした各データを返却する__iter__が実装されているので、for文で各要素をループすることができます。
    * パースされると、multipart/form-dataの各要素が`MultipartPart`クラスとして生成されます。メンバ変数にアクセスして各情報を取り出すことができます。

# サンプルコード（APIGateway + Lambda）
参考までに、AWSのAPIGateway + Lambda（プロキシ統合）の構成で、multipart/form-dataでPOSTされたファイルをS3にアップロードするコードも掲載しておきます。

``` python
import base64
import io
import boto3
from multipart import MultipartParser


s3 = boto3.client("s3")
bucket_name = "bucket_name"


def lambda_handler(event, context):
    # リクエストボディがbase64エンコードされてるのでデコード（生のボディを受け取る場合は不要）
    body = base64.b64decode(event["body"])

    stream = io.BytesIO(body)
    boundary = event["headers"]["Content-Type"].split("boundary=")[1]

    # MultipartParserクラスにパースした各データを返却する__iter__が実装されているので、ループで回せる。
    for part in MultipartParser(stream, boundary):
        print(
            {
                "size": part.size,
                "name": part.name,
                "filename": part.filename,
                "headerlist": part.headerlist,
            }
        )
        if part.filename:
            # ファイルはS3アップロード（Content-DispositionヘッダーにfileNameがあるデータをアップロード）
            s3.upload_fileobj(part.file, bucket_name, part.filename)
        else:
            # part.valueによって文字デコードされたボディが返される（生データはpart.raw）
            print(f"This is not file-like data. Payload:{part.value}")

    ret = {
        "isBase64Encoded": "false",
        "statusCode": 200,
        "headers": {"test-header-result": "ok"},
        "body": "Files upload to s3 is complete.",
    }

    return ret

```

APIGatewayを用いた場合にLambdaに入力されるデータの構造は以下。
https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html

# 参考

https://peps.python.org/pep-0594/#cgi

https://ja.stackoverflow.com/questions/92372/python%E3%81%A7cgi-fieldstorage-%E3%82%92%E4%BD%BF%E3%82%8F%E3%81%9A%E3%81%AB%E3%83%95%E3%82%A9%E3%83%BC%E3%83%A0%E3%81%AE%E5%80%A4%E3%82%92%E5%8F%97%E3%81%91%E5%8F%96%E3%82%8B%E6%96%B9%E6%B3%95%E3%81%AF%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%81%8B

[^1]:`.read(size)`が実装されていればよい
https://github.com/defnull/multipart/blob/2c19b3961d180f22b5c9cfec5c51e260af92e515/multipart.py#L578