test: validate openapi contract graph
This commit is contained in:
parent
02097596fa
commit
b86a729792
169
api/openapi/validation_test.go
Normal file
169
api/openapi/validation_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
var httpOperationMethods = map[string]struct{}{
|
||||
"delete": {}, "get": {}, "head": {}, "options": {},
|
||||
"patch": {}, "post": {}, "put": {}, "trace": {},
|
||||
}
|
||||
|
||||
func TestOpenAPIDocumentsHaveClosedContracts(t *testing.T) {
|
||||
for _, name := range []string{"proxy-pool.yaml", "admin.yaml"} {
|
||||
name := name
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := readOpenAPIRoot(t, name)
|
||||
validateLocalReferences(t, root)
|
||||
validateOperations(t, root)
|
||||
validateSecurityRequirements(t, root)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validateLocalReferences(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
walkOpenAPI(root, func(path string, value any) {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reference, exists := object["$ref"]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
text, ok := reference.(string)
|
||||
if !ok || !strings.HasPrefix(text, "#/") {
|
||||
t.Errorf("%s has unsupported reference %v", path, reference)
|
||||
return
|
||||
}
|
||||
if _, ok := resolveLocalReference(root, text); !ok {
|
||||
t.Errorf("%s points to missing local reference %q", path, text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func validateOperations(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
paths, ok := root["paths"].(map[string]any)
|
||||
if !ok || len(paths) == 0 {
|
||||
t.Fatal("paths must be a non-empty object")
|
||||
}
|
||||
operationIDs := make(map[string]string)
|
||||
for path, rawItem := range paths {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("path %q has type %T, want object", path, rawItem)
|
||||
continue
|
||||
}
|
||||
for method, rawOperation := range item {
|
||||
if _, ok := httpOperationMethods[strings.ToLower(method)]; !ok {
|
||||
continue
|
||||
}
|
||||
operation, ok := rawOperation.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s %s has type %T, want object", method, path, rawOperation)
|
||||
continue
|
||||
}
|
||||
operationID, _ := operation["operationId"].(string)
|
||||
if strings.TrimSpace(operationID) == "" {
|
||||
t.Errorf("%s %s has no operationId", method, path)
|
||||
} else if previous, duplicate := operationIDs[operationID]; duplicate {
|
||||
t.Errorf("operationId %q is shared by %s and %s %s", operationID, previous, method, path)
|
||||
} else {
|
||||
operationIDs[operationID] = method + " " + path
|
||||
}
|
||||
responses, ok := operation["responses"].(map[string]any)
|
||||
if !ok || len(responses) == 0 {
|
||||
t.Errorf("%s %s has no responses", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validateSecurityRequirements(t *testing.T, root map[string]any) {
|
||||
t.Helper()
|
||||
components, _ := root["components"].(map[string]any)
|
||||
schemes, _ := components["securitySchemes"].(map[string]any)
|
||||
walkOpenAPI(root, func(path string, value any) {
|
||||
if !strings.HasSuffix(path, ".security") {
|
||||
return
|
||||
}
|
||||
requirements, ok := value.([]any)
|
||||
if !ok {
|
||||
t.Errorf("%s has type %T, want array", path, value)
|
||||
return
|
||||
}
|
||||
for _, rawRequirement := range requirements {
|
||||
requirement, ok := rawRequirement.(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("%s contains %T, want object", path, rawRequirement)
|
||||
continue
|
||||
}
|
||||
for name := range requirement {
|
||||
if _, exists := schemes[name]; !exists {
|
||||
t.Errorf("%s references unknown security scheme %q", path, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func readOpenAPIRoot(t *testing.T, name string) map[string]any {
|
||||
t.Helper()
|
||||
payload, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
var root map[string]any
|
||||
if err := yaml.Unmarshal(payload, &root); err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func walkOpenAPI(value any, visit func(string, any)) {
|
||||
var walk func(string, any)
|
||||
walk = func(path string, current any) {
|
||||
visit(path, current)
|
||||
switch item := current.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range item {
|
||||
walk(joinOpenAPIPath(path, key), child)
|
||||
}
|
||||
case []any:
|
||||
for index, child := range item {
|
||||
walk(fmt.Sprintf("%s[%d]", path, index), child)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk("$", value)
|
||||
}
|
||||
|
||||
func resolveLocalReference(root map[string]any, reference string) (any, bool) {
|
||||
var current any = root
|
||||
for _, token := range strings.Split(strings.TrimPrefix(reference, "#/"), "/") {
|
||||
token = strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[token]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func joinOpenAPIPath(parent, child string) string {
|
||||
if parent == "" {
|
||||
return child
|
||||
}
|
||||
return parent + "." + child
|
||||
}
|
||||
@ -224,6 +224,10 @@ Adapter 已通过真实 Redis 8.2 运行同一套公用契约;原子 Lua 覆
|
||||
and resync messages.
|
||||
- [ ] Validate OpenAPI and compile protobuf descriptors in CI.
|
||||
|
||||
当前进度(2026-07-29):Distribution/Admin OpenAPI 已由 Go 测试在双平台 CI
|
||||
校验本地 `$ref` 闭合、operationId 唯一、响应存在及 security scheme 引用;
|
||||
完整 OAS 工具验证与 Protobuf descriptor CI 编译仍待完成。
|
||||
|
||||
## Task 13: Deployment and Observability
|
||||
|
||||
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`
|
||||
|
||||
@ -17,6 +17,8 @@
|
||||
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
||||
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
||||
- Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。
|
||||
- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和
|
||||
security scheme;完整标准工具验证仍待补齐。
|
||||
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
||||
结果上报、Checker 任务与 Observation。
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
|
||||
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
|
||||
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
|
||||
- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。
|
||||
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
|
||||
|
||||
### 集成与端到端
|
||||
|
||||
@ -2,6 +2,11 @@
|
||||
|
||||
## 2026-07-29
|
||||
|
||||
- 新增 Distribution/Admin OpenAPI 公用 CI 结构门禁,递归验证本地 `$ref`、
|
||||
operationId 唯一性、HTTP operation 响应和 security scheme 引用;完整 OAS
|
||||
工具验证与 Protobuf descriptor CI 编译仍保持未完成。
|
||||
- 一次 OpenAPI 检索在 PowerShell 双引号中误触发 `$ref` 变量解析;已改用单引号
|
||||
模式继续审计,未重复原命令。
|
||||
- 新增 PostgreSQL 18 隔离测试服务的静态契约:回环端口、正确 PG18 tmpfs 数据
|
||||
根目录、零持久卷;Redis 测试改用独立 Compose 项目和显式服务启动,避免两个
|
||||
fixture 相互影响。未拉取或启动容器,pgx Adapter/真实契约仍待依赖确认。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user