api/openapi_test.go

1213 lines
33 KiB
Go
Raw Normal View History

// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"testing"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
// ── Test helpers ──────────────────────────────────────────────────────────
type specStubGroup struct {
name string
basePath string
descs []api.RouteDescription
}
func (s *specStubGroup) Name() string { return s.name }
func (s *specStubGroup) BasePath() string { return s.basePath }
func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs }
type plainStubGroup struct{}
func (plainStubGroup) Name() string { return "plain" }
func (plainStubGroup) BasePath() string { return "/plain" }
func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
// ── SpecBuilder tests ─────────────────────────────────────────────────────
func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Empty test",
Version: "0.0.1",
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Verify OpenAPI version.
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
// Verify /health path exists.
paths := spec["paths"].(map[string]any)
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
health := paths["/health"].(map[string]any)["get"].(map[string]any)
healthResponses := health["responses"].(map[string]any)
if _, ok := healthResponses["429"]; !ok {
t.Fatal("expected 429 response on /health")
}
if _, ok := healthResponses["504"]; !ok {
t.Fatal("expected 504 response on /health")
}
if _, ok := healthResponses["500"]; !ok {
t.Fatal("expected 500 response on /health")
}
rateLimit429 := healthResponses["429"].(map[string]any)
headers := rateLimit429["headers"].(map[string]any)
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header on /health 429 response")
}
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 429 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 429 response")
}
health504 := healthResponses["504"].(map[string]any)
health504Headers := health504["headers"].(map[string]any)
if _, ok := health504Headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 504 response")
}
if _, ok := health504Headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 504 response")
}
health200 := health["responses"].(map[string]any)["200"].(map[string]any)
health200Headers := health200["headers"].(map[string]any)
if _, ok := health200Headers["X-Cache"]; !ok {
t.Fatal("expected X-Cache header on /health 200 response")
}
// Verify system tag exists.
tags := spec["tags"].([]any)
found := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "system" {
found = true
break
}
}
if !found {
t.Fatal("expected system tag in spec")
}
components := spec["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
if _, ok := schemas["Response"]; !ok {
t.Fatal("expected Response component schema in spec")
}
securitySchemes := components["securitySchemes"].(map[string]any)
bearerAuth := securitySchemes["bearerAuth"].(map[string]any)
if bearerAuth["type"] != "http" {
t.Fatalf("expected bearerAuth.type=http, got %v", bearerAuth["type"])
}
if bearerAuth["scheme"] != "bearer" {
t.Fatalf("expected bearerAuth.scheme=bearer, got %v", bearerAuth["scheme"])
}
security := spec["security"].([]any)
if len(security) != 1 {
t.Fatalf("expected one default security requirement, got %d", len(security))
}
req := security[0].(map[string]any)
if _, ok := req["bearerAuth"]; !ok {
t.Fatal("expected default bearerAuth security requirement")
}
}
func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Description: "Test API",
Version: "1.0.0",
}
group := &specStubGroup{
name: "items",
basePath: "/api/items",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/list",
Summary: "List items",
Tags: []string{"items"},
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
{
Method: "POST",
Path: "/create",
Summary: "Create item",
Description: "Creates a new item",
Tags: []string{"items"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "integer"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
// Verify GET /api/items/list exists.
listPath, ok := paths["/api/items/list"]
if !ok {
t.Fatal("expected /api/items/list path in spec")
}
getOp := listPath.(map[string]any)["get"]
if getOp == nil {
t.Fatal("expected GET operation on /api/items/list")
}
if getOp.(map[string]any)["summary"] != "List items" {
t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"])
}
if getOp.(map[string]any)["operationId"] != "get_api_items_list" {
t.Fatalf("expected operationId='get_api_items_list', got %v", getOp.(map[string]any)["operationId"])
}
// Verify POST /api/items/create exists with request body.
createPath, ok := paths["/api/items/create"]
if !ok {
t.Fatal("expected /api/items/create path in spec")
}
postOp := createPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /api/items/create")
}
if postOp.(map[string]any)["summary"] != "Create item" {
t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["operationId"] != "post_api_items_create" {
t.Fatalf("expected operationId='post_api_items_create', got %v", postOp.(map[string]any)["operationId"])
}
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /api/items/create")
}
}
func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "secure",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/private",
Summary: "Private endpoint",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
responses := spec["paths"].(map[string]any)["/api/private"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
if _, ok := responses["401"]; !ok {
t.Fatal("expected 401 response in secured operation")
}
if _, ok := responses["403"]; !ok {
t.Fatal("expected 403 response in secured operation")
}
if _, ok := responses["429"]; !ok {
t.Fatal("expected 429 response in secured operation")
}
if _, ok := responses["504"]; !ok {
t.Fatal("expected 504 response in secured operation")
}
if _, ok := responses["500"]; !ok {
t.Fatal("expected 500 response in secured operation")
}
rateLimit429 := responses["429"].(map[string]any)
headers := rateLimit429["headers"].(map[string]any)
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header in secured operation 429 response")
}
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header in secured operation 429 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header in secured operation 429 response")
}
for _, code := range []string{"400", "401", "403", "504", "500"} {
resp := responses[code].(map[string]any)
respHeaders := resp["headers"].(map[string]any)
if _, ok := respHeaders["X-Request-ID"]; !ok {
t.Fatalf("expected X-Request-ID header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Limit"]; !ok {
t.Fatalf("expected X-RateLimit-Limit header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Remaining"]; !ok {
t.Fatalf("expected X-RateLimit-Remaining header in secured operation %s response", code)
}
if _, ok := respHeaders["X-RateLimit-Reset"]; !ok {
t.Fatalf("expected X-RateLimit-Reset header in secured operation %s response", code)
}
}
}
func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "security",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/public",
Summary: "Public endpoint",
Security: []map[string][]string{},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/scoped",
Summary: "Scoped endpoint",
Security: []map[string][]string{
{
"bearerAuth": []string{},
},
{
"oauth2": []string{"read:items"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
publicOp := paths["/api/public"].(map[string]any)["get"].(map[string]any)
publicSecurity, ok := publicOp["security"].([]any)
if !ok {
t.Fatalf("expected public security array, got %T", publicOp["security"])
}
if len(publicSecurity) != 0 {
t.Fatalf("expected public route to have empty security requirement, got %v", publicSecurity)
}
scopedOp := paths["/api/scoped"].(map[string]any)["get"].(map[string]any)
scopedSecurity, ok := scopedOp["security"].([]any)
if !ok {
t.Fatalf("expected scoped security array, got %T", scopedOp["security"])
}
if len(scopedSecurity) != 2 {
t.Fatalf("expected 2 security requirements, got %d", len(scopedSecurity))
}
firstReq := scopedSecurity[0].(map[string]any)
if _, ok := firstReq["bearerAuth"]; !ok {
t.Fatalf("expected bearerAuth requirement, got %v", firstReq)
}
secondReq := scopedSecurity[1].(map[string]any)
if scopes, ok := secondReq["oauth2"].([]any); !ok || len(scopes) != 1 || scopes[0] != "read:items" {
t.Fatalf("expected oauth2 read:items requirement, got %v", secondReq["oauth2"])
}
}
func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "data",
basePath: "/data",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/fetch",
Summary: "Fetch data",
Tags: []string{"data"},
Response: map[string]any{
"type": "object",
"properties": map[string]any{
"value": map[string]any{"type": "string"},
},
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
fetchPath := paths["/data/fetch"].(map[string]any)
getOp := fetchPath["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
headers := resp200["headers"].(map[string]any)
if _, ok := headers["X-Request-ID"]; !ok {
t.Fatal("expected X-Request-ID header on 200 response")
}
if _, ok := headers["X-RateLimit-Limit"]; !ok {
t.Fatal("expected X-RateLimit-Limit header on 200 response")
}
if _, ok := headers["X-RateLimit-Remaining"]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on 200 response")
}
if _, ok := headers["X-RateLimit-Reset"]; !ok {
t.Fatal("expected X-RateLimit-Reset header on 200 response")
}
if _, ok := headers["X-Cache"]; !ok {
t.Fatal("expected X-Cache header on 200 response")
}
content := resp200["content"].(map[string]any)
appJSON := content["application/json"].(map[string]any)
schema := appJSON["schema"].(map[string]any)
if getOp["operationId"] != "get_data_fetch" {
t.Fatalf("expected operationId='get_data_fetch', got %v", getOp["operationId"])
}
// Verify envelope structure.
if schema["type"] != "object" {
t.Fatalf("expected schema type=object, got %v", schema["type"])
}
properties := schema["properties"].(map[string]any)
// Verify success field.
success := properties["success"].(map[string]any)
if success["type"] != "boolean" {
t.Fatalf("expected success.type=boolean, got %v", success["type"])
}
// Verify data field contains the original response schema.
dataField := properties["data"].(map[string]any)
if dataField["type"] != "object" {
t.Fatalf("expected data.type=object, got %v", dataField["type"])
}
dataProps := dataField["properties"].(map[string]any)
if dataProps["value"] == nil {
t.Fatal("expected data.properties.value to exist")
}
// Verify required contains "success".
required := schema["required"].([]any)
foundSuccess := false
for _, r := range required {
if r == "success" {
foundSuccess = true
break
}
}
if !foundSuccess {
t.Fatal("expected 'success' in required array")
}
}
func TestSpecBuilder_Good_OperationIDPreservesPathParams(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}",
Summary: "Get user by id",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "/users/{name}",
Summary: "Get user by name",
Tags: []string{"users"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
byID := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
byName := paths["/api/users/{name}"].(map[string]any)["get"].(map[string]any)
if byID["operationId"] != "get_api_users_id" {
t.Fatalf("expected operationId='get_api_users_id', got %v", byID["operationId"])
}
if byName["operationId"] != "get_api_users_name" {
t.Fatalf("expected operationId='get_api_users_name', got %v", byName["operationId"])
}
if byID["operationId"] == byName["operationId"] {
t.Fatal("expected unique operationId values for distinct path parameters")
}
}
func TestSpecBuilder_Good_RequestBodyOnDelete(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "DELETE",
Path: "/resources/{id}",
Summary: "Delete resource",
Tags: []string{"resources"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"reason": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
deleteOp := paths["/api/resources/{id}"].(map[string]any)["delete"].(map[string]any)
if deleteOp["requestBody"] == nil {
t.Fatal("expected requestBody on DELETE /api/resources/{id}")
}
}
func TestSpecBuilder_Good_RequestBodyOnHead(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "resources",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "HEAD",
Path: "/resources/{id}",
Summary: "Check resource",
Tags: []string{"resources"},
RequestBody: map[string]any{
"type": "object",
"properties": map[string]any{
"include": map[string]any{"type": "string"},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
headOp := paths["/api/resources/{id}"].(map[string]any)["head"].(map[string]any)
if headOp["requestBody"] == nil {
t.Fatal("expected requestBody on HEAD /api/resources/{id}")
}
}
func TestSpecBuilder_Good_PathParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}/{slug}",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
op := spec["paths"].(map[string]any)["/api/users/{id}/{slug}"].(map[string]any)["get"].(map[string]any)
params, ok := op["parameters"].([]any)
if !ok {
t.Fatalf("expected parameters array, got %T", op["parameters"])
}
if len(params) != 2 {
t.Fatalf("expected 2 path parameters, got %d", len(params))
}
first := params[0].(map[string]any)
if first["name"] != "id" {
t.Fatalf("expected first parameter name=id, got %v", first["name"])
}
if first["in"] != "path" {
t.Fatalf("expected first parameter in=path, got %v", first["in"])
}
if required, ok := first["required"].(bool); !ok || !required {
t.Fatalf("expected first parameter to be required, got %v", first["required"])
}
second := params[1].(map[string]any)
if second["name"] != "slug" {
t.Fatalf("expected second parameter name=slug, got %v", second["name"])
}
}
func TestSpecBuilder_Good_PathNormalisation(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api/",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "users/{id}",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/api/users/{id}"]; !ok {
t.Fatalf("expected normalised path /api/users/{id}, got %v", paths)
}
}
func TestSpecBuilder_Good_GinPathParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api/",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "users/:id",
Summary: "Get user",
Response: map[string]any{
"type": "object",
},
},
{
Method: "GET",
Path: "files/*path",
Summary: "Get file",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
userOp := paths["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
userParams := userOp["parameters"].([]any)
if len(userParams) != 1 {
t.Fatalf("expected 1 parameter for gin path, got %d", len(userParams))
}
if userParams[0].(map[string]any)["name"] != "id" {
t.Fatalf("expected gin path parameter name=id, got %v", userParams[0])
}
fileOp := paths["/api/files/{path}"].(map[string]any)["get"].(map[string]any)
fileParams := fileOp["parameters"].([]any)
if len(fileParams) != 1 {
t.Fatalf("expected 1 parameter for wildcard path, got %d", len(fileParams))
}
if fileParams[0].(map[string]any)["name"] != "path" {
t.Fatalf("expected wildcard parameter name=path, got %v", fileParams[0])
}
}
func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "users",
basePath: "/api",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/users/{id}",
Summary: "Get user",
Parameters: []api.ParameterDescription{
{
Name: "id",
In: "path",
Description: "User identifier",
Schema: map[string]any{
"type": "string",
},
},
{
Name: "verbose",
In: "query",
Description: "Include verbose details",
Schema: map[string]any{
"type": "boolean",
},
},
},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
op := spec["paths"].(map[string]any)["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
params, ok := op["parameters"].([]any)
if !ok {
t.Fatalf("expected parameters array, got %T", op["parameters"])
}
if len(params) != 2 {
t.Fatalf("expected 2 parameters, got %d", len(params))
}
pathParam := params[0].(map[string]any)
if pathParam["name"] != "id" {
t.Fatalf("expected path parameter name=id, got %v", pathParam["name"])
}
if pathParam["in"] != "path" {
t.Fatalf("expected path parameter in=path, got %v", pathParam["in"])
}
if pathParam["description"] != "User identifier" {
t.Fatalf("expected merged path parameter description, got %v", pathParam["description"])
}
queryParam := params[1].(map[string]any)
if queryParam["name"] != "verbose" {
t.Fatalf("expected query parameter name=verbose, got %v", queryParam["name"])
}
if queryParam["in"] != "query" {
t.Fatalf("expected query parameter in=query, got %v", queryParam["in"])
}
if required, ok := queryParam["required"].(bool); !ok || required {
t.Fatalf("expected query parameter to be optional, got %v", queryParam["required"])
}
}
func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
data, err := sb.Build([]api.RouteGroup{plainStubGroup{}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Verify plainStubGroup appears in tags.
tags := spec["tags"].([]any)
foundPlain := false
for _, tag := range tags {
tm := tag.(map[string]any)
if tm["name"] == "plain" {
foundPlain = true
break
}
}
if !foundPlain {
t.Fatal("expected 'plain' tag in spec for non-describable group")
}
// Verify only /health exists in paths (plain group adds no paths).
paths := spec["paths"].(map[string]any)
if len(paths) != 1 {
t.Fatalf("expected 1 path (/health only), got %d", len(paths))
}
if _, ok := paths["/health"]; !ok {
t.Fatal("expected /health path in spec")
}
health := paths["/health"].(map[string]any)["get"].(map[string]any)
if health["operationId"] != "get_health" {
t.Fatalf("expected operationId='get_health', got %v", health["operationId"])
}
if security := health["security"]; security == nil {
t.Fatal("expected explicit public security override on /health")
}
if len(health["security"].([]any)) != 0 {
t.Fatalf("expected /health security to be empty, got %v", health["security"])
}
}
func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: "fallback",
basePath: "/api/fallback",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
operation := spec["paths"].(map[string]any)["/api/fallback/status"].(map[string]any)["get"].(map[string]any)
tags, ok := operation["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", operation["tags"])
}
if len(tags) != 1 || tags[0] != "fallback" {
t.Fatalf("expected fallback tag from group name, got %v", operation["tags"])
}
}
func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
}
group := &specStubGroup{
name: " ",
basePath: "/api/blank",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/status",
Summary: "Check status",
Tags: []string{"", " ", "data", "data"},
Response: map[string]any{
"type": "object",
},
},
},
}
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
tags := spec["tags"].([]any)
var foundData bool
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
if name == "" {
t.Fatal("expected blank tag names to be ignored")
}
if name == "data" {
foundData = true
}
}
if !foundData {
t.Fatal("expected data tag to be retained")
}
op := spec["paths"].(map[string]any)["/api/blank/status"].(map[string]any)["get"].(map[string]any)
opTags, ok := op["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", op["tags"])
}
if len(opTags) != 1 || opTags[0] != "data" {
t.Fatalf("expected operation tags to be cleaned to [data], got %v", opTags)
}
}
func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
gin.SetMode(gin.TestMode)
sb := &api.SpecBuilder{
Title: "Tool API",
Version: "1.0.0",
}
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"content": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
data, err := sb.Build([]api.RouteGroup{bridge})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
tags, ok := spec["tags"].([]any)
if !ok {
t.Fatalf("expected tags array, got %T", spec["tags"])
}
expectedTags := map[string]bool{
"system": true,
"tools": true,
"files": true,
"metrics": true,
}
for _, raw := range tags {
tag := raw.(map[string]any)
name, _ := tag["name"].(string)
delete(expectedTags, name)
}
if len(expectedTags) != 0 {
t.Fatalf("expected declared tags to include system, tools, files, and metrics, missing %v", expectedTags)
}
paths := spec["paths"].(map[string]any)
// Verify POST /tools/file_read exists.
fileReadPath, ok := paths["/tools/file_read"]
if !ok {
t.Fatal("expected /tools/file_read path in spec")
}
postOp := fileReadPath.(map[string]any)["post"]
if postOp == nil {
t.Fatal("expected POST operation on /tools/file_read")
}
if postOp.(map[string]any)["summary"] != "Read a file from disk" {
t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"])
}
if postOp.(map[string]any)["operationId"] != "post_tools_file_read" {
t.Fatalf("expected operationId='post_tools_file_read', got %v", postOp.(map[string]any)["operationId"])
}
// Verify POST /tools/metrics_query exists.
metricsPath, ok := paths["/tools/metrics_query"]
if !ok {
t.Fatal("expected /tools/metrics_query path in spec")
}
metricsOp := metricsPath.(map[string]any)["post"]
if metricsOp == nil {
t.Fatal("expected POST operation on /tools/metrics_query")
}
if metricsOp.(map[string]any)["summary"] != "Query metrics data" {
t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"])
}
if metricsOp.(map[string]any)["operationId"] != "post_tools_metrics_query" {
t.Fatalf("expected operationId='post_tools_metrics_query', got %v", metricsOp.(map[string]any)["operationId"])
}
// Verify request body is present on both (both are POST with InputSchema).
if postOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/file_read")
}
if metricsOp.(map[string]any)["requestBody"] == nil {
t.Fatal("expected requestBody on POST /tools/metrics_query")
}
}
func TestSpecBuilder_Bad_InfoFields(t *testing.T) {
sb := &api.SpecBuilder{
Title: "MyAPI",
Description: "Test API",
Version: "1.0.0",
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
info := spec["info"].(map[string]any)
if info["title"] != "MyAPI" {
t.Fatalf("expected title=MyAPI, got %v", info["title"])
}
if info["description"] != "Test API" {
t.Fatalf("expected description='Test API', got %v", info["description"])
}
if info["version"] != "1.0.0" {
t.Fatalf("expected version=1.0.0, got %v", info["version"])
}
}
func TestSpecBuilder_Good_Servers(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
Servers: []string{
" https://api.example.com ",
"/",
"",
"https://api.example.com",
},
}
data, err := sb.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
servers, ok := spec["servers"].([]any)
if !ok {
t.Fatalf("expected servers array, got %T", spec["servers"])
}
if len(servers) != 2 {
t.Fatalf("expected 2 normalised servers, got %d", len(servers))
}
first := servers[0].(map[string]any)
if first["url"] != "https://api.example.com" {
t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/" {
t.Fatalf("expected second server url=%q, got %v", "/", second["url"])
}
}