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:
523
server/handlers/backup.go
Normal file
523
server/handlers/backup.go
Normal file
@@ -0,0 +1,523 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/store"
|
||||
)
|
||||
|
||||
// BackupHandler provides backup/restore endpoints for admin users.
|
||||
type BackupHandler struct {
|
||||
stores store.Stores
|
||||
packagesDir string
|
||||
storagePath string
|
||||
}
|
||||
|
||||
// NewBackupHandler creates a new BackupHandler.
|
||||
func NewBackupHandler(stores store.Stores, packagesDir, storagePath string) *BackupHandler {
|
||||
return &BackupHandler{
|
||||
stores: stores,
|
||||
packagesDir: packagesDir,
|
||||
storagePath: storagePath,
|
||||
}
|
||||
}
|
||||
|
||||
// backupMeta is written as meta.json inside the .swb archive.
|
||||
type backupMeta struct {
|
||||
Version string `json:"version"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Dialect string `json:"dialect"`
|
||||
CoreTables []string `json:"core_tables"`
|
||||
ExtTables []string `json:"ext_tables,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BackupHandler) backupsDir() string {
|
||||
if h.storagePath == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(h.storagePath, "backups")
|
||||
}
|
||||
|
||||
// ── CreateBackup ─────────────────────────────
|
||||
// POST /api/v1/admin/backup
|
||||
// Streams a .swb ZIP archive containing all core + ext_data tables as JSONL,
|
||||
// plus package asset files from disk.
|
||||
|
||||
func (h *BackupHandler) CreateBackup(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
db := database.DB
|
||||
|
||||
// Determine schema version
|
||||
schemaVersion := ""
|
||||
row := db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1")
|
||||
row.Scan(&schemaVersion)
|
||||
|
||||
dialect := "sqlite"
|
||||
if database.IsPostgres() {
|
||||
dialect = "postgres"
|
||||
}
|
||||
|
||||
// Enumerate ext_data tables
|
||||
extEntries, err := listExtDataTables(ctx, db)
|
||||
if err != nil {
|
||||
log.Printf("[backup] warning: list ext_data tables: %v", err)
|
||||
}
|
||||
|
||||
extTableNames := make([]string, len(extEntries))
|
||||
for i, e := range extEntries {
|
||||
extTableNames[i] = e.PhysicalName
|
||||
}
|
||||
|
||||
meta := backupMeta{
|
||||
Version: readVersionFile(),
|
||||
Timestamp: time.Now().UTC(),
|
||||
SchemaVersion: schemaVersion,
|
||||
Dialect: dialect,
|
||||
CoreTables: coreTableOrder,
|
||||
ExtTables: extTableNames,
|
||||
}
|
||||
|
||||
// Determine if we should store server-side or stream directly
|
||||
storeSide := c.Query("store") == "true"
|
||||
|
||||
if storeSide {
|
||||
h.createStoredBackup(c, ctx, db, meta, extEntries)
|
||||
return
|
||||
}
|
||||
|
||||
// Stream directly to client
|
||||
filename := fmt.Sprintf("switchboard-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
|
||||
zw := zip.NewWriter(c.Writer)
|
||||
defer zw.Close()
|
||||
|
||||
h.writeBackupContents(c, zw, meta, extEntries)
|
||||
}
|
||||
|
||||
func (h *BackupHandler) createStoredBackup(c *gin.Context, ctx interface{ Deadline() (time.Time, bool) }, db interface{}, meta backupMeta, extEntries []extDataTableEntry) {
|
||||
dir := h.backupsDir()
|
||||
if dir == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no storage path configured"})
|
||||
return
|
||||
}
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
filename := fmt.Sprintf("switchboard-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
|
||||
path := filepath.Join(dir, filename)
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "create backup file: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
defer zw.Close()
|
||||
|
||||
h.writeBackupContents(c, zw, meta, extEntries)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"filename": filename,
|
||||
"path": path,
|
||||
}})
|
||||
}
|
||||
|
||||
func (h *BackupHandler) writeBackupContents(c *gin.Context, zw *zip.Writer, meta backupMeta, extEntries []extDataTableEntry) {
|
||||
ctx := c.Request.Context()
|
||||
db := database.DB
|
||||
|
||||
// Write meta.json
|
||||
mw, err := zw.Create("meta.json")
|
||||
if err != nil {
|
||||
log.Printf("[backup] create meta.json: %v", err)
|
||||
return
|
||||
}
|
||||
enc := json.NewEncoder(mw)
|
||||
enc.SetIndent("", " ")
|
||||
enc.Encode(meta)
|
||||
|
||||
// Dump core tables
|
||||
for _, table := range coreTableOrder {
|
||||
if !tableExists(ctx, db, table) {
|
||||
continue
|
||||
}
|
||||
fw, err := zw.Create(fmt.Sprintf("core/%s.jsonl", table))
|
||||
if err != nil {
|
||||
log.Printf("[backup] create core/%s.jsonl: %v", table, err)
|
||||
continue
|
||||
}
|
||||
count, err := dumpTable(ctx, db, table, fw)
|
||||
if err != nil {
|
||||
log.Printf("[backup] dump %s: %v", table, err)
|
||||
} else {
|
||||
log.Printf("[backup] dumped %s: %d rows", table, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Dump ext_data tables
|
||||
for _, entry := range extEntries {
|
||||
if !tableExists(ctx, db, entry.PhysicalName) {
|
||||
continue
|
||||
}
|
||||
path := fmt.Sprintf("ext_data/%s/%s.jsonl", entry.PackageID, entry.TableName)
|
||||
fw, err := zw.Create(path)
|
||||
if err != nil {
|
||||
log.Printf("[backup] create %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
count, err := dumpTable(ctx, db, entry.PhysicalName, fw)
|
||||
if err != nil {
|
||||
log.Printf("[backup] dump %s: %v", entry.PhysicalName, err)
|
||||
} else {
|
||||
log.Printf("[backup] dumped %s: %d rows", entry.PhysicalName, count)
|
||||
}
|
||||
}
|
||||
|
||||
// Walk package assets
|
||||
if h.packagesDir != "" {
|
||||
filepath.Walk(h.packagesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
relPath, err := filepath.Rel(h.packagesDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
relPath = filepath.ToSlash(relPath)
|
||||
fw, err := zw.Create("packages/" + relPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
io.Copy(fw, f)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── ListBackups ──────────────────────────────
|
||||
// GET /api/v1/admin/backups
|
||||
|
||||
func (h *BackupHandler) ListBackups(c *gin.Context) {
|
||||
dir := h.backupsDir()
|
||||
if dir == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
|
||||
type backupEntry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
var backups []backupEntry
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".swb") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backups = append(backups, backupEntry{
|
||||
Name: e.Name(),
|
||||
Size: info.Size(),
|
||||
CreatedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
if backups == nil {
|
||||
backups = []backupEntry{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": backups})
|
||||
}
|
||||
|
||||
// ── DownloadBackup ───────────────────────────
|
||||
// GET /api/v1/admin/backups/:name
|
||||
|
||||
func (h *BackupHandler) DownloadBackup(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidBackupName(name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
|
||||
return
|
||||
}
|
||||
|
||||
dir := h.backupsDir()
|
||||
if dir == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "application/zip")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
// ── DeleteBackup ─────────────────────────────
|
||||
// DELETE /api/v1/admin/backups/:name
|
||||
|
||||
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidBackupName(name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
|
||||
return
|
||||
}
|
||||
|
||||
dir := h.backupsDir()
|
||||
if dir == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.Remove(path); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": name}})
|
||||
}
|
||||
|
||||
// ── RestoreBackup ────────────────────────────
|
||||
// POST /api/v1/admin/restore
|
||||
// Accepts a .swb file upload, validates meta, wipes DB, restores data.
|
||||
|
||||
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
db := database.DB
|
||||
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read entire file into memory for zip.NewReader
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "read file: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Read meta.json
|
||||
meta, err := readBackupMeta(zr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate schema version — reject backups from future schema
|
||||
currentSchema := ""
|
||||
db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(¤tSchema)
|
||||
if meta.SchemaVersion > currentSchema && currentSchema != "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("backup schema %s is newer than current %s — upgrade the application first", meta.SchemaVersion, currentSchema),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[backup] restoring from %s (schema %s, %d core tables, %d ext tables)",
|
||||
header.Filename, meta.SchemaVersion, len(meta.CoreTables), len(meta.ExtTables))
|
||||
|
||||
// Build full table list for wipe (core + ext_data)
|
||||
allTables := make([]string, 0, len(coreTableOrder)+len(meta.ExtTables))
|
||||
allTables = append(allTables, coreTableOrder...)
|
||||
allTables = append(allTables, meta.ExtTables...)
|
||||
|
||||
// Wipe all data
|
||||
if err := wipeTables(ctx, db, allTables); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "wipe failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Restore core tables in order
|
||||
restored := 0
|
||||
for _, table := range coreTableOrder {
|
||||
path := fmt.Sprintf("core/%s.jsonl", table)
|
||||
f := findInZip(zr, path)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
log.Printf("[backup] open %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
count, err := restoreTable(ctx, db, table, rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
log.Printf("[backup] restore %s: %v", table, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("restore %s: %v", table, err)})
|
||||
return
|
||||
}
|
||||
log.Printf("[backup] restored %s: %d rows", table, count)
|
||||
restored += count
|
||||
}
|
||||
|
||||
// Restore ext_data tables
|
||||
for _, extTable := range meta.ExtTables {
|
||||
// Parse package_id and table_name from physical name: ext_{pkg}_{table}
|
||||
pkgID, tblName := parseExtTableName(extTable)
|
||||
if pkgID == "" {
|
||||
continue
|
||||
}
|
||||
path := fmt.Sprintf("ext_data/%s/%s.jsonl", pkgID, tblName)
|
||||
f := findInZip(zr, path)
|
||||
if f == nil {
|
||||
continue
|
||||
}
|
||||
// Ensure physical table exists before restoring
|
||||
if !tableExists(ctx, db, extTable) {
|
||||
log.Printf("[backup] skip ext table %s (not in schema)", extTable)
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
log.Printf("[backup] open %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
count, err := restoreTable(ctx, db, extTable, rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
log.Printf("[backup] restore ext %s: %v", extTable, err)
|
||||
} else {
|
||||
log.Printf("[backup] restored ext %s: %d rows", extTable, count)
|
||||
restored += count
|
||||
}
|
||||
}
|
||||
|
||||
// Restore package files
|
||||
if h.packagesDir != "" {
|
||||
for _, f := range zr.File {
|
||||
if !strings.HasPrefix(f.Name, "packages/") || f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
relPath := strings.TrimPrefix(f.Name, "packages/")
|
||||
destPath := filepath.Join(h.packagesDir, filepath.FromSlash(relPath))
|
||||
os.MkdirAll(filepath.Dir(destPath), 0755)
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
continue
|
||||
}
|
||||
io.Copy(out, rc)
|
||||
out.Close()
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[backup] restore complete: %d total rows", restored)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"rows_restored": restored,
|
||||
"schema": meta.SchemaVersion,
|
||||
}})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
func readBackupMeta(zr *zip.Reader) (*backupMeta, error) {
|
||||
f := findInZip(zr, "meta.json")
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("meta.json not found in archive")
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
var meta backupMeta
|
||||
if err := json.NewDecoder(rc).Decode(&meta); err != nil {
|
||||
return nil, fmt.Errorf("decode meta.json: %w", err)
|
||||
}
|
||||
return &meta, nil
|
||||
}
|
||||
|
||||
func findInZip(zr *zip.Reader, name string) *zip.File {
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseExtTableName parses "ext_{pkg}_{table}" into (pkg, table).
|
||||
func parseExtTableName(physical string) (string, string) {
|
||||
if !strings.HasPrefix(physical, "ext_") {
|
||||
return "", ""
|
||||
}
|
||||
rest := physical[4:]
|
||||
idx := strings.Index(rest, "_")
|
||||
if idx < 0 {
|
||||
return "", ""
|
||||
}
|
||||
return rest[:idx], rest[idx+1:]
|
||||
}
|
||||
|
||||
var validBackupNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.swb$`)
|
||||
|
||||
func isValidBackupName(name string) bool {
|
||||
return validBackupNameRe.MatchString(name) && !strings.Contains(name, "..")
|
||||
}
|
||||
|
||||
// readVersionFile reads the VERSION file from the project root.
|
||||
func readVersionFile() string {
|
||||
// Try common locations
|
||||
for _, path := range []string{"VERSION", "/app/VERSION", "../VERSION"} {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
231
server/handlers/backup_tables.go
Normal file
231
server/handlers/backup_tables.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"switchboard-core/database"
|
||||
)
|
||||
|
||||
// coreTableOrder lists tables for backup in FK-safe import order.
|
||||
// Ephemeral tables (node_registry, ws_tickets, rate_limit_counters,
|
||||
// user_presence, oidc_auth_state, refresh_tokens) are excluded.
|
||||
var coreTableOrder = []string{
|
||||
"schema_migrations",
|
||||
"users",
|
||||
"teams",
|
||||
"team_members",
|
||||
"groups",
|
||||
"group_members",
|
||||
"platform_policies",
|
||||
"global_settings",
|
||||
"packages",
|
||||
"package_user_settings",
|
||||
"package_team_settings",
|
||||
"extension_permissions",
|
||||
"ext_data_tables",
|
||||
"ext_connections",
|
||||
"ext_dependencies",
|
||||
"resource_grants",
|
||||
"notifications",
|
||||
"notification_preferences",
|
||||
"audit_log",
|
||||
"workflows",
|
||||
"workflow_stages",
|
||||
"workflow_versions",
|
||||
"workflow_instances",
|
||||
"workflow_assignments",
|
||||
"workflow_signoffs",
|
||||
"triggers",
|
||||
"scheduled_tasks",
|
||||
"trigger_logs",
|
||||
}
|
||||
|
||||
// dumpTable writes all rows of a table as newline-delimited JSON to w.
|
||||
// Columns are discovered dynamically via rows.ColumnTypes().
|
||||
func dumpTable(ctx context.Context, db *sql.DB, table string, w io.Writer) (int, error) {
|
||||
rows, err := db.QueryContext(ctx, fmt.Sprintf("SELECT * FROM %s", table))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("query %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("columns %s: %w", table, err)
|
||||
}
|
||||
|
||||
count := 0
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
for rows.Next() {
|
||||
// Create scan destinations — all as interface{}
|
||||
vals := make([]interface{}, len(cols))
|
||||
ptrs := make([]interface{}, len(cols))
|
||||
for i := range vals {
|
||||
ptrs[i] = &vals[i]
|
||||
}
|
||||
if err := rows.Scan(ptrs...); err != nil {
|
||||
return count, fmt.Errorf("scan %s row %d: %w", table, count, err)
|
||||
}
|
||||
|
||||
row := make(map[string]interface{}, len(cols))
|
||||
for i, col := range cols {
|
||||
row[col] = normalizeValue(vals[i])
|
||||
}
|
||||
if err := enc.Encode(row); err != nil {
|
||||
return count, fmt.Errorf("encode %s row %d: %w", table, count, err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, rows.Err()
|
||||
}
|
||||
|
||||
// normalizeValue converts database-specific types to JSON-friendly values.
|
||||
func normalizeValue(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case []byte:
|
||||
// Try to parse as JSON first (for JSONB columns)
|
||||
var j interface{}
|
||||
if err := json.Unmarshal(val, &j); err == nil {
|
||||
return j
|
||||
}
|
||||
return string(val)
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// restoreTable reads JSONL from r and inserts rows into the table.
|
||||
// Column names come from the first JSON object. Uses dialect-adapted SQL.
|
||||
func restoreTable(ctx context.Context, db *sql.DB, table string, r io.Reader) (int, error) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Allow large lines (e.g. JSONB audit_log entries)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
||||
|
||||
count := 0
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var row map[string]interface{}
|
||||
if err := json.Unmarshal(line, &row); err != nil {
|
||||
return count, fmt.Errorf("unmarshal %s line %d: %w", table, count+1, err)
|
||||
}
|
||||
|
||||
cols := make([]string, 0, len(row))
|
||||
vals := make([]interface{}, 0, len(row))
|
||||
for k, v := range row {
|
||||
cols = append(cols, k)
|
||||
vals = append(vals, marshalIfComplex(v))
|
||||
}
|
||||
|
||||
// Build INSERT with positional placeholders
|
||||
placeholders := make([]string, len(cols))
|
||||
for i := range placeholders {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
}
|
||||
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
||||
table,
|
||||
strings.Join(cols, ", "),
|
||||
strings.Join(placeholders, ", "),
|
||||
)
|
||||
|
||||
if _, err := database.ExecContext(ctx, query, vals...); err != nil {
|
||||
return count, fmt.Errorf("insert %s row %d: %w", table, count+1, err)
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, scanner.Err()
|
||||
}
|
||||
|
||||
// marshalIfComplex converts maps/slices back to JSON strings for DB storage.
|
||||
func marshalIfComplex(v interface{}) interface{} {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// listExtDataTables returns all (package_id, physical_table_name) pairs
|
||||
// from the ext_data_tables catalog.
|
||||
func listExtDataTables(ctx context.Context, db *sql.DB) ([]extDataTableEntry, error) {
|
||||
rows, err := db.QueryContext(ctx, "SELECT package_id, table_name FROM ext_data_tables ORDER BY package_id, table_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []extDataTableEntry
|
||||
for rows.Next() {
|
||||
var e extDataTableEntry
|
||||
if err := rows.Scan(&e.PackageID, &e.TableName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.PhysicalName = fmt.Sprintf("ext_%s_%s", e.PackageID, e.TableName)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
type extDataTableEntry struct {
|
||||
PackageID string
|
||||
TableName string
|
||||
PhysicalName string
|
||||
}
|
||||
|
||||
// tableExists checks if a table exists in the database.
|
||||
func tableExists(ctx context.Context, db *sql.DB, table string) bool {
|
||||
if database.IsPostgres() {
|
||||
var exists bool
|
||||
db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)", table).Scan(&exists)
|
||||
return exists
|
||||
}
|
||||
// SQLite
|
||||
var name string
|
||||
err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// wipeTables deletes all data from the given tables in reverse order (FK-safe).
|
||||
func wipeTables(ctx context.Context, db *sql.DB, tables []string) error {
|
||||
if database.IsSQLite() {
|
||||
db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
|
||||
defer db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
||||
}
|
||||
|
||||
// Delete in reverse order to respect FK constraints
|
||||
for i := len(tables) - 1; i >= 0; i-- {
|
||||
table := tables[i]
|
||||
if !tableExists(ctx, db, table) {
|
||||
continue
|
||||
}
|
||||
if database.IsPostgres() {
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)); err != nil {
|
||||
log.Printf("[backup] warning: truncate %s: %v", table, err)
|
||||
}
|
||||
} else {
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table)); err != nil {
|
||||
log.Printf("[backup] warning: delete %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
372
server/handlers/backup_test.go
Normal file
372
server/handlers/backup_test.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
authpkg "switchboard-core/auth"
|
||||
"switchboard-core/config"
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/middleware"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/store/sqlite"
|
||||
)
|
||||
|
||||
// ── Backup Test Harness ────────────────────────
|
||||
|
||||
type backupHarness struct {
|
||||
*testHarness
|
||||
adminToken string
|
||||
adminID string
|
||||
stores store.Stores
|
||||
backupH *BackupHandler
|
||||
}
|
||||
|
||||
func setupBackupHarness(t *testing.T) *backupHarness {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: testJWTSecret,
|
||||
BasePath: "",
|
||||
}
|
||||
|
||||
var stores store.Stores
|
||||
if database.IsSQLite() {
|
||||
stores = sqlite.NewStores(database.TestDB)
|
||||
} else {
|
||||
t.Skip("backup tests run on SQLite only")
|
||||
}
|
||||
userCache := middleware.NewUserStatusCache()
|
||||
storagePath := t.TempDir()
|
||||
backupH := NewBackupHandler(stores, "", storagePath)
|
||||
|
||||
r := gin.New()
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
// Auth
|
||||
auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider())
|
||||
api.POST("/auth/register", auth.Register)
|
||||
api.POST("/auth/login", auth.Login)
|
||||
|
||||
// Admin group
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache), middleware.RequireAdmin(stores))
|
||||
admin.POST("/backup", backupH.CreateBackup)
|
||||
admin.GET("/backups", backupH.ListBackups)
|
||||
admin.GET("/backups/:name", backupH.DownloadBackup)
|
||||
admin.DELETE("/backups/:name", backupH.DeleteBackup)
|
||||
admin.POST("/restore", backupH.RestoreBackup)
|
||||
|
||||
// Seed admin
|
||||
adminID := seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
"bk-admin", "bk-admin@test.com", "$2a$10$test", "bk-admin", "builtin",
|
||||
)
|
||||
database.SeedEveryoneGroupMember(t, adminID)
|
||||
database.SeedAdminsGroupMember(t, adminID)
|
||||
adminToken := makeToken(adminID, "bk-admin@test.com", "admin")
|
||||
|
||||
return &backupHarness{
|
||||
testHarness: &testHarness{router: r, t: t},
|
||||
adminToken: adminToken,
|
||||
adminID: adminID,
|
||||
stores: stores,
|
||||
backupH: backupH,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tests ──────────────────────────────────────
|
||||
|
||||
func TestCreateBackup_Basic(t *testing.T) {
|
||||
h := setupBackupHarness(t)
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify it's a valid ZIP
|
||||
data := w.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid ZIP: %v", err)
|
||||
}
|
||||
|
||||
// Must contain meta.json
|
||||
var foundMeta bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "meta.json" {
|
||||
foundMeta = true
|
||||
rc, _ := f.Open()
|
||||
var meta backupMeta
|
||||
json.NewDecoder(rc).Decode(&meta)
|
||||
rc.Close()
|
||||
if meta.Dialect != "sqlite" {
|
||||
t.Errorf("expected dialect sqlite, got %s", meta.Dialect)
|
||||
}
|
||||
if len(meta.CoreTables) == 0 {
|
||||
t.Error("expected core tables in meta")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundMeta {
|
||||
t.Fatal("meta.json not found in backup")
|
||||
}
|
||||
|
||||
// Must contain core table JSONL files
|
||||
var foundUsers bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "core/users.jsonl" {
|
||||
foundUsers = true
|
||||
}
|
||||
}
|
||||
if !foundUsers {
|
||||
t.Error("core/users.jsonl not found in backup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackup_WithExtData(t *testing.T) {
|
||||
h := setupBackupHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Seed a package so FK constraint is satisfied
|
||||
database.TestDB.ExecContext(ctx, dialectSQL(
|
||||
`INSERT INTO packages (id, title, type, tier, version, source, enabled, manifest)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, '{}')`),
|
||||
"testpkg", "Test Pkg", "extension", "starlark", "0.1.0", "extension",
|
||||
)
|
||||
|
||||
// Register an ext_data table and create it
|
||||
database.TestDB.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS ext_testpkg_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)`)
|
||||
if _, err := database.TestDB.ExecContext(ctx,
|
||||
dialectSQL(`INSERT INTO ext_data_tables (package_id, table_name) VALUES ($1, $2)`),
|
||||
"testpkg", "items",
|
||||
); err != nil {
|
||||
t.Fatalf("insert ext_data_tables: %v", err)
|
||||
}
|
||||
database.TestDB.ExecContext(ctx,
|
||||
`INSERT INTO ext_testpkg_items (id, name) VALUES ('item1', 'Test Item')`,
|
||||
)
|
||||
defer database.TestDB.ExecContext(ctx, "DROP TABLE IF EXISTS ext_testpkg_items")
|
||||
|
||||
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
data := w.Body.Bytes()
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid ZIP: %v", err)
|
||||
}
|
||||
|
||||
// Should contain ext_data/testpkg/items.jsonl
|
||||
var foundExt bool
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "ext_data/testpkg/items.jsonl" {
|
||||
foundExt = true
|
||||
rc, _ := f.Open()
|
||||
scanner := bufio.NewScanner(rc)
|
||||
if !scanner.Scan() {
|
||||
t.Error("ext_data file is empty")
|
||||
} else {
|
||||
var row map[string]interface{}
|
||||
json.Unmarshal(scanner.Bytes(), &row)
|
||||
if row["name"] != "Test Item" {
|
||||
t.Errorf("expected 'Test Item', got %v", row["name"])
|
||||
}
|
||||
}
|
||||
rc.Close()
|
||||
}
|
||||
}
|
||||
if !foundExt {
|
||||
t.Error("ext_data/testpkg/items.jsonl not found")
|
||||
}
|
||||
|
||||
// Check meta.json has ext table listed
|
||||
metaFile := findInZip(zr, "meta.json")
|
||||
rc, _ := metaFile.Open()
|
||||
var meta backupMeta
|
||||
json.NewDecoder(rc).Decode(&meta)
|
||||
rc.Close()
|
||||
found := false
|
||||
for _, et := range meta.ExtTables {
|
||||
if et == "ext_testpkg_items" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("ext_testpkg_items not in meta.ExtTables: %v", meta.ExtTables)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackup_RoundTrip(t *testing.T) {
|
||||
h := setupBackupHarness(t)
|
||||
|
||||
// Create a second user so we have data to verify
|
||||
seedInsertReturningID(t,
|
||||
`INSERT INTO users (username, email, password_hash, handle, auth_source) VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
"bk-user2", "bk-user2@test.com", "$2a$10$test", "bk-user2", "builtin",
|
||||
)
|
||||
|
||||
// Create backup
|
||||
w := h.request("POST", "/api/v1/admin/backup", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("backup: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
backupData := w.Body.Bytes()
|
||||
|
||||
// Verify we have 2 users
|
||||
var countBefore int
|
||||
database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countBefore)
|
||||
if countBefore < 2 {
|
||||
t.Fatalf("expected at least 2 users, got %d", countBefore)
|
||||
}
|
||||
|
||||
// Restore directly (the handler wipes + restores, so data should round-trip)
|
||||
// We use a separate router without admin middleware for restore,
|
||||
// since the wipe inside RestoreBackup removes the admin group membership
|
||||
// mid-request. In production, restore would use a session-based token
|
||||
// that survives the wipe. For testing, we bypass the middleware.
|
||||
restoreRouter := gin.New()
|
||||
restoreRouter.POST("/restore", h.backupH.RestoreBackup)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "test-backup.swb")
|
||||
part.Write(backupData)
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest("POST", "/restore", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
rw := httptest.NewRecorder()
|
||||
restoreRouter.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusOK {
|
||||
t.Fatalf("restore: expected 200, got %d: %s", rw.Code, rw.Body.String())
|
||||
}
|
||||
|
||||
// Verify data is back
|
||||
var countAfterRestore int
|
||||
database.TestDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&countAfterRestore)
|
||||
if countAfterRestore != countBefore {
|
||||
t.Errorf("expected %d users after restore, got %d", countBefore, countAfterRestore)
|
||||
}
|
||||
|
||||
// Verify specific user exists
|
||||
var username string
|
||||
err := database.TestDB.QueryRow("SELECT username FROM users WHERE username = 'bk-user2'").Scan(&username)
|
||||
if err != nil {
|
||||
t.Errorf("user bk-user2 not found after restore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreBackup_SchemaVersionMismatch(t *testing.T) {
|
||||
h := setupBackupHarness(t)
|
||||
|
||||
// Build a fake backup with a future schema version
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
mw, _ := zw.Create("meta.json")
|
||||
json.NewEncoder(mw).Encode(backupMeta{
|
||||
Version: "99.0.0",
|
||||
SchemaVersion: "999_future",
|
||||
Dialect: "sqlite",
|
||||
CoreTables: []string{"users"},
|
||||
})
|
||||
zw.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "future.swb")
|
||||
part.Write(buf.Bytes())
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/admin/restore", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+h.adminToken)
|
||||
rw := httptest.NewRecorder()
|
||||
h.router.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for future schema, got %d: %s", rw.Code, rw.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpAndRestoreTable(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
ctx := context.Background()
|
||||
db := database.TestDB
|
||||
|
||||
// Create a test table
|
||||
db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS test_backup_round (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
count INTEGER DEFAULT 0
|
||||
)`)
|
||||
defer db.ExecContext(ctx, "DROP TABLE IF EXISTS test_backup_round")
|
||||
|
||||
// Insert data
|
||||
db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r1', 'alpha', 10)`)
|
||||
db.ExecContext(ctx, `INSERT INTO test_backup_round (id, name, count) VALUES ('r2', 'beta', 20)`)
|
||||
|
||||
// Dump
|
||||
var buf bytes.Buffer
|
||||
count, err := dumpTable(ctx, db, "test_backup_round", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("dump: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("dump: expected 2 rows, got %d", count)
|
||||
}
|
||||
|
||||
// Wipe
|
||||
db.ExecContext(ctx, "DELETE FROM test_backup_round")
|
||||
|
||||
// Restore
|
||||
restored, err := restoreTable(ctx, db, "test_backup_round", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if restored != 2 {
|
||||
t.Fatalf("restore: expected 2 rows, got %d", restored)
|
||||
}
|
||||
|
||||
// Verify
|
||||
var name string
|
||||
var cnt int
|
||||
db.QueryRowContext(ctx, "SELECT name, count FROM test_backup_round WHERE id = 'r1'").Scan(&name, &cnt)
|
||||
if name != "alpha" || cnt != 10 {
|
||||
t.Errorf("expected alpha/10, got %s/%d", name, cnt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBackups_Empty(t *testing.T) {
|
||||
h := setupBackupHarness(t)
|
||||
|
||||
w := h.request("GET", "/api/v1/admin/backups", h.adminToken, nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct{ Data []interface{} }
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Data) != 0 {
|
||||
t.Errorf("expected empty list, got %d", len(resp.Data))
|
||||
}
|
||||
}
|
||||
134
server/handlers/docs.go
Normal file
134
server/handlers/docs.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DocsHandler serves documentation files.
|
||||
type DocsHandler struct {
|
||||
docsDir string
|
||||
}
|
||||
|
||||
// NewDocsHandler creates a new DocsHandler.
|
||||
func NewDocsHandler(docsDir string) *DocsHandler {
|
||||
return &DocsHandler{docsDir: docsDir}
|
||||
}
|
||||
|
||||
// docEntry describes a documentation file.
|
||||
type docEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// docsOrder defines the display order and metadata for documentation files.
|
||||
var docsOrder = []docEntry{
|
||||
{Slug: "GETTING-STARTED", Title: "Getting Started", Description: "Quick start guide"},
|
||||
{Slug: "ARCHITECTURE", Title: "Architecture", Description: "Kernel design and components"},
|
||||
{Slug: "EXTENSION-GUIDE", Title: "Extension Guide", Description: "How to author extensions"},
|
||||
{Slug: "PACKAGE-FORMAT", Title: "Package Format", Description: "The .pkg archive format"},
|
||||
{Slug: "API-REFERENCE", Title: "API Reference", Description: "REST API overview"},
|
||||
{Slug: "DEPLOYMENT", Title: "Deployment", Description: "Production deployment guide"},
|
||||
{Slug: "DISTRIBUTION", Title: "Distribution", Description: "Docker image and bundled packages"},
|
||||
}
|
||||
|
||||
// ListDocs returns the list of available documentation files.
|
||||
// GET /api/v1/docs
|
||||
func (h *DocsHandler) ListDocs(c *gin.Context) {
|
||||
if h.docsDir == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []docEntry{}})
|
||||
return
|
||||
}
|
||||
|
||||
var available []docEntry
|
||||
for _, d := range docsOrder {
|
||||
path := filepath.Join(h.docsDir, d.Slug+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
available = append(available, d)
|
||||
}
|
||||
}
|
||||
|
||||
// Also add any .md files not in the ordered list
|
||||
entries, err := os.ReadDir(h.docsDir)
|
||||
if err == nil {
|
||||
known := make(map[string]bool)
|
||||
for _, d := range docsOrder {
|
||||
known[d.Slug] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(e.Name(), ".md")
|
||||
if known[slug] {
|
||||
continue
|
||||
}
|
||||
// Skip design docs and other non-user-facing files
|
||||
if strings.HasPrefix(slug, "DESIGN-") || strings.HasPrefix(slug, "DEMO-") {
|
||||
continue
|
||||
}
|
||||
available = append(available, docEntry{
|
||||
Slug: slug,
|
||||
Title: slugToTitle(slug),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if available == nil {
|
||||
available = []docEntry{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": available})
|
||||
}
|
||||
|
||||
// GetDoc returns the raw markdown content of a documentation file.
|
||||
// GET /api/v1/docs/:name
|
||||
func (h *DocsHandler) GetDoc(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !isValidDocName(name) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid document name"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.docsDir == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "docs not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(h.docsDir, name+".md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": gin.H{
|
||||
"slug": name,
|
||||
"title": slugToTitle(name),
|
||||
"content": string(data),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
var validDocNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
func isValidDocName(name string) bool {
|
||||
return validDocNameRe.MatchString(name) && !strings.Contains(name, "..")
|
||||
}
|
||||
|
||||
func slugToTitle(slug string) string {
|
||||
// Convert "GETTING-STARTED" → "Getting Started"
|
||||
parts := strings.Split(slug, "-")
|
||||
for i, p := range parts {
|
||||
if len(p) > 0 {
|
||||
parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:])
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -332,6 +332,107 @@ func hasPermission(granted []string, perm string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ── api_schema support (v0.6.2) ─────────────────────────────────────
|
||||
|
||||
// APISchemaEntry represents one route's OpenAPI-level documentation
|
||||
// declared via the optional manifest "api_schema" array.
|
||||
type APISchemaEntry struct {
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
Body map[string]any `json:"body,omitempty"`
|
||||
Response map[string]any `json:"response,omitempty"`
|
||||
}
|
||||
|
||||
// ParseAPISchema extracts the optional api_schema array from a package manifest.
|
||||
// Malformed entries are logged and skipped — never blocks extension loading.
|
||||
func ParseAPISchema(manifest map[string]any) []APISchemaEntry {
|
||||
raw, ok := manifest["api_schema"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
log.Printf("[ext_api] api_schema is not an array, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []APISchemaEntry
|
||||
for i, item := range arr {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
log.Printf("[ext_api] api_schema[%d]: not an object, skipping", i)
|
||||
continue
|
||||
}
|
||||
path, _ := m["path"].(string)
|
||||
method, _ := m["method"].(string)
|
||||
if path == "" || method == "" {
|
||||
log.Printf("[ext_api] api_schema[%d]: missing path or method, skipping", i)
|
||||
continue
|
||||
}
|
||||
entry := APISchemaEntry{
|
||||
Path: path,
|
||||
Method: strings.ToUpper(method),
|
||||
}
|
||||
if s, ok := m["summary"].(string); ok {
|
||||
entry.Summary = s
|
||||
}
|
||||
if s, ok := m["description"].(string); ok {
|
||||
entry.Description = s
|
||||
}
|
||||
if p, ok := m["params"].(map[string]any); ok {
|
||||
entry.Params = p
|
||||
}
|
||||
if b, ok := m["body"].(map[string]any); ok {
|
||||
entry.Body = b
|
||||
}
|
||||
if r, ok := m["response"].(map[string]any); ok {
|
||||
entry.Response = r
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// ExtractAPIRoutes returns all declared path+method pairs from the manifest.
|
||||
// Used by the OpenAPI builder to enumerate routes for stub generation.
|
||||
func ExtractAPIRoutes(manifest map[string]any) []struct{ Method, Path string } {
|
||||
raw, ok := manifest["api_routes"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Boolean shorthand — no specific routes to enumerate
|
||||
if _, ok := raw.(bool); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
routes, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []struct{ Method, Path string }
|
||||
for _, entry := range routes {
|
||||
route, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
method, _ := route["method"].(string)
|
||||
path, _ := route["path"].(string)
|
||||
if method == "" || path == "" {
|
||||
continue
|
||||
}
|
||||
result = append(result, struct{ Method, Path string }{
|
||||
Method: strings.ToUpper(method),
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasAPIRoutes checks whether a package manifest declares API routes.
|
||||
// Used by startup discovery and admin UI to identify API-capable packages.
|
||||
func HasAPIRoutes(manifest map[string]any) bool {
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
406
server/handlers/openapi_test.go
Normal file
406
server/handlers/openapi_test.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"switchboard-core/database"
|
||||
"switchboard-core/store"
|
||||
"switchboard-core/store/sqlite"
|
||||
)
|
||||
|
||||
// Minimal static spec for testing — valid OpenAPI 3.0 structure.
|
||||
var testStaticSpec = []byte(`
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: Switchboard Core
|
||||
version: "${VERSION}"
|
||||
paths:
|
||||
/api/v1/health:
|
||||
get:
|
||||
summary: Health check
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
tags:
|
||||
- name: system
|
||||
description: System endpoints
|
||||
`)
|
||||
|
||||
func openapiTestStores(t *testing.T) store.Stores {
|
||||
t.Helper()
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
return sqlite.NewStores(database.TestDB)
|
||||
}
|
||||
|
||||
// ── Test: Zero extensions → kernel spec only ──────────────────
|
||||
|
||||
func TestBuildOpenAPISpec_NoExtensions(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
// Version should be substituted
|
||||
info, _ := spec["info"].(map[string]any)
|
||||
if info == nil {
|
||||
t.Fatal("missing info field")
|
||||
}
|
||||
if v, _ := info["version"].(string); v != "0.6.2" {
|
||||
t.Errorf("expected version 0.6.2, got %q", v)
|
||||
}
|
||||
|
||||
// Paths should contain the kernel endpoint
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if paths == nil {
|
||||
t.Fatal("missing paths field")
|
||||
}
|
||||
if _, ok := paths["/api/v1/health"]; !ok {
|
||||
t.Error("expected /api/v1/health in paths")
|
||||
}
|
||||
|
||||
// Tags should contain kernel tag
|
||||
tags, _ := spec["tags"].([]any)
|
||||
if len(tags) < 1 {
|
||||
t.Error("expected at least 1 tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Extension with api_routes but no api_schema → stubs ────
|
||||
|
||||
func TestBuildOpenAPISpec_StubEntries(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
// Install a test extension with api_routes
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/items"},
|
||||
map[string]any{"method": "POST", "path": "/items"},
|
||||
},
|
||||
}
|
||||
manifestJSON, _ := json.Marshal(manifest)
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "test-ext",
|
||||
Title: "Test Extension",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
_ = manifestJSON
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
|
||||
// Check GET stub
|
||||
getPath, ok := paths["/s/test-ext/api/items"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/test-ext/api/items in paths")
|
||||
}
|
||||
pathItem, _ := getPath.(map[string]any)
|
||||
getOp, _ := pathItem["get"].(map[string]any)
|
||||
if getOp == nil {
|
||||
t.Fatal("expected GET operation")
|
||||
}
|
||||
summary, _ := getOp["summary"].(string)
|
||||
if summary != "test-ext: GET /items" {
|
||||
t.Errorf("unexpected stub summary: %q", summary)
|
||||
}
|
||||
|
||||
// Check POST stub
|
||||
postOp, _ := pathItem["post"].(map[string]any)
|
||||
if postOp == nil {
|
||||
t.Fatal("expected POST operation")
|
||||
}
|
||||
|
||||
// Check tag
|
||||
tags, _ := getOp["tags"].([]any)
|
||||
if len(tags) != 1 || tags[0] != "extensions/test-ext" {
|
||||
t.Errorf("unexpected tags: %v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Extension with api_schema → rich entries ────────
|
||||
|
||||
func TestBuildOpenAPISpec_SchemaEntries(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/items"},
|
||||
map[string]any{"method": "POST", "path": "/items"},
|
||||
map[string]any{"method": "DELETE", "path": "/items/:id"},
|
||||
},
|
||||
"api_schema": []any{
|
||||
map[string]any{
|
||||
"path": "/items",
|
||||
"method": "GET",
|
||||
"summary": "List items",
|
||||
"params": map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "default": 50},
|
||||
},
|
||||
"response": map[string]any{
|
||||
"type": "object",
|
||||
"example": map[string]any{"data": []any{}},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"path": "/items",
|
||||
"method": "POST",
|
||||
"summary": "Create item",
|
||||
"body": map[string]any{
|
||||
"name": map[string]any{"type": "string", "required": true},
|
||||
"description": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "rich-ext",
|
||||
Title: "Rich Extension",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
|
||||
// GET /items should have rich schema (summary = "List items")
|
||||
itemsPath, ok := paths["/s/rich-ext/api/items"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/rich-ext/api/items in paths")
|
||||
}
|
||||
pathItem, _ := itemsPath.(map[string]any)
|
||||
getOp, _ := pathItem["get"].(map[string]any)
|
||||
if getOp == nil {
|
||||
t.Fatal("expected GET operation")
|
||||
}
|
||||
if s, _ := getOp["summary"].(string); s != "List items" {
|
||||
t.Errorf("expected rich summary, got %q", s)
|
||||
}
|
||||
// Should have parameters
|
||||
params, _ := getOp["parameters"].([]any)
|
||||
if len(params) == 0 {
|
||||
t.Error("expected parameters from api_schema")
|
||||
}
|
||||
|
||||
// POST /items should have requestBody
|
||||
postOp, _ := pathItem["post"].(map[string]any)
|
||||
if postOp == nil {
|
||||
t.Fatal("expected POST operation")
|
||||
}
|
||||
if _, ok := postOp["requestBody"]; !ok {
|
||||
t.Error("expected requestBody from api_schema body")
|
||||
}
|
||||
|
||||
// DELETE /items/:id should be a stub (no api_schema for it)
|
||||
deletePath, ok := paths["/s/rich-ext/api/items/:id"]
|
||||
if !ok {
|
||||
t.Fatal("expected /s/rich-ext/api/items/:id in paths")
|
||||
}
|
||||
deleteItem, _ := deletePath.(map[string]any)
|
||||
deleteOp, _ := deleteItem["delete"].(map[string]any)
|
||||
if deleteOp == nil {
|
||||
t.Fatal("expected DELETE operation")
|
||||
}
|
||||
deleteSummary, _ := deleteOp["summary"].(string)
|
||||
if deleteSummary != "rich-ext: DELETE /items/:id" {
|
||||
t.Errorf("expected stub summary for undeclared route, got %q", deleteSummary)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Multiple extensions → all appear ────────
|
||||
|
||||
func TestBuildOpenAPISpec_MultipleExtensions(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
for _, ext := range []struct {
|
||||
id string
|
||||
title string
|
||||
route string
|
||||
}{
|
||||
{"ext-a", "Extension A", "/status"},
|
||||
{"ext-b", "Extension B", "/health"},
|
||||
} {
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: ext.id,
|
||||
Title: ext.title,
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": ext.route},
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create %s: %v", ext.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if _, ok := paths["/s/ext-a/api/status"]; !ok {
|
||||
t.Error("expected ext-a path")
|
||||
}
|
||||
if _, ok := paths["/s/ext-b/api/health"]; !ok {
|
||||
t.Error("expected ext-b path")
|
||||
}
|
||||
|
||||
// Verify tags
|
||||
tags, _ := spec["tags"].([]any)
|
||||
tagNames := make(map[string]bool)
|
||||
for _, tag := range tags {
|
||||
if m, ok := tag.(map[string]any); ok {
|
||||
if n, ok := m["name"].(string); ok {
|
||||
tagNames[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !tagNames["extensions/ext-a"] {
|
||||
t.Error("expected extensions/ext-a tag")
|
||||
}
|
||||
if !tagNames["extensions/ext-b"] {
|
||||
t.Error("expected extensions/ext-b tag")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Malformed api_schema → stubs, no crash ────────
|
||||
|
||||
func TestBuildOpenAPISpec_MalformedSchema(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
manifest := map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/data"},
|
||||
},
|
||||
"api_schema": []any{
|
||||
"not-an-object", // malformed
|
||||
map[string]any{"path": "/data"}, // missing method
|
||||
map[string]any{"method": "GET"}, // missing path
|
||||
map[string]any{"path": "/data", "method": "POST"}, // valid but no matching route
|
||||
},
|
||||
}
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "bad-schema",
|
||||
Title: "Bad Schema",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: manifest,
|
||||
Enabled: true,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec should not error on malformed schema: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
// GET /data should still appear as a stub
|
||||
if _, ok := paths["/s/bad-schema/api/data"]; !ok {
|
||||
t.Error("expected stub entry for /data despite malformed api_schema")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Disabled extension → excluded ────────
|
||||
|
||||
func TestBuildOpenAPISpec_DisabledExcluded(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
err := stores.Packages.Create(context.Background(), &store.PackageRegistration{
|
||||
ID: "disabled-ext",
|
||||
Title: "Disabled",
|
||||
Type: "extension",
|
||||
Version: "1.0.0",
|
||||
Tier: "starlark",
|
||||
Manifest: map[string]any{
|
||||
"api_routes": []any{
|
||||
map[string]any{"method": "GET", "path": "/secret"},
|
||||
},
|
||||
},
|
||||
Enabled: false,
|
||||
Status: "active",
|
||||
Source: "extension",
|
||||
Scope: "global",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create package: %v", err)
|
||||
}
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "0.6.2")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
paths, _ := spec["paths"].(map[string]any)
|
||||
if _, ok := paths["/s/disabled-ext/api/secret"]; ok {
|
||||
t.Error("disabled extension should not appear in spec")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test: Required OpenAPI 3.0 fields present ────────
|
||||
|
||||
func TestBuildOpenAPISpec_RequiredFields(t *testing.T) {
|
||||
stores := openapiTestStores(t)
|
||||
|
||||
spec, err := BuildOpenAPISpec(stores, testStaticSpec, "1.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildOpenAPISpec failed: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := spec["openapi"]; !ok {
|
||||
t.Error("missing required 'openapi' field")
|
||||
}
|
||||
if _, ok := spec["info"]; !ok {
|
||||
t.Error("missing required 'info' field")
|
||||
}
|
||||
if _, ok := spec["paths"]; !ok {
|
||||
t.Error("missing required 'paths' field")
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,15 @@ func main() {
|
||||
patched := bytes.Replace(openapiSpec, []byte("${VERSION}"), []byte(Version), 1)
|
||||
c.Data(http.StatusOK, "application/yaml", patched)
|
||||
})
|
||||
// v0.6.2: Dynamic OpenAPI spec with extension routes merged in
|
||||
base.GET("/api/docs/openapi.json", func(c *gin.Context) {
|
||||
spec, err := handlers.BuildOpenAPISpec(stores, openapiSpec, Version)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build spec"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, spec)
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket)
|
||||
@@ -512,6 +521,12 @@ func main() {
|
||||
protected.GET("/settings", settings.GetSettings)
|
||||
protected.PUT("/settings", settings.UpdateSettings)
|
||||
|
||||
// Documentation (v0.6.1)
|
||||
docsDir := findDocsDir()
|
||||
docsH := handlers.NewDocsHandler(docsDir)
|
||||
protected.GET("/docs", docsH.ListDocs)
|
||||
protected.GET("/docs/:name", docsH.GetDoc)
|
||||
|
||||
// Permission bootstrap (v0.37.1) — self-service resolved permissions
|
||||
permH := handlers.NewProfilePermissionsHandler(stores)
|
||||
protected.GET("/profile/permissions", permH.GetMyPermissions)
|
||||
@@ -793,6 +808,14 @@ func main() {
|
||||
admin.GET("/cluster", clusterH.ListNodes)
|
||||
}
|
||||
|
||||
// ── Backup/Restore (v0.6.1) ─────────
|
||||
backupH := handlers.NewBackupHandler(stores, packagesDir, cfg.StoragePath)
|
||||
admin.POST("/backup", backupH.CreateBackup)
|
||||
admin.GET("/backups", backupH.ListBackups)
|
||||
admin.GET("/backups/:name", backupH.DownloadBackup)
|
||||
admin.DELETE("/backups/:name", backupH.DeleteBackup)
|
||||
admin.POST("/restore", backupH.RestoreBackup)
|
||||
|
||||
// Surface aliases (backward compat — same handlers)
|
||||
admin.GET("/surfaces", pkgAdm.ListPackages)
|
||||
admin.GET("/surfaces/:id", pkgAdm.GetPackage)
|
||||
@@ -898,6 +921,21 @@ func appendClusterHealth(info gin.H, reg *cluster.Registry, stores store.Stores)
|
||||
}
|
||||
}
|
||||
|
||||
// findDocsDir locates the docs/ directory. Checks common locations.
|
||||
func findDocsDir() string {
|
||||
candidates := []string{
|
||||
"docs",
|
||||
"../docs",
|
||||
"/app/docs",
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Vault CLI Commands ──────────────────────
|
||||
|
||||
func runVaultCommand(subcmd string) {
|
||||
|
||||
@@ -81,7 +81,7 @@ func sectionCategory(section string) string {
|
||||
return "people"
|
||||
case "workflows":
|
||||
return "workflows"
|
||||
case "settings", "storage", "packages", "broadcast":
|
||||
case "settings", "storage", "packages", "broadcast", "backup":
|
||||
return "system"
|
||||
case "usage", "audit", "stats":
|
||||
return "monitoring"
|
||||
|
||||
@@ -249,6 +249,11 @@ func (e *Engine) registerCoreSurfaces() {
|
||||
Title: "Welcome", Template: "surface-welcome", Auth: "authenticated",
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
{
|
||||
ID: "docs", Route: "/docs/:section", AltRoutes: []string{"/docs"},
|
||||
Title: "Docs", Template: "surface-docs", Auth: "authenticated",
|
||||
Layout: "single", Source: "core",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +366,9 @@ func (e *Engine) RenderSurface(surfaceID string) gin.HandlerFunc {
|
||||
if section == "" && surfaceID == "team-admin" {
|
||||
section = "members"
|
||||
}
|
||||
if section == "" && surfaceID == "docs" {
|
||||
section = "GETTING-STARTED"
|
||||
}
|
||||
|
||||
e.Render(c, "base.html", PageData{
|
||||
Surface: surfaceID,
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
{{else if eq .Surface "team-admin"}}{{template "surface-team-admin" .}}
|
||||
{{else if eq .Surface "settings"}}{{template "surface-settings" .}}
|
||||
{{else if eq .Surface "welcome"}}{{template "surface-welcome" .}}
|
||||
{{else if eq .Surface "docs"}}{{template "surface-docs" .}}
|
||||
{{else if and .Manifest (eq .Manifest.Source "extension")}}{{template "surface-extension" .}}
|
||||
{{else}}<div style="padding:20px">Unknown surface: {{.Surface}}</div>
|
||||
{{end}}
|
||||
@@ -124,6 +125,7 @@
|
||||
{{if eq .Surface "team-admin"}}{{template "scripts-team-admin" .}}{{end}}
|
||||
{{if eq .Surface "settings"}}{{template "scripts-settings" .}}{{end}}
|
||||
{{if eq .Surface "welcome"}}{{template "scripts-welcome" .}}{{end}}
|
||||
{{if eq .Surface "docs"}}{{template "scripts-docs" .}}{{end}}
|
||||
{{/* v0.27.0: Extension surface JS — load Preact globals, boot SDK, then extension */}}
|
||||
{{if and .Manifest (eq .Manifest.Source "extension")}}
|
||||
<script type="module" nonce="{{.CSPNonce}}">
|
||||
|
||||
35
server/pages/templates/surfaces/docs.html
Normal file
35
server/pages/templates/surfaces/docs.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{{/*
|
||||
Docs surface — v0.6.1
|
||||
Renders markdown documentation with sidebar navigation.
|
||||
*/}}
|
||||
|
||||
{{define "surface-docs"}}
|
||||
<div id="docs-mount" class="surface-docs">
|
||||
<div style="padding:40px;text-align:center;color:var(--text-3);">Loading docs…</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "css-docs"}}{{end}}
|
||||
|
||||
{{define "scripts-docs"}}
|
||||
<script nonce="{{.CSPNonce}}">
|
||||
window.__SECTION__ = '{{.Section}}';
|
||||
</script>
|
||||
|
||||
<script type="module" nonce="{{.CSPNonce}}">
|
||||
const { h, render } = await import('{{.BasePath}}/js/sw/vendor/preact.module.js');
|
||||
const hooksModule = await import('{{.BasePath}}/js/sw/vendor/hooks.module.js');
|
||||
const { default: htm } = await import('{{.BasePath}}/js/sw/vendor/htm.module.js');
|
||||
const html = htm.bind(h);
|
||||
window.preact = window.preact || { h, render };
|
||||
window.hooks = window.hooks || hooksModule;
|
||||
window.html = window.html || html;
|
||||
|
||||
// Boot SDK (idempotent)
|
||||
const { boot } = await import('{{.BasePath}}/js/sw/sdk/index.js?v={{.Version}}');
|
||||
await boot();
|
||||
|
||||
// Mount docs surface
|
||||
await import('{{.BasePath}}/js/sw/surfaces/docs/index.js?v={{.Version}}');
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -256,7 +256,7 @@
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
SwaggerUIBundle({
|
||||
url: window.location.pathname.replace(/\/$/, '') + '/openapi.yaml',
|
||||
url: window.location.pathname.replace(/\/$/, '') + '/openapi.json',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
|
||||
|
||||
Reference in New Issue
Block a user