RequestsモジュールのHTTPリクエストメソッドに必要な引数を理解せよ

どうも、遅刻魔の月歩人です。
この記事は[JSL(日本システム技研) Advent Calendar 2020年 14日](https://qiita.com/advent-calendar/2020/jsl)の記事です。

バックアップ用に購入したポータブルHDDが初期不良で交換した矢先に買い替えたmacも初期不良で画面が一部映らなくなり、絶望的な状況で書いています :innocent: 

## この記事を書くに至った経緯

少し前RequestsでHTTP/IFを実装する場面で、HTTPメソッドを実行するときのパラメータを理解していなかったがためにリクエスト先でparseが失敗していたにも関わらず、「APIが繋がらない！」と騒いだことがありました。
自分や他のエンジニアが同じ轍を踏まないために備忘録として残そうと思ったのがきっかけです。

## まずRequestsが対応しているHTTPメソッドを把握

requests/api.pyより

* requests.get(url, params=None, **kwargs)
* requests.post(url, data=None, json=None, **kwargs)
* requests.put(url, data=None, **kwargs)
* requests.patch(url, data=None, **kwargs)
* requests.delete(url, **kwargs)
* requests.options(url, **kwargs)
* requests.head(url, **kwargs)

このうち、CRUD以外に使用されるOPTIONSとHEADは今回対象外とします。

これらは共通してapi.py内にあるrequestメソッドへHTTPメソッド名を始めとした引数を渡します。

```py
def request(method, url, **kwargs):
    with sessions.Session() as session:
        return session.request(method=method, url=url, **kwargs)
```

## session.requestを掘り下げる

```py
    ....
    def request(self, method, url,
            params=None, data=None, headers=None, cookies=None, files=None,
            auth=None, timeout=None, allow_redirects=True, proxies=None,
            hooks=None, stream=None, verify=None, cert=None, json=None):
    ....
```

多くのキーワード引数が出てきましたが、HTTPリクエスト関数で明示されているパラメータは

* params
* data
* json

の3つだけです。(他によく使うとしたらheaders?)

## httpbinでそれぞれのパラメータに入れた値を確認する

### params

paramsを使用しているのはgetだけですね。
コードはすごくシンプルに

```py:params_test.py
import pprint
import requests


if __name__ == '__main__':
    response = requests.get(
        'http://httpbin.org/get',
        params={'name': 'moonwalker'},
    )
    pprint.pprint(response.json())
```
結果...

```zsh
$ python params_test.py
{'args': {'name': 'moonwalker'},
 'headers': {'Accept': '*/*',
             'Accept-Encoding': 'gzip, deflate',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.25.1',
             'X-Amzn-Trace-Id': 'Root=1-5fe9fabf-007c29751b44b5817125a68e'},
 'origin': '126.194.217.32',
 'url': 'http://httpbin.org/get?name=moonwalker'}
```
paramsを指定するとurlにクエリストリングとして指定されます。

### data

dataは`POST/PUT/PATCH`で使用しています。
exampleとしてPOSTを実行します。

```py:data_test.py
import pprint
import requests


if __name__ == '__main__':
    response = requests.post(
        'http://httpbin.org/post',
        data={'name': 'moonwalker'},
    )
    pprint.pprint(response.json())
```
結果...

```zsh
$ python data_test.py
{'args': {},
 'data': '',
 'files': {},
 'form': {'name': 'moonwalker'},
 'headers': {'Accept': '*/*',
             'Accept-Encoding': 'gzip, deflate',
             'Content-Length': '15',
             'Content-Type': 'application/x-www-form-urlencoded',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.25.1',
             'X-Amzn-Trace-Id': 'Root=1-5fe9fc82-43df8cad5846bad3276bb4d9'},
 'json': None,
 'origin': '126.194.217.32',
 'url': 'http://httpbin.org/post'}
```
data引数に辞書を挿入するとデフォルトでContent-Typeが`application/x-www-form-urlencoded`になることに注目してください。
これは以下と同等の動きになります。

```py
...
    data = urllib.parse.urlencode({'name': 'moonwalker'})
    response = requests.patch(
        'http://httpbin.org/patch',
        data=data,
        headers={'Content-Type': 'application/x-www-form-urlencoded'},
    )
...
```

### json

最後にjsonです。
私はこやつに翻弄されました。。。
jsonはpost関数にしか明示されていません。
こちらは主にjsonで受け入れているAPIリクエストに使うことが多いのではないでしょうか。

```py:json_test.py
import pprint
import requests

if __name__ == '__main__':
    response = requests.post(
        'http://httpbin.org/post',
        json={'name': 'moonwalker'},
    )
    pprint.pprint(response.json())
```
結果...

```zsh
$ python json_test.py
{'args': {},
 'data': '{"name": "moonwalker"}',
 'files': {},
 'form': {},
 'headers': {'Accept': '*/*',
             'Accept-Encoding': 'gzip, deflate',
             'Content-Length': '22',
             'Content-Type': 'application/json',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.25.1',
             'X-Amzn-Trace-Id': 'Root=1-5fea0046-7ff25f850de04a71401e3046'},
 'json': {'name': 'moonwalker'},
 'origin': '126.194.217.32',
 'url': 'http://httpbin.org/post'}
```
json引数に辞書を挿入するとデフォルトでContent-Typeが`application/json`になることに注目してください。
これは以下と同等の動きになります。

```py
import json
...
    response = requests.post(
        'http://httpbin.org/post',
        data=json.dumps({'name': 'moonwalker'}),
        headers={'Content-Type': 'application/json'},
    )
...
```

## 私の失敗

post時のdataとjsonをふわっとした理解で使っていたため、こんな書き方をしていました。

```py:miss_test.py
...
    response = requests.post(
        'http://httpbin.org/post',
        json=json.dumps({'name': 'moonwalker'}),
    )
...
```
リクエストデータは色々おかしい

```zsh
$ python miss_test.py
{'args': {},
 'data': '"{\\"name\\": \\"moonwalker\\"}"',
 'files': {},
 'form': {},
 'headers': {'Accept': '*/*',
             'Accept-Encoding': 'gzip, deflate',
             'Content-Length': '28',
             'Content-Type': 'application/json',
             'Host': 'httpbin.org',
             'User-Agent': 'python-requests/2.25.1',
             'X-Amzn-Trace-Id': 'Root=1-5fea0160-4290dd525d9fb77b419f693d'},
 'json': '{"name": "moonwalker"}',
 'origin': '126.194.217.32',
 'url': 'http://httpbin.org/post'}
```

↑のdataとjsonを見て分かる通り、二重でjson.dumpsをしてしまっていたわけです。
そりゃあリクエスト先でparseできないわけだ…
なまじdata引数に渡す場合でもjson.dumpsすることで同じ挙動を期待できるため、雰囲気で使っているとケアレスミスに繋がります。

## まとめ

* parmasにはクエリストリングとなるパラメータを辞書で渡す
* application/x-www-form-urlencodedのデータはurlencodeせず、そのままdata引数に辞書形式で渡し、Content-Typeの設定はしない(Requestsに任せる)
* application/jsonのデータはjson引数に辞書形式で渡し、Content-Typeの設定はしない(Requestsに任せる)
    * json.dumpsした辞書データをdata引数に渡し、Content-Typeをheadersに指定するという冗長なことはしない。

## 参考記事

[Python Requestsモジュールについて](https://qiita.com/hththt/items/14bfc2bf23192b020371)