feat(contract,gateway): B4 枚举权威表 — GET /api/v1/meta/enums + 未知值回落约定
- 新增 internal/contract/enums 叶子包:platform/mood/mode 三表 + UnknownFallback="display-raw";每次调用返回新切片,无包级可变全局 - GET /api/v1/meta/enums(anyAuth 只读、纯投影、无 store/无错误路径) - 反漂移 4 处:真构造内置适配器断言 Name() 与表双向相等、真工厂 buildAccountAdapter 逐项可建、mood 对齐 emotion 常量 + 默认转移表、mode 对齐前端 MODES(跨语言,文件缺失则 skip) - Docs/api-client §1.1 枚举权威表 + 行15 + v0.38;api-reference §3.7 + 行12
This commit is contained in:
1 parent
a828edacc5
commit
22e8398110
8 files changed
+617
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/contract/enums"
|
||||
)
|
||||
|
||||
// This file owns the read-only client metadata surface: GET /api/v1/meta/enums,
|
||||
// which publishes the authoritative platform/mood/mode tables (Android
|
||||
// collaboration B4, design/07 §6). Clients (Web, Android, an IM bridge) read the
|
||||
// tables instead of hardcoding their own id → label mapping, so a source badge or
|
||||
// a mood label cannot drift per client. The tables themselves live in
|
||||
// internal/contract/enums; this handler only serves them, so there is one source
|
||||
// of truth rather than a copy per consumer.
|
||||
|
||||
// metaEnums is the GET /api/v1/meta/enums payload. `unknown` carries the
|
||||
// machine-readable fallback policy (enums.UnknownFallback) so a client reads the
|
||||
// tables and the rule for an unlisted value from the same response.
|
||||
type metaEnums struct {
|
||||
Platforms []enums.Item `json:"platforms"`
|
||||
Moods []enums.Item `json:"moods"`
|
||||
Modes []enums.Item `json:"modes"`
|
||||
Unknown string `json:"unknown"`
|
||||
}
|
||||
|
||||
// handleListEnums serves the shared client enum tables. It is a pure projection
|
||||
// of the Go-side authority (no store, no error path) and contains nothing
|
||||
// sensitive, so it is gated like GET /api/v1/sessions: any authenticated role may
|
||||
// read it.
|
||||
//
|
||||
// Adding a value to a table is a non-breaking change for existing clients, which
|
||||
// must display an unlisted value raw (see enums.UnknownFallback) rather than
|
||||
// dropping it or erroring.
|
||||
func (s *Server) handleListEnums(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, metaEnums{
|
||||
Platforms: enums.Platforms(),
|
||||
Moods: enums.Moods(),
|
||||
Modes: enums.Modes(),
|
||||
Unknown: enums.UnknownFallback,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
)
|
||||
|
||||
// These tests cover the shared client enum endpoint (Android collaboration B4,
|
||||
// design/07 §6): the wire shape of GET /api/v1/meta/enums, the values it
|
||||
// publishes, and the fact that it is readable by any authenticated role. They
|
||||
// assert the JSON contract with literals rather than by comparing against
|
||||
// internal/contract/enums, so the client-facing shape is pinned independently of
|
||||
// the Go table (the table's own drift tests live in that package).
|
||||
|
||||
// enumEntry is the wire shape of one published enum item.
|
||||
type enumEntry struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// metaEnumsWire decodes GET /api/v1/meta/enums and asserts the envelope and the
|
||||
// per-item shape, returning the ids of each table for value assertions.
|
||||
func metaEnumsWire(t *testing.T, ts *httptest.Server, tok string) (platforms, moods, modes []string, unknown string) {
|
||||
t.Helper()
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/meta/enums", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
env := decodeBody(t, body)
|
||||
if len(env) != 1 {
|
||||
t.Fatalf("envelope keys = %v, want only data", env)
|
||||
}
|
||||
var data struct {
|
||||
Platforms []enumEntry `json:"platforms"`
|
||||
Moods []enumEntry `json:"moods"`
|
||||
Modes []enumEntry `json:"modes"`
|
||||
Unknown string `json:"unknown"`
|
||||
}
|
||||
if err := json.Unmarshal(env["data"], &data); err != nil {
|
||||
t.Fatalf("decode data: %v (body=%s)", err, body)
|
||||
}
|
||||
for _, table := range []struct {
|
||||
name string
|
||||
entries []enumEntry
|
||||
}{{"platforms", data.Platforms}, {"moods", data.Moods}, {"modes", data.Modes}} {
|
||||
if len(table.entries) == 0 {
|
||||
t.Fatalf("%s is empty (body=%s)", table.name, body)
|
||||
}
|
||||
for _, e := range table.entries {
|
||||
if e.ID == "" || e.Label == "" {
|
||||
t.Errorf("%s entry %+v must carry both id and label", table.name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entryIDs(data.Platforms), entryIDs(data.Moods), entryIDs(data.Modes), data.Unknown
|
||||
}
|
||||
|
||||
func entryIDs(items []enumEntry) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestMetaEnumsShape pins the published contract: the three tables name the real
|
||||
// platform/mood/mode values and the response carries the unknown-value fallback
|
||||
// policy.
|
||||
func TestMetaEnumsShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
platforms, moods, modes, unknown := metaEnumsWire(t, ts, tok)
|
||||
|
||||
wantPlatforms := []string{"web", "kook", "qq-obv11", "weixin-ilink", "telegram", "webhook"}
|
||||
if !sameStringSet(platforms, wantPlatforms) {
|
||||
t.Errorf("platforms = %v, want %v", platforms, wantPlatforms)
|
||||
}
|
||||
wantMoods := []string{"calm", "happy", "excited", "sad", "angry"}
|
||||
if !sameStringSet(moods, wantMoods) {
|
||||
t.Errorf("moods = %v, want %v", moods, wantMoods)
|
||||
}
|
||||
wantModes := []string{"chat", "code", "home", "work", "docs", "voice", "vision", "agents", "settings"}
|
||||
if !sameStringSet(modes, wantModes) {
|
||||
t.Errorf("modes = %v, want %v", modes, wantModes)
|
||||
}
|
||||
if unknown != "display-raw" {
|
||||
t.Errorf("unknown = %q, want display-raw", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaEnumsAnyAuthenticatedRole asserts the endpoint is a read-only,
|
||||
// non-sensitive table: every role may read it, and anonymous callers get 401
|
||||
// rather than the data.
|
||||
func TestMetaEnumsAnyAuthenticatedRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
|
||||
url := ts.URL + "/api/v1/meta/enums"
|
||||
|
||||
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
|
||||
tok := signAny(t, jwt, "acct", role)
|
||||
resp, body := doReq(t, http.MethodGet, url, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("role %s status = %d, want 200 (body=%s)", role, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, url, nil, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorShape(t, body)
|
||||
}
|
||||
|
||||
// sameStringSet reports whether got and want hold the same values, ignoring order.
|
||||
func sameStringSet(got, want []string) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]int, len(got))
|
||||
for _, v := range got {
|
||||
seen[v]++
|
||||
}
|
||||
for _, v := range want {
|
||||
seen[v]--
|
||||
}
|
||||
for _, n := range seen {
|
||||
if n != 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -58,6 +58,12 @@ func (s *Server) registerUserRoutes(
|
||||
mux.Handle("POST /api/v1/chat", writeAuth(http.HandlerFunc(s.handleChat)))
|
||||
mux.Handle("GET /api/v1/sessions", anyAuth(http.HandlerFunc(s.handleListSessions)))
|
||||
mux.Handle("GET /api/v1/personas", anyAuth(http.HandlerFunc(s.handleListPersonas)))
|
||||
// Shared client enum tables (Android collaboration B4, design/07 §6): the
|
||||
// authority every client maps ids to labels from, so a source badge or a mood
|
||||
// label cannot drift per client. A read-only projection of
|
||||
// internal/contract/enums with nothing sensitive in it, so any authenticated
|
||||
// role may read it (same gate as /sessions above).
|
||||
mux.Handle("GET /api/v1/meta/enums", anyAuth(http.HandlerFunc(s.handleListEnums)))
|
||||
|
||||
mux.Handle("POST /api/v1/audio/transcribe", ownerAuth(http.HandlerFunc(s.handleTranscribe)))
|
||||
mux.Handle("POST /api/v1/audio/speech", ownerAuth(http.HandlerFunc(s.handleSpeech)))
|
||||
|
||||
Reference in new issue
Block a user