📋

4_AIで下駄を履いてなんかすごそうなゲームやらツールを作るのではなく、まずは再学習も兼ねて身の丈に合った開発から始めた話

に公開

https://zenn.dev/sonnerie_9695/articles/ed02d3453bfa29

前回↑は、GitHub Actionsを使って、

GitHubへpush
↓
別の環境を用意
↓
pytestを実行
↓
成功・失敗をGitHub上で確認する

という最小限のCIを作った。


今回はそこから少し離れて、
FastAPIを触り始めることにした。


最初からAPIを色々作りたいわけではない。

まず知りたかったのは、

「ブラウザなどから来たHTTPリクエストが、Pythonコードを通ってHTTPレスポンスとして返る」とは、実際にはどういうことなのか?

というところ。


なので今回の目標もかなり小さく、

FastAPIでGET /healthを作る
↓
pytestで確認する
↓
GitHub Actionsでも緑にする

ところまでにした。




1. 今回形にしたかったもの




作りたかったAPIは一つだけ。

GET /health

へアクセスすると、

{
  "status": "ok"
}

が返ってくるもの。


コードとしては最終的に、

from fastapi import FastAPI

app = FastAPI()


@app.get("/health")
def get_health():
    return {"status": "ok"}

というかなり小さいものになった。


ただ、今回知りたかったのはこの6行そのものというより、

HTTP Request
↓
Python
↓
HTTP Response

の間で何が起きているのかだった。




2. 新しいプロジェクトなので、今回はuvを使うことにした




Pythonの環境構築はこれまで、

venv
+
pip

でやることが多かった。


ただ、新しくプロジェクトを作るときは、
今後できるだけuvを使うようにしたいと思っていた。

なので今回は、

uv init --bare

でプロジェクトを初期化し、

必要なものを、

uv add ...

で追加していくことにした。

開発用の依存関係については、

uv add --dev ...

を使う。


そして実行するときは、

uv run ...

を使う。


最初は、

uvを使う
↓
venvを使わない

くらいのイメージが少しあった。

ただ実際にはそういう関係ではなく、

従来

python -m venv
↓
activate
↓
pip install
↓
python ...


uv

pyproject.toml / uv.lock
↓
uvが.venvも含めて環境を管理
↓
uv run ...

という違いだと整理した。


uvとvenvが競合しているというより、

仮想環境を自分で直接操作する部分まで、uv側へ寄せられる

という理解の方が近そうだった。




3. まずはGET /healthを動かした




開発用サーバーは、

uv run fastapi dev src/main.py

で起動した。


そして、

/health

へGETすると、

{"status":"ok"}

が返ってくる。


ここまではかなりあっさり動いた。

ただ、

@app.get("/health")
def get_health():
    return {"status":"ok"}

を見ているだけだと、

これのどこがHTTPなのか?

という感じもまだ残る。


今回整理した範囲では、

ブラウザ / curl
↓
HTTP Request
↓
Uvicorn
↓
FastAPI
↓
get_health()
↓
FastAPI
↓
Uvicorn
↓
HTTP Response
↓
ブラウザ / curl

という流れになる。


@app.get("/health")自体が、
ネットワークからHTTPを受信しているわけではない。

UvicornがHTTPリクエストを受け取り、
FastAPIへ渡す。

FastAPIは、

GET
+
/health

に対応する処理として登録されている、

get_health()

を呼ぶ。

そして返された、

{"status": "ok"}

をレスポンスへ変換し、
最終的にはUvicornを通してクライアントへ返す。


単純化すると、

受け取ったHTTPリクエストをuvicornが解釈してFastAPIに渡す
→対応する処理をFastAPIが行なってレスポンスを返す(返す時もuvicorn経由)

つまり、

Uvicorn
→ HTTPとPython Webアプリの境界

FastAPI
→ どのPython処理を呼び、何を返すか

という役割分担だと考えると分かりやすかった。




4. pytestから同じappを使う




次にテストを書いた。

from fastapi.testclient import TestClient
from src.main import app


client = TestClient(app)


def test_check_health():
    response = client.get("/health")

    assert response.status_code == 200
    assert response.json() == {"status":"ok"}


ここで少し面白かったのが、

from src.main import app

の部分。

最初は、

importするとファイルの中身を実行して、その中に作られたappを持ってくる

くらいに考えていた。


これは大体合っていたが、
関数についてはもう少し分けて考える必要があった。

src.mainをimportすると、

app = FastAPI()
↓
FastAPIインスタンス作成

def get_health():
↓
関数オブジェクト作成

@app.get("/health")
↓
その関数をGET /healthの処理としてappへ登録

までは行われる。


ただ、

return {"status":"ok"}

がその時点で実行されるわけではない。


実際に、

client.get("/health")

が実行されたときに初めて、

get_health()

の中身が走る。


つまり、

import時
↓
アプリを作る
↓
ルートを登録する

と、

リクエスト時
↓
登録した関数を実行する

は別だった。




5. TestClientではUvicornを通っていなかった




もう一つ整理できたのが、

client.get("/health")

と、

ブラウザやcurlから、

http://localhost:8000/health

へアクセスする場合の違い。


ブラウザやcurlなら、

client
↓
実際のHTTP通信
↓
Uvicorn
↓
FastAPI
↓
get_health()

となる。


一方、pytestで使っているTestClientでは、

pytest
↓
TestClient
↓
FastAPI app
↓
get_health()

という形になる。


つまり今回のテストは、

Uvicornを起動してネットワーク越しにアクセスしているわけではない。


同じ、

client.get("/health")

という見た目なのでHTTP通信しているようにも見えたが、
FastAPIアプリをテスト用クライアントから直接呼んでいる。


この違いは今回初めて少し具体的になった。




6. 本体より、httpx周りで一度止まった




FastAPI本体の処理より、
テスト環境の方で一度引っかかった。

pytest自体は、

1 passed

になったものの、

同時にwarningが出た。

内容は、

Using `httpx` with `starlette.testclient` is deprecated;
install `httpx2` instead.

というもの。


最初は、

テストに必要なhttpx自体が廃止されたのか?

くらいに受け取った。

ひとまず表示されている案内に従い、
httpx2を追加した。


あとから整理すると、

TestClient周辺が
httpx
↓
httpx2

へ移行している途中らしい。


自分が書いた、

TestClient(app)

自体はかなり単純なのに、

その下では別ライブラリが動いていて、
そこにも依存関係がある。

今回はその境界でwarningを踏むことになった。




7. GitHub Actionsでもuvを使おうとして、uv initで転んだ




ローカルでpytestが通ったので、
次はGitHub Actions。


今回も以前作ったCIと同じく、

push
↓
runner起動
↓
コード取得
↓
Python環境を用意
↓
pytest

までを作る。


ただ今回はuvを使っているので、
Workflow側にもuvを入れる必要がある。


途中で、

uv init

もWorkflowに入れていた。

すると、

Project is already initialized
(`pyproject.toml` file exists)

として失敗した。


少し考えれば当然で、

ローカル
↓
uv init
↓
pyproject.tomlを作る
↓
GitHubへpush

している。

つまりGitHub Actionsでcheckoutした時点で、
すでにpyproject.tomlは存在している。


そこでもう一度、

uv init

をする必要はなかった。


uv initは、

プロジェクトを最初に作る処理

であって、

プロジェクトを動かすたびに行う処理

ではない。


最終的には、

Checkout repo
↓
Pythonを用意
↓
uvを用意
↓
uv run python -m pytest

というところまで減らした。




8. ActionsのYAMLも、やっぱりまだ思い出しながらだった




前回一度GitHub Actionsを書いていたので、

今回は何も分からない状態ではなかった。

ただ、

name:
on:
permissions:
jobs:
runs-on:
steps:
uses:
with:
run:

あたりは、
やはり思い出しながら書くことになった。


今の理解としては、

name
→ Workflowの名前

on
→ 起動条件

permissions
→ Workflowへ与えるGitHub上の権限

jobs
→ 実行する処理のまとまり

runs-on
→ jobを動かすrunner

steps
→ job内で順番に行う処理

uses
→ 既存Actionを使う

with
→ そのActionへ入力値を渡す

run
→ shell上でコマンドを実行

くらい。


途中、

with:

を環境変数の指定だと思っていたが、
これはusesで呼んだActionへの入力値だった。

環境変数なら、

env:

を使う。


一度使ったものでも、
数日後には普通に細かい書き方を忘れている。

ただ今回は、

全部暗記するより、YAMLの階層と各要素の役割が分かっていれば戻ってこられる

くらいに考えることにした。




9. 最小構成が緑になった




最終的に、

GET /health
↓
ローカルでレスポンス確認
↓
pytest
↓
GitHub Actions

まで通った。


pytestでは、

assert response.status_code == 200
assert response.json() == {"status":"ok"}

を確認している。


GitHub Actions上でもpytestが成功し、
今回最初に置いていた、

FastAPIでGET /healthを作り、pytest+GitHub Actionsまで緑にする

というところまでは到達した。




10. 今回一番残ったのは、FastAPIだけではなかった




始める前は、

FastAPIを触る
↓
GET /healthを書く

くらいが中心になると思っていた。


実際に時間を使ったのは、

uvとvenvの違いを整理する
↓
TestClient周辺のwarningを見る
↓
httpx2を追加する
↓
GitHub ActionsのYAMLを思い出す
↓
CIでuv initして失敗する

といった周辺部分の方だった。


ただ、その過程で、

Uvicorn
FastAPI
TestClient
uv
GitHub Actions
import

が全部同じものではなく、
少しずつ役割を分けて見られるようになった。


例えば今なら、

Uvicorn
→ HTTPを受け取り、FastAPIとの間をつなぐ

FastAPI
→ ルーティングしてPython処理を呼ぶ

TestClient
→ Uvicornを起動せずFastAPIアプリをテストする

uv
→ Pythonプロジェクトの依存関係や環境を管理する

GitHub Actions
→ 別のrunner上で指定したコマンドを実行する

というくらいには分けられる。




11. 今日できたもの




今回の終了時点では、

  • FastAPIアプリの最小構成
  • GET /health
  • {"status":"ok"}のJSONレスポンス
  • TestClientを使ったpytest
  • HTTP status code 200の確認
  • response bodyの確認
  • uvを使ったプロジェクト環境
  • GitHub Actionsからのpytest実行
  • push時にCIが緑になる状態

までが残った。


APIとしては、
まだ/health一つしかない。

ただ、

HTTP Request
↓
FastAPI
↓
Python関数
↓
HTTP Response

という一番小さい流れを、
実際に動くコードとして確認するところまではできた。




12. 次は「HTTPからPythonへ値が入るところ」を見る




今回の/healthには入力がない。

GET /health
↓
固定のJSONを返す

だけ。


なので次は、

URLに含まれる値
Query Parameter
JSON Body

などをFastAPIがどうPythonへ渡すのかを見ていく予定。


小さいTask APIのようなものを作って、

POST /tasks
GET /tasks/{task_id}
GET /tasks?done=false

くらいまで触ってみたい。


その中で、

HTTP入力
↓
FastAPI / Pydanticによる検証
↓
Pythonオブジェクト
↓
Python関数
↓
正常レスポンス / エラー

という流れを見る。


DBまで入れると、
FastAPIを理解しているのかDBを理解しているのか分からなくなりそうなので、
最初はメモリ上のdict程度に留めるつもり。




13. 終わりに




今回は本当に最小構成だった。

書いたFastAPIのコードだけを見れば、
数行しかない。


それでも実際に動かしてみると、

HTTPを受けるのは誰なのか

@app.getは何をしているのか

returnしたdictはどう返るのか

pytestではUvicornも動いているのか

importした時点で何が実行されるのか

uvはvenvの代わりなのか

CIでは何を初期化する必要があるのか

と、細かい疑問が色々出てきた。


最初は、

FastAPIでAPIを一つ作る

くらいのつもりだった。

今のところは、

HTTPとPythonの間に何が挟まっているのかを、一つずつ名前付きで見られるようにする

作業だった気がする。


まだ、

GET /health

しかない。

次は実際にHTTPから値を受け取って、
Python側で扱ってみる。

まずはそこまででいい気がしている。

Discussion