iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 14

Day 14: API 文件自動化:從 Swagger/OpenAPI 一鍵生成 Markdown 規格書

  • 分享至 

  • xImage
  •  

Day 14: API 文件自動化:從 Swagger/OpenAPI 一鍵生成 Markdown 規格書 (OpenAPI to Markdown Docs)

本日核心價值 (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 參數 orderIdPOST 的 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.pygit 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_idbearerAuth:前者應出現在 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 驗證。
  • YAML 失敗就整份重寫:模型補上不存在的 endpoint。修法:Prompt 要求 needs-human 清單,禁止發明路徑。
  • Example 與 schema 不一致:schema 要求 user_id,example 仍放 user。修法:生成後人工抽一條 POST 對 schema required;Day 17 再自動化。
  • 把 Markdown 當授權來源:文件寫了 bearer,實際 action 沒掛 filter。修法:auth 以程式與 OpenAPI security 為準,Markdown 只是投影。

本日總結 (Takeaways)

  • OpenAPI JSON → 腳本 → Markdown:列出 method、path、auth、params、example req/res。
  • 兩個 endpoint 的最小 spec 就夠驗證工作流;不要從聊天視窗手寫規格書。
  • YAML 混亂時用 Codex 正規化成 JSON,並禁止發明路徑或改欄位名。
  • 生成文件會與 code 漂移;命令先固定,CI 閘道放到 Day 17。
  • 契約欄位(扁平 user_id)必須在文件、schema、程式三處一致。

明日預告 (Next)

明天進入多 Agent 協同工作流 (Multi-Agent Workflows):觀念介紹與架構設計,把測試、審查、文件生成拆成 Planner / Implementer / Reviewer / Tester,並標出什麼時候不該拆。


上一篇
Day 13: 快速 Debug 工作流:結合 Error Logs 與 AI 自動對齊 Stack Trace 定位問題
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言