merge: issue/0008-sqlite-api-web — API REST HTTP read-only para registry.db y operations.db

This commit is contained in:
2026-04-13 01:29:03 +02:00
7 changed files with 1101 additions and 1 deletions
+59
View File
@@ -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: "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).
+190
View File
@@ -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
View File
@@ -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, " ")
}
+276
View File
@@ -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)
}
}
}
+68
View File
@@ -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")
}
+1 -1
View File
@@ -13,4 +13,4 @@
| [0007c](completed/0007c-execution-store.md) | DAG engine: execution store (SQLite) | completado | alta | feature | 0007e |
| [0007d](completed/0007d-scheduler.md) | DAG engine: scheduler (cron match) | completado | media | feature | 0007e |
| [0007e](completed/0007e-dag-executor-app.md) | DAG engine: CLI + web app que reemplaza Dagu | completado | alta | feature | — |
| **0008** | **SQLite API Web** | pendiente | alta | feature | — |
| [0008](completed/0008-sqlite-api-web.md) | SQLite API Web | completado | alta | feature | — |
+183
View File
@@ -0,0 +1,183 @@
# 0008 — SQLite API Web
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0008 |
| **Estado** | completado |
| **Prioridad** | alta |
| **Tipo** | feature |
## Dependencias
Ninguna.
---
## Objetivo
App que expone `registry.db` y los `operations.db` de cada app como API REST HTTP, permitiendo que herramientas externas (dashboards, scripts, agentes, frontends) consulten las bases de datos del registry sin necesidad de acceso directo al filesystem ni SQLite CLI.
## Contexto
- Actualmente para consultar `registry.db` hay que estar en la misma máquina y usar `sqlite3` directamente o funciones Go que abren el archivo.
- Las apps existentes (metabase_registry, registry_dashboard) acceden a SQLite localmente. Cualquier herramienta nueva que necesite datos del registry tiene que reimplementar la conexión.
- Con una API web, cualquier cliente HTTP (curl, fetch, Python requests, frontends React) puede consultar el registry de forma uniforme.
- Metabase ya resuelve visualización, pero no da acceso programático limpio a los datos para agentes y scripts remotos.
## Arquitectura
```
apps/sqlite_api/
├── main.go — NEW: Entry point, configura rutas y arranca servidor
├── handlers.go — NEW: Handlers HTTP (query, tables, schema)
├── config.go — NEW: Configuración (puerto, DBs permitidas, read-only)
├── app.md — NEW: Metadata de la app (tag: service)
└── operations.db — Runtime: operaciones propias
```
### Patrón pure core / impure shell
- **Funciones del registry usadas:** `http_get_json_go_infra`, `http_post_json_go_infra` (para tests/clientes), `cache_to_sqlite_go_infra` (opcional para cache de queries)
- **Core puro:** validación de queries (solo SELECT/PRAGMA permitidos), parsing de parámetros, formateo de resultados JSON
- **Shell impuro:** servidor HTTP, apertura de SQLite, ejecución de queries
## Diseño de API
### Endpoints
```
GET /api/databases — Lista de DBs disponibles
GET /api/databases/:db/tables — Lista tablas de una DB
GET /api/databases/:db/schema — Schema completo (.schema)
POST /api/databases/:db/query — Ejecuta query SQL (solo SELECT)
GET /api/databases/:db/fts?q=texto&table=functions — Búsqueda FTS5 directa
GET /health — Health check
```
### Bases de datos expuestas
| Alias | Path real | Descripción |
|-------|-----------|-------------|
| `registry` | `registry.db` (raíz) | Funciones, tipos, proposals |
| `ops:{app}` | `apps/{app}/operations.db` | Entities, relations, executions de cada app |
### Seguridad
- **Read-only obligatorio:** Solo queries SELECT y PRAGMA. Cualquier INSERT/UPDATE/DELETE/DROP se rechaza antes de ejecutar.
- **Bind por defecto a localhost** (`127.0.0.1:8484`). Flag `--bind` para cambiar.
- **Sin autenticación** en v1 (solo acceso local). Documentar cómo poner detrás de reverse proxy si se necesita auth.
- **Query timeout:** máximo 5 segundos por query para evitar bloqueos.
- **Apertura con `?mode=ro`** en el connection string de SQLite para doble protección.
### Formato de respuesta
```json
// POST /api/databases/registry/query
// Body: {"sql": "SELECT id, name, purity FROM functions WHERE domain = 'core' LIMIT 5"}
{
"columns": ["id", "name", "purity"],
"rows": [
["filter_slice_go_core", "filter_slice", "pure"],
["map_slice_go_core", "map_slice", "pure"]
],
"count": 2,
"duration_ms": 3
}
```
## Tareas
### Fase 1: Servidor base
- [ ] **1.1** Crear `apps/sqlite_api/` con `main.go`, `go.mod` (o usar módulo raíz)
- [ ] **1.2** Handler `/health` y `/api/databases` (lista estática de DBs detectadas)
- [ ] **1.3** Handler `POST /api/databases/:db/query` con validación read-only
- [ ] **1.4** Abrir DBs con `?mode=ro` y `-tags fts5`
- [ ] **1.5** `app.md` con tag `service`, documentar puerto y health check
### Fase 2: Endpoints de exploración
- [ ] **2.1** Handler `/api/databases/:db/tables` (lista tablas vía `sqlite_master`)
- [ ] **2.2** Handler `/api/databases/:db/schema` (output de `.schema`)
- [ ] **2.3** Handler `/api/databases/:db/fts` para búsqueda FTS5 sin escribir SQL
### Fase 3: Operations discovery
- [ ] **3.1** Auto-detectar `apps/*/operations.db` al arrancar
- [ ] **3.2** Exponer cada operations.db como `ops:{app_name}`
- [ ] **3.3** Endpoint `GET /api/databases` incluye las operations detectadas
### Fase 4: Cleanup y docs
- [ ] Crear `app.md` completo
- [ ] Ejecutar `go vet` y `go test`
- [ ] Actualizar issue en `dev/issues/README.md`
---
## Ejemplo de uso
```bash
# Arrancar el servicio
cd apps/sqlite_api && go run . --port 8484
# Health check
curl http://localhost:8484/health
# Listar databases disponibles
curl http://localhost:8484/api/databases
# Query al registry
curl -X POST http://localhost:8484/api/databases/registry/query \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT id, purity, description FROM functions WHERE domain = '\''core'\'' LIMIT 5"}'
# Búsqueda FTS5
curl "http://localhost:8484/api/databases/registry/fts?q=slice&table=functions"
# Schema
curl http://localhost:8484/api/databases/registry/schema
# Query a operations de una app
curl -X POST http://localhost:8484/api/databases/ops:pipeline_launcher/query \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT * FROM executions ORDER BY started_at DESC LIMIT 10"}'
```
```python
# Desde Python
import requests
r = requests.post("http://localhost:8484/api/databases/registry/query", json={
"sql": "SELECT id, name FROM functions WHERE purity = 'pure' AND domain = 'core'"
})
data = r.json()
for row in data["rows"]:
print(row[0], row[1])
```
## Decisiones de diseño
- **Go con net/http estándar**: sin framework externo, coherente con el resto del registry. Router simple con `http.ServeMux`.
- **Puerto 8484**: no colisiona con Metabase (3000), Jupyter (8888), ni otros servicios comunes.
- **Read-only estricto**: la API nunca modifica datos. Para escribir se usan los mecanismos existentes (`fn ops`, `fn index`).
- **Sin ORM**: queries se pasan tal cual a SQLite. El valor es el acceso HTTP, no una capa de abstracción SQL.
- **Auto-discovery de operations.db**: escanea `apps/*/operations.db` al inicio para no tener que configurar cada app manualmente.
## Riesgos
- **SQL injection vía queries arbitrarias**: Mitigado con apertura read-only (`?mode=ro`) + validación de que el statement empieza con SELECT o PRAGMA.
- **Queries pesadas bloquean el servidor**: Mitigado con timeout de 5s por query y context cancelable.
- **Archivos SQLite bloqueados por escritores concurrentes**: Mitigado con `journal_mode=wal` y apertura read-only que no bloquea escritores.
## Criterios de aceptación
- [ ] `curl localhost:8484/health` retorna 200
- [ ] Queries SELECT funcionan contra registry.db
- [ ] Queries INSERT/UPDATE/DELETE son rechazadas con 400
- [ ] Operations.db de apps existentes son accesibles como `ops:{nombre}`
- [ ] FTS5 funciona a través de la API
- [ ] Tag `service` en app.md
- [ ] El servidor arranca con `go run .` sin configuración adicional