All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
276 lines
7.2 KiB
Go
276 lines
7.2 KiB
Go
// Package handlers — openapi.go
|
|
//
|
|
// 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"
|
|
}
|
|
}
|