# API Handler Adapter 规范

> Canonical path: `specs/api/handler-adapter.md`
> Related product specs: [`specs/trigger/trigger.md`](../trigger/trigger.md), [`specs/task/board.md`](../task/board.md), [`specs/message/delivery-runtime.md`](../message/delivery-runtime.md)

状态：living spec
范围：`internal/api` 及其 `internal/api/{domain}` packages 的 handler 重构模式，包括 middleware、typed request、handler adapter、JSON bind、error mapping、domain-local wrapper、依赖不变量和分阶段迁移标准。
非目标：重写 HTTP route 产品契约；全局改变 error/status/message；DTO/task contract 去重；把 root `internal/api` 扩展成 domain error/DTO hub；把 typed request 封装成 Gin facade v2。

---

## 1. 背景与目标

`internal/api` 当前存在大量低价值 handler 样板：

- 重复从 Gin context 取 authenticated user；
- 重复从 Gin context 取 current workspace；
- 重复 `ShouldBindJSON` 与 invalid-body response；
- 重复 `StoreError` / HTTP error 写出。

这些重复让每个 domain handler 都承担了相同的 HTTP plumbing，增加了文案不一致、错误映射漂移、依赖边界膨胀的风险。

本规范的目标是定义一套可渐进迁移的 handler 模式：

1. middleware 只负责前置条件与 context population；
2. typed request 暴露稳定的 user / workspace / request context；
3. handler adapter 统一写 response / error；
4. domain package 保留自己的兼容语义；
5. root `internal/api` 保持薄而通用，不成为新的 domain hub。

---

## 2. 核心决策

### 2.1 Middleware 只处理 cross-cutting preconditions

保留现有 middleware 职责：

- `RequireAuthenticatedUser`：解析 bearer token，写入 authenticated user；
- `RequireVerifiedIdentity`：校验 email/mobile verification；
- `RequireCurrentWorkspace`：校验 `X-Workspace-Id` 与当前用户 workspace 权限，写入 current workspace。

禁止把 `RequireCurrentWorkspace` 全局挂到整个 `/api`。`auth`、`workspace`、`public`、`admin`、`org`、internal routes 等有不同 scope，必须继续由各 domain route group 显式选择 middleware。

### 2.2 Typed handler 代替 raw Gin handler 样板

新 handler 目标形态：

```go
func (handler *DetailHandler) Get(request api.WorkspaceScopedRequest) (api.Response, error) {
    result, err := handler.service.GetForOwner(
        request.Context(),
        request.UserID(),
        request.WorkspaceID(),
        strings.TrimSpace(request.Gin.Param("id")),
    )
    if err != nil {
        return api.Response{}, err
    }
    return api.OK(result), nil
}
```

route 注册通过 adapter：

```go
workspaceScoped.GET("/items/:id", handleDomainWorkspace(handler.Get))
workspaceScoped.POST("/items", handleDomainWorkspaceJSON[dto.ItemMutationRequest](handler.Create))
```

handler 里不再重复：

- `api.AuthenticatedUserFromContext(ctx)`；
- `api.CurrentWorkspaceFromContext(ctx)`；
- `ctx.ShouldBindJSON(&request)`；
- `api.StoreError(ctx, err)`。

---

## 3. Root `internal/api` 边界

root `internal/api` 可以提供通用 adapter primitives：

- `AuthenticatedRequest`；
- `WorkspaceScopedRequest`；
- `AuthenticatedJSON[T]` / `WorkspaceScopedJSON[T]`；
- `Response` / `OK` / `Created` / `NoContent`；
- `Content`（seekable binary/media response，统一保留 headers、ETag、
  conditional request 与 byte range）；
- `HandleAuth` / `HandleWorkspaceScoped`；
- `BindJSON[T]`；
- `BindEmptyOrJSON[T]` as a low-level binder for domain-local legacy optional-body adapters；
- `HandleAuthJSON[T]` / `HandleWorkspaceScopedJSON[T]`；
- `HTTPError` / `NewHTTPError` / `NewHTTPErrorWithCode`；
- `ErrorClassifier` / `WithErrorClassifier`；
- `WithInvalidJSONMessage`。

root `internal/api` 不允许承担以下职责：

- 新增 domain DTO / presenter / converter；
- 维护所有 domain-specific error mapping；
- import `internal/api/{domain}` 子包来识别子包 error；
- import store/domain 子包只是为了做某个业务 domain 的 response mapping；
- 封装 Gin 的所有 request API，形成新的 facade。

原则：root `api` 是 HTTP adapter toolkit，不是业务编排层。

命名原则：新代码在代码层面统一使用 workspace 术语，例如 `RequireCurrentWorkspace`、`SetCurrentWorkspace`、`CurrentWorkspaceFromContext`、`WorkspaceScopedRequest`、`HandleWorkspaceScoped`、`HandleWorkspaceScopedJSON`。不要为旧 type/function 名保留 alias 或 shim；通过修改调用点完成一次性迁移。外部 `X-Workspace-Id` 是 canonical workspace header，内部代码不继续扩散旧 server/current-server 术语。

最终命名表：

| 语义 | 代码名 |
| --- | --- |
| current workspace middleware | `RequireCurrentWorkspace` |
| context setter | `SetCurrentWorkspace` |
| context getter | `CurrentWorkspaceFromContext` |
| workspace-scoped typed request | `WorkspaceScopedRequest` |
| auth-scoped JSON request | `AuthenticatedJSON[T]` |
| workspace-scoped JSON request | `WorkspaceScopedJSON[T]` |
| workspace-scoped handler adapter | `HandleWorkspaceScoped` |
| workspace-scoped JSON handler adapter | `HandleWorkspaceScopedJSON` |

旧代码名（`RequireCurrentServer`、`SetCurrentServer`、`CurrentServerFromContext`、`ServerRequest`、`HandleServer`、`HandleServerJSON` 等）不保留 alias/shim，必须通过修改调用点完成迁移。

---

## 4. Typed request 标准

### 4.1 `AuthenticatedRequest`

`AuthenticatedRequest` 表示已经通过 auth middleware 的请求。

推荐字段：

```go
type AuthenticatedRequest struct {
    Gin  *gin.Context
    User identity.AuthenticatedUser
}
```

推荐稳定 accessor：

```go
func (request AuthenticatedRequest) Context() context.Context
func (request AuthenticatedRequest) UserID() string
```

`Context()` 可以 nil-safe fallback 到 `context.Background()`。`UserID()` 只是纯字段 convenience，等价于 `request.User.User.ID`，不引入权限、空值或 error 语义。

### 4.2 `WorkspaceScopedRequest`

`WorkspaceScopedRequest` 表示已经通过 auth + current-workspace middleware 的请求。代码层面采用 workspace 术语，因为 handler 实际需要的是 workspace scope，且底层字段类型是 `workspace.Summary`。

推荐字段：

```go
type WorkspaceScopedRequest struct {
    AuthenticatedRequest
    Workspace workspace.Summary
}
```

推荐稳定 accessor：

```go
func (request WorkspaceScopedRequest) WorkspaceID() string
```

`Context()`、`UserID()`、`Gin` 与 `User` 通过 embedded `AuthenticatedRequest` 获得。`WorkspaceID()` 只是纯字段 convenience，等价于 `request.Workspace.WorkspaceID`。

本规范要求代码层面直接使用 `WorkspaceScopedRequest`，不通过 `type ServerRequest = WorkspaceScopedRequest` 这类 alias 过渡。`RequireCurrentServer`、`CurrentServerFromContext`、`SetCurrentServer` 等旧代码 API 必须使用 workspace 语义，并同步修改调用点，不保留 alias/shim。`X-Workspace-Id` 是 canonical workspace wire contract。

### 4.3 JSON request wrapper

JSON body binding 是 request scope 之外的正交语义。base `AuthenticatedRequest` / `WorkspaceScopedRequest` **保持非泛型**；不要把 base request 改成 `AuthenticatedRequest[T]` / `WorkspaceScopedRequest[T]`，也不要引入 `NoBody` 占位类型污染 GET / DELETE 等无 body handler。

JSON handler 使用专门的 wrapper：

```go
type AuthenticatedJSON[T any] struct {
    AuthenticatedRequest
    Body T
}

type WorkspaceScopedJSON[T any] struct {
    WorkspaceScopedRequest
    Body T
}
```

`Body` 表示已由 adapter 使用 Gin JSON binder 解析出的 request body。handler 内部仍通过 embedded request 使用 `Context()` / `UserID()` / `WorkspaceID()` / `Gin`。

Optional-body legacy routes are not a public typed-handler tier. Do not add `AuthenticatedOptionalJSON`, `WorkspaceScopedOptionalJSON`, or `Handle*OptionalJSON` wrappers to root `internal/api`. If an existing endpoint has an explicit empty-or-JSON wire contract, keep that exception domain-local in `routes.go` and use the low-level binder:

```go
func BindEmptyOrJSON[T any](ctx *gin.Context) (body T, hasBody bool, err error)
```

Empty-or-JSON semantics for such domain-local adapters:

- domain handler code must run route/domain preconditions before decoding when legacy behavior did so before reading the body;
- absent body, empty body, and whitespace-only body are valid and produce zero-value `body` with `hasBody == false`;
- `{}` is a present JSON body and produces zero-value `body` with `hasBody == true`;
- malformed JSON returns `400` using the same invalid-body message policy as strict JSON adapters.

Ordinary no-body routes use `AuthenticatedRequest` / `WorkspaceScopedRequest`. Required JSON routes use `AuthenticatedJSON[T]` / `WorkspaceScopedJSON[T]` and must continue to reject absent or empty bodies.

推荐 JSON handler 形态：

```go
func (handler *CreationHandler) Create(
    r api.WorkspaceScopedJSON[dto.ItemMutationRequest],
) (api.Response, error) {
    body := r.Body
    result, err := handler.service.CreateForOwner(
        r.Context(),
        r.UserID(),
        r.WorkspaceID(),
        body.Input(),
    )
    if err != nil {
        return api.Response{}, err
    }
    return api.OK(result), nil
}
```

root JSON adapter 签名：

```go
func HandleAuthJSON[T any](
    handler func(api.AuthenticatedJSON[T]) (api.Response, error),
    opts ...api.HandlerOption,
) gin.HandlerFunc

func HandleWorkspaceScopedJSON[T any](
    handler func(api.WorkspaceScopedJSON[T]) (api.Response, error),
    opts ...api.HandlerOption,
) gin.HandlerFunc

```

### 4.4 不封装 broad Gin API

以下不进入 typed request 标准 accessor：

- `Param`；
- `Query`；
- `Header`；
- `Bind` / `ShouldBindJSON`；
- file upload / raw body helpers。

读取 route param/query/header 仍可通过 `request.Gin` 使用；这不等于 handler
可以直接接收 `*gin.Context`。Seekable binary/media response 统一返回
`api.Content(...)`，由 adapter 内部调用 `http.ServeContent`。multipart、
upgrade stream 等缺失 typed primitive 的历史例外必须保留在 AST guardrail
基线中，并随迁移删除；不得把 typed request 扩展成 Gin facade v2。

---

## 5. JSON binding 策略

### 5.1 Root 默认文案

root `HandleAuthJSON[T]` / `HandleWorkspaceScopedJSON[T]` 的 default invalid-body 文案保持：

```text
Invalid request body
```

这是当前多数 API handler 的 legacy 主流文案。不得为了某个 domain 的小写文案把 root default 改成小写，因为这会隐式改变后续 `channel`、`task`、`message` 等迁移后的 response 行为。

### 5.2 Domain-local wrapper 保兼容文案

如果某个 domain 有特殊 legacy 文案，domain 自己定义本地 wrapper。

以 `trigger` 为例，legacy 文案是小写 `invalid request body`：

```go
func handleTriggerWorkspaceJSON[T any](
    handler func(api.WorkspaceScopedJSON[T]) (api.Response, error),
) gin.HandlerFunc {
    return api.HandleWorkspaceScopedJSON(
        handler,
        api.WithInvalidJSONMessage("invalid request body"),
    )
}
```

routes 使用本地 wrapper：

```go
workspaceScoped.POST("/triggers", handleTriggerWorkspaceJSON[dto.TriggerMutationRequest](handler.Create))
```

后续 domain 迁移规则：

- legacy 文案与 root default 一致：直接用 root adapter；
- legacy 文案不同：用 domain-local wrapper；
- 不允许通过 root default 改动来“顺便统一”所有 domain 的旧响应。

---

## 6. Error handling 策略

### 6.1 Handler 返回 error，adapter 写 response

迁移后的 handler 不应直接写 store error：

```go
// 不推荐
if err != nil {
    api.StoreError(ctx, err)
    return
}
```

应改为：

```go
if err != nil {
    return api.Response{}, err
}
```

adapter 负责调用统一 error writer。

### 6.2 Domain-specific classifier 留在 domain package

root `api` 只识别通用 API/store sentinel，例如 unauthorized、forbidden、not found、conflict、bad request 等。

domain-specific error mapping 必须由对应 domain package 拥有，例如：

```go
func classifyTaskError(err error) (api.ErrorResponse, bool) {
    switch {
    case errors.Is(err, taskboard.ErrConflict):
        return api.ErrorResponse{Status: http.StatusConflict, Message: err.Error()}, true
    default:
        return api.ErrorResponse{}, false
    }
}
```

route wrapper 组合 classifier：

```go
func handleTaskWorkspace(
    handler func(api.WorkspaceScopedRequest) (api.Response, error),
) gin.HandlerFunc {
    return api.HandleWorkspaceScoped(handler, api.WithErrorClassifier(classifyTaskError))
}
```

禁止把 taskboard、daemon、provider 等 domain-specific error 全部上收到 root `internal/api`。

---

## 7. Dependency invariant 策略

必需依赖是 construction-time invariant，不应在每个 business handler 中反复 `requireX()`。

### 7.1 标准做法

- 在 `RegisterRoutes` / `newRouteHandlers` / handler base constructor 边界处理依赖；
- business handler 直接使用已注入字段；
- handler 只处理业务输入和业务 error，不处理“理论上不该缺失的依赖”。

### 7.2 保留既有 nil-dependency 外部行为

如果现有 contract 要求 nil dependency 时 route 仍注册并返回固定错误，不要把这次 adapter 重构顺手改成 fail-fast 或不注册 route。

以 `trigger` 为例，现有外部行为是：

- `RegisterRoutes(router, Dependencies{})` 仍注册 trigger routes；
- 调用 handler 返回 `501`；
- response error 文案是 `Trigger service is not configured`。

低风险迁移方式：

1. 删除 per-handler `requireService()`；
2. 在 construction/base 边界把 nil `TriggerService` 替换成 `notConfiguredTriggerService{}`；
3. `notConfiguredTriggerService` 实现 `TriggerService` 全部方法，并统一返回：

   ```go
   api.NewHTTPError(http.StatusNotImplemented, "Trigger service is not configured")
   ```

4. business handler 直接调用 `handler.triggerService`；
5. 测试证明 nil-service 外部行为不变。

长期可以通过 dependency validation 做 fail-fast，但那是行为变更，必须单独声明和评审。

---

## 8. Domain package 推荐结构

每个 `internal/api/{domain}` 可以采用以下结构：

```text
internal/api/{domain}/
  routes.go              // route group registration + domain-local adapter wrapper functions
  handlers.go            // handler structs and constructor/base wiring
  api_*.go               // typed handler implementations
  errors.go              // domain-local ErrorClassifier (if needed)
  *_test.go              // adapter/default/legacy behavior tests
```

Domain-local adapter wrapper functions（如 `handleDomainWorkspace`、`handleDomainWorkspaceJSON`）直接定义在 `routes.go` 中，不需要单独文件。这些 wrapper 是纯 delegation 函数，负责：

1. 调用 root adapter（`api.HandleWorkspaceScoped` / `api.HandleAuth` 等）；
2. 如有 domain-specific error classifier，通过 `api.WithErrorClassifier(...)` 注入；
3. 如有特殊 invalid-body 文案，通过 `api.WithInvalidJSONMessage(...)` 注入。

示例 wrapper（直接在 `routes.go` 中定义）：

```go
func handleDomainWorkspace(handler api.WorkspaceScopedHandler) gin.HandlerFunc {
    return api.HandleWorkspaceScoped(handler)
}

func handleDomainWorkspaceJSON[T any](
    handler func(api.WorkspaceScopedJSON[T]) (api.Response, error),
) gin.HandlerFunc {
    return api.HandleWorkspaceScopedJSON[T](handler)
}
```

如果 domain 需要自定义 error classifier：

```go
func handleDomainWorkspace(handler api.WorkspaceScopedHandler) gin.HandlerFunc {
    return api.HandleWorkspaceScoped(handler, api.WithErrorClassifier(classifyDomainError))
}
```

如果 domain 有特殊 invalid-body 文案（如 `trigger` 的小写文案）：

```go
const triggerInvalidRequestBodyMessage = "invalid request body"

func handleTriggerWorkspaceJSON[T any](handler func(api.WorkspaceScopedJSON[T]) (api.Response, error)) gin.HandlerFunc {
    return api.HandleWorkspaceScopedJSON[T](handler, api.WithInvalidJSONMessage(triggerInvalidRequestBodyMessage))
}
```

---

## 9. 已迁移 domain 列表

以下 domain 已完成 adapter 模式迁移，使用 `HandleAuth` / `HandleWorkspaceScoped` / `HandleAuthJSON[T]` / `HandleWorkspaceScopedJSON[T]` typed handler adapter：

- `trigger` — workspace-scoped，含自定义 invalid-body 文案
- `channel` — workspace-scoped，含 `HandlerOption` variadic 透传
- `task` — workspace-scoped
- `message` — workspace-scoped
- `provider` — workspace-scoped
- `webagent` — workspace-scoped
- `role` — mixed scope（auth + workspace-scoped canonical Role routes）
- `app` — mixed scope（workspace-scoped + auth），含 optional JSON binding
- `auth` — auth-scoped
- `workspace` — auth-scoped
- `org` — auth-scoped
- `admin` — auth-scoped（直接使用 `api.Handle*`）
- `kb` — mixed scope（直接使用 `api.Handle*`）

未迁移的 domain（保留 raw Gin handler 或使用独立的 auth 模式）：

- `agent` — 使用独立的 bearer token auth 模式
- `daemon` — websocket，不适用 typed adapter
- `public` — 无 auth
- `realtime` — Socket.io
- `media` — 部分已迁移；workspace thumbnail 使用 typed
  `WorkspaceScopedRequest` + `api.Content`，multipart 与其余 legacy media
  handler 仍在 raw-handler 迁移基线中

代码术语要求：除非某个 route 确实只是 auth-scoped，否则代码应直接使用 workspace 术语（`RequireCurrentWorkspace`、`CurrentWorkspaceFromContext`、`WorkspaceScopedRequest`、`HandleWorkspaceScoped`、`HandleWorkspaceScopedJSON`）。不新增或保留旧 server/current-server 符号或 alias/shim。

---

## 10. 新增 domain handler 验收标准

新增 domain handler 或修改现有 handler 时，必须确保：

- handler 使用 typed request（`AuthenticatedRequest` / `WorkspaceScopedRequest`）或 JSON wrapper（`AuthenticatedJSON[T]` / `WorkspaceScopedJSON[T]`，或少数 domain-local empty-or-JSON wrapper），不直接使用 `AuthenticatedUserFromContext` / `CurrentWorkspaceFromContext` / `ShouldBindJSON`；
- binary/media read 使用 typed `api.Content`，不以 streaming 为由新增
  `*gin.Context` handler；
- `raw_gin_handler_baseline.txt` 必须与 AST 扫描结果精确一致；迁移 raw
  handler 时同步删除 baseline 项，禁止新增或复活；
- 代码使用 canonical workspace 术语，不新增旧 server/current-server 符号或 alias/shim；
- JSON handler 使用单参数 wrapper + `request.Body`（strict JSON）或 domain-local `DecodeBody()`（empty-or-JSON legacy route），不使用 `func(Request, T)` 双参数 JSON handler 形态；
- route path / method / middleware order / response status / error 文案不变；
- domain-local wrapper 定义在 `routes.go` 中，不创建单独的 helper 文件。

验证命令：

```bash
git diff --check
GOWORK=off go build ./...
GOWORK=off go vet ./...
GOWORK=off go test -run '^$' ./internal/api/...
```

---

## 11. 明确禁止的反模式

- 把 JSON bind 做成全局 middleware；
- 用 `ctx.Errors` tail middleware 承接所有业务 error，导致 control flow 隐式化；
- 全局给 `/api` 套 current-workspace middleware；
- 为减少 option 重复而改变 root invalid JSON default，造成其它 domain 文案漂移；
- 把 domain-specific error mapping 全部放入 root `internal/api`；
- 把 domain DTO/converter 上收到 root `internal/api/dto`；
- 在每个 handler 中重复检查必需依赖是否 nil；
- 保留或新增 `RequireCurrentServer` / `SetCurrentServer` / `CurrentServerFromContext` / `ServerRequest` / `HandleServer` / `HandleServerJSON` / `NewServerRequest` 等旧代码 API/alias/shim；
- 把 base `AuthenticatedRequest` / `WorkspaceScopedRequest` 改成泛型，或为无 body handler 引入 `NoBody` 占位模式；
- 把 typed request 扩展成 `Param/Query/Header/Bind` 全家桶 facade；
- 在 product handler method 中新增 `*gin.Context` 参数，或保留已迁移
  method 的 baseline 项以允许未来复活；
- 在 handler adapter cleanup 中顺手做 DTO/task contract 去重或 store interface 大迁移。

---

## 12. 设计原则总结

这套模式的核心不是引入大框架，而是把重复 HTTP plumbing 收敛到一层很薄、可测试、可渐进迁移的 adapter：

- precondition 由 middleware 负责；
- stable request facts 由 typed request 负责；
- response/error writing 由 adapter 负责；
- legacy 文案、domain error、依赖兼容行为由 domain package 自己负责；
- root `internal/api` 保持通用和稳定。

后续所有 `internal/api/{domain}` 重构都应先对照本规范，确认是否保持 scope、status、message、dependency behavior 兼容，再进入实现。
