// Package handlers — openapi.go // // v0.6.2: Dynamic OpenAPI spec generation. Merges the static kernel spec // with extension-declared API routes. Extensions with api_schema get rich // path items; others get auto-generated stubs. // // Two tiers: // Tier 1 — Stubs: zero extension author effort, every route gets a stub // Tier 2 — api_schema: optional manifest field for richer docs package handlers import ( "context" "fmt" "log" "strings" "switchboard-core/store" "gopkg.in/yaml.v3" ) // BuildOpenAPISpec produces a complete OpenAPI 3.0 spec by merging the static // kernel spec with dynamically discovered extension routes. // // staticSpec is the raw YAML of the kernel's openapi.yaml. // version is substituted into the spec's info.version field. func BuildOpenAPISpec(stores store.Stores, staticSpec []byte, version string) (map[string]any, error) { // 1. Parse static spec spec := make(map[string]any) patched := strings.Replace(string(staticSpec), "${VERSION}", version, 1) if err := yaml.Unmarshal([]byte(patched), &spec); err != nil { return nil, fmt.Errorf("failed to parse static OpenAPI spec: %w", err) } // Ensure paths map exists paths, _ := spec["paths"].(map[string]any) if paths == nil { paths = make(map[string]any) spec["paths"] = paths } // Ensure tags array exists tags, _ := spec["tags"].([]any) // 2. Enumerate enabled extensions with API routes if stores.Packages == nil { return spec, nil } pkgs, err := stores.Packages.List(context.Background()) if err != nil { log.Printf("[openapi] Failed to list packages: %v", err) return spec, nil // degrade gracefully — return kernel-only spec } for _, pkg := range pkgs { if !pkg.Enabled || pkg.Manifest == nil { continue } if !HasAPIRoutes(pkg.Manifest) { continue } slug := pkg.ID tagName := "extensions/" + slug // Add extension tag tags = append(tags, map[string]any{ "name": tagName, "description": fmt.Sprintf("API routes for %s extension", pkg.Title), }) // Build schema lookup from api_schema (if present) schemaEntries := ParseAPISchema(pkg.Manifest) schemaMap := make(map[string]APISchemaEntry) // key: "METHOD /path" for _, entry := range schemaEntries { key := entry.Method + " " + entry.Path schemaMap[key] = entry } // Iterate declared routes routes := ExtractAPIRoutes(pkg.Manifest) if len(routes) == 0 { // api_routes: true — no specific routes to enumerate. // If api_schema is declared, generate entries from those. for _, entry := range schemaEntries { oaPath := fmt.Sprintf("/s/%s/api%s", slug, entry.Path) method := strings.ToLower(entry.Method) pathItem := getOrCreatePathItem(paths, oaPath) pathItem[method] = buildSchemaOperation(entry, tagName) } continue } for _, route := range routes { // Skip wildcard paths — can't represent meaningfully in OpenAPI if strings.HasSuffix(route.Path, "*") { continue } oaPath := fmt.Sprintf("/s/%s/api%s", slug, route.Path) method := strings.ToLower(route.Method) // Wildcard method: generate for common methods methods := []string{method} if route.Method == "*" { methods = []string{"get", "post", "put", "delete", "patch"} } for _, m := range methods { pathItem := getOrCreatePathItem(paths, oaPath) key := strings.ToUpper(m) + " " + route.Path if schema, ok := schemaMap[key]; ok { pathItem[m] = buildSchemaOperation(schema, tagName) } else { pathItem[m] = buildStubOperation(slug, m, route.Path, tagName) } } } } spec["tags"] = tags return spec, nil } // getOrCreatePathItem returns the path item map for the given OpenAPI path, // creating it if it doesn't exist. func getOrCreatePathItem(paths map[string]any, path string) map[string]any { if existing, ok := paths[path].(map[string]any); ok { return existing } item := make(map[string]any) paths[path] = item return item } // buildStubOperation generates an auto-generated stub operation for routes // without api_schema declarations. func buildStubOperation(slug, method, path, tag string) map[string]any { return map[string]any{ "summary": fmt.Sprintf("%s: %s %s", slug, strings.ToUpper(method), path), "tags": []any{tag}, "responses": map[string]any{ "200": map[string]any{ "description": "Extension-defined response", }, }, } } // buildSchemaOperation converts an APISchemaEntry into a full OpenAPI operation. func buildSchemaOperation(entry APISchemaEntry, tag string) map[string]any { op := map[string]any{ "summary": entry.Summary, "tags": []any{tag}, } if entry.Summary == "" { op["summary"] = fmt.Sprintf("%s %s", entry.Method, entry.Path) } if entry.Description != "" { op["description"] = entry.Description } // Query/path parameters if len(entry.Params) > 0 { var params []any for name, def := range entry.Params { param := map[string]any{ "name": name, "in": "query", } if m, ok := def.(map[string]any); ok { if t, ok := m["type"].(string); ok { param["schema"] = map[string]any{"type": mapJSONType(t)} } if desc, ok := m["description"].(string); ok { param["description"] = desc } if req, ok := m["required"].(bool); ok && req { param["required"] = true } } else { // Simple type string if t, ok := def.(string); ok { param["schema"] = map[string]any{"type": mapJSONType(t)} } } params = append(params, param) } op["parameters"] = params } // Request body if len(entry.Body) > 0 { properties := make(map[string]any) var required []any for name, def := range entry.Body { prop := map[string]any{} if m, ok := def.(map[string]any); ok { if t, ok := m["type"].(string); ok { prop["type"] = mapJSONType(t) } if desc, ok := m["description"].(string); ok { prop["description"] = desc } if req, ok := m["required"].(bool); ok && req { required = append(required, name) } } else if t, ok := def.(string); ok { prop["type"] = mapJSONType(t) } properties[name] = prop } schema := map[string]any{ "type": "object", "properties": properties, } if len(required) > 0 { schema["required"] = required } op["requestBody"] = map[string]any{ "required": true, "content": map[string]any{ "application/json": map[string]any{ "schema": schema, }, }, } } // Response resp := map[string]any{ "200": map[string]any{ "description": "Successful response", }, } if entry.Response != nil { respSchema := make(map[string]any) if t, ok := entry.Response["type"].(string); ok { respSchema["type"] = mapJSONType(t) } if ex, ok := entry.Response["example"]; ok { respSchema["example"] = ex } resp["200"] = map[string]any{ "description": "Successful response", "content": map[string]any{ "application/json": map[string]any{ "schema": respSchema, }, }, } } op["responses"] = resp return op } // mapJSONType converts common type names to OpenAPI types. func mapJSONType(t string) string { switch strings.ToLower(t) { case "int", "integer": return "integer" case "float", "number", "double": return "number" case "bool", "boolean": return "boolean" case "array", "list": return "array" case "object", "map", "dict": return "object" default: return "string" } }