PythonのaiohttpモジュールとGeneratorを使って、ページネーションを処理するHTTPクライアントを実装する
Python で GET リクエストを実行する際、ページネーションの処理が必要な場合があります。
AWS SDK のようなライブラリは Paginator クラスなどの実装が用意されていますが、REST API を使用するために HTTP クライアントを使う場合は、自分でページネーションの実装を行う必要があります。
今回は aiohttp を使ってページネーションの処理を書いてみます。
関連: https://blog.takenoko.dev/blog/2023/04/aws-client-pagination/
| バージョン | |
|---|---|
| Python | 3.11.4 | 
$ python3 -m venv .venv$ source ./.venv/bin/activate$ pip install aiohttpGitHub REST API
Section titled “GitHub REST API”今回の例では、リクエスト先としてGitHub REST API を使用します。
使用するリポジトリは grafana/grafana であり、使用するAPIエンドポイントは「List repository workflows」の URL です。
このエンドポイントのクエリパラメータには per_pageがあります。デフォルトでは30ですが、最大値は100です。クエリパラメータを渡さない場合、レスポンスに含まれる workflows の配列は最大30件しかデータが返されないことになります。
{  "total_count": 1,  "workflows": [    {      ....    }  ]}ページネーションについて知る
Section titled “ページネーションについて知る”GitHub REST API でページネーションを使用するには、レスポンスヘッダーの Link を見る必要があります。取得したいデータが 1 回のレスポンスにすべて収まっている場合は、Link がありません。
When a response is paginated, the response headers will include a link header. The link header will be omitted if the endpoint does not support pagination or if all results fit on a single page. The link header contains URLs that you can used to fetch additional pages of results.
レスポンスがページ分割される場合、レスポンスヘッダはリンクヘッダを含みます。エンドポイントがページ分割をサポートしていない場合や、すべての結果が1ページに収まる場合は、リンクヘッダは省略されます。リンクヘッダには、結果の追加ページを取得するために使用できる URL が含まれます。
リクエストを送って確かめてみる
Section titled “リクエストを送って確かめてみる”まずは GET リクエストができることを確認
import aiohttpimport asyncio
async def main():    headers = {        "Accept": "application/vnd.github+json",        "X-GitHub-Api-Version": "2022-11-28",    }    async with aiohttp.ClientSession() as session:        async with session.get(            "https://api.github.com/repos/grafana/grafana/actions/workflows",            headers=headers,        ) as resp:            print(await resp.text())
if __name__ == "__main__":    asyncio.run(main())$ python src/list_repository_workflows.py | jq{  "total_count": 53,  "workflows": [    {      "id": 3035099,      "node_id": "MDg6V29ya2Zsb3czMDM1MDk5",      "name": "Backport PR Creator",      "path": ".github/workflows/backport.yml",      "state": "active",次に Link を確認します。
import aiohttpimport asyncio
async def main():    headers = {        "Accept": "application/vnd.github+json",        "X-GitHub-Api-Version": "2022-11-28",    }    async with aiohttp.ClientSession() as session:        async with session.get(            "https://api.github.com/repos/grafana/grafana/actions/workflows",            headers=headers,        ) as resp:            print(await resp.text())            link = resp.headers.get("Link")            print(link)
if __name__ == "__main__":    asyncio.run(main())$ python src/list_repository_workflows.py<https://api.github.com/repositories/15111821/actions/workflows?page=2>; rel="next", <https://api.github.com/repositories/15111821/actions/workflows?page=2>; rel="last"ドキュメントに書いてあるとおり、以下のような形式になります。
link: <https://xxx>; rel="prev", <https://xxx>; rel="next", <https://xxx>; rel="last", <https://xxx>; rel="first"rel=“next” の URL を取り出す
Section titled “rel=“next” の URL を取り出す”re モジュールを使用して rel="next" の URLを取り出してみます。
import re
link = '<https://api.github.com/repositories/15111821/actions/workflows?page=2>; rel="next", <https://api.github.com/repositories/15111821/actions/workflows?page=2>; rel="last"'
def extract_next_url(link: str) -> str | None:    m = re.findall(r'<(https?://[\w/:%#\$&\?\(\)~\.=\+\-]+)>; rel="next"', link)    return m[0] if len(m) >= 1 else None
print(extract_next_url(link))<https://api.github.com/repositories/15111821/actions/workflows?page=2>; rel="next" から次ページの URL を取り出すことができました。
$ python src/extract_next_url.pyhttps://api.github.com/repositories/15111821/actions/workflows?page=2参考:https://www.megasoft.co.jp/mifes/seiki/s310.html
ページネーションのリクエスト
Section titled “ページネーションのリクエスト”アルゴリズム
Section titled “アルゴリズム”処理の順番は以下の通りです。次ページのURLを取得するためには、最初に必ず GET リクエストを実行する必要があります。
Python で実装
Section titled “Python で実装”from typing import Any, AsyncGeneratorimport aiohttpimport asyncioimport re
def extract_next_url(link: str) -> str | None:    m = re.findall(r'<(https?://[\w/:%#\$&\?\(\)~\.=\+\-]+)>; rel="next"', link)    return m[0] if len(m) >= 1 else None
async def get(url: str) -> AsyncGenerator[str, None]:    headers = {        "Accept": "application/vnd.github+json",        "X-GitHub-Api-Version": "2022-11-28",    }    async with aiohttp.ClientSession() as session:        next_url: str | None = None        async with session.get(url, headers=headers) as resp:            next_url = extract_next_url(resp.headers.get("Link"))            yield await resp.text()
        while next_url is not None:            async with session.get(next_url, headers=headers) as resp:                next_url = extract_next_url(resp.headers.get("Link"))                yield await resp.text()
async def main():    url = "https://api.github.com/repos/grafana/grafana/actions/workflows"
    async for resp in get(url):        print(resp)
if __name__ == "__main__":    asyncio.run(main())実行はできたので、問題なさそうです。
$ python src/main.py | jq{  "total_count": 53,  "workflows": [    {      "id": 3035099,      "node_id": "MDg6V29ya2Zsb3czMDM1MDk5",      "name": "Backport PR Creator",      "path": ".github/workflows/backpo先ほどのレスポンスに total_count がありました。値は 53 です。つまり、workflows のリストには全部で 53 個あるはずです。
workflow の id から重複がないことを確認してみます。
$ python src/main.py | jq '.workflows[] | .id' | sort | uniq | wc -l      53問題なさそうですね。
unitテストで動作確認
Section titled “unitテストで動作確認”私が書いたコードが正しいか確認してみましょう。
$ pip install pytest pytest-aiohttptests ディレクトリとファイルを作成します。
 $ tree -L 2 -I __pycache__  .  ├── README.md  ├── requirements.txt  ├── src  │   ├── extract_next_url.py  │   ├── list_repository_workflows.py  │   └── main.py  └── tests      └── test_get.pyページネーションされているように振る舞うテストサーバを作成します。
処理は handler 関数です。
import json
import pytestfrom typing import Anyfrom aiohttp import webfrom aiohttp.test_utils import TestServer
from src.main import get
async def handler(request: web.Request) -> web.Response:    pageNum: str | None = request.query.get("page")    host = request.url.host    port = request.url.port
    if pageNum == "2":        return web.json_response(            {                "total_count": 50,                "workflows": [                    {                        "id": 2,                    }                ],            },            headers={"Link": f'<http://{host}:{port}/?page=3>; rel="next"'},        )    if pageNum == "3":        return web.json_response(            {                "total_count": 50,                "workflows": [                    {                        "id": 3,                    }                ],            },            headers={"Link": f'<http://{host}:{port}/?page=2>; rel="last"'},        )
    return web.json_response(        {            "total_count": 50,            "workflows": [                {                    "id": 1,                }            ],        },        headers={"Link": f'<http://{host}:{port}/?page=2>; rel="next"'},    )
@pytest.mark.asyncioasync def test_get(aiohttp_server: Any) -> None:    app = web.Application()    app.add_routes([web.get("/", handler)])    server: TestServer = await aiohttp_server(app)
    gen = get(server.make_url("/"))
    resp = await gen.__anext__()    assert json.loads(resp)["workflows"][0]["id"] == 1
    resp = await gen.__anext__()    assert json.loads(resp)["workflows"][0]["id"] == 2
    resp = await gen.__anext__()    assert json.loads(resp)["workflows"][0]["id"] == 3参考: Testing client with fake server
テストが通りました。実装は問題なさそうです。
$ python -m pytest tests -s================================== test session starts ===================================platform darwin -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0rootdir: /Users/hogehoge/blog-code/2023/07/github-actions-workflow-logplugins: asyncio-0.21.0, aiohttp-1.0.4asyncio: mode=Mode.STRICTcollected 1 item
tests/test_get.py .
=================================== 1 passed in 0.01s ====================================aiohttp + Generatorでページネーションに対応したHTTPクライアントを作成することができました。
StopIteration、StopAyncIterationだった場合のエラーハンドリングやバックオフについては考慮していないため実装としては物足りませんが、最低限 HTTPクライアントとして使えるのではないでしょうか。
今回使用したコードは以下に置きました。 https://github.com/kntks/blog-code/tree/main/2023/07/python-aiohttp-pagination-client
実装の参考になれば幸いです。