本日核心價值 (Core Focus): 用小型 Python 腳本讀
openapi.json,產出含路徑、方法、認證、參數與範例請求/回應的 Markdown;YAML 混亂時才退回 Codex Prompt,並預告文件與程式漂移要在 Day 17 掛進 CI。
概念說明與實戰情境 (Overview)
Day 13 的 KeyError: 'user' 本質是契約沒寫清。Swagger / OpenAPI 3 已經是機器可讀的來源,不該再靠聊天視窗「幫我寫一份 API 文件」。正確工作流是:以 openapi.json 為單一來源,腳本生成 Markdown(endpoint、HTTP method、auth、params、example request/response)。生成物給人類閱讀與 PR 討論;執行契約仍以 OpenAPI 與程式為準。YAML 縮排一亂,解析器就失敗,那時才用 Codex 把 YAML 正規化成 JSON,而不是一開始就讓模型自由改寫欄位。生成文件若不進 CI,過幾週一定會與 code 漂移——這條線接到 Day 17。
關鍵操作與範例 (Implementation & Example)
先準備最小 OpenAPI 3(兩個 endpoint:查訂單、建立訂單),對應本系列的金額與 user_id 契約:
{
"openapi": "3.0.3",
"info": { "title": "Orders API", "version": "1.0.0" },
"paths": {
"/orders/{orderId}": {
"get": {
"operationId": "getOrder",
"security": [{ "bearerAuth": [] }],
"parameters": [
{
"name": "orderId",
"in": "path",
"required": true,
"schema": { "type": "string" }
}
],
"responses": {
"200": {
"description": "Order found",
"content": {
"application/json": {
"example": { "id": "ord-1", "user_id": "u-1", "total": "20.99" }
}
}
}
}
}
},
"/orders": {
"post": {
"operationId": "createOrder",
"security": [{ "bearerAuth": [] }],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateOrder" },
"example": {
"user_id": "u-1",
"unit_price": "19.99",
"quantity": 1,
"tax_rate": "0.05"
}
}
}
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"example": { "id": "ord-1", "user_id": "u-1", "total": "20.99" }
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": { "type": "http", "scheme": "bearer" }
},
"schemas": {
"CreateOrder": {
"type": "object",
"required": ["user_id", "unit_price", "quantity"],
"properties": {
"user_id": { "type": "string" },
"unit_price": { "type": "string" },
"quantity": { "type": "integer" },
"tax_rate": { "type": "string" },
"coupon_percent": { "type": "string" }
}
}
}
}
}
生成腳本只做投影,不「潤飾」欄位名稱。這樣 Markdown 才會跟 schema 一致:user_id 是扁平字串,不會被模型寫回 user.id。
# tools/openapi_to_md.py
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
def security_names(op: dict[str, Any], spec: dict[str, Any]) -> str:
blocks = op.get("security", spec.get("security") or [])
if not blocks:
return "None (public)"
names: list[str] = []
for block in blocks:
names.extend(block.keys())
schemes = spec.get("components", {}).get("securitySchemes", {})
parts = []
for name in names:
scheme = schemes.get(name, {})
parts.append(f"{name} ({scheme.get('type', '?')} {scheme.get('scheme', '')})".strip())
return ", ".join(parts)
def params_table(op: dict[str, Any]) -> str:
params = op.get("parameters") or []
if not params:
return "_No parameters._"
lines = ["| Name | In | Required | Type |", "| --- | --- | --- | --- |"]
for p in params:
schema = p.get("schema") or {}
lines.append(
f"| `{p.get('name')}` | {p.get('in')} | {p.get('required', False)} | {schema.get('type', '')} |"
)
return "\n".join(lines)
def example_payload(op: dict[str, Any], where: str) -> str:
if where == "request":
content = (op.get("requestBody") or {}).get("content") or {}
else:
responses = op.get("responses") or {}
first = next(iter(responses.values()), {})
content = first.get("content") or {}
json_body = content.get("application/json") or {}
example = json_body.get("example")
if example is None:
return "_No example._"
return "```json\n" + json.dumps(example, ensure_ascii=False, indent=2) + "\n```"
def render(spec: dict[str, Any]) -> str:
info = spec.get("info") or {}
out = [f"# {info.get('title', 'API')} `{info.get('version', '')}`", ""]
for path, methods in (spec.get("paths") or {}).items():
for method, op in methods.items():
if method.startswith("x-") or not isinstance(op, dict):
continue
out.append(f"## `{method.upper()} {path}`")
out.append("")
out.append(f"- Operation: `{op.get('operationId', '-')}`")
out.append(f"- Auth: {security_names(op, spec)}")
out.append("")
out.append("### Parameters")
out.append(params_table(op))
out.append("")
out.append("### Example request")
out.append(example_payload(op, "request"))
out.append("")
out.append("### Example response")
out.append(example_payload(op, "response"))
out.append("")
return "\n".join(out).rstrip() + "\n"
def main() -> None:
src = Path(sys.argv[1] if len(sys.argv) > 1 else "openapi.json")
dest = Path(sys.argv[2] if len(sys.argv) > 2 else "docs/api.md")
spec = json.loads(src.read_text(encoding="utf-8"))
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(render(spec), encoding="utf-8")
if __name__ == "__main__":
main()
python tools/openapi_to_md.py openapi.json docs/api.md
產出會包含 GET /orders/{orderId} 與 POST /orders、bearer auth、path 參數,以及 JSON 範例。這份 Markdown 適合放 PR 描述或內部 Wiki;不要把它當 runtime 驗證器。每個 endpoint 區塊固定五段:標題(方法 + 路徑)、Auth、Parameters 表、Example request、Example response。缺 example 時腳本應寫「No example.」,不要讓模型編造一筆看起來合理的訂單。POST /orders 的 example 必須出現扁平 user_id,這才能跟 Day 13 的根因對上。
生成後請用眼睛掃三件事:GET 是否列出 path 參數 orderId、POST 的 Auth 是否為 bearer、example 是否仍是字串金額而不是 float。這三項都能用腳本檢查,但本日先養成「生成後立刻對 spec 抽查」的習慣,否則 Markdown 只是把錯誤契約印得更漂亮。金額欄位維持字串,是為了和 Day 11 的 Decimal 對齊,避免文件示例用 19.99 當 JSON number 造成前端再走 float。
當來源是縮排混亂的 YAML(常見於手動合併衝突後),不要在腳本裡猜縮排。改走 Codex,目標是「變成合法 OpenAPI JSON」,不是改業務欄位:
The file openapi.yaml fails to parse. Do not invent endpoints.
1) Normalize YAML to valid OpenAPI 3 JSON at openapi.json.
2) Preserve path strings, operationId, required fields, and examples.
3) If a mapping is ambiguous, list it under "needs-human" and skip that path.
4) Then run: python tools/openapi_to_md.py openapi.json docs/api.md
Do not rename user_id to user.id. Do not add endpoints that are not in the source.
codex exec --sandbox workspace-write "$(cat prompts/normalize-openapi.txt)"
漂移警告要寫進 AGENTS.md:Markdown 是生成物;改 API 時先改 OpenAPI 或 code-first 產生器,再跑腳本。Day 17 會把 python tools/openapi_to_md.py 與 git diff --exit-code docs/api.md 放進 GitHub Actions,讓文件過期變成 CI 紅燈。本日先把命令固定,不要靠有人「記得更新 Wiki」。實務上漂移有三種:程式加了欄位但 OpenAPI 沒改、OpenAPI 改了但沒重跑腳本、有人直接改 docs/api.md。前兩種用「匯出 spec → 生成 → diff」就能抓;第三種用 CI 覆蓋生成檔來禁止手改。鐵人賽先把本地命令當成閘道,等 Day 17 再接到 workflow 檔。
若團隊是 ASP.NET Swashbuckle / 註解產生 swagger,流程相同:CI 先匯出 openapi.json,再跑同一支 Python。不要維護兩份手寫 endpoint 表。PHP 或純靜態 JSON 也一樣:來源檔進版控,生成命令單一。參數表要區分 in=path|query|header,否則 Markdown 會把路徑參數寫成可選 query,呼叫端再踩一次 Day 13 的欄位錯誤。Auth 列要寫出 scheme 名稱(例如 bearerAuth (http bearer)),不要只寫「要登入」。若 OpenAPI 在 root 設了全域 security、個別 operation 又覆寫成空陣列,腳本必須以 operation 為準,否則公開的 health 端點會被寫成要帶 token。本日最小 spec 沒有第三個 endpoint,就是為了讓你先核對這兩條規則,而不是一次生成整份大型 API 手冊。
本地檢查可先對生成檔搜尋 user_id 與 bearerAuth:前者應出現在 POST example,後者應出現在兩個 endpoint 的 Auth 行。找不到就代表 spec 或腳本其中一邊壞了,不要先改 Markdown。若你用 ChatGPT 當後備、YAML 又很亂,Prompt 仍要禁止「補齊常見 REST 端點」;鐵人賽範例只有查單與建單,多出來的 PUT /orders/{id} 一律視為幻覺。文件自動化的價值是可重跑,不是一次生成很長的手冊。生成命令應寫進 README 或 AGENTS.md,讓下一次改契約時有人(或 CI)知道要跑哪一行。
注意事項與常見失敗 (Pitfalls)
user_id 寫成巢狀 user,文件看起來漂亮,執行時再現 Day 13 的 KeyError。修法:腳本只投影,禁止同義詞改寫。docs/api.md、不提交 openapi.json:下一次生成無法重現。修法:規格來源與生成腳本入庫,Markdown 可生成則在 CI 驗證。needs-human 清單,禁止發明路徑。user_id,example 仍放 user。修法:生成後人工抽一條 POST 對 schema required;Day 17 再自動化。security 為準,Markdown 只是投影。本日總結 (Takeaways)
user_id)必須在文件、schema、程式三處一致。明日預告 (Next)
明天進入多 Agent 協同工作流 (Multi-Agent Workflows):觀念介紹與架構設計,把測試、審查、文件生成拆成 Planner / Implementer / Reviewer / Tester,並標出什麼時候不該拆。