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) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,12 @@ go run -tags fts5 . --bind 0.0.0.0:8484
|
|||||||
| GET | `/api/databases/:db/schema` | Schema SQL completo |
|
| GET | `/api/databases/:db/schema` | Schema SQL completo |
|
||||||
| POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
|
| POST | `/api/databases/:db/query` | Ejecuta query SQL read-only |
|
||||||
| GET | `/api/databases/:db/fts?q=...&table=...` | Busqueda FTS5 directa |
|
| 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
|
## Seguridad
|
||||||
|
|
||||||
@@ -57,3 +63,20 @@ curl http://localhost:8484/health
|
|||||||
## Puerto
|
## Puerto
|
||||||
|
|
||||||
8484 (no colisiona con Metabase 3000, Jupyter 8888, deploy_server 9090).
|
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`.
|
||||||
|
|||||||
+14
-3
@@ -13,11 +13,12 @@ const queryTimeout = 5 * time.Second
|
|||||||
|
|
||||||
// Server holds the HTTP handlers and DB pool.
|
// Server holds the HTTP handlers and DB pool.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
pool *DBPool
|
pool *DBPool
|
||||||
|
registryRoot string // raiz del fn_registry (para exec fn ...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(pool *DBPool) *Server {
|
func NewServer(pool *DBPool, registryRoot string) *Server {
|
||||||
return &Server{pool: pool}
|
return &Server{pool: pool, registryRoot: registryRoot}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes registers all API routes on the given mux.
|
// 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("GET /api/databases/{db}/schema", s.handleSchema)
|
||||||
mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery)
|
mux.HandleFunc("POST /api/databases/{db}/query", s.handleQuery)
|
||||||
mux.HandleFunc("GET /api/databases/{db}/fts", s.handleFTS)
|
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) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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 <args...>` 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 <p>] [--desc "..."] <name> [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/<p>/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/<p>/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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
+7
-7
@@ -45,7 +45,7 @@ func setupTestDB(t *testing.T) (*DBPool, string) {
|
|||||||
|
|
||||||
func TestHealthEndpoint(t *testing.T) {
|
func TestHealthEndpoint(t *testing.T) {
|
||||||
pool := NewDBPool()
|
pool := NewDBPool()
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
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: "registry", Path: "/fake/path", Kind: "registry"})
|
||||||
pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"})
|
pool.Register(DBEntry{Alias: "ops:myapp", Path: "/fake/path2", Kind: "operations"})
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ func TestQueryEndpoint(t *testing.T) {
|
|||||||
pool, _ := setupTestDB(t)
|
pool, _ := setupTestDB(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ func TestQueryRejectsWrite(t *testing.T) {
|
|||||||
pool, _ := setupTestDB(t)
|
pool, _ := setupTestDB(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ func TestTablesEndpoint(t *testing.T) {
|
|||||||
pool, _ := setupTestDB(t)
|
pool, _ := setupTestDB(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ func TestSchemaEndpoint(t *testing.T) {
|
|||||||
pool, _ := setupTestDB(t)
|
pool, _ := setupTestDB(t)
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ func TestSchemaEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
func TestNotFoundDB(t *testing.T) {
|
func TestNotFoundDB(t *testing.T) {
|
||||||
pool := NewDBPool()
|
pool := NewDBPool()
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func main() {
|
|||||||
log.Printf("registered database: %s (%s)", entry.Alias, entry.Path)
|
log.Printf("registered database: %s (%s)", entry.Alias, entry.Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := NewServer(pool)
|
srv := NewServer(pool, root)
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
srv.Routes(mux)
|
srv.Routes(mux)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user