11c986edc7
- handlers.go - handlers_test.go - main.go - datafactory_events.go - handlers_datafactory.go Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
394 lines
11 KiB
Go
394 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"fn-registry/apps/data_factory/datafactory"
|
|
"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
|
|
dfHub *DataFactoryHub
|
|
|
|
// data_factory.db (RW) is opened lazily on first request and reused.
|
|
dfPath string
|
|
dfMigrationsDir string
|
|
dfMu sync.Mutex
|
|
dfDB *sql.DB
|
|
dfErr error // sticky if a previous open failed
|
|
}
|
|
|
|
func NewServer(pool *DBPool, registryRoot, dfPath, dfMigrationsDir string) *Server {
|
|
s := &Server{
|
|
pool: pool,
|
|
registryRoot: registryRoot,
|
|
hub: NewCallMonitorHub(pool),
|
|
dfPath: dfPath,
|
|
dfMigrationsDir: dfMigrationsDir,
|
|
}
|
|
s.dfHub = NewDataFactoryHub(s.dataFactoryDB)
|
|
return s
|
|
}
|
|
|
|
// dataFactoryDB returns a RW connection to data_factory.db, lazy-opened on
|
|
// first call. Subsequent calls return the cached connection. If the open
|
|
// fails it is cached as a sticky error to avoid retry storms; retry after
|
|
// process restart.
|
|
func (s *Server) dataFactoryDB() (*sql.DB, error) {
|
|
s.dfMu.Lock()
|
|
defer s.dfMu.Unlock()
|
|
if s.dfDB != nil {
|
|
return s.dfDB, nil
|
|
}
|
|
if s.dfErr != nil {
|
|
return nil, s.dfErr
|
|
}
|
|
db, err := datafactory.Open(s.dfPath, s.dfMigrationsDir)
|
|
if err != nil {
|
|
s.dfErr = err
|
|
return nil, err
|
|
}
|
|
s.dfDB = db
|
|
return db, nil
|
|
}
|
|
|
|
// 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))
|
|
|
|
// Issue 0097: data_factory.db (nodes, runs, databases).
|
|
// REST read-only + WS live stream sobre `runs`.
|
|
mux.HandleFunc("GET /api/datafactory/nodes", s.handleDataFactoryNodes)
|
|
mux.HandleFunc("GET /api/datafactory/runs", s.handleDataFactoryRuns)
|
|
mux.HandleFunc("GET /api/datafactory/databases", s.handleDataFactoryDatabases)
|
|
mux.HandleFunc("GET /api/ws/datafactory", s.handleDataFactoryEvents(s.dfHub))
|
|
}
|
|
|
|
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, 0, len(entries)+1)
|
|
for _, e := range entries {
|
|
out = append(out, dbInfo{Alias: e.Alias, Kind: e.Kind})
|
|
}
|
|
// Surface data_factory.db too. It is opened RW outside the pool but
|
|
// belongs in the discovery list so dashboards can see it.
|
|
if s.dfPath != "" {
|
|
out = append(out, dbInfo{Alias: "data_factory", Kind: "data_factory"})
|
|
}
|
|
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, " ")
|
|
}
|