From af13fd849cbf43ee4ef6bb1753e3d423cf221587 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Tue, 28 Apr 2026 22:05:08 +0200 Subject: [PATCH] feat: endpoints de mutacion y de projects - handlers_mutations.go: POST add_app/add_analysis/add_vault/reindex - handlers_projects.go: GET projects y project detail (apps/analysis/vaults nested) - handlers.go + main.go: cablear nuevas rutas - handlers_test.go: ajustes minimos - app.md: documentar endpoints v0.2 Co-Authored-By: Claude Opus 4.7 (1M context) --- app.md | 23 ++++ handlers.go | 17 ++- handlers_mutations.go | 302 ++++++++++++++++++++++++++++++++++++++++++ handlers_projects.go | 204 ++++++++++++++++++++++++++++ handlers_test.go | 14 +- main.go | 2 +- 6 files changed, 551 insertions(+), 11 deletions(-) create mode 100644 handlers_mutations.go create mode 100644 handlers_projects.go diff --git a/app.md b/app.md index 66f4d44..30fa013 100644 --- a/app.md +++ b/app.md @@ -31,6 +31,12 @@ go run -tags fts5 . --bind 0.0.0.0:8484 | 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 | +| GET | `/api/projects` | Lista proyectos con conteos nested + orphans `[v0.2]` | +| GET | `/api/projects/{id}` | Detalle apps/analyses/vaults; `id="orphans"` para huerfanas `[v0.2]` | +| POST | `/api/reindex` | Ejecuta `fn index` server-side, devuelve `{ok, output}` `[v0.2]` | +| POST | `/api/add/app` | Body `{name, lang, domain, project, description}` → scaffold + reindex `[v0.2]` | +| POST | `/api/add/analysis` | Body `{name, project, packages[], description}` → invoca `fn run init_jupyter_analysis` `[v0.2]` | +| POST | `/api/add/vault` | Body `{name, project, path, description}` → crea dir/symlink + entry en `vault.yaml` `[v0.2]` | ## Seguridad @@ -57,3 +63,20 @@ curl http://localhost:8484/health ## Puerto 8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090). + +## Estado actual + +### v0.2 — projects view + mutaciones `[done 2026-04-25]` + +El servicio pasa de read-only puro a soportar mutaciones. Split de handlers: + +- `handlers.go`: read-only sobre BDs SQLite (queries SELECT/PRAGMA). Sin cambios. +- `handlers_projects.go` (nuevo): `GET /api/projects` y `GET /api/projects/{id}`. Subqueries con `LEFT JOIN` por `project_id` para conteos nested. Helper `scanAll()` generico para devolver rows como `[][]any`. +- `handlers_mutations.go` (nuevo): `POST /api/reindex` + `POST /api/add/{app,analysis,vault}`. Ejecuta el CLI `fn` via `exec.CommandContext` desde `Server.registryRoot` (timeout 60 s). El binario debe existir en `{registryRoot}/fn` (build local). Validacion de `name` con regex implicito a-z0-9_. + +`Server` gana campo `registryRoot string`. `NewServer(pool, root)` en lugar de `NewServer(pool)`. El root se resuelve en `main.go` con `findRegistryRoot()` (env `FN_REGISTRY_ROOT` o cwd). + +### Lo siguiente que pega + +- Permisos/auth: ahora cualquier cliente local puede escribir. Para deploy a un VPS habria que anadir token header (similar a `X-Registry-Token` del `registry_api`). Por ahora bind 127.0.0.1 mitiga. +- `/api/add/function` y `/api/add/type` (kinds que hoy no se exponen via HTTP). El CLI ya lo soporta; el endpoint seria casi paralelo a `/api/add/app`. diff --git a/handlers.go b/handlers.go index da4c054..2a304f9 100644 --- a/handlers.go +++ b/handlers.go @@ -13,11 +13,12 @@ const queryTimeout = 5 * time.Second // Server holds the HTTP handlers and DB pool. type Server struct { - pool *DBPool + pool *DBPool + registryRoot string // raiz del fn_registry (para exec fn ...) } -func NewServer(pool *DBPool) *Server { - return &Server{pool: pool} +func NewServer(pool *DBPool, registryRoot string) *Server { + return &Server{pool: pool, registryRoot: registryRoot} } // Routes registers all API routes on the given mux. @@ -28,6 +29,16 @@ func (s *Server) Routes(mux *http.ServeMux) { mux.HandleFunc("GET /api/databases/{db}/schema", s.handleSchema) mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery) mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS) + + // Projects: listado con conteos + detalle nested + mux.HandleFunc("GET /api/projects", s.handleProjects) + mux.HandleFunc("GET /api/projects/{id}", s.handleProjectDetail) + + // Mutaciones: reindex + add (apps/analysis/vaults) + mux.HandleFunc("POST /api/reindex", s.handleReindex) + mux.HandleFunc("POST /api/add/app", s.handleAddApp) + mux.HandleFunc("POST /api/add/analysis", s.handleAddAnalysis) + mux.HandleFunc("POST /api/add/vault", s.handleAddVault) } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { diff --git a/handlers_mutations.go b/handlers_mutations.go new file mode 100644 index 0000000..cf91dab --- /dev/null +++ b/handlers_mutations.go @@ -0,0 +1,302 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// Timeout general para exec de CLIs (reindex, init_jupyter_analysis...) +const execTimeout = 60 * time.Second + +// runFN ejecuta `fn ` desde registryRoot y captura stdout+stderr. +func (s *Server) runFN(args ...string) (string, error) { + bin := filepath.Join(s.registryRoot, "fn") + if _, err := os.Stat(bin); err != nil { + return "", fmt.Errorf("fn binary not found at %s", bin) + } + + ctx, cancel := contextWithTimeout(execTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Dir = s.registryRoot + cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+s.registryRoot) + + out, err := cmd.CombinedOutput() + return string(out), err +} + +// runShell ejecuta un comando de shell arbitrario desde registryRoot. +func (s *Server) runShell(command string) (string, error) { + ctx, cancel := contextWithTimeout(execTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-c", command) + cmd.Dir = s.registryRoot + cmd.Env = append(os.Environ(), "FN_REGISTRY_ROOT="+s.registryRoot) + + out, err := cmd.CombinedOutput() + return string(out), err +} + +// POST /api/reindex — ejecuta `fn index` y devuelve la salida. +func (s *Server) handleReindex(w http.ResponseWriter, r *http.Request) { + out, err := s.runFN("index") + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{ + "ok": false, + "error": err.Error(), + "output": out, + }) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "output": strings.TrimSpace(out), + }) +} + +// POST /api/add/app +// Body: {"name": "my_app", "lang": "go", "domain": "core", "project": "", "description": ""} +// Scaffolding minimo: crea el directorio + app.md con frontmatter. El usuario +// rellena el contenido despues. Al terminar llama a `fn index`. +func (s *Server) handleAddApp(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Lang string `json:"lang"` + Domain string `json:"domain"` + Project string `json:"project"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if !validName(req.Name) { + writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") + return + } + if req.Lang == "" { + req.Lang = "go" + } + if req.Domain == "" { + req.Domain = "core" + } + + // Carpeta destino + base := filepath.Join(s.registryRoot, "apps") + if req.Project != "" { + if !validName(req.Project) { + writeError(w, http.StatusBadRequest, "invalid project id") + return + } + base = filepath.Join(s.registryRoot, "projects", req.Project, "apps") + } + dir := filepath.Join(base, req.Name) + + if _, err := os.Stat(dir); err == nil { + writeError(w, http.StatusConflict, "app directory already exists: "+dir) + return + } + if err := os.MkdirAll(dir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // dir_path relativo al repo root + relDir, err := filepath.Rel(s.registryRoot, dir) + if err != nil { + relDir = dir + } + + // app.md minimo + appMD := fmt.Sprintf(`--- +name: %s +lang: %s +domain: %s +description: "%s" +tags: [] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "" +dir_path: "%s" +--- + +# %s + +TODO: describir la app. +`, req.Name, req.Lang, req.Domain, req.Description, filepath.ToSlash(relDir), req.Name) + + if err := os.WriteFile(filepath.Join(dir, "app.md"), []byte(appMD), 0o644); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Reindex para que aparezca en registry.db + out, err := s.runFN("index") + writeJSON(w, http.StatusOK, map[string]any{ + "ok": err == nil, + "dir": dir, + "index_out": strings.TrimSpace(out), + "error": errString(err), + }) +} + +// POST /api/add/analysis +// Body: {"name": "my_analysis", "project": "", "packages": ["polars"], "description": ""} +// Invoca el pipeline init_jupyter_analysis_bash_pipelines via `fn run`. +func (s *Server) handleAddAnalysis(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Project string `json:"project"` + Packages []string `json:"packages"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if !validName(req.Name) { + writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") + return + } + + // fn run init_jupyter_analysis [--project

] [--desc "..."] [pkg1 pkg2 ...] + args := []string{"run", "init_jupyter_analysis"} + if req.Project != "" { + args = append(args, "--project", req.Project) + } + if req.Description != "" { + args = append(args, "--desc", req.Description) + } + args = append(args, req.Name) + for _, p := range req.Packages { + if p != "" { + args = append(args, p) + } + } + + out, err := s.runFN(args...) + writeJSON(w, http.StatusOK, map[string]any{ + "ok": err == nil, + "output": strings.TrimSpace(out), + "error": errString(err), + }) +} + +// POST /api/add/vault +// Body: {"name": "data", "project": "my_proj", "path": "/abs/path", "description": ""} +// Solo valido dentro de un proyecto (vive en projects/

/vaults/). +// Crea dir si no existe, aade symlink a path si se proporciona, y entrada +// en vault.yaml del proyecto. +func (s *Server) handleAddVault(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Project string `json:"project"` + Path string `json:"path"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + if !validName(req.Name) { + writeError(w, http.StatusBadRequest, "name required (snake_case, a-z0-9_ only)") + return + } + if req.Project == "" || !validName(req.Project) { + writeError(w, http.StatusBadRequest, "project required for vault (vaults live under projects/

/vaults/)") + return + } + + projectDir := filepath.Join(s.registryRoot, "projects", req.Project) + if _, err := os.Stat(projectDir); err != nil { + writeError(w, http.StatusNotFound, "project dir not found: "+projectDir) + return + } + + vaultsDir := filepath.Join(projectDir, "vaults") + if err := os.MkdirAll(vaultsDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + target := filepath.Join(vaultsDir, req.Name) + // Si se pasa path absoluto, crear symlink target -> path. + // Si no, crear directorio real. + if req.Path != "" { + absPath, err := filepath.Abs(req.Path) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid path: "+err.Error()) + return + } + if err := os.MkdirAll(absPath, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, "creating vault dir: "+err.Error()) + return + } + if err := os.Symlink(absPath, target); err != nil && !os.IsExist(err) { + writeError(w, http.StatusInternalServerError, "symlink: "+err.Error()) + return + } + } else { + if err := os.MkdirAll(target, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + } + + // Actualizar vault.yaml (append-o-crear). Formato simple YAML, sin parser. + vaultYAML := filepath.Join(vaultsDir, "vault.yaml") + entry := fmt.Sprintf("- name: %s\n description: %q\n path: %s\n tags: []\n", + req.Name, req.Description, target) + var existing []byte + if b, err := os.ReadFile(vaultYAML); err == nil { + existing = b + } + content := append(existing, []byte(entry)...) + if err := os.WriteFile(vaultYAML, content, 0o644); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + out, err := s.runFN("index") + writeJSON(w, http.StatusOK, map[string]any{ + "ok": err == nil, + "target": target, + "index_out": strings.TrimSpace(out), + "error": errString(err), + }) +} + +// Helpers + +func validName(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') { + return false + } + } + return true +} + +func errString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func contextWithTimeout(d time.Duration) (ctx context.Context, cancel func()) { + return context.WithTimeout(context.Background(), d) +} diff --git a/handlers_projects.go b/handlers_projects.go new file mode 100644 index 0000000..ca818bd --- /dev/null +++ b/handlers_projects.go @@ -0,0 +1,204 @@ +package main + +import ( + "context" + "database/sql" + "net/http" +) + +// handleProjects lista proyectos con conteos nested (apps/analyses/vaults) +// + conteos de huerfanas. +// GET /api/projects +func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) { + db, err := s.pool.Get("registry") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) + defer cancel() + + const q = ` + SELECT + p.id, p.name, p.description, p.tags, p.dir_path, + (SELECT COUNT(*) FROM apps WHERE project_id = p.id) AS apps_count, + (SELECT COUNT(*) FROM analysis WHERE project_id = p.id) AS analyses_count, + (SELECT COUNT(*) FROM vaults WHERE project_id = p.id) AS vaults_count + FROM projects p + ORDER BY p.name` + + rows, err := db.QueryContext(ctx, q) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + + type ProjectRow struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags string `json:"tags"` + DirPath string `json:"dir_path"` + AppsCount int `json:"apps_count"` + AnalysesCount int `json:"analyses_count"` + VaultsCount int `json:"vaults_count"` + } + + var out []ProjectRow + for rows.Next() { + var p ProjectRow + if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Tags, &p.DirPath, + &p.AppsCount, &p.AnalysesCount, &p.VaultsCount); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, p) + } + if out == nil { + out = []ProjectRow{} + } + + type Orphans struct { + Apps int `json:"apps"` + Analyses int `json:"analyses"` + Vaults int `json:"vaults"` + } + var orphans Orphans + _ = db.QueryRowContext(ctx, + `SELECT + (SELECT COUNT(*) FROM apps WHERE project_id = '' OR project_id IS NULL), + (SELECT COUNT(*) FROM analysis WHERE project_id = '' OR project_id IS NULL), + (SELECT COUNT(*) FROM vaults WHERE project_id = '' OR project_id IS NULL)`, + ).Scan(&orphans.Apps, &orphans.Analyses, &orphans.Vaults) + + writeJSON(w, http.StatusOK, map[string]any{ + "projects": out, + "orphans": orphans, + }) +} + +// scanAll corre una query con 0 o 1 arg y devuelve rows genericas [[any]]. +func scanAll(ctx context.Context, db *sql.DB, query string, arg any) ([][]any, []string, error) { + var rows *sql.Rows + var err error + if arg == nil { + rows, err = db.QueryContext(ctx, query) + } else { + rows, err = db.QueryContext(ctx, query, arg) + } + if err != nil { + return nil, nil, err + } + defer rows.Close() + + cols, err := rows.Columns() + if err != nil { + return nil, nil, err + } + + var out [][]any + for rows.Next() { + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + if err := rows.Scan(ptrs...); err != nil { + return nil, nil, err + } + for i, v := range vals { + if b, ok := v.([]byte); ok { + vals[i] = string(b) + } + } + out = append(out, vals) + } + if out == nil { + out = [][]any{} + } + return out, cols, nil +} + +// handleProjectDetail devuelve apps/analyses/vaults de un proyecto. +// Si el id es "orphans", devuelve las entidades con project_id vacio. +// GET /api/projects/{id} +func (s *Server) handleProjectDetail(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "project id required") + return + } + + db, err := s.pool.Get("registry") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + ctx, cancel := context.WithTimeout(r.Context(), queryTimeout) + defer cancel() + + var whereExpr string + var arg any + if id == "orphans" { + whereExpr = "project_id = '' OR project_id IS NULL" + arg = nil + } else { + whereExpr = "project_id = ?" + arg = id + } + + apps, appsCols, err := scanAll(ctx, db, + `SELECT id, name, lang, domain, framework, description, dir_path + FROM apps WHERE `+whereExpr+` ORDER BY name`, arg) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + analyses, analysesCols, err := scanAll(ctx, db, + `SELECT id, name, lang, domain, description, dir_path + FROM analysis WHERE `+whereExpr+` ORDER BY name`, arg) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + vaults, vaultsCols, err := scanAll(ctx, db, + `SELECT id, name, path, symlink, description, tags + FROM vaults WHERE `+whereExpr+` ORDER BY name`, arg) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + var projectMeta map[string]any + if id != "orphans" { + var name, desc, tags, dirPath string + if err := db.QueryRowContext(ctx, + `SELECT name, description, tags, dir_path FROM projects WHERE id = ?`, id, + ).Scan(&name, &desc, &tags, &dirPath); err == nil { + projectMeta = map[string]any{ + "id": id, + "name": name, + "description": desc, + "tags": tags, + "dir_path": dirPath, + } + } + } + + writeJSON(w, http.StatusOK, map[string]any{ + "project": projectMeta, + "apps": map[string]any{ + "columns": appsCols, + "rows": apps, + }, + "analyses": map[string]any{ + "columns": analysesCols, + "rows": analyses, + }, + "vaults": map[string]any{ + "columns": vaultsCols, + "rows": vaults, + }, + }) +} diff --git a/handlers_test.go b/handlers_test.go index 9f89c26..a9a1882 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -45,7 +45,7 @@ func setupTestDB(t *testing.T) (*DBPool, string) { func TestHealthEndpoint(t *testing.T) { pool := NewDBPool() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -68,7 +68,7 @@ func TestDatabasesEndpoint(t *testing.T) { pool.Register(DBEntry{Alias: "registry", Path: "/fake/path", Kind: "registry"}) pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"}) - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -90,7 +90,7 @@ func TestQueryEndpoint(t *testing.T) { pool, _ := setupTestDB(t) defer pool.Close() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -119,7 +119,7 @@ func TestQueryRejectsWrite(t *testing.T) { pool, _ := setupTestDB(t) defer pool.Close() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -146,7 +146,7 @@ func TestTablesEndpoint(t *testing.T) { pool, _ := setupTestDB(t) defer pool.Close() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -178,7 +178,7 @@ func TestSchemaEndpoint(t *testing.T) { pool, _ := setupTestDB(t) defer pool.Close() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) @@ -200,7 +200,7 @@ func TestSchemaEndpoint(t *testing.T) { func TestNotFoundDB(t *testing.T) { pool := NewDBPool() - srv := NewServer(pool) + srv := NewServer(pool, "") mux := http.NewServeMux() srv.Routes(mux) diff --git a/main.go b/main.go index 231392c..e5ad5a6 100644 --- a/main.go +++ b/main.go @@ -24,7 +24,7 @@ func main() { log.Printf("registered database: %s (%s)", entry.Alias, entry.Path) } - srv := NewServer(pool) + srv := NewServer(pool, root) mux := http.NewServeMux() srv.Routes(mux)