iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0

Day 28 為使用系統的人寫了 docs/USAGE.md,並用測試確保環境變數、CLI 參數與程式一致。但要把這套系統接進自己的工具(例如需求管理平台、CI 腳本、聊天機器人)的開發者,需要的是另一種文件:

  • 有哪些端點、要帶什麼參數、會回傳什麼
  • 怎麼登入、token 過期怎麼辦
  • 出錯時的回應長什麼樣子
  • 一段可以直接複製來跑的範例

FastAPI 已經自動產生了 /docs 的互動式文件,今天在它的基礎上,匯出可以交給其他工具的 OpenAPI 規格、寫一份人看的 API 參考,並提供 Python 與 curl 兩種範例。和 Day 28 一樣,這些文件都要能自動檢查是否與程式一致。

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 28 → 使用文檔與同步檢查
  • Day 29 → API 文檔與範例 ← 今天
  • Day 30 → 開源發佈與 30 天回顧

今日目標

讀完這篇後,你會完成:

  1. 整理 API 本身:統一版本號來源、統一分類標籤、更新 /api/v1/info
  2. 匯出 OpenAPI src/export_openapi.py:產生 docs/api/openapi.json,--check 可在 CI 檢查是否過時
  3. API 參考 docs/API.md:17 個端點、認證流程、錯誤格式、約束與結果格式
  4. 範例 examples/python_client.py、examples/curl_examples.sh:從註冊到登出的完整流程
  5. 測試 tests/test_day29_api_docs.py:6 個測試,包含「API 文件與 OpenAPI 一一對應」與「範例程式真的能跑」

問題背景:先整理 API 本身

寫文件的第一步,是把 FastAPI 產生的端點清單完整列一次。這一列就發現了三個不一致:

問題 現況
版本號有兩個 FastAPI(version="2.0.0"),但 /、/api/v1/health、/api/v1/info 回傳 "1.0.0";CLI 的 --version 又是另一個值
分類標籤混用語言 認證相關是英文 authentication,其他是中文;Day 13 的三個免登入端點分在「衝突檢測」與「衝突檢測 (舊版)」兩類
/api/v1/info 過時 端點清單只有 Day 13 的免登入端點,沒有 Day 16 的 /api/v1/detect、Day 17 的 /refresh

這些問題在 /docs 頁面上看得一清二楚,對串接的開發者來說也最困惑:到底該看哪個版本號?要用哪個檢測端點?所以先修 API,再寫文件。


實現方法

src/__init__.py:__version__(唯一的版本來源)
  ├─ src/api_main.py:FastAPI(version=…)、/、/api/v1/health、/api/v1/info
  └─ src/cli.py:--version

app.openapi()(FastAPI 依程式自動產生)
  ↓ python3 -m src.export_openapi
docs/api/openapi.json      ← 交給 Postman、Swagger Editor、openapi-generator
docs/API.md                ← 給人讀的參考
examples/python_client.py  ← 可以 import 的範例函數
examples/curl_examples.sh  ← 不寫程式也能試

tests/test_day29_api_docs.py
  ├─ openapi.json == app.openapi()
  ├─ API.md 的端點 == OpenAPI 的端點
  ├─ python_client.run_demo() 用 TestClient 跑完整流程
  └─ 所有地方的版本號 == __version__
  • 輸入:FastAPI 應用
  • 輸出:OpenAPI 規格、API 文件、範例程式
  • 檔案:src/export_openapi.py、docs/API.md、docs/api/openapi.json、examples/(新增);src/__init__.py、src/api_main.py、src/api_auth.py、src/cli.py、docs/USAGE.md(修改)
  • 下游:Day 30 以 __version__ 發佈 1.0.0

專案結構變化:

srs-review-agent/
├── src/
│   ├── __init__.py            ← 修改:__version__
│   ├── export_openapi.py      ← 【新增】
│   ├── api_main.py            ← 修改:版本、標籤、/info
│   ├── api_auth.py            ← 修改:標籤
│   └── cli.py                 ← 修改:版本改從 src 讀取
├── docs/
│   ├── API.md                 ← 【新增】
│   ├── api/openapi.json       ← 【新增】
│   └── USAGE.md               ← 修改:補上 API.md 連結
├── examples/
│   ├── python_client.py       ← 【新增】
│   └── curl_examples.sh       ← 【新增】
└── tests/
    └── test_day29_api_docs.py ← 【新增】6 個測試

今天不需要新的套件(範例使用的 httpx 已在 requirements.txt)。


代碼示例

1. 唯一的版本來源

修改 src/__init__.py:

"""SRS Review Agent"""

# 單一版本來源:API(/、/api/v1/health、/api/v1/info、OpenAPI)與 CLI(--version)共用(Day 29)
__version__ = "0.29.0"

src/api_main.py 與 src/cli.py 都改成 from src import __version__:

app = FastAPI(
    title="SRS Review Agent API",
    description="Software Requirements Specification 自動審查系統",
    version=__version__,
)

/、/api/v1/health、/api/v1/info 回傳的 "version" 也全部改成 __version__。今天的版本號是 0.29.0;明天發佈時只要改這一行,所有地方就一起變成 1.0.0。

分類標籤統一為中文:src/api_auth.py 的路由改成 tags=["認證與任務"],Day 13 的三個免登入端點統一為「衝突檢測(免登入)」。/api/v1/info 的端點清單補上 /api/v1/detect、/api/v1/auth/jobs、/api/v1/auth/login、/api/v1/auth/refresh 等目前主要使用的端點。

2. 匯出 OpenAPI

建立 src/export_openapi.py:

DEFAULT_OUT = Path(__file__).resolve().parent.parent / "docs" / "api" / "openapi.json"


def current_spec() -> dict:
    from src.api_main import app
    return app.openapi()


def render(spec: dict) -> str:
    return json.dumps(spec, ensure_ascii=False, indent=2) + "\n"


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(description="匯出 OpenAPI 規格")
    parser.add_argument("--out", default=str(DEFAULT_OUT))
    parser.add_argument("--check", action="store_true", help="檔案與程式不一致時結束碼為 1")
    args = parser.parse_args(argv)

    content = render(current_spec())
    out = Path(args.out)
    if args.check:
        if not out.exists() or out.read_text(encoding="utf-8") != content:
            print(f"{out} 與程式不一致,請執行 python3 -m src.export_openapi", file=sys.stderr)
            return 1
        return 0
    ...

為什麼要把規格存成檔案,而不是請大家看 /docs 就好?

  • 不用啟動伺服器就能看:OpenAPI 檔案可以直接匯入 Postman、Swagger Editor
  • 可以產生 client:openapi-generator 能據此產生 TypeScript、Java 等語言的 client
  • API 的變更會出現在版本控制的 diff 裡:改了一個端點,openapi.json 就會跟著變,code review 時一眼就看得到

ensure_ascii=False 讓中文說明保持可讀,indent=2 讓 diff 只顯示真正改動的行。--check 讓 CI 可以確認有人改了 API 卻忘了重新匯出。

3. API 參考文件

docs/API.md 不是把 OpenAPI 再抄一次,而是補上 OpenAPI 表達不清楚的部分:

  • 認證流程:登入 → 帶 Bearer token → 過期時 /refresh → 登出,以及「登入用表單格式,送 JSON 會得到 422」這種最常卡住的細節
  • 錯誤格式:Day 13 統一的 {"error", "code", "detail", "message", "timestamp"},以及每個狀態碼的常見原因
  • 約束格式:純文字與 {id, text} 兩種都接受(Day 13、16)
  • 結果格式:req_id_1 == req_id_2 代表自我矛盾(Day 21);verified 不代表一定正確

每個端點用固定的標題格式:

#### `POST /api/v1/detect`

提交檢測任務,立即回傳 `job_id`,檢測在背景執行,結果寫入資料庫。

- 標頭:`Authorization: Bearer <access_token>`
- 請求:`{"constraints": [...]}`(見約束格式)
- 回應:`{"job_id": "e5b21b64-...", "status": "queued", "message": "檢測任務已提交"}`

#### `方法 路徑` 這個格式是給測試讀的:測試用它抓出文件中的所有端點,和 OpenAPI 比對。

Day 13 的免登入端點也列在文件裡,但特別標註「正式部署時應關閉或加上認證:任何人都能列出所有免登入任務」。文件不只說明怎麼用,也要說明哪些不該用。

4. Python 範例

建立 examples/python_client.py,每個步驟都是一個獨立的函數:

class APIError(RuntimeError):
    """API 回傳錯誤;message 取自回應的 detail 欄位(Day 13 統一的錯誤格式)"""

    def __init__(self, response: httpx.Response):
        try:
            detail = response.json().get("detail")
        except ValueError:
            detail = response.text
        super().__init__(f"HTTP {response.status_code}:{detail}")
        self.status_code = response.status_code


def login(client: httpx.Client, email: str, password: str) -> Dict:
    """登入要用表單格式(OAuth2 password flow,Day 15);返回 access_token 與 refresh_token"""
    return _check(client.post("/api/v1/auth/login", data={"username": email, "password": password}))


def wait_for_job(client: httpx.Client, token: str, job_id: str,
                 interval: float = 1.0, timeout: float = 600.0) -> Dict:
    """每 interval 秒輪詢一次,直到 completed / failed(與前端 DetectPage 相同,Day 14)"""
    deadline = time.monotonic() + timeout
    while True:
        job = _check(client.get(f"/api/v1/auth/jobs/{job_id}",
                                headers={"Authorization": f"Bearer {token}"}))
        if job["status"] in ("completed", "failed"):
            return job
        if time.monotonic() > deadline:
            raise TimeoutError(f"任務 {job_id} 在 {timeout} 秒內沒有完成")
        time.sleep(interval)

所有函數的第一個參數都是 httpx.Client,而不是寫死的網址。這個設計有兩個目的:

  • 讀者可以只拿需要的函數,例如只用 login() 和 submit() 串進自己的程式
  • 測試可以傳入 FastAPI 的 TestClient(它本身就是 httpx.Client 的子類別),不必真的啟動伺服器,範例程式就能在每次測試時被執行

run_demo() 把所有步驟串起來:註冊 → 登入 → 提交 → 輪詢 → 列出衝突 → 刷新令牌 → 登出。wait_for_job() 有逾時,login() 的 docstring 寫明了表單格式,範例本身就是文件。

5. curl 範例

建立 examples/curl_examples.sh,給不寫 Python 的讀者:

set -euo pipefail

BASE_URL="${BASE_URL:-http://localhost:8000}"
json() { python3 -c "import sys, json; print(json.load(sys.stdin)$1)"; }

echo "== 3. 登入(表單格式)"
LOGIN=$(curl -sf -X POST "$BASE_URL/api/v1/auth/login" -d "username=$EMAIL&password=$PASSWORD")
TOKEN=$(echo "$LOGIN" | json "['access_token']")
REFRESH=$(echo "$LOGIN" | json "['refresh_token']")

只依賴 curl 與 python3(不需要另外安裝 jq)。set -euo pipefail 加上 curl -sf,任何一步失敗腳本就停下來,不會帶著空的 token 繼續往下跑。BASE_URL 可以覆寫,指向 Day 26 Compose 的 nginx(:8080)也能用。


驗證結果

測試

cd srs-review-agent
python3 -m pytest tests/test_day29_api_docs.py -v
tests/test_day29_api_docs.py::test_openapi_file_up_to_date PASSED        [ 16%]
tests/test_day29_api_docs.py::test_api_doc_matches_spec PASSED           [ 33%]
tests/test_day29_api_docs.py::test_python_client_example PASSED          [ 50%]
tests/test_day29_api_docs.py::test_python_client_error_message PASSED    [ 66%]
tests/test_day29_api_docs.py::test_single_version_source PASSED          [ 83%]
tests/test_day29_api_docs.py::test_curl_script_syntax PASSED             [100%]

============================== 6 passed in 0.51s ===============================

文件與 OpenAPI 的比對:

def test_api_doc_matches_spec():
    """測試 2:API.md 的端點與 OpenAPI 一一對應"""
    spec, doc = _spec_operations(), _documented_operations()
    assert doc == spec, f"文件缺少:{sorted(spec - doc)};文件多出:{sorted(doc - spec)}"

之後有人新增端點卻沒寫進 API.md,或刪了端點卻沒從文件拿掉,這個測試都會失敗,訊息直接指出是哪一個。

test_single_version_source 同時檢查 OpenAPI、三個 API 端點與 CLI 的版本號,CLI 的部分是真的開一個子行程執行 python3 -m src.cli --version。

Day 28 的 test_referenced_files_exist 這時也發揮了作用:USAGE.md 加回「端點說明見 docs/API.md」的連結後,它確認這個檔案存在。

匯出

python3 -m src.export_openapi
已寫入 docs/api/openapi.json(17 個路徑,版本 0.29.0)
python3 -m src.export_openapi --check
# docs/api/openapi.json 與程式一致

用真實的伺服器跑範例

uvicorn src.api_main:app --port 8000
python3 examples/python_client.py
✅ 已登入:demo_8e9fc83c@example.com
✅ 已提交:b3c1686a-fe82-4e36-af11-77daf272fc00
✅ 完成:發現 1 個衝突
   [高] REQ-1 ↔ REQ-2:檢測到 多用戶 vs 單用戶
✅ 已刷新令牌
✅ 已登出
bash examples/curl_examples.sh
== 1. 健康檢查
healthy
== 2. 註冊
curl_1790920845@example.com
== 3. 登入(表單格式)
access token:eyJhbGciOiJIUzI1NiIs...
== 4. 提交檢測
job_id:eb52566f-e7be-446e-be9a-e9fdc6660a75
== 5. 輪詢結果
狀態:completed
  [高] REQ-1 ↔ REQ-2:檢測到 多用戶 vs 單用戶
== 6. 刷新令牌
新的 access token:eyJhbGciOiJIUzI1NiIs...
== 7. 登出,舊令牌失效
已登出
登出後呼叫 /me:401

最後一行重現了 Day 17 的成果:登出後舊令牌立即失效。


權衡與限制

  • API 沒有版本化的路徑策略:路徑雖然是 /api/v1/...,但目前沒有 v2,也沒有棄用政策。真的要改動回應格式時,需要決定是開 /api/v2 還是在 v1 中相容
  • OpenAPI 的說明是英文函數名稱:端點的 summary 由函數名稱產生(例如「Detect Conflicts」),中文說明寫在 docstring 與 API.md。要完全中文化,需要在每個端點加上 summary= 參數
  • 範例沒有處理 token 過期:python_client.py 的流程很短,不會遇到 15 分鐘的效期。長時間執行的程式要參考 Day 17 前端的 authRequest():遇到 401 先刷新再重試
  • API.md 的回應範例是手寫的:測試只比對端點清單,沒有比對回應範例的欄位。欄位改名時,範例可能過時

提交變更

git add src/__init__.py src/export_openapi.py src/api_main.py src/api_auth.py src/cli.py \
        docs/API.md docs/api/openapi.json docs/USAGE.md examples/ tests/test_day29_api_docs.py
git commit -m "Day 29: OpenAPI 匯出、API 參考文件、Python 與 curl 範例"

明天預告

最後一天 Day 30。程式、文件、範例、容器都準備好了,剩下讓別人能拿去用:README、授權、貢獻指南、版本紀錄、pip install 的套件設定,並把版本號改成 1.0.0。最後回顧這 30 天:哪些決定是對的、哪些數字比預期差,以及這個系統還不能做什麼。


上一篇
Day 28:使用文檔與同步檢查
下一篇
Day 30:開源發佈與回顧
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言