package main import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" ) const queryTimeout = 5 * time.Second // Server holds the HTTP handlers and DB pool. type Server struct { pool *DBPool } func NewServer(pool *DBPool) *Server { return &Server{pool: 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) } 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) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(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, " ") }