170 lines
4.3 KiB
Go
170 lines
4.3 KiB
Go
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
|
|
}
|