-
Notifications
You must be signed in to change notification settings - Fork 0
contract.md
根據 schema-first 路由的 metadata 自動驗證 handler 行為,確保 AI 生成的程式碼符合宣告的合約。
- 設計理念
- 快速上手
- Test — 手動單一路由測試
- TestAll — 自動測試所有路由
- TestRoute — 簡易存在性測試
- Observe — 完整 HTTP 觀察模式
- HTML 報告
- CLI:hyp observe
- 驗證機制
- 自動測試生成策略
- 架構與依賴
AI 生成了一個 handler,怎麼知道它是對的?Contract Testing 讓「schema 即合約」——請求和回應的 body 結構都必須符合 Schema() 宣告的 Input/Output 型別:
Schema 宣告 Input: CreateUserRequest{Name, Email}
Schema 宣告 Output: UserResponse{ID, Name, Email}
↓
Request {"name":"alice"} ← 缺 email → Input 驗證失敗 ✅
Response {"id":1,"name":"alice"} ← 缺 email → Output 驗證失敗 ✅
| 模式 | 函式 | 特點 |
|---|---|---|
| Test | Test(t, r, TestCase{...}) |
手動指定路由、body、預期結果,精準控制 |
| TestAll | TestAll(t, r) |
自動生成並測試所有 schema 路由,一行程式碼 |
| Observe |
ObserveAll(t, r) / Observe(t, r, "func")
|
完整 HTTP 捕捉 + 逐步驗證 + HTML 報告 |
import ( "testing" "github.com/maoxiaoyue/hypgo/pkg/contract" ) // 方式一:手動測試單一路由 func TestCreateUser(t *testing.T) { contract.Test(t, setupRouter(), contract.TestCase{ Route: "POST /api/users", Input: `{"name":"alice","email":"alice@test.com"}`, ExpectStatus: 201, ExpectSchema: true, }) } // 方式二:自動測試所有 schema 路由 func TestAllContracts(t *testing.T) { contract.TestAll(t, setupRouter()) } // 方式三:觀察模式(完整報告) func TestObserveAll(t *testing.T) { contract.ObserveAll(t, setupRouter()) // → 生成 .hyp/observe_20260523_143022.html } // 方式四:觀察特定函式相關的路由 func TestObserveOrder(t *testing.T) { contract.Observe(t, setupRouter(), "createOrderHandler") }
Test 讓你精確控制一個路由的測試場景,適合邊界條件和錯誤路徑測試。
func Test(t *testing.T, r *router.Router, tc TestCase) bool
type TestCase struct { Route string // "METHOD /path",例如 "POST /api/users" Input string // JSON 請求 body Headers map[string]string // 自訂請求標頭 Query map[string]string // URL query 參數 ExpectStatus int // 期望的 HTTP 狀態碼(0 = 不驗證) ExpectSchema bool // 是否驗證回應符合 Output schema ExpectBody string // 精確比對回應 body(空字串 = 不驗證) }
// 基本狀態碼驗證 func TestGetUser(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "GET /api/users/1", ExpectStatus: 200, }) } // 帶 body 的 POST 測試,並驗證回應 schema func TestCreateUser(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "POST /api/users", Input: `{"name":"alice","email":"alice@test.com"}`, ExpectStatus: 201, ExpectSchema: true, }) } // 帶 query 參數 func TestSearchUsers(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "GET /api/users", Query: map[string]string{"page": "1", "limit": "10"}, ExpectStatus: 200, }) } // 帶認證標頭 func TestAuthRequired(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "GET /api/admin", Headers: map[string]string{"Authorization": "Bearer valid-token"}, ExpectStatus: 200, }) } // 精確 body 比對 func TestPingBody(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "GET /ping", ExpectStatus: 200, ExpectBody: `{"message":"pong"}`, }) }
TestAll 從 schema.Global() 取得所有已註冊的 schema 路由,自動生成測試案例並執行。
func TestAll(t *testing.T, r *router.Router)
func TestAllContracts(t *testing.T) { r := setupRouter() contract.TestAll(t, r) // 輸出類似: // --- PASS: TestAllContracts/POST_/api/users (0.00s) // --- PASS: TestAllContracts/GET_/api/users/:id (0.00s) // --- FAIL: TestAllContracts/DELETE_/api/users/:id (0.00s) // contract: status = 500, want 204 }
| 協議 | 行為 |
|---|---|
REST(Protocol 為空或 "rest") |
發送完整 HTTP 請求,驗證 Input/Output schema 和狀態碼 |
| gRPC / Bot / MCP / WebSocket / CLI | 驗證 schema 定義完整性(Command 非空、Summary 非空、型別名稱已填入) |
// 混合多協議 schema.RegisterGRPC("UserService/CreateUser", "Create user", req, resp) schema.RegisterBot("/start", "Start the bot", nil, WelcomeMsg{}) contract.TestAll(t, router) // → REST 路由:完整 HTTP 測試 // → gRPC / Bot 路由:驗證 schema 定義完整性
不需要 schema,只驗證路由是否存在且回傳正確狀態碼。
func TestRoute(t *testing.T, r *router.Router, method, path string, expectStatus int)
func TestHealthCheck(t *testing.T) { r := setupRouter() contract.TestRoute(t, r, "GET", "/health", 200) contract.TestRoute(t, r, "GET", "/metrics", 200) contract.TestRoute(t, r, "GET", "/nonexistent", 404) }
Observe 模式在執行 Contract Testing 的同時,完整捕捉每條路由的 HTTP 交換記錄,包含請求/回應的 headers、body,並逐步列出驗證結果,最終生成可視化的 HTML 報告。
與 TestAll 的差異:TestAll 只回傳 pass/fail 結果。Observe 額外捕捉完整的 HTTP 交換記錄,讓你看到實際發出了什麼、收到了什麼,以及每個驗證步驟為何通過或失敗。
// 觀察所有 schema-registered REST 路由 func ObserveAll(t *testing.T, r *router.Router, opts ...ObserveOptions) []ObserveResult // 觀察符合 funcName 過濾條件的路由 func Observe(t *testing.T, r *router.Router, funcName string, opts ...ObserveOptions) []ObserveResult
type ObserveOptions struct { // OutputPath 指定 HTML 報告的輸出路徑 // 預設:.hyp/observe_YYYYMMDD_HHMMSS.html OutputPath string // OpenBrowser 生成報告後自動在瀏覽器開啟 // macOS: open, Linux: xdg-open, Windows: start OpenBrowser bool // Silent 抑制 t.Logf 的報告路徑輸出 Silent bool }
// 觀察所有路由,生成 HTML 報告 func TestObserveAll(t *testing.T) { contract.ObserveAll(t, setupRouter()) // [observe] report → .hyp/observe_20260523_143022.html } // 觀察後自動開啟瀏覽器 func TestObserveWithBrowser(t *testing.T) { contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{ OpenBrowser: true, }) } // 觀察特定函式名稱相關的路由 func TestObserveCreateOrder(t *testing.T) { contract.Observe(t, setupRouter(), "createOrderHandler") // 只測試 handler 名稱含 "createOrderHandler" 的路由 } // 以路徑片段過濾 func TestObserveOrderRoutes(t *testing.T) { contract.Observe(t, setupRouter(), "/orders") // 測試所有路徑含 "/orders" 的路由 } // 以標籤過濾 func TestObservePayments(t *testing.T) { contract.Observe(t, setupRouter(), "payments") // 測試 Tags 含 "payments" 的路由 } // 以 Summary 關鍵字過濾 func TestObserveUserCreation(t *testing.T) { contract.Observe(t, setupRouter(), "建立使用者") } // 指定輸出路徑(不使用預設的 .hyp/ 目錄) func TestObserveCustomOutput(t *testing.T) { contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{ OutputPath: "testdata/observe_report.html", OpenBrowser: false, Silent: true, }) } // 在 CI 中使用(靜默模式) func TestObserveCI(t *testing.T) { results := contract.ObserveAll(t, setupRouter(), contract.ObserveOptions{ Silent: true, }) for _, r := range results { if !r.Pass { t.Errorf("路由 %s %s 驗證失敗:%s", r.Route.Method, r.Route.Path, r.FailReason) } } }
Observe 的 funcName 參數對以下四個維度做大小寫不敏感的子字串比對:
| 維度 | 範例 |
|---|---|
Handler 函式名稱(HandlerNames) |
"createOrder" 匹配 "controllers.CreateOrderHandler"
|
路由路徑(Path) |
"orders" 匹配 "/api/v1/orders/:id"
|
摘要(Summary) |
"建立" 匹配 "建立訂單"
|
標籤(Tags) |
"pay" 匹配 ["payments", "billing"]
|
空字串等同於 ObserveAll(不過濾)。
HandlerNames 自動填入:使用
r.Schema(...).Handle(myHandler)時,框架自動透過反射取得函式名稱並儲存至route.HandlerNames,無需手動填寫。
type ObserveResult struct { Route schema.Route // 路由的 schema metadata Request CapturedRequest // 實際發出的 HTTP 請求詳情 Response CapturedResponse // 實際收到的 HTTP 回應詳情 Steps []ValidationStep // 逐步驗證記錄 Pass bool // 整體是否通過 FailReason string // 失敗原因(Pass=false 時非空) Duration time.Duration // 請求執行耗時 Timestamp time.Time // 執行時間戳 } type CapturedRequest struct { Method string Path string // 含 query string 的完整 URL Headers map[string]string Body string } type CapturedResponse struct { StatusCode int Headers map[string]string Body string } type ValidationStep struct { Name string // 驗證步驟名稱 Pass bool Detail string // 詳細說明(通過:describes match;失敗:error message) }
每條路由依序執行以下驗證,結果記錄於 ObserveResult.Steps:
| 步驟 | 條件 | 說明 |
|---|---|---|
| Step 1: Status Code | 始終執行 | got X, want Y |
| Step 2: Response Body | 有 Output schema 時 | 檢查 body 非空 |
| Step 3: Input Schema | 有 Input schema 且有生成 body 時 | 驗證請求 body 符合 Input struct |
| Step 4: Output Schema | 有 Output schema 且 body 非空時 | 驗證回應 body 符合 Output struct |
Observe 模式自動生成完整的自包含 HTML 報告,儲存於 .hyp/observe_YYYYMMDD_HHMMSS.html。
-
深色主題(
--bg: #0f1419,--primary: #6ee7b7),與 HypGo 工具鏈視覺一致 - 摘要統計列:總路由數、通過、失敗、通過率
- 過濾條件顯示:一眼看出本次觀察的範圍
-
路由卡片:預設折疊,失敗路由自動展開
- Method badge(顏色對應 HTTP 方法)
- 路由路徑、Summary、Tags、Handler 函式名稱
- Input / Output Schema 型別名稱
- HTTP 請求詳情(方法、完整 URL、headers、body)
- HTTP 回應詳情(狀態碼、headers、body)
- 逐步驗證結果(✅ / ❌)
- 執行耗時
- 失敗原因 banner:失敗路由頂部顯示紅色說明文字
若需要自行控制 HTML 生成(例如整合到自訂報告系統):
import "github.com/maoxiaoyue/hypgo/pkg/contract" // 取得結果 results := contract.ObserveAll(t, r, contract.ObserveOptions{Silent: true}) // 手動生成 HTML html := contract.GenerateObserveHTML(results, "myFilter") os.WriteFile("custom_report.html", []byte(html), 0o644)
所有報告預設存放於 .hyp/ 目錄(自動建立)。建議將此目錄加入 .gitignore:
# HypGo generated files .hyp/
hyp observe 命令用於列出和開啟現有的 observe HTML 報告。
注意:CLI 命令只能管理已生成的報告,報告本身須透過 Go 測試(
contract.ObserveAll等函式)生成。
go install github.com/maoxiaoyue/hypgo/cmd/hyp@latest
# 確認 $GOPATH/bin 已在 PATH 中hyp observe [func-name] [flags]
Flags:
-o, --open 在系統預設瀏覽器中開啟報告(預設:最新報告)
-n int 指定開啟第 n 個報告(1 = 最新,2 = 次新,...)(預設:1)
# 列出所有可用報告 hyp observe # 輸出: # 📊 找到 3 個 observe 報告: # # ▶ [1] observe_20260523_143022.html 42.1 KB # [2] observe_20260522_091845.html 38.7 KB # [3] observe_20260521_163310.html 35.2 KB # # 💡 使用 `hyp observe --open` 在瀏覽器中開啟最新報告 # 開啟最新報告 hyp observe --open # 開啟第 2 個(次新)報告 hyp observe --open -n 2 # 顯示如何觀察特定函式的提示 hyp observe createOrderHandler # 💡 提示:在測試中使用以下命令觀察 "createOrderHandler" 相關路由: # contract.Observe(t, setupRouter(), "createOrderHandler")
先在測試中生成報告,再用 CLI 開啟:
# 1. 執行測試(生成報告) go test ./... -run TestObserve -v # 2. 列出報告 hyp observe # 3. 開啟最新報告 hyp observe --open
當 ExpectSchema: true(Test 模式)或 route 有 Output schema(Observe 模式)時:
- 從
schema.Global()查找路由的 schema - 取得
Output型別(Go struct) - 嘗試將回應 body JSON 反序列化為該 struct
- 檢查所有必填欄位是否存在
// schema 宣告 r.Schema(schema.Route{ Method: "GET", Path: "/api/users/:id", Output: UserResponse{}, // 有 ID, Name, Email 三個必填欄位 }).Handle(getUserHandler) // 若 handler 回傳 {"id":1} → 失敗(缺 name, email) // 若 handler 回傳 {"id":1,"name":"a","email":"b"} → 通過
| struct tag 形式 | Required? | 說明 |
|---|---|---|
json:"name" |
✅ 是 | 無 omitempty,非 pointer |
json:"bio,omitempty" |
❌ 否 | 有 omitempty |
Bio *string \json:"bio"`` |
❌ 否 | pointer 型別視為可選 |
Contract Testing 同時驗證 Input(請求 body)和 Output(回應 body):
-
Input 驗證:Auto-generated body 或
tc.Input是否符合 Input struct 的必填欄位 - Output 驗證:Handler 回應的 JSON 是否包含所有必填欄位
TestAll 和 Observe 使用相同的自動生成策略,基於 schema metadata 產生測試案例。
| 路徑 | 解析結果 |
|---|---|
/api/users/:id |
/api/users/1 |
/api/users/:userId/posts/:postId |
/api/users/1/posts/1 |
/api/:slug |
/api/test-slug |
/api/:name |
/api/test |
/files/*filepath |
/files/test.txt |
所有 :param 佔位符均替換為對應的預設值,確保請求能路由到正確的 handler。
| 條件 | 推測的狀態碼 |
|---|---|
Responses 中有明確宣告的 2xx |
使用最小的 2xx(例如 201) |
| POST(無宣告) | 201 |
| DELETE(無宣告) | 204 |
| 其他(無宣告) | 200 |
僅對 POST、PUT、PATCH 自動生成 body,根據 Input struct 欄位填入合理預設值:
| Go 型別 | 生成的值 |
|---|---|
string |
"test" |
int, int64
|
0 |
float64 |
0.0 |
bool |
false |
[]T |
[] |
map[K]V |
{} |
package api_test import ( "testing" "github.com/maoxiaoyue/hypgo/pkg/contract" hypcontext "github.com/maoxiaoyue/hypgo/pkg/context" "github.com/maoxiaoyue/hypgo/pkg/router" "github.com/maoxiaoyue/hypgo/pkg/schema" ) type CreateUserReq struct { Name string `json:"name"` Email string `json:"email"` } type UserResp struct { ID int `json:"id"` Name string `json:"name"` Email string `json:"email"` } func setupRouter() *router.Router { r := router.New() r.Schema(schema.Route{ Method: "POST", Path: "/api/users", Summary: "建立使用者", Tags: []string{"users"}, Input: CreateUserReq{}, Output: UserResp{}, Responses: map[int]schema.ResponseSchema{ 201: {Description: "Created"}, }, }).Handle(createUserHandler) r.Schema(schema.Route{ Method: "GET", Path: "/api/users/:id", Summary: "查詢使用者", Tags: []string{"users"}, Output: UserResp{}, }).Handle(getUserHandler) r.GET("/health", healthHandler) return r } func createUserHandler(c *hypcontext.Context) { c.JSON(201, UserResp{ID: 1, Name: "alice", Email: "alice@test.com"}) } func getUserHandler(c *hypcontext.Context) { c.JSON(200, UserResp{ID: 1, Name: "alice", Email: "alice@test.com"}) } func healthHandler(c *hypcontext.Context) { c.JSON(200, map[string]string{"status": "ok"}) }
// 基本合約測試 func TestCreateUser(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "POST /api/users", Input: `{"name":"alice","email":"alice@test.com"}`, ExpectStatus: 201, ExpectSchema: true, }) } // 缺少必填欄位 → 應測試 handler 的 validation 行為 func TestCreateUserMissingEmail(t *testing.T) { r := setupRouter() contract.Test(t, r, contract.TestCase{ Route: "POST /api/users", Input: `{"name":"alice"}`, ExpectStatus: 400, // handler 應回傳 400 }) }
func TestAllContracts(t *testing.T) { schema.Global().Reset() // 清除其他測試留下的 schema r := setupRouter() contract.TestAll(t, r) }
// 觀察所有路由(開發階段除錯用) func TestObserveAll(t *testing.T) { r := setupRouter() contract.ObserveAll(t, r) } // 觀察特定函式,並在瀏覽器中開啟結果 func TestObserveCreateUser(t *testing.T) { r := setupRouter() contract.Observe(t, r, "createUserHandler", contract.ObserveOptions{ OpenBrowser: true, }) } // 觀察 "users" 相關的所有路由 func TestObserveUserRoutes(t *testing.T) { r := setupRouter() results := contract.Observe(t, r, "users", contract.ObserveOptions{ Silent: true, }) // 可對結果進行程式化斷言 for _, res := range results { if !res.Pass { t.Errorf("[%s %s] %s", res.Route.Method, res.Route.Path, res.FailReason) } } }
pkg/contract/
├── contract.go Test()、TestAll()、TestRoute() — 核心測試函式
├── validate.go validateResponse()、validateRequest()、validateRequiredFields()
├── generate.go generateTestCase()、generateMinimalJSON()、resolvePath()
├── observe.go ObserveAll()、Observe()、captureRouteExchange() 🆕
├── report.go GenerateObserveHTML() — HTML 報告生成 🆕
├── contract_test.go 24 個單元測試
└── observe_test.go 34 個 Observe 相關測試 🆕
pkg/contract → pkg/router (Router.ServeHTTP 執行 HTTP 請求)
→ pkg/schema (Global() 查找 schema metadata)
→ net/http/httptest (模擬 HTTP 請求/回應)
→ html (report.go HTML 轉義)
→ os / filepath (report.go 檔案輸出)
→ os/exec / runtime (observe.go 瀏覽器開啟)
Schema-first 路由 Contract Testing
┌──────────────────────┐ ┌──────────────────────────────┐
│ Route{ │ │ Test:ExpectSchema: true │
│ Input: Req{} │────→│ TestAll:自動 schema 驗證 │
│ Output: Resp{} │ │ Observe:捕捉完整 HTTP 交換 │
│ HandlerNames: [..] │ │ + 逐步驗證 │
│ Tags: [..] │ │ + HTML 報告 │
│ } │ └──────────────────────────────┘
└──────────────────────┘ │
│ │
└────── schema.Global() ─────┘
(共用 Registry)
| 功能 | Test | TestAll | Observe |
|---|---|---|---|
| 手動指定請求 body | ✅ | ❌(自動生成) | ❌(自動生成) |
| 自動生成所有路由 | ❌ | ✅ | ✅ |
| 過濾特定路由 | ✅(直接指定) | ❌ | ✅(funcName 過濾) |
| 捕捉完整 HTTP 交換 | ❌ | ❌ | ✅ |
| 逐步驗證記錄 | ❌ | ❌ | ✅ |
| HTML 報告 | ❌ | ❌ | ✅ |
| 回傳結果物件 | bool | 無 | []ObserveResult |
| 適用場景 | 精準邊界測試 | CI 全覆蓋驗證 | 除錯 / AI 觀察 / 文檔 |
不使用 Schema() 註冊的路由無法進行 schema 驗證,但仍可使用 TestRoute() 檢查狀態碼。
設計文件
套件
- config — 設定
- context — 請求上下文
- router — 路由器
- server — 伺服器
- middleware — 中介層
- websocket — WebSocket
- hidb — 資料庫 ORM
- hidb/cassandra — Cassandra
- logger — 日誌
- json — JSON 處理
- grpc — gRPC
AI 協作工具鏈
- schema — Schema-first 路由
- manifest — 專案 Manifest
- contract — Contract Testing
- errors — Typed Error Catalog
- migrate — Migration Diff
- scaffold — 智慧 Scaffold
- airules — AI Rules
CLI 命令
- hyp 總覽
- hyp new
- hyp api
- hyp run
- hyp restart
- hyp generate
- hyp migrate
- hyp context
- hyp ai-rules
- hyp chkcomment
- hyp impact
- hyp docker
- hyp health
- hyp version
- hyp difflog
Design Docs
Packages
- config — Configuration
- context — Request Context
- router — Router
- server — Server
- middleware — Middleware
- websocket — WebSocket
- hidb — Database ORM
- hidb/cassandra - Cassandra 5.0
- logger — Logger
- json — JSON
- grpc — gRPC
AI Collaboration Toolchain
- schema — Schema-first Routing
- manifest — Project Manifest
- contract — Contract Testing
- errors — Typed Error Catalog
- migrate — Migration Diff
- scaffold — Smart Scaffold
- airules — AI Rules
CLI Commands