Initial extraction from fn_registry
sqlite_api: API REST HTTP read-only sobre registry.db y operations.db. Bind por defecto 127.0.0.1:8484. Go + net/http + SQLite FTS5. Extraido de fn_registry/projects/fn_monitoring/apps/sqlite_api/ como repo independiente. La metadata del registry queda en project.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
# Compiled binary
|
||||
sqlite_api
|
||||
|
||||
# Runtime artifacts
|
||||
sqlite_api.log
|
||||
sqlite_api.pid
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
name: sqlite_api
|
||||
lang: go
|
||||
domain: infra
|
||||
description: "API REST HTTP read-only sobre registry.db y operations.db de cada app. Permite consultas SQL (solo SELECT/PRAGMA), busqueda FTS5, exploracion de tablas y schema. Bind por defecto a localhost:8484."
|
||||
tags: [service, api, sqlite, http, registry, fts5]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
framework: "net/http"
|
||||
entry_point: "main.go"
|
||||
dir_path: "projects/fn_monitoring/apps/sqlite_api"
|
||||
---
|
||||
|
||||
## Uso
|
||||
|
||||
```bash
|
||||
# Arrancar (default: 127.0.0.1:8484)
|
||||
cd apps/sqlite_api && go run -tags fts5 .
|
||||
|
||||
# Bind personalizado
|
||||
go run -tags fts5 . --bind 0.0.0.0:8484
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Metodo | Path | Descripcion |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/api/databases` | Lista DBs disponibles (registry + ops:*) |
|
||||
| GET | `/api/databases/:db/tables` | Tablas y vistas de una DB |
|
||||
| GET | `/api/databases/:db/schema` | Schema SQL completo |
|
||||
| POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
|
||||
| GET | `/api/databases/:db/fts?q=...&table=...` | Busqueda FTS5 directa |
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Solo queries SELECT, PRAGMA, WITH y EXPLAIN
|
||||
- SQLite abierto con `?mode=ro` (read-only a nivel driver)
|
||||
- Timeout de 5 segundos por query
|
||||
- Bind a localhost por defecto
|
||||
- CORS habilitado para acceso desde frontends
|
||||
|
||||
## Bases de datos
|
||||
|
||||
- `registry` — registry.db de la raiz
|
||||
- `ops:{app}` — operations.db de apps/{app}/ y projects/*/apps/{app}/
|
||||
|
||||
Auto-descubre operations.db al arrancar escaneando apps/ y projects/*/apps/.
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl http://localhost:8484/health
|
||||
# {"status":"ok"}
|
||||
```
|
||||
|
||||
## Puerto
|
||||
|
||||
8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090).
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DBEntry represents a registered database.
|
||||
type DBEntry struct {
|
||||
Alias string // "registry" or "ops:app_name"
|
||||
Path string // absolute path to .db file
|
||||
Kind string // "registry" or "operations"
|
||||
}
|
||||
|
||||
// DBPool manages read-only connections to registered databases.
|
||||
type DBPool struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]DBEntry
|
||||
conns map[string]*sql.DB
|
||||
}
|
||||
|
||||
func NewDBPool() *DBPool {
|
||||
return &DBPool{
|
||||
entries: make(map[string]DBEntry),
|
||||
conns: make(map[string]*sql.DB),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a database entry. Does not open the connection until first use.
|
||||
func (p *DBPool) Register(entry DBEntry) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.entries[entry.Alias] = entry
|
||||
}
|
||||
|
||||
// Get returns a read-only connection to the named database.
|
||||
func (p *DBPool) Get(alias string) (*sql.DB, error) {
|
||||
p.mu.RLock()
|
||||
if db, ok := p.conns[alias]; ok {
|
||||
p.mu.RUnlock()
|
||||
return db, nil
|
||||
}
|
||||
p.mu.RUnlock()
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock.
|
||||
if db, ok := p.conns[alias]; ok {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
entry, ok := p.entries[alias]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("database %q not found", alias)
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=ro", entry.Path)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opening %s: %w", alias, err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("pinging %s: %w", alias, err)
|
||||
}
|
||||
db.SetMaxOpenConns(4)
|
||||
p.conns[alias] = db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// List returns all registered database entries.
|
||||
func (p *DBPool) List() []DBEntry {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]DBEntry, 0, len(p.entries))
|
||||
for _, e := range p.entries {
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Close closes all open connections.
|
||||
func (p *DBPool) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, db := range p.conns {
|
||||
db.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Config holds the server configuration.
|
||||
type Config struct {
|
||||
Bind string // address to bind (default "127.0.0.1:8484")
|
||||
RegistryDir string // root of fn_registry (where registry.db lives)
|
||||
}
|
||||
|
||||
// DiscoverDatabases finds registry.db and all operations.db files.
|
||||
func DiscoverDatabases(root string) []DBEntry {
|
||||
var entries []DBEntry
|
||||
|
||||
// registry.db
|
||||
regPath := filepath.Join(root, "registry.db")
|
||||
if _, err := os.Stat(regPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "registry",
|
||||
Path: regPath,
|
||||
Kind: "registry",
|
||||
})
|
||||
}
|
||||
|
||||
// apps/*/operations.db
|
||||
appsDir := filepath.Join(root, "apps")
|
||||
dirEntries, err := os.ReadDir(appsDir)
|
||||
if err != nil {
|
||||
return entries
|
||||
}
|
||||
for _, d := range dirEntries {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
opsPath := filepath.Join(appsDir, d.Name(), "operations.db")
|
||||
if _, err := os.Stat(opsPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "ops:" + d.Name(),
|
||||
Path: opsPath,
|
||||
Kind: "operations",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// projects/*/apps/*/operations.db
|
||||
projectsDir := filepath.Join(root, "projects")
|
||||
projEntries, err := os.ReadDir(projectsDir)
|
||||
if err != nil {
|
||||
return entries
|
||||
}
|
||||
for _, p := range projEntries {
|
||||
if !p.IsDir() {
|
||||
continue
|
||||
}
|
||||
projAppsDir := filepath.Join(projectsDir, p.Name(), "apps")
|
||||
projAppEntries, err := os.ReadDir(projAppsDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range projAppEntries {
|
||||
if !a.IsDir() {
|
||||
continue
|
||||
}
|
||||
opsPath := filepath.Join(projAppsDir, a.Name(), "operations.db")
|
||||
if _, err := os.Stat(opsPath); err == nil {
|
||||
entries = append(entries, DBEntry{
|
||||
Alias: "ops:" + a.Name(),
|
||||
Path: opsPath,
|
||||
Kind: "operations",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// ValidateQuery checks that the SQL is a read-only statement (SELECT or PRAGMA).
|
||||
func ValidateQuery(sql string) error {
|
||||
trimmed := strings.TrimSpace(sql)
|
||||
upper := strings.ToUpper(trimmed)
|
||||
|
||||
// Remove leading comments
|
||||
for strings.HasPrefix(upper, "--") {
|
||||
idx := strings.Index(upper, "\n")
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("query contains only comments")
|
||||
}
|
||||
upper = strings.TrimSpace(upper[idx+1:])
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "SELECT") || strings.HasPrefix(upper, "PRAGMA") ||
|
||||
strings.HasPrefix(upper, "WITH") || strings.HasPrefix(upper, "EXPLAIN") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("only SELECT, PRAGMA, WITH, and EXPLAIN queries are allowed")
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
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, " ")
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) (*DBPool, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
// Create a small test database with FTS5.
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stmts := []string{
|
||||
`CREATE TABLE items (id TEXT PRIMARY KEY, name TEXT, kind TEXT)`,
|
||||
`INSERT INTO items VALUES ('a', 'alpha', 'first')`,
|
||||
`INSERT INTO items VALUES ('b', 'beta', 'second')`,
|
||||
`CREATE VIRTUAL TABLE items_fts USING fts5(id, name, kind, content=items, content_rowid=rowid)`,
|
||||
`INSERT INTO items_fts(items_fts) VALUES('rebuild')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("setup sql %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
db.Close()
|
||||
|
||||
pool := NewDBPool()
|
||||
pool.Register(DBEntry{Alias: "testdb", Path: dbPath, Kind: "test"})
|
||||
return pool, dir
|
||||
}
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Fatalf("expected status ok, got %s", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabasesEndpoint(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
pool.Register(DBEntry{Alias: "registry", Path: "/fake/path", Kind: "registry"})
|
||||
pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"})
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp) != 2 {
|
||||
t.Fatalf("expected 2 databases, got %d", len(resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
body := `{"sql": "SELECT id, name FROM items ORDER BY id"}`
|
||||
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if int(resp["count"].(float64)) != 2 {
|
||||
t.Fatalf("expected 2 rows, got %v", resp["count"])
|
||||
}
|
||||
cols := resp["columns"].([]any)
|
||||
if len(cols) != 2 || cols[0] != "id" || cols[1] != "name" {
|
||||
t.Fatalf("unexpected columns: %v", cols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRejectsWrite(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
cases := []string{
|
||||
`{"sql": "INSERT INTO items VALUES ('c', 'gamma', 'third')"}`,
|
||||
`{"sql": "UPDATE items SET name = 'x' WHERE id = 'a'"}`,
|
||||
`{"sql": "DELETE FROM items WHERE id = 'a'"}`,
|
||||
`{"sql": "DROP TABLE items"}`,
|
||||
}
|
||||
|
||||
for _, body := range cases {
|
||||
req := httptest.NewRequest("POST", "/api/databases/testdb/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for %s, got %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTablesEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases/testdb/tables", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp []map[string]string
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp) == 0 {
|
||||
t.Fatal("expected at least one table")
|
||||
}
|
||||
found := false
|
||||
for _, tbl := range resp {
|
||||
if tbl["name"] == "items" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'items' table in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaEndpoint(t *testing.T) {
|
||||
pool, _ := setupTestDB(t)
|
||||
defer pool.Close()
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/databases/testdb/schema", nil)
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]any
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
count := int(resp["count"].(float64))
|
||||
if count == 0 {
|
||||
t.Fatal("expected at least one schema statement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotFoundDB(t *testing.T) {
|
||||
pool := NewDBPool()
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
body := `{"sql": "SELECT 1"}`
|
||||
req := httptest.NewRequest("POST", "/api/databases/nonexistent/query", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
mux.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateQuery(t *testing.T) {
|
||||
valid := []string{
|
||||
"SELECT * FROM t",
|
||||
" select id from t",
|
||||
"PRAGMA table_info(t)",
|
||||
"WITH cte AS (SELECT 1) SELECT * FROM cte",
|
||||
"EXPLAIN SELECT * FROM t",
|
||||
"-- comment\nSELECT 1",
|
||||
}
|
||||
for _, q := range valid {
|
||||
if err := ValidateQuery(q); err != nil {
|
||||
t.Errorf("expected valid: %q, got error: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"UPDATE t SET x = 1",
|
||||
"DELETE FROM t",
|
||||
"DROP TABLE t",
|
||||
"CREATE TABLE t (id INT)",
|
||||
"ALTER TABLE t ADD COLUMN x INT",
|
||||
}
|
||||
for _, q := range invalid {
|
||||
if err := ValidateQuery(q); err == nil {
|
||||
t.Errorf("expected invalid: %q, got nil error", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverDatabases(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Create registry.db
|
||||
os.WriteFile(filepath.Join(dir, "registry.db"), []byte{}, 0644)
|
||||
|
||||
// Create apps/myapp/operations.db
|
||||
os.MkdirAll(filepath.Join(dir, "apps", "myapp"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "apps", "myapp", "operations.db"), []byte{}, 0644)
|
||||
|
||||
// Create projects/proj1/apps/papp/operations.db
|
||||
os.MkdirAll(filepath.Join(dir, "projects", "proj1", "apps", "papp"), 0755)
|
||||
os.WriteFile(filepath.Join(dir, "projects", "proj1", "apps", "papp", "operations.db"), []byte{}, 0644)
|
||||
|
||||
entries := DiscoverDatabases(dir)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("expected 3 entries, got %d: %+v", len(entries), entries)
|
||||
}
|
||||
|
||||
aliases := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
aliases[e.Alias] = true
|
||||
}
|
||||
for _, want := range []string{"registry", "ops:myapp", "ops:papp"} {
|
||||
if !aliases[want] {
|
||||
t.Errorf("missing alias %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func main() {
|
||||
bind := flag.String("bind", "127.0.0.1:8484", "address to bind")
|
||||
flag.Parse()
|
||||
|
||||
root := findRegistryRoot()
|
||||
if root == "" {
|
||||
log.Fatal("cannot find fn_registry root (no registry.db found). Set FN_REGISTRY_ROOT or run from the registry directory.")
|
||||
}
|
||||
|
||||
pool := NewDBPool()
|
||||
for _, entry := range DiscoverDatabases(root) {
|
||||
pool.Register(entry)
|
||||
log.Printf("registered database: %s (%s)", entry.Alias, entry.Path)
|
||||
}
|
||||
|
||||
srv := NewServer(pool)
|
||||
mux := http.NewServeMux()
|
||||
srv.Routes(mux)
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
|
||||
log.Printf("sqlite_api listening on %s (registry root: %s)", *bind, root)
|
||||
if err := http.ListenAndServe(*bind, handler); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// findRegistryRoot walks up from cwd (or uses FN_REGISTRY_ROOT) to find registry.db.
|
||||
func findRegistryRoot() string {
|
||||
if env := os.Getenv("FN_REGISTRY_ROOT"); env != "" {
|
||||
if _, err := os.Stat(filepath.Join(env, "registry.db")); err == nil {
|
||||
return env
|
||||
}
|
||||
}
|
||||
|
||||
dir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil {
|
||||
return dir
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
log.SetFlags(log.Ltime)
|
||||
log.SetPrefix("[sqlite_api] ")
|
||||
fmt.Fprintln(os.Stderr, "sqlite_api — HTTP API for fn_registry databases")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start sqlite_api in the background. Logs to sqlite_api.log.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||||
LOG="$SCRIPT_DIR/sqlite_api.log"
|
||||
PID_FILE="$SCRIPT_DIR/sqlite_api.pid"
|
||||
|
||||
# Kill previous instance if running
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
old_pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$old_pid" 2>/dev/null; then
|
||||
echo "Stopping previous instance (PID $old_pid)"
|
||||
kill "$old_pid"
|
||||
sleep 0.5
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
export FN_REGISTRY_ROOT="$REGISTRY_ROOT"
|
||||
|
||||
cd "$REGISTRY_ROOT"
|
||||
CGO_ENABLED=1 go run -tags fts5 ./projects/fn_monitoring/apps/sqlite_api/ "$@" \
|
||||
>"$LOG" 2>&1 &
|
||||
|
||||
echo $! > "$PID_FILE"
|
||||
echo "sqlite_api started (PID $!, log: $LOG)"
|
||||
sleep 0.5
|
||||
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
|
||||
echo "Health check OK"
|
||||
else
|
||||
echo "Waiting for startup..."
|
||||
sleep 1.5
|
||||
if curl -sf http://127.0.0.1:8484/health >/dev/null 2>&1; then
|
||||
echo "Health check OK"
|
||||
else
|
||||
echo "Warning: health check failed — check $LOG"
|
||||
fi
|
||||
fi
|
||||
Reference in New Issue
Block a user