input_output.md - maoxiaoyue/hypgo GitHub Wiki
Input/Output 機制詳解
HypGo Framework — Schema Input/Output 完整技術指南 版本:0.8.1-alpha | 2026-04
總覽
在 HypGo 中,Input 和 Output 是 Schema-first 路由的核心概念。它們不只是文檔,而是可執行的合約 — 框架用它們來驗證請求、測試回應、生成 manifest。
Input(請求合約) Output(回應合約)
│ │
├→ Contract Testing 驗證請求 body ├→ Contract Testing 驗證回應 body
├→ Manifest 記錄 input_type ├→ Manifest 記錄 output_type
├→ AI 知道要傳什麼 ├→ AI 知道會收到什麼
└→ 自動生成測試資料 └→ 自動驗證必填欄位
第一部分:定義 Input/Output
基本規則
Input 和 Output 都是普通的 Go struct,透過 json tag 定義 JSON 欄位名:
// Input — 客戶端發送的請求 body
type CreateUserReq struct {
Name string `json:"name"` // 必填(非 pointer、無 omitempty)
Email string `json:"email"` // 必填
Bio string `json:"bio,omitempty"` // 選填(有 omitempty)
Age *int `json:"age"` // 選填(pointer)
}
// Output — 伺服器回傳的回應 body
type UserResp struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Bio string `json:"bio,omitempty"`
CreatedAt string `json:"created_at"`
}
必填 vs 選填的判斷規則
框架透過 reflect 自動判斷每個欄位是否為必填:
| 條件 | 判斷結果 | 範例 |
|---|---|---|
非 pointer + 無 omitempty |
必填 | Name string \json:"name"`` |
有 omitempty |
選填 | Bio string \json:"bio,omitempty"`` |
| pointer 型別 | 選填 | Age *int \json:"age"`` |
json:"-" |
忽略(不出現在 JSON 中) | Internal string \json:"-"`` |
內部實作(pkg/schema/reflect.go):
required := !omitempty && f.Type.Kind() != reflect.Ptr
這一行決定了 Contract Testing 是否會檢查該欄位的存在。
Input ≠ Output ≠ DB Model
三者有不同的職責,不應混用:
// ❌ 錯誤:用 DB model 當 Input
r.Schema(schema.Route{
Input: models.User{}, // 包含 ID、CreatedAt — 客戶端不該提供
})
// ✅ 正確:分開定義
type User struct { // DB model — 對應資料庫 table
ID int64
Name string
CreatedAt time.Time
}
type CreateUserReq struct { // Input — 只有客戶端可提供的欄位
Name string `json:"name"`
Email string `json:"email"`
}
type UserResp struct { // Output — 只有要回傳的欄位
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
| struct | 用途 | 包含 ID? | 包含密碼? | 在哪裡用 |
|---|---|---|---|---|
User |
DB model | ✅ | ✅ | Bun ORM |
CreateUserReq |
Input | ❌ | ❌ | Schema Input |
UserResp |
Output | ✅ | ❌ | Schema Output |
第二部分:在 Schema 中使用 Input/Output
註冊時傳入 struct 實例
r.Schema(schema.Route{
Method: "POST",
Path: "/api/users",
Summary: "Create user",
Input: CreateUserReq{}, // ← 傳入零值實例,框架只用型別資訊
Output: UserResp{}, // ← 傳入零值實例
}).Handle(createUserHandler)
重要:傳的是 CreateUserReq{}(零值實例),不是指標 &CreateUserReq{}。框架只讀取型別資訊(reflect.TypeOf),不使用實例的值。
框架在註冊時做了什麼
當你呼叫 router.Schema(route).Handle(handler) 時,框架內部執行:
1. NewSchemaRoute(route, registrar)
│
├─ route.Input != nil ?
│ └─ route.InputName = TypeName(route.Input)
│ → reflect.TypeOf(CreateUserReq{}).Name() → "CreateUserReq"
│
├─ route.Output != nil ?
│ └─ route.OutputName = TypeName(route.Output)
│ → "UserResp"
│
└─ Responses 中的 Type != nil ?
└─ resp.TypeName = TypeName(resp.Type)
2. registrar.RegisterSchema(route, handlers...)
│
├─ router.addRoute(method, path, handlers) ← 正常路由註冊
│
└─ schema.Global().Register(route) ← 存入全域 Schema Registry
→ key = "POST /api/users"
→ value = Route{Input: CreateUserReq{}, Output: UserResp{}, ...}
Schema Registry 儲存了什麼
// 全域 Registry 結構
type Registry struct {
schemas []Route // 有序列表
byKey map[string]*Route // "METHOD /path" → Route
}
// 每個 Route 中與 Input/Output 相關的欄位:
type Route struct {
Input interface{} // Go struct 零值(CreateUserReq{})
Output interface{} // Go struct 零值(UserResp{})
InputName string // "CreateUserReq"(自動填入)
OutputName string // "UserResp"(自動填入)
}
Input 和 Output 欄位帶有 json:"-" tag,所以它們不會出現在 JSON/YAML 序列化中。Manifest 使用 InputName / OutputName(字串)。
第三部分:Input/Output 在各層的作用
3.1 Manifest(AI 理解層)
hyp context 或 AutoSync 產出的 manifest 中,Input/Output 以型別名稱呈現:
routes:
- method: POST
path: /api/users
summary: "Create user"
input_type: CreateUserReq # ← InputName
output_type: UserResp # ← OutputName
handler_names: [controllers.CreateUser]
AI 看到 input_type: CreateUserReq 就知道:
- 這個路由接受
CreateUserReqstruct 作為 JSON body - 可以去
app/models/找到CreateUserReq的欄位定義 - 生成的 handler 要用
c.ShouldBindJSON(&req)解析
3.2 Contract Testing(驗證層)
自動測試(TestAll)
contract.TestAll(t, router) 對每個 schema 路由自動執行:
對每個路由:
1. 有 Input?
└─ generateMinimalJSON(route.Input)
→ 為每個欄位生成合理值:
string → "test"
int → 0
bool → false
slice → []
→ 結果:{"name":"test","email":"test","bio":"test"}
2. 發送 HTTP 請求
→ method = route.Method
→ path = resolvePath(route.Path) // :id → "1"
→ body = 生成的 JSON
3. 有 Output?
└─ validateResponse(responseBody, route.Output)
│
├─ json.Unmarshal(body, &OutputType{})
│ → 確認 JSON 格式正確、欄位型別匹配
│
└─ validateRequiredFields(body, OutputType)
→ 遍歷 Output struct 的所有欄位
→ 非 pointer + 非 omitempty → 必填
→ 必填欄位不在 JSON 中 → 回報錯誤
4. 驗證狀態碼
→ Responses 有宣告?用最小的 2xx 狀態碼
→ 沒有宣告?POST→201, DELETE→204, 其他→200
手動測試(Test)
contract.Test(t, router, contract.TestCase{
Route: "POST /api/users",
Input: `{"name":"alice","email":"[email protected]"}`,
ExpectStatus: 201,
ExpectSchema: true, // ← 啟用 Output 驗證
})
ExpectSchema: true 時,框架會:
- 從 Schema Registry 查找
"POST /api/users"的 Output 型別 - 把回應 body
json.Unmarshal進UserResp{} - 檢查
UserResp中所有必填欄位都存在於回應中
3.3 Scaffold(生成層)
hyp generate model user 自動生成配對的 struct:
// 生成的 Input struct
type CreateUserReq struct {
Name string `json:"name"` // 必填
Description string `json:"description,omitempty"` // 選填
}
type UpdateUserReq struct {
Name string `json:"name,omitempty"` // 全部選填(部分更新)
Description string `json:"description,omitempty"`
Active *bool `json:"active,omitempty"` // pointer = 選填
}
hyp generate controller user 生成的 handler 自動使用:
func (ctrl *UserController) Create(c *hypcontext.Context) {
var req models.CreateUserReq // ← 引用 Input struct
if err := c.ShouldBindJSON(&req); err != nil {
errors.AbortWithAppError(c, ErrUserInvalid.With("reason", err.Error()))
return
}
// ... 業務邏輯 ...
c.JSON(201, models.UserResp{ // ← 引用 Output struct
ID: user.ID,
Name: req.Name,
})
}
3.4 AI Rules(跨工具層)
hyp ai-rules 生成的 AGENTS.md 包含路由表(如果 manifest 存在):
## Current Routes
| Method | Path | Summary |
|--------|------|---------|
| POST | /api/users | Create user |
| GET | /api/users/:id | Get user |
AI 工具看到這個表格,就知道去 manifest 查 input_type 和 output_type。
第四部分:型別反射機制
TypeName — 取得型別名稱
schema.TypeName(CreateUserReq{}) → "CreateUserReq"
schema.TypeName(&CreateUserReq{}) → "CreateUserReq" // 自動解指標
schema.TypeName((*UserResp)(nil)) → "UserResp" // nil 指標也行
schema.TypeName(nil) → ""
schema.TypeName(42) → "int"
FieldsOf — 取得欄位清單
fields := schema.FieldsOf(CreateUserReq{})
// [
// {Name: "name", Type: "string", Required: true},
// {Name: "email", Type: "string", Required: true},
// {Name: "bio", Type: "string", Required: false}, // omitempty
// {Name: "age", Type: "integer", Required: false}, // pointer
// ]
ValidateJSON — 驗證 JSON 合規性
// 通過
err := schema.ValidateJSON(
[]byte(`{"name":"alice","email":"[email protected]"}`),
CreateUserReq{},
)
// err == nil
// 失敗:缺少必填欄位
err := schema.ValidateJSON(
[]byte(`{"bio":"hello"}`),
CreateUserReq{},
)
// err == "missing required field: name"
// 失敗:JSON 格式錯誤
err := schema.ValidateJSON(
[]byte(`{invalid json}`),
CreateUserReq{},
)
// err == "JSON does not match schema CreateUserReq: ..."
GenerateZeroJSON — 生成零值 JSON
data := schema.GenerateZeroJSON(CreateUserReq{})
// {"name":"","email":"","bio":"","age":null}
data := schema.GenerateZeroJSON(nil)
// {}
Go 型別 → Schema 型別對照
| Go 型別 | Schema 型別 |
|---|---|
string |
"string" |
int, int64, uint |
"integer" |
float32, float64 |
"number" |
bool |
"boolean" |
[]T |
"array" |
map[K]V, struct |
"object" |
第五部分:完整生命週期
一個 Input/Output struct 從定義到驗證的完整路徑:
👤 人定義 struct
│
▼
┌──────────────────────────────────────────────────────┐
│ type CreateUserReq struct { │
│ Name string `json:"name"` │
│ Email string `json:"email"` │
│ } │
└──────────────────────────────────────────────────────┘
│
▼
👤 人在 Schema 路由中引用
│
▼
┌──────────────────────────────────────────────────────┐
│ r.Schema(schema.Route{ │
│ Input: CreateUserReq{}, ← 型別資訊進入框架 │
│ Output: UserResp{}, │
│ }).Handle(handler) │
└──────────────────────────────────────────────────────┘
│
├──→ 🔧 Schema Registry 儲存型別
│ key: "POST /api/users"
│ Input: CreateUserReq{}(reflect.Type)
│ InputName: "CreateUserReq"(string)
│
├──→ 🔧 Manifest 輸出型別名稱
│ input_type: CreateUserReq
│ output_type: UserResp
│
├──→ 🤖 AI 讀 manifest → 知道 Input/Output
│ → 生成 handler 時使用正確的 struct
│
└──→ 🔧 Contract Testing 驗證
TestAll()
│
├─ generateMinimalJSON(CreateUserReq{})
│ → {"name":"test","email":"test"}
│
├─ 發送 POST /api/users,body = 上述 JSON
│
└─ validateResponse(body, UserResp{})
→ json.Unmarshal 到 UserResp
→ 檢查 id、name、email 都存在
第六部分:常見模式
列表回應(帶分頁)
type UserListResp struct {
Data []UserResp `json:"data"`
Total int `json:"total"`
}
r.Schema(schema.Route{
Method: "GET",
Path: "/api/users",
Output: UserListResp{}, // ← 列表用 ListResp
}).Handle(ctrl.List)
部分更新(PATCH/PUT)
type UpdateUserReq struct {
Name string `json:"name,omitempty"` // 全部 omitempty
Email string `json:"email,omitempty"`
Active *bool `json:"active,omitempty"` // 用 pointer 區分「沒傳」vs「傳 false」
}
無 body 的路由
// GET — 無 Input
r.Schema(schema.Route{
Method: "GET",
Path: "/api/users/:id",
Output: UserResp{}, // 只有 Output
}).Handle(ctrl.Get)
// DELETE — 無 Input、無 Output
r.Schema(schema.Route{
Method: "DELETE",
Path: "/api/users/:id",
// Input: nil, Output: nil
}).Handle(ctrl.Delete)
錯誤回應
錯誤不用定義在 Output 中。HypGo 使用 Error Catalog 統一處理:
// 定義錯誤碼
var ErrUserNotFound = errors.Define("E_user_001", 404, "User not found", "user")
// Handler 中使用
func (ctrl *UserController) Get(c *hypcontext.Context) {
user := findUser(id)
if user == nil {
errors.AbortWithAppError(c, ErrUserNotFound.With("id", id))
return // ← 回傳 {"code":"E_user_001","message":"User not found","details":{"id":"1"}}
}
c.JSON(200, UserResp{...}) // ← 回傳 Output struct
}
Contract Testing 只驗證成功路徑的 Output。錯誤路徑由 Error Catalog 保證格式一致。
第七部分:除錯技巧
查看某個路由的 Input/Output
route, ok := schema.Global().Get("POST", "/api/users")
if ok {
fmt.Printf("Input: %s\n", route.InputName) // "CreateUserReq"
fmt.Printf("Output: %s\n", route.OutputName) // "UserResp"
// 查看 Input 欄位
fields := schema.FieldsOf(route.Input)
for _, f := range fields {
fmt.Printf(" %s (%s) required=%v\n", f.Name, f.Type, f.Required)
}
}
手動驗證 JSON
err := schema.ValidateJSON(
[]byte(`{"name":"alice"}`), // 缺少 email
CreateUserReq{},
)
fmt.Println(err) // "missing required field: email"
查看 Contract Testing 為某路由生成的測試資料
route := schema.Route{
Method: "POST",
Path: "/api/users",
Input: CreateUserReq{},
}
// 框架內部用的是 generateMinimalJSON(unexported),但你可以用 GenerateZeroJSON
data := schema.GenerateZeroJSON(CreateUserReq{})
fmt.Println(string(data))
// {"name":"","email":"","bio":"","age":null}
HypGo · Input/Output 機制詳解 · 2026-04