Python で HTML フォームから配列を受けとる

PHP や Ruby on Rails では、以下のように名前に `[]` を使ってフォームデータを送信するとサーバサイド側では配列を受け取ることができる。[^php]

``` html:ブラウザのフォーム
<form action="index.php" method="POST">
  <input type="text" name="foo[]" value="1" />
  <input type="text" name="foo[]" value="2" />
  <input type="text" name="foo[]" value="3" />
</form>
```

``` php:サーバサイド側で得られる構造
array(
  'foo' => array(
    '1',
    '2',
    '3',
  ),
)
```

[^php]: https://www.php.net/manual/ja/faq.html.php#faq.html.arrays

これは単に PHP や Ruby on Rails などの一部の Web サーバがフォームデータのキーをよしなに解釈してくれているだけであり、別にこのような挙動が HTML や multipart/form-data の仕様として定義されているわけでも何でもない。

そのため、例えば Python 製の WAF (Flask や aiohttp など) で Web サーバを実装したときに同じような記法でフォームデータを送信したとしても配列として解釈してくれるわけではない。[^django]

[^django]: もしからしたら Django なら出来るのかもしれないが、Django は使ったことがないので知らない。

今回、この記法を使って Web ブラウザのフォームから複雑な構造を Python 製 Web サーバに送信したいということがあり、よしなに解釈する部分を Python で実装してみた。

ただし、PHP や Ruby on Rails の挙動を完全に再現したわけではないので、あくまで「簡易版実装」と考えて欲しい。

## 実装
### キーをパース
まずはキー (`foo[a][]` のような文字列) を解釈できるようにする。

``` python
def parse_key(s: str) -> tuple:
    try:
        assert s.count('[') == s.count(']')
        a = s.split('[')
        t = (a[0],)
        for b in a[1:]:
            assert b[-1] == ']'
            t += (b[:-1],)
        return t
    except:
        raise ValueError(f'Invalid format key: "{s}"')
```

こういう風に使える。

``` python
parse_key('foo')  #=> ('foo',)
parse_key('foo[]')  #=> ('foo', '')
parse_key('foo[a]')  #=> ('foo', 'a')
parse_key('foo[a][]')  #=> ('foo', 'a', '')
parse_key('foo[a][b]')  #=> ('foo', 'a', 'b')
parse_key('')  #=> ('',)
parse_key('[]')  #=> ('', '')
parse_key('foo][')  #=> ValueError
parse_key('foo[[a]]')  #=> ValueError
```

### フォームデータを変換する
キーを解釈できるようになったので、その結果を使ってフォームデータ全体を変換していく。

``` python
def parse_formdata(d: list) -> dict:
    def update(ref, keys: tuple, value):
        key, keys = keys[0], keys[1:]
        if key == '':
            assert type(ref) == list, 'Containing multiple types in the same key is invalid.'
            assert len(keys) == 0, 'Containing nested structure in list is not supported.'
            ref.append(value)
        else:
            assert type(ref) == dict, 'Containing multiple types in the same key is invalid.'
            if len(keys) == 0:
                ref[key] = value
            else:
                if key not in ref:
                    ref[key] = [] if keys[0] == '' else {}
                update(ref[key], keys, value)
    
    res = {}
    for k, v in d:
        try:
            update(res, parse_key(k), v)
        except AssertionError as e:
            if len(e.args) > 0:
                raise ValueError(f'Parse failed at key "{k}": {e.args[0]}', *e.args[1:])
            else:
                raise ValueError(f'Parse failed at key "{k}"')
    return res
```

ただし、 `parse_formdata` の引数 `d` は以下のような `Iterable[Tuple[str, Any]]` なフォーマットを想定している。

``` python:Input
[
    ('foo[a][]', 1),
    ('foo[a][]', 2),
    ('foo[a][]', 3),
    ('foo[b][]', 4),
    ('foo[b][]', 5),
    ('foo[c][]', 6),
]
```

あらかじめデータをこのフォーマットに変換しておく必要があるが、難しくはないと思う。
例えば aiohttp の場合はこんな感じで書ける。[^aiohttp]

[^aiohttp]: aiohttp の `request.post()` で得られるデータは [MultiDictProxy](https://multidict.readthedocs.io/en/stable/multidict.html#multidict.MultiDictProxy) オブジェクトなので、 `items()` メソッドで `(key, value)` タプルのリストが得られる。

``` python
data = await request.post()
parsed_data = parse_formdata(data.items())
```

上記の入力データを `parse_formdata` に入力すると以下の出力が得られる。

``` python:Output
{
    'foo': {
        'a': [1, 2, 3],
        'b': [4, 5],
        'c': [6],
    },
}
```

それっぽく変換できている。

## 解釈できるケースとできないケース
冒頭でも言ったが PHP や Ruby on Rails の挙動を完全に再現したわけではないので、うまく解釈できないケースも存在する。

たとえば以下のように、末尾がリスト `[]` になっているのは解釈できるが、

``` python
# 入力データ
before = [
    ('foo[a][]', 1),
    ('foo[a][]', 2),
    ('foo[a][]', 3),
    ('foo[b][]', 4),
    ('foo[b][]', 5),
    ('foo[c][]', 6),
]

# 期待する出力データ
after = {
    'foo': {
        'a': [1, 2, 3],
        'b': [4, 5],
        'c': [6],
    },
}

# OK
assert parse_formdata(before) == after
```

以下のように、リストの中にネストして構造を入れることはできない。

``` python
# 入力データ
before = [
    ('foo[][a]', 1),
    ('foo[][b]', 2),
    ('foo[][c]', 3),
    ('foo[][a]', 4),
    ('foo[][b]', 5),
    ('foo[][c]', 6),
]

# 期待する出力データ
after = {
    'foo': [
        {
            'a': 1,
            'b': 2,
            'c': 3,
        },
        {
            'a': 4,
            'b': 5,
            'c': 6,
        },
    ],
}

# ValueError: Parse failed in key "foo[][a]": Containing nested structure in list is not supported.
assert parse_formdata(before) == after
```

これは PHP や Ruby on Rails では解釈できる (多分) のだが、難しいので今回は実装しなかった。

例えば、上記の例で入力データ `('foo[][c]', 3)` が抜け落ちてたとすると、 `('foo[][c]', 6)` がリストのどの要素の `c` なのかが解釈できないという問題が発生する。(これをよしなに解釈するのは非常に難しいので、気合いのある人はやってみてほしい。)

個人的には「解釈が曖昧なデータ構造を許すべきではない」と思うので、そういう用途の場合はリストではなく数値の連番を渡したほうがいいと思う。

``` python
# 入力データ
before = [
    ('foo[0][a]', 1),
    ('foo[0][b]', 2),
    ('foo[0][c]', 3),
    ('foo[1][a]', 4),
    ('foo[1][b]', 5),
    ('foo[1][c]', 6),
]

# 期待する出力データ
after = {
    'foo': {
        '0': {
            'a': 1,
            'b': 2,
            'c': 3,
        },
        '1': {
            'a': 4,
            'b': 5,
            'c': 6,
        },
    },
}

# OK
assert parse_formdata(before) == after
```

また、Python の list と dict に相当するデータ構造は PHP では両方とも array で表現できるので、PHP では例えば以下のような入力が解釈できるが、Python では list と dict は違うデータ型なので混在させることはできない。

``` python
# PHP だと array('foo' => array(0 => 1, 'a' => 2)) と解釈される
before = [
    ('foo[0][]', 1),
    ('foo[0][a]', 2)
]

# ValueError: Parse failed at key "foo[0][a]": Containing multiple types in the same key is invalid.
parse_formdata(before)
```