input_output.md - maoxiaoyue/hypgo GitHub Wiki

Input/Output 機制詳解

HypGo Framework — Schema Input/Output 完整技術指南 版本:0.8.1-alpha | 2026-04


總覽

在 HypGo 中,InputOutput 是 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"(自動填入)
}

InputOutput 欄位帶有 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 就知道:

  1. 這個路由接受 CreateUserReq struct 作為 JSON body
  2. 可以去 app/models/ 找到 CreateUserReq 的欄位定義
  3. 生成的 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 時,框架會:

  1. 從 Schema Registry 查找 "POST /api/users" 的 Output 型別
  2. 把回應 body json.UnmarshalUserResp{}
  3. 檢查 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_typeoutput_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