Feat v0.6.1-v0.6.2: backup/restore, docs surface, dynamic OpenAPI
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 20s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
v0.6.1 — Backup tooling (.swb ZIP export/import), admin backup section, docs surface with markdown rendering, 5 reference docs, 6 handler tests. v0.6.2 — Dark mode contrast fixes, topbar nav, 📖 docs icon, dynamic OpenAPI spec with extension route merging, docs outline sidebar, scroll/routing fixes; 7 new tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
276
server/handlers/openapi.go
Normal file
276
server/handlers/openapi.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user