auto(0129): agents_dashboard — secret_store_cpp_infra + CMakeLists register #4
@@ -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).
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user