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.
|
and resync messages.
|
||||||
- [ ] Validate OpenAPI and compile protobuf descriptors in CI.
|
- [ ] 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
|
## Task 13: Deployment and Observability
|
||||||
|
|
||||||
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`
|
**Files:** `deploy/**`, `internal/platform/**`, `docs/operations/**`
|
||||||
|
|||||||
@ -17,6 +17,8 @@
|
|||||||
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
- Distribution OpenAPI:一次性独占提取、partial/allOrNothing、幂等键、
|
||||||
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
Redis TTL 活动池原子语义、TTL/健康过滤结果与标准错误。
|
||||||
- Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。
|
- Admin OpenAPI:状态、Upstream 启停、Routing 切换和配置重载。
|
||||||
|
- 两份 OpenAPI 已进入 Go/CI 结构门禁,覆盖本地引用闭合、operationId、响应和
|
||||||
|
security scheme;完整标准工具验证仍待补齐。
|
||||||
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
- Protobuf:Worker 注册、全量/增量 Snapshot、`usable_until`、ACK、运行态/
|
||||||
结果上报、Checker 任务与 Observation。
|
结果上报、Checker 任务与 Observation。
|
||||||
|
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
|
- Redis TTL 活动池、Leader 租约、限流、短期幂等窗口和失联恢复。
|
||||||
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
|
- Snapshot/Delta/ACK/Report 的版本与校验和兼容性。
|
||||||
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
|
- OpenAPI 错误模型、认证矩阵、批量 fulfillment。
|
||||||
|
- OpenAPI 本地引用闭合、operationId 唯一、响应集合和 security scheme 引用。
|
||||||
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
|
- README、设计/部署文档中的相对链接,以及公开 Go 命令引用的仓库目标。
|
||||||
|
|
||||||
### 集成与端到端
|
### 集成与端到端
|
||||||
|
|||||||
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
## 2026-07-29
|
## 2026-07-29
|
||||||
|
|
||||||
|
- 新增 Distribution/Admin OpenAPI 公用 CI 结构门禁,递归验证本地 `$ref`、
|
||||||
|
operationId 唯一性、HTTP operation 响应和 security scheme 引用;完整 OAS
|
||||||
|
工具验证与 Protobuf descriptor CI 编译仍保持未完成。
|
||||||
|
- 一次 OpenAPI 检索在 PowerShell 双引号中误触发 `$ref` 变量解析;已改用单引号
|
||||||
|
模式继续审计,未重复原命令。
|
||||||
- 新增 PostgreSQL 18 隔离测试服务的静态契约:回环端口、正确 PG18 tmpfs 数据
|
- 新增 PostgreSQL 18 隔离测试服务的静态契约:回环端口、正确 PG18 tmpfs 数据
|
||||||
根目录、零持久卷;Redis 测试改用独立 Compose 项目和显式服务启动,避免两个
|
根目录、零持久卷;Redis 测试改用独立 Compose 项目和显式服务启动,避免两个
|
||||||
fixture 相互影响。未拉取或启动容器,pgx Adapter/真实契约仍待依赖确认。
|
fixture 相互影响。未拉取或启动容器,pgx Adapter/真实契约仍待依赖确认。
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user