Files
sqlite_api/handlers.go
T
Egutierrez a1769a3976 chore: auto-commit (3 archivos)
- app.md
- handlers.go
- events.go

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:28:22 +02:00

345 lines
8.9 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fn-registry/functions/infra"
)
const queryTimeout = 5 * time.Second
// Server holds the HTTP handlers and DB pool.
type Server struct {
pool *DBPool
registryRoot string // raiz del fn_registry (para exec fn ...)
hub *CallMonitorHub
}
func NewServer(pool *DBPool, registryRoot string) *Server {
return &Server{
pool: pool,
registryRoot: registryRoot,
hub: NewCallMonitorHub(pool),
}
}
// Routes registers all API routes on the given mux.
func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /health", s.handleHealth)
mux.HandleFunc("GET /api/databases", s.handleDatabases)
mux.HandleFunc("GET /api/databases/{db}/tables", s.handleTables)
mux.HandleFunc("GET /api/databases/{db}/schema", s.handleSchema)
mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery)
mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS)
// Projects: listado con conteos + detalle nested
mux.HandleFunc("GET /api/projects", s.handleProjects)
mux.HandleFunc("GET /api/projects/{id}", s.handleProjectDetail)
// Mutaciones: reindex + add (apps/analysis/vaults)
mux.HandleFunc("POST /api/reindex", s.handleReindex)
mux.HandleFunc("POST /api/add/app", s.handleAddApp)
mux.HandleFunc("POST /api/add/analysis", s.handleAddAnalysis)
mux.HandleFunc("POST /api/add/vault", s.handleAddVault)
// Issue 0086: WebSocket live stream de ops:call_monitor (calls table).
// Hub global con ticker bajo demanda (solo corre con >=1 subscriber).
mux.HandleFunc("GET /api/events/call_monitor", s.handleEvents(s.hub))
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleDatabases(w http.ResponseWriter, r *http.Request) {
entries := s.pool.List()
type dbInfo struct {
Alias string `json:"alias"`
Kind string `json:"kind"`
}
out := make([]dbInfo, len(entries))
for i, e := range entries {
out[i] = dbInfo{Alias: e.Alias, Kind: e.Kind}
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleTables(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT type, name FROM sqlite_master WHERE type IN ('table','view') ORDER BY type, name")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
type tableInfo struct {
Type string `json:"type"`
Name string `json:"name"`
}
var tables []tableInfo
for rows.Next() {
var t tableInfo
if err := rows.Scan(&t.Type, &t.Name); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
tables = append(tables, t)
}
if tables == nil {
tables = []tableInfo{}
}
writeJSON(w, http.StatusOK, tables)
}
func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type, name")
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
defer rows.Close()
var statements []string
for rows.Next() {
var stmt string
if err := rows.Scan(&stmt); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
statements = append(statements, stmt)
}
writeJSON(w, http.StatusOK, map[string]any{
"statements": statements,
"count": len(statements),
})
}
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
var req struct {
SQL string `json:"sql"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return
}
if req.SQL == "" {
writeError(w, http.StatusBadRequest, "sql field is required")
return
}
if err := ValidateQuery(req.SQL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
start := time.Now()
rows, err := db.QueryContext(ctx, req.SQL)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var resultRows [][]any
for rows.Next() {
vals := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Convert []byte to string for JSON serialization.
for i, v := range vals {
if b, ok := v.([]byte); ok {
vals[i] = string(b)
}
}
resultRows = append(resultRows, vals)
}
if resultRows == nil {
resultRows = [][]any{}
}
durationMs := time.Since(start).Milliseconds()
writeJSON(w, http.StatusOK, map[string]any{
"columns": columns,
"rows": resultRows,
"count": len(resultRows),
"duration_ms": durationMs,
})
}
func (s *Server) handleFTS(w http.ResponseWriter, r *http.Request) {
dbAlias := resolveDBAlias(r.PathValue("db"))
db, err := s.pool.Get(dbAlias)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
q := r.URL.Query().Get("q")
if q == "" {
writeError(w, http.StatusBadRequest, "q parameter is required")
return
}
table := r.URL.Query().Get("table")
if table == "" {
table = "functions"
}
// Map table to its FTS table and columns to return.
ftsTable := table + "_fts"
var selectCols string
switch table {
case "functions":
selectCols = "id, name, kind, purity, domain, description"
case "types":
selectCols = "id, name, algebraic, domain, description"
case "unit_tests":
selectCols = "id, name, function_id, lang"
default:
writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported FTS table: %s", table))
return
}
limit := r.URL.Query().Get("limit")
if limit == "" {
limit = "20"
}
query := fmt.Sprintf(
"SELECT %s FROM %s WHERE id IN (SELECT id FROM %s WHERE %s MATCH ?) LIMIT %s",
selectCols, table, ftsTable, ftsTable, limit,
)
ctx, cancel := context.WithTimeout(r.Context(), queryTimeout)
defer cancel()
start := time.Now()
rows, err := db.QueryContext(ctx, query, q)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
var resultRows [][]any
for rows.Next() {
vals := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for i, v := range vals {
if b, ok := v.([]byte); ok {
vals[i] = string(b)
}
}
resultRows = append(resultRows, vals)
}
if resultRows == nil {
resultRows = [][]any{}
}
durationMs := time.Since(start).Milliseconds()
writeJSON(w, http.StatusOK, map[string]any{
"columns": columns,
"rows": resultRows,
"count": len(resultRows),
"duration_ms": durationMs,
})
}
// resolveDBAlias converts URL path segment to pool alias.
// "registry" → "registry", "ops:app_name" → "ops:app_name"
// URL-encoded colons: "ops%3Aapp_name" → already decoded by net/http.
func resolveDBAlias(raw string) string {
return raw
}
// CORS middleware for browser access.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, data any) {
infra.HTTPJSONResponse(w, status, data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// sanitizeFTS escapes special FTS5 characters in user input.
func sanitizeFTS(input string) string {
// Wrap each term in double quotes to treat as literal.
terms := strings.Fields(input)
for i, t := range terms {
terms[i] = `"` + strings.ReplaceAll(t, `"`, `""`) + `"`
}
return strings.Join(terms, " ")
}