diff --git a/dev/issues/0008-sqlite-api-web.md b/dev/issues/0008-sqlite-api-web.md deleted file mode 100644 index ffb91dce..00000000 --- a/dev/issues/0008-sqlite-api-web.md +++ /dev/null @@ -1,183 +0,0 @@ -# 0008 β€” SQLite API Web - -## Metadata - -| Campo | Valor | -|-------|-------| -| **ID** | 0008 | -| **Estado** | 🟑 pendiente | -| **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 diff --git a/dev/issues/0010-auth-system.md b/dev/issues/0010-auth-system.md new file mode 100644 index 00000000..13de63fa --- /dev/null +++ b/dev/issues/0010-auth-system.md @@ -0,0 +1,435 @@ +# 0010 β€” Auth System + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0010 | +| **Estado** | pendiente | +| **Prioridad** | alta | +| **Tipo** | feature | + +## Dependencias + +Depende de 0009 (HTTP Server Foundation) para los middleware de auth (`Middleware` type, `http_middleware_chain`, `http_error_response`). + +--- + +## Objetivo + +Crear funciones reutilizables de autenticacion y autorizacion en Go (dominio infra) cubriendo JWT, hashing de passwords, sesiones SQLite, cliente OAuth2 y checks RBAC, de forma que cualquier app nueva del registry pueda tener auth completo componiendo primitivas en vez de implementarlo desde cero. + +## Contexto + +- Actualmente el registry tiene **CERO funciones de auth genericas**. Solo existe `auth_form_ts_ui` (componente UI de login) y funciones de auth especificas para Metabase (`metabase_auth_go_infra`, `metabase_auth_py_infra`). +- Existen funciones de password store (`pass_get`, `pass_set`, `pass_generate` en Bash) pero son wrappers de `pass`, no primitivas de hashing/verificacion. +- `generate_password_bash_cybersecurity` genera passwords pero no los hashea. +- Cada vez que una app necesita auth (como `sqlite_api` o `deploy_server`), hay que implementar JWT, sesiones y validacion ad-hoc. +- Con estas funciones, una app nueva solo hace: `password_hash` al registrar, `jwt_generate` al login, `jwt_middleware` en las rutas protegidas, y opcionalmente `rbac_middleware` para permisos granulares. +- Las funciones HTTP server de 0009 (`Middleware` type, `http_middleware_chain`) son el punto de integracion natural para los middlewares de auth. + +## Arquitectura + +``` +functions/infra/ +β”œβ”€β”€ jwt_generate.go β€” NEW: genera JWT firmado desde claims + secret +β”œβ”€β”€ jwt_generate.md β€” NEW +β”œβ”€β”€ jwt_validate.go β€” NEW: valida JWT, retorna claims o error +β”œβ”€β”€ jwt_validate.md β€” NEW +β”œβ”€β”€ jwt_middleware.go β€” NEW: middleware HTTP que valida JWT del header Authorization +β”œβ”€β”€ jwt_middleware.md β€” NEW +β”œβ”€β”€ password_hash.go β€” NEW: hashea password con bcrypt +β”œβ”€β”€ password_hash.md β€” NEW +β”œβ”€β”€ password_verify.go β€” NEW: verifica password contra hash bcrypt +β”œβ”€β”€ password_verify.md β€” NEW +β”œβ”€β”€ session_create.go β€” NEW: crea sesion en SQLite, retorna token +β”œβ”€β”€ session_create.md β€” NEW +β”œβ”€β”€ session_validate.go β€” NEW: valida token de sesion, retorna datos de usuario +β”œβ”€β”€ session_validate.md β€” NEW +β”œβ”€β”€ session_cleanup.go β€” NEW: elimina sesiones expiradas +β”œβ”€β”€ session_cleanup.md β€” NEW +β”œβ”€β”€ oauth2_auth_url.go β€” NEW: construye URL de autorizacion OAuth2 +β”œβ”€β”€ oauth2_auth_url.md β€” NEW +β”œβ”€β”€ oauth2_exchange.go β€” NEW: intercambia auth code por tokens +β”œβ”€β”€ oauth2_exchange.md β€” NEW +β”œβ”€β”€ oauth2_refresh.go β€” NEW: renueva access token con refresh token +β”œβ”€β”€ oauth2_refresh.md β€” NEW +β”œβ”€β”€ rbac_check.go β€” NEW: verifica si un rol tiene un permiso (datos puros) +β”œβ”€β”€ rbac_check.md β€” NEW +β”œβ”€β”€ rbac_middleware.go β€” NEW: middleware HTTP que verifica permisos tras auth +β”œβ”€β”€ rbac_middleware.md β€” NEW + +types/infra/ +β”œβ”€β”€ jwt_claims.md β€” NEW: metadata del tipo JWTClaims +β”œβ”€β”€ session.md β€” NEW: metadata del tipo Session +β”œβ”€β”€ oauth_config.md β€” NEW: metadata del tipo OAuthConfig +β”œβ”€β”€ oauth_tokens.md β€” NEW: metadata del tipo OAuthTokens +β”œβ”€β”€ permission.md β€” NEW: metadata del tipo Permission +β”œβ”€β”€ role.md β€” NEW: metadata del tipo Role +``` + +### Patron pure core / impure shell + +- **Pure:** `oauth2_auth_url` (construye string URL sin I/O), `rbac_check` (evaluacion de datos en memoria) +- **Impure:** todo lo demas β€” JWT usa `time.Now()` para expiracion/validacion, bcrypt hace trabajo criptografico con entropia del OS, sesiones interactuan con SQLite, OAuth2 hace HTTP requests, middlewares interactuan con `http.Request`/`http.ResponseWriter` + +## Diseno + +### Tipos + +```go +// JWTClaims contiene claims estandar y custom para un JWT +type JWTClaims struct { + Subject string `json:"sub"` + Issuer string `json:"iss,omitempty"` + Audience string `json:"aud,omitempty"` + ExpiresAt int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Custom map[string]any `json:"custom,omitempty"` +} + +// Session representa una sesion de usuario almacenada en SQLite +type Session struct { + Token string `json:"token"` + UserID string `json:"user_id"` + ExpiresAt int64 `json:"expires_at"` + CreatedAt int64 `json:"created_at"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// OAuthConfig contiene la configuracion de un proveedor OAuth2 +type OAuthConfig struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + RedirectURL string `json:"redirect_url"` + Scopes []string `json:"scopes"` +} + +// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2 +type OAuthTokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` +} + +// Permission representa una accion sobre un recurso +type Permission struct { + Resource string `json:"resource"` + Action string `json:"action"` +} + +// Role agrupa permisos bajo un nombre +type Role struct { + Name string `json:"name"` + Permissions []Permission `json:"permissions"` +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `jwt_generate` | impure | `(claims JWTClaims, secret string) (string, error)` | +| `jwt_validate` | impure | `(token string, secret string) (JWTClaims, error)` | +| `jwt_middleware` | impure | `(secret string) Middleware` | +| `password_hash` | impure | `(password string, cost int) (string, error)` | +| `password_verify` | impure | `(password string, hash string) error` | +| `session_create` | impure | `(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)` | +| `session_validate` | impure | `(db *sql.DB, token string) (Session, error)` | +| `session_cleanup` | impure | `(db *sql.DB) (int64, error)` | +| `oauth2_auth_url` | pure | `(config OAuthConfig, state string) string` | +| `oauth2_exchange` | impure | `(config OAuthConfig, code string) (OAuthTokens, error)` | +| `oauth2_refresh` | impure | `(config OAuthConfig, refreshToken string) (OAuthTokens, error)` | +| `rbac_check` | pure | `(roles []Role, roleName string, perm Permission) bool` | +| `rbac_middleware` | impure | `(roles []Role, requiredPerm Permission) Middleware` | + +## Tareas + +### Fase 1: Tipos + +- [ ] **1.1** Crear tipo `JWTClaims` en `functions/infra/jwt_claims.go` con `.md` en `types/infra/jwt_claims.md` +- [ ] **1.2** Crear tipo `Session` en `functions/infra/session.go` con `.md` en `types/infra/session.md` +- [ ] **1.3** Crear tipo `OAuthConfig` en `functions/infra/oauth_config.go` con `.md` en `types/infra/oauth_config.md` +- [ ] **1.4** Crear tipo `OAuthTokens` en `functions/infra/oauth_tokens.go` con `.md` en `types/infra/oauth_tokens.md` +- [ ] **1.5** Crear tipo `Permission` en `functions/infra/permission.go` con `.md` en `types/infra/permission.md` +- [ ] **1.6** Crear tipo `Role` en `functions/infra/role.go` con `.md` en `types/infra/role.md` + +### Fase 2: JWT + Password (auth core) + +- [ ] **2.1** `jwt_generate` β€” codifica header+payload en base64url, firma con HMAC-SHA256, retorna token string. Setea `iat` automaticamente si no viene en claims. +- [ ] **2.2** `jwt_validate` β€” split por `.`, verifica firma HMAC-SHA256, decodifica claims, valida `exp` contra `time.Now()`. Error descriptivo si firma invalida, expirado, o malformado. +- [ ] **2.3** `password_hash` β€” wrapper de `golang.org/x/crypto/bcrypt.GenerateFromPassword`. Cost por defecto: 12. +- [ ] **2.4** `password_verify` β€” wrapper de `bcrypt.CompareHashAndPassword`. Retorna nil si match, error si no. + +### Fase 3: Sesiones SQLite + +- [ ] **3.1** `session_create` β€” genera token con `crypto/rand` (32 bytes hex), inserta en tabla `sessions` (la funcion crea la tabla si no existe via `CREATE TABLE IF NOT EXISTS`), retorna `Session`. +- [ ] **3.2** `session_validate` β€” busca token en tabla `sessions`, verifica que no este expirado, retorna `Session` o error. +- [ ] **3.3** `session_cleanup` β€” `DELETE FROM sessions WHERE expires_at < ?` con timestamp actual. Retorna cantidad de filas eliminadas. + +### Fase 4: OAuth2 + +- [ ] **4.1** `oauth2_auth_url` β€” construye URL con query params: `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`. Funcion pura, solo concatena strings. +- [ ] **4.2** `oauth2_exchange` β€” POST a `token_url` con `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, `redirect_uri`. Parsea JSON response a `OAuthTokens`. +- [ ] **4.3** `oauth2_refresh` β€” POST a `token_url` con `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. Parsea JSON response a `OAuthTokens`. + +### Fase 5: RBAC + Middlewares + +- [ ] **5.1** `rbac_check` β€” busca el rol por nombre en `[]Role`, itera sus permisos, retorna `true` si encuentra match de `resource` + `action`. Funcion pura. +- [ ] **5.2** `jwt_middleware` β€” extrae token del header `Authorization: Bearer `, valida con `jwt_validate`, inyecta claims en `r.Context()` con `context.WithValue`, llama `next.ServeHTTP`. Retorna 401 si falta token o es invalido. Usa tipo `Middleware` de 0009. +- [ ] **5.3** `rbac_middleware` β€” extrae claims del context (puestas por `jwt_middleware`), lee el rol del campo `custom["role"]`, evalua con `rbac_check`. Retorna 403 si no tiene permiso. Requiere que `jwt_middleware` se ejecute antes en la chain. + +### Fase 6: Tests y cleanup + +- [ ] **6.1** Tests para JWT: generar token, validar token valido, rechazar token expirado, rechazar firma invalida +- [ ] **6.2** Tests para password: hash y verify OK, verify con password incorrecto falla +- [ ] **6.3** Tests para sesiones: crear, validar, validar expirada, cleanup +- [ ] **6.4** Tests para OAuth2: `oauth2_auth_url` genera URL correcta (test puro), exchange/refresh con `httptest.NewServer` mock +- [ ] **6.5** Tests para RBAC: check con permiso, sin permiso, rol inexistente +- [ ] **6.6** Tests para middlewares: jwt_middleware con token valido/invalido/ausente, rbac_middleware con/sin permiso +- [ ] **6.7** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db +- [ ] **6.8** Verificar `go vet -tags fts5` y `go test -tags fts5 ./functions/infra/` + +--- + +## Ejemplo de uso + +### Registro de usuario y login con JWT + +```go +// Handler de registro +func registerHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := infra.HttpParseBody(r, &input, 1<<20); err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()}) + return + } + + hash, err := infra.PasswordHash(input.Password, 12) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()}) + return + } + + // Guardar email + hash en la BD de la app... + userID := saveUser(input.Email, hash) + + infra.HttpJsonResponse(w, 201, map[string]string{"id": userID}) +} + +// Handler de login +func loginHandler(w http.ResponseWriter, r *http.Request) { + var input struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := infra.HttpParseBody(r, &input, 1<<20); err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()}) + return + } + + user, err := findUserByEmail(input.Email) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return + } + + if err := infra.PasswordVerify(input.Password, user.PasswordHash); err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"}) + return + } + + claims := infra.JWTClaims{ + Subject: user.ID, + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + Custom: map[string]any{"role": "admin", "email": user.Email}, + } + token, err := infra.JwtGenerate(claims, os.Getenv("JWT_SECRET")) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "token_error", Message: err.Error()}) + return + } + + infra.HttpJsonResponse(w, 200, map[string]string{"token": token}) +} +``` + +### Rutas protegidas con JWT + RBAC + +```go +// Definir roles y permisos como datos +roles := []infra.Role{ + { + Name: "admin", + Permissions: []infra.Permission{ + {Resource: "users", Action: "read"}, + {Resource: "users", Action: "write"}, + {Resource: "users", Action: "delete"}, + }, + }, + { + Name: "viewer", + Permissions: []infra.Permission{ + {Resource: "users", Action: "read"}, + }, + }, +} + +// Rutas publicas +publicRoutes := []infra.Route{ + {Method: "POST", Path: "/register", Handler: registerHandler}, + {Method: "POST", Path: "/login", Handler: loginHandler}, +} + +// Rutas protegidas con JWT +protectedRoutes := []infra.Route{ + {Method: "GET", Path: "/api/me", Handler: meHandler}, +} + +// Rutas protegidas con JWT + RBAC +adminRoutes := []infra.Route{ + {Method: "DELETE", Path: "/api/users/{id}", Handler: deleteUserHandler}, +} + +secret := os.Getenv("JWT_SECRET") + +mux := http.NewServeMux() + +// Publicas: sin middleware de auth +for _, r := range publicRoutes { + mux.HandleFunc(r.Method+" "+r.Path, r.Handler) +} + +// Protegidas: JWT middleware +jwtProtected := infra.HttpMiddlewareChain( + infra.JwtMiddleware(secret), +) +for _, r := range protectedRoutes { + mux.Handle(r.Method+" "+r.Path, jwtProtected(http.HandlerFunc(r.Handler))) +} + +// Admin: JWT + RBAC +adminProtected := infra.HttpMiddlewareChain( + infra.JwtMiddleware(secret), + infra.RbacMiddleware(roles, infra.Permission{Resource: "users", Action: "delete"}), +) +for _, r := range adminRoutes { + mux.Handle(r.Method+" "+r.Path, adminProtected(http.HandlerFunc(r.Handler))) +} + +ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +defer cancel() +infra.HttpServe(":8080", mux, ctx) +``` + +### Flujo OAuth2 (ej. Google) + +```go +googleConfig := infra.OAuthConfig{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + AuthURL: "https://accounts.google.com/o/oauth2/v2/auth", + TokenURL: "https://oauth2.googleapis.com/token", + RedirectURL: "http://localhost:8080/callback", + Scopes: []string{"openid", "email", "profile"}, +} + +// 1. Redirigir al usuario al proveedor +func oauthLoginHandler(w http.ResponseWriter, r *http.Request) { + state := generateRandomState() // guardar en cookie/session para validar despues + url := infra.Oauth2AuthUrl(googleConfig, state) + http.Redirect(w, r, url, http.StatusTemporaryRedirect) +} + +// 2. Callback: intercambiar code por tokens +func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + + // Validar state contra el guardado en cookie/session... + + tokens, err := infra.Oauth2Exchange(googleConfig, code) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()}) + return + } + + // Usar tokens.AccessToken para obtener datos del usuario... + // Crear sesion local o JWT propio + infra.HttpJsonResponse(w, 200, map[string]string{"access_token": tokens.AccessToken}) +} + +// 3. Renovar token cuando expire +func refreshHandler(w http.ResponseWriter, r *http.Request) { + refreshToken := extractRefreshToken(r) + newTokens, err := infra.Oauth2Refresh(googleConfig, refreshToken) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()}) + return + } + infra.HttpJsonResponse(w, 200, newTokens) +} +``` + +### Sesiones SQLite (alternativa a JWT para apps con estado) + +```go +db, _ := sql.Open("sqlite3", "app.db?_journal_mode=wal") + +// Crear sesion al login +func sessionLoginHandler(w http.ResponseWriter, r *http.Request) { + // ... validar credenciales ... + session, err := infra.SessionCreate(db, user.ID, 24*time.Hour, map[string]any{ + "email": user.Email, + "role": "admin", + }) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "session_error", Message: err.Error()}) + return + } + infra.HttpJsonResponse(w, 200, map[string]string{"session_token": session.Token}) +} + +// Validar sesion en cada request +func protectedHandler(w http.ResponseWriter, r *http.Request) { + token := r.Header.Get("X-Session-Token") + session, err := infra.SessionValidate(db, token) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"}) + return + } + infra.HttpJsonResponse(w, 200, map[string]any{"user_id": session.UserID, "metadata": session.Metadata}) +} + +// Limpiar sesiones expiradas periodicamente +deleted, _ := infra.SessionCleanup(db) +fmt.Printf("sesiones eliminadas: %d\n", deleted) +``` + +## Decisiones de diseno + +- **bcrypt sobre argon2:** bcrypt esta en `golang.org/x/crypto` (dependencia semi-oficial de Go), es robusto y ampliamente adoptado. Argon2 es mas moderno pero agrega complejidad sin beneficio practico para la mayoria de apps del registry. Cost por defecto 12 (buen balance velocidad/seguridad). +- **JWT con HMAC-SHA256 sin libreria externa:** implementacion manual con `crypto/hmac` + `crypto/sha256` + `encoding/base64`. Solo soporta HS256 β€” suficiente para apps single-server. Si se necesita RS256 (multiples servicios verificando), se crea una funcion separada en el futuro. +- **Sesiones en SQLite:** coherente con la filosofia del registry (SQLite en todas partes). La tabla `sessions` se crea en la BD de la app, no en registry.db. `CREATE TABLE IF NOT EXISTS` evita setup manual. +- **OAuth2 sin `golang.org/x/oauth2`:** las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar con `net/http` y `encoding/json`. Evita una dependencia mas y mantiene el control total del flujo. +- **RBAC como datos puros:** roles y permisos son slices pasados como argumento, no leidos de BD. Cada app decide donde los almacena (hardcoded, JSON, SQLite). `rbac_check` es pura β€” la unica funcion pura del modulo junto con `oauth2_auth_url`. +- **Claims en context con key privada:** `jwt_middleware` inyecta claims en `r.Context()` usando un tipo no exportado como key (`type contextKey struct{}`) para evitar colisiones. `rbac_middleware` las extrae del mismo context. + +## Prerequisitos + +- **0009 (HTTP Server Foundation):** tipo `Middleware`, funciones `http_middleware_chain`, `http_error_response`, `http_json_response`, `http_parse_body`. Los middlewares de auth (`jwt_middleware`, `rbac_middleware`) retornan `Middleware` y usan los helpers de response para errores 401/403. + +## Riesgos + +- **Seguridad de la implementacion JWT manual:** Mitigado manteniendo el scope en HS256, usando `hmac.Equal` para comparacion constant-time, y validando siempre `exp`. Documentar en cada `.md` que NO es apto para escenarios multi-servicio donde se necesita RSA/ECDSA. +- **Almacenamiento de secrets:** Las funciones reciben el secret como parametro (`string`), no lo leen de env ni de archivos. Es responsabilidad de la app obtener el secret de forma segura (env var, `pass_get`, etc.). Documentar este patron. +- **Token en header vs cookie:** `jwt_middleware` solo lee `Authorization: Bearer`. Para apps que necesiten cookies (frontend SSR), se crearia un middleware separado en el futuro. No mezclar ambos patrones en la misma funcion. +- **SQL injection en sesiones:** Mitigado usando prepared statements con `?` placeholders en todas las queries de sesion. Nunca concatenar strings. +- **Session fixation:** `session_create` genera tokens con `crypto/rand` (32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens. +- **bcrypt timing attacks:** `bcrypt.CompareHashAndPassword` ya es constant-time internamente. No se necesita proteccion adicional. diff --git a/dev/issues/0011-websocket-sse.md b/dev/issues/0011-websocket-sse.md new file mode 100644 index 00000000..002f540b --- /dev/null +++ b/dev/issues/0011-websocket-sse.md @@ -0,0 +1,327 @@ +# 0011 β€” WebSocket & SSE Server + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0011 | +| **Estado** | pendiente | +| **Prioridad** | alta | +| **Tipo** | feature | + +## Dependencias + +| ID | Titulo | Estado | Requerido | +|----|--------|--------|-----------| +| 0009 | HTTP Server Foundation | pendiente | si | + +**Bloqueada por:** `#0009` β€” las funciones WebSocket y SSE son handlers HTTP que se montan sobre las primitivas de servidor (router, middleware chain, graceful shutdown). + +**Desbloquea:** apps de dashboard en tiempo real, notificaciones push, pipelines con feedback live. + +--- + +## Objetivo + +Crear funciones reutilizables de WebSocket y Server-Sent Events en Go (dominio infra) que permitan anadir comunicacion bidireccional (WS) y unidireccional server-to-client (SSE) a cualquier app del registry, componiendo con las primitivas HTTP de `#0009`. + +## Contexto + +- Actualmente hay **CERO** funciones de WebSocket/SSE server en el registry. Las unicas conexiones WebSocket existentes son de **cliente**: `cdp_connect_go_browser` (conecta a Chrome DevTools), `stream_ticks_go_finance` (consume un stream de ticks), y las funciones de `jupyter_*_py_notebook` (hablan con kernels Jupyter). +- `stream_ticks_go_finance` abre un WebSocket como cliente para recibir datos financieros β€” es especifico de un dominio, no una primitiva de server. +- Patrones comunes que necesitan WS/SSE server: dashboards con datos en vivo, logs en streaming, notificaciones de estado de pipelines, chat entre agentes. +- WebSocket es bidireccional (cliente y servidor envian mensajes). SSE es unidireccional server-to-client (mas simple, funciona sobre HTTP normal, reconexion automatica en el browser). +- Go stdlib soporta SSE nativamente con `http.Flusher`. Para WebSocket se necesita una dependencia externa: `nhooyr.io/websocket` (moderna, context-aware) o `gorilla/websocket` (clasica, ampliamente usada). +- Con estas funciones, una app nueva que necesite real-time solo hace: montar el `ws_handler` o `sse_handler` como una ruta mas del router de `#0009`. + +## Arquitectura + +``` +functions/infra/ +β”œβ”€β”€ ws_upgrader.go β€” NEW: upgrade HTTP connection a WebSocket +β”œβ”€β”€ ws_upgrader.md β€” NEW +β”œβ”€β”€ ws_hub.go β€” NEW: hub de conexiones (register/unregister/broadcast) +β”œβ”€β”€ ws_hub.md β€” NEW +β”œβ”€β”€ ws_broadcast.go β€” NEW: enviar mensaje a todos los clientes conectados +β”œβ”€β”€ ws_broadcast.md β€” NEW +β”œβ”€β”€ ws_send.go β€” NEW: enviar mensaje a un cliente especifico +β”œβ”€β”€ ws_send.md β€” NEW +β”œβ”€β”€ ws_handler.go β€” NEW: HTTP handler que upgradea y gestiona una conexion WS +β”œβ”€β”€ ws_handler.md β€” NEW +β”œβ”€β”€ sse_handler.go β€” NEW: HTTP handler para stream SSE con flush +β”œβ”€β”€ sse_handler.md β€” NEW +β”œβ”€β”€ sse_send.go β€” NEW: enviar un evento SSE (event, data, id) +β”œβ”€β”€ sse_send.md β€” NEW +β”œβ”€β”€ sse_keepalive.go β€” NEW: enviar comentarios keepalive periodicos +β”œβ”€β”€ sse_keepalive.md β€” NEW + +types/infra/ +β”œβ”€β”€ ws_hub.md β€” NEW: metadata del tipo WSHub +β”œβ”€β”€ ws_client.md β€” NEW: metadata del tipo WSClient +β”œβ”€β”€ ws_message.md β€” NEW: metadata del tipo WSMessage +β”œβ”€β”€ sse_event.md β€” NEW: metadata del tipo SSEEvent +``` + +### Patron pure core / impure shell + +Todas las funciones de este issue son **impuras** β€” manejan conexiones de red, goroutines y estado mutable (el hub mantiene un mapa de clientes). No hay funciones puras en este issue porque la naturaleza del real-time es inherentemente I/O-bound. + +El core puro vive en los tipos (structs sin metodos con side effects) y en la logica de serializado/parseado de mensajes que se delega a funciones existentes del registry (`json_marshal`, etc.). + +## Diseno + +### Tipos + +```go +// WSHub gestiona el ciclo de vida de conexiones WebSocket. +// Mantiene un mapa de clientes activos y canales para registro, +// desregistro y broadcast. Se ejecuta como goroutine via Run(). +type WSHub struct { + Clients map[*WSClient]bool + Broadcast chan []byte + Register chan *WSClient + Unregister chan *WSClient +} + +// WSClient representa una conexion WebSocket individual. +// Cada cliente tiene su propio canal de envio buffereado +// y una referencia al hub al que pertenece. +type WSClient struct { + Hub *WSHub + Conn *websocket.Conn + Send chan []byte + ID string +} + +// WSMessage es un mensaje tipado que viaja por WebSocket. +// El campo Type permite al receptor decidir como procesar el payload. +type WSMessage struct { + Type string `json:"type"` + Payload []byte `json:"payload"` + SenderID string `json:"sender_id"` + Ts int64 `json:"ts"` +} + +// SSEEvent es un evento Server-Sent Events segun la spec W3C. +// Campos opcionales: si Event esta vacio se envia solo data, +// si ID esta vacio no se incluye campo id, Retry en ms (0 = omitir). +type SSEEvent struct { + Event string `json:"event"` + Data string `json:"data"` + ID string `json:"id"` + Retry int `json:"retry"` +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | Descripcion | +|---------|--------|---------------------|-------------| +| `ws_upgrader` | impure | `(w http.ResponseWriter, r *http.Request, origins []string) (*websocket.Conn, error)` | Upgrade HTTP a WebSocket con validacion de origen | +| `ws_hub` | impure | `() *WSHub` + `(hub *WSHub) Run()` | Crea hub y lo ejecuta como goroutine (loop select sobre canales) | +| `ws_broadcast` | impure | `(hub *WSHub, msg []byte)` | Envia mensaje al canal Broadcast del hub | +| `ws_send` | impure | `(client *WSClient, msg []byte) error` | Envia mensaje al canal Send de un cliente especifico | +| `ws_handler` | impure | `(hub *WSHub, origins []string) http.HandlerFunc` | Retorna handler que upgradea la conexion, registra el cliente en el hub, y lanza read/write pumps | +| `sse_handler` | impure | `(events <-chan SSEEvent) http.HandlerFunc` | Retorna handler que setea headers SSE, consume del canal y flushea cada evento | +| `sse_send` | impure | `(w http.ResponseWriter, event SSEEvent) error` | Escribe un evento SSE formateado al writer y hace flush | +| `sse_keepalive` | impure | `(w http.ResponseWriter, interval time.Duration, done <-chan struct{})` | Goroutine que envia `: keepalive\n\n` periodicamente hasta que done se cierre | + +### Protocolo WebSocket + +El hub sigue el patron clasico de Go concurrency: + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + HTTP request ──→ β”‚ws_handlerβ”‚ ──→ ws_upgrader ──→ *websocket.Conn + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ WSClient β”‚ + β”‚ .Send ch β”‚ + β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ + β”‚ Register + β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ WSHub β”‚ ←── ws_broadcast + β”‚ .Run() β”‚ + β”‚ loop { β”‚ + β”‚ select β”‚ + β”‚ } β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ Broadcast + β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” + β”‚ client.Sendβ”‚ ──→ writePump ──→ conn.WriteMessage + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +Cada cliente tiene dos goroutines internas: +- **readPump**: lee mensajes del conn y los envia al hub Broadcast (o los procesa con un callback) +- **writePump**: consume del canal Send y escribe al conn + +### Protocolo SSE + +``` +HTTP/1.1 200 OK +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + +: keepalive + +event: price_update +id: 42 +data: {"symbol":"BTC","price":67000} + +data: simple message without event type + +: keepalive +``` + +`sse_handler` detecta si el `ResponseWriter` implementa `http.Flusher` y hace flush despues de cada evento. Si el cliente se desconecta (context cancelado), el handler retorna limpiamente. + +## Tareas + +### Fase 1: Tipos + +- [ ] **1.1** Crear tipo `WSHub` en `functions/infra/ws_hub.go` con `.md` en `types/infra/ws_hub.md` +- [ ] **1.2** Crear tipo `WSClient` en `functions/infra/ws_client.go` con `.md` en `types/infra/ws_client.md` +- [ ] **1.3** Crear tipo `WSMessage` en `functions/infra/ws_message.go` con `.md` en `types/infra/ws_message.md` +- [ ] **1.4** Crear tipo `SSEEvent` en `functions/infra/sse_event.go` con `.md` en `types/infra/sse_event.md` + +### Fase 2: SSE (mas simple, sin dependencia externa) + +- [ ] **2.1** `sse_send` β€” formatea y escribe un SSEEvent al writer, hace flush +- [ ] **2.2** `sse_keepalive` β€” goroutine que envia comentarios keepalive periodicamente +- [ ] **2.3** `sse_handler` β€” HTTP handler completo: setea headers, consume canal de eventos, flush, detecta desconexion + +### Fase 3: WebSocket + +- [ ] **3.1** Elegir dependencia: `nhooyr.io/websocket` (preferida por soporte nativo de context) o `gorilla/websocket` (mas madura). Anadir a `go.mod`. +- [ ] **3.2** `ws_upgrader` β€” upgrade HTTP a WebSocket con validacion de origenes permitidos +- [ ] **3.3** `ws_hub` β€” constructor + metodo Run() con loop select sobre Register/Unregister/Broadcast +- [ ] **3.4** `ws_send` β€” envia bytes al canal Send de un cliente +- [ ] **3.5** `ws_broadcast` β€” envia bytes al canal Broadcast del hub +- [ ] **3.6** `ws_handler` β€” handler HTTP que upgradea, crea WSClient, registra en hub, lanza readPump/writePump + +### Fase 4: Tests + +- [ ] **4.1** Tests de SSE con `httptest.NewRecorder` y pipe para simular flush +- [ ] **4.2** Tests de WebSocket con `httptest.NewServer` y cliente WS de test +- [ ] **4.3** Test de integracion: hub con multiples clientes, broadcast, desconexion +- [ ] **4.4** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db +- [ ] **4.5** `go vet -tags fts5` limpio + +--- + +## Ejemplo de uso + +### Chat-like: broadcast de mensajes entre clientes + +```go +// Montar WebSocket en una app con las primitivas de #0009 +hub := infra.NewWSHub() +go hub.Run() + +routes := []infra.Route{ + {Method: "GET", Path: "/health", Handler: healthHandler}, + {Method: "GET", Path: "/api/data", Handler: dataHandler}, + {Method: "GET", Path: "/ws", Handler: infra.WsHandler(hub, []string{"*"})}, +} + +mux := infra.HttpRouter(routes) +middleware := infra.HttpMiddlewareChain( + infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}), + infra.HttpLoggerMiddleware(os.Stdout), +) + +ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +defer cancel() +infra.HttpServe(":8080", middleware(mux), ctx) +``` + +```javascript +// Cliente browser +const ws = new WebSocket("ws://localhost:8080/ws"); +ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + console.log(`[${msg.sender_id}] ${msg.type}: ${new TextDecoder().decode(msg.payload)}`); +}; +ws.send(JSON.stringify({type: "chat", payload: btoa("hola"), sender_id: "user1"})); +``` + +### Dashboard live updates via SSE + +```go +// Canal de eventos que se alimenta desde cualquier goroutine +events := make(chan infra.SSEEvent, 100) + +// Goroutine que genera eventos (ej: watch a operations.db) +go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for i := 0; ; i++ { + <-ticker.C + events <- infra.SSEEvent{ + Event: "metrics_update", + ID: fmt.Sprintf("%d", i), + Data: fmt.Sprintf(`{"cpu": %.1f, "mem": %.1f}`, rand.Float64()*100, rand.Float64()*100), + } + } +}() + +routes := []infra.Route{ + {Method: "GET", Path: "/health", Handler: healthHandler}, + {Method: "GET", Path: "/events", Handler: infra.SseHandler(events)}, + {Method: "GET", Path: "/api/snapshot", Handler: snapshotHandler}, +} + +mux := infra.HttpRouter(routes) +infra.HttpServe(":8080", mux, ctx) +``` + +```javascript +// Cliente browser β€” reconexion automatica gratis con EventSource +const es = new EventSource("http://localhost:8080/events"); +es.addEventListener("metrics_update", (e) => { + const data = JSON.parse(e.data); + updateDashboard(data.cpu, data.mem); +}); +es.onerror = () => console.log("reconectando..."); +``` + +### Notificaciones de estado de pipelines + +```go +// En una app que ejecuta pipelines del registry: +hub := infra.NewWSHub() +go hub.Run() + +// Cada vez que un step termina, broadcast a todos los clientes +func onStepComplete(step string, status string, durationMs int) { + msg, _ := json.Marshal(infra.WSMessage{ + Type: "step_complete", + Payload: []byte(fmt.Sprintf(`{"step":%q,"status":%q,"ms":%d}`, step, status, durationMs)), + SenderID: "pipeline_runner", + Ts: time.Now().UnixMilli(), + }) + infra.WsBroadcast(hub, msg) +} +``` + +## Decisiones de diseno + +- **`nhooyr.io/websocket` como primera opcion:** mas moderna que gorilla/websocket, soporta `context.Context` nativamente (encaja con graceful shutdown de `#0009`), API mas simple. Si da problemas de compatibilidad, fallback a gorilla. +- **Hub como goroutine con canales:** patron estandar de Go para gestionar estado compartido sin mutex. Un solo punto de escritura al mapa de clientes evita races. +- **SSE sin dependencias externas:** solo usa `net/http` stdlib + `http.Flusher` interface. Mas simple que WebSocket y suficiente para dashboards y notificaciones unidireccionales. +- **Separar send de broadcast:** `ws_send` (un cliente) y `ws_broadcast` (todos) son funciones distintas porque tienen patrones de uso y error handling diferentes. +- **Canal buffereado en WSClient.Send:** evita que un cliente lento bloquee el broadcast a los demas. Si el canal se llena, el hub desconecta al cliente. +- **SSEEvent.Retry opcional:** el campo retry en SSE le dice al browser cuanto esperar antes de reconectar. Dejarlo en 0 usa el default del browser (~3 segundos). +- **Validacion de origenes en ws_upgrader:** proteccion basica contra cross-origin WebSocket hijacking. Para produccion se complementa con auth middleware de `#0009`. +- **Todas impuras, sin excepciones:** a diferencia de `#0009` donde hay funciones puras (middleware chain, CORS config), aqui todo toca red o estado mutable. No forzar pureza artificial. + +## Riesgos + +- **Leak de goroutines:** Cada cliente WS genera 2 goroutines (readPump + writePump). Si no se limpian bien al desconectar, se acumulan. Mitigado con el patron hub.Unregister + defer cleanup en cada pump. +- **Clientes lentos saturan el hub:** Un cliente que no consume su canal Send puede bloquear el broadcast. Mitigado con canal buffereado y desconexion forzada si el buffer se llena (write deadline). +- **Dependencia externa para WebSocket:** `nhooyr.io/websocket` anade un import fuera de stdlib. Mitigado porque es una dependencia mantenida, sin subdependencias transitivas, y solo afecta a las funciones `ws_*` (no contamina el resto del paquete infra). +- **Compatibilidad con proxies/load balancers:** WebSocket requiere que proxies soporten upgrade HTTP. SSE funciona sobre HTTP normal sin problema. Documentar en los `.md` que WS detras de nginx/caddy necesita config especifica (`proxy_pass` con upgrade headers). +- **Scope creep hacia un framework de real-time:** Mitigado manteniendo funciones atomicas. El hub es un mapa de conexiones, no un pub/sub con rooms, channels, ni autenticacion. Esas abstracciones se componen encima si se necesitan. diff --git a/dev/issues/0014-file-upload.md b/dev/issues/0014-file-upload.md new file mode 100644 index 00000000..bf5994b9 --- /dev/null +++ b/dev/issues/0014-file-upload.md @@ -0,0 +1,270 @@ +# 0014 β€” File Upload & Storage + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0014 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | feature | + +## Dependencias + +- **0009 β€” HTTP Server Foundation**: las funciones `upload_handler` y `file_serve` son handlers HTTP que dependen de los tipos y patrones definidos en 0009 (Route, Middleware, HTTPError, http_json_response, http_error_response, http_parse_body). + +--- + +## Objetivo + +Crear funciones reutilizables en Go (dominio infra) para manejar subida de archivos, almacenamiento en disco, servido de archivos estaticos, y opcionalmente almacenamiento en S3. Cualquier app que necesite gestionar imagenes, documentos o media puede componer estas primitivas en vez de reimplementar el manejo de archivos desde cero. + +## Contexto + +- No existen funciones de upload/storage en el registry. Cada app que necesita manejar archivos tiene que construir el handler de upload, la validacion, el almacenamiento y el servido desde cero. +- El patron es comun: apps con imagenes de perfil, documentos adjuntos, exports de datos, thumbnails, etc. +- Go stdlib tiene todo lo necesario para multipart parsing (`mime/multipart`), manipulacion de imagenes (`image`, `image/jpeg`, `image/png`), y filesystem. Para S3 se necesita el SDK de AWS, pero como dependencia opcional. +- Con estas funciones, una app nueva que necesite uploads solo hace: montar `upload_handler` como ruta, configurar `StorageConfig`, y usar `file_serve` para servir los archivos. + +## Arquitectura + +``` +functions/infra/ + upload_handler.go -- NEW: HTTP handler multipart upload + upload_handler.md -- NEW + upload_parse.go -- NEW: parse multipart form, extraer archivos + upload_parse.md -- NEW + file_save_disk.go -- NEW: guardar archivo en disco con nombre unico + file_save_disk.md -- NEW + file_serve.go -- NEW: HTTP handler para servir archivos estaticos + file_serve.md -- NEW + file_delete.go -- NEW: eliminar archivo del disco + file_delete.md -- NEW + thumbnail_generate.go -- NEW: generar thumbnail de imagen + thumbnail_generate.md -- NEW + file_validate_type.go -- NEW: validar MIME type por magic bytes + file_validate_type.md -- NEW + file_unique_name.go -- NEW: generar nombre unico UUID + extension + file_unique_name.md -- NEW + s3_upload.go -- NEW: subir archivo a S3-compatible + s3_upload.md -- NEW + s3_download.go -- NEW: descargar archivo desde S3-compatible + s3_download.md -- NEW + s3_presign_url.go -- NEW: generar URL presignada S3 + s3_presign_url.md -- NEW + +types/infra/ + uploaded_file.md -- NEW: metadata del tipo UploadedFile + storage_config.md -- NEW: metadata del tipo StorageConfig + s3_config.md -- NEW: metadata del tipo S3Config +``` + +### Patron pure core / impure shell + +- **Pure:** `file_validate_type` (lee bytes en memoria, sin I/O), `file_unique_name` (genera string, determinista dado un UUID) +- **Impure:** todo lo demas β€” interactuan con disco, red, HTTP requests/responses + +## Diseno + +### Tipos + +```go +// UploadedFile contiene la metadata de un archivo subido y almacenado. +type UploadedFile struct { + Filename string `json:"filename"` // nombre original del archivo + StoredName string `json:"stored_name"` // nombre en disco (UUID-based) + Size int64 `json:"size"` // tamano en bytes + ContentType string `json:"content_type"` // MIME type detectado + Path string `json:"path"` // ruta completa en disco + CreatedAt time.Time `json:"created_at"` +} + +// StorageConfig configura el almacenamiento local de archivos. +type StorageConfig struct { + BaseDir string `json:"base_dir"` // directorio base para almacenar archivos + MaxFileSize int64 `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB) + AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"]) +} + +// S3Config configura la conexion a almacenamiento S3-compatible. +type S3Config struct { + Endpoint string `json:"endpoint"` // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000") + Bucket string `json:"bucket"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Region string `json:"region"` + UseSSL bool `json:"use_ssl"` +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `upload_handler` | impure | `(cfg StorageConfig) http.HandlerFunc` | +| `upload_parse` | impure | `(r *http.Request, maxSize int64) ([]ParsedFile, error)` | +| `file_save_disk` | impure | `(baseDir string, filename string, data io.Reader) (UploadedFile, error)` | +| `file_serve` | impure | `(dir string, pathPrefix string, maxAge int) http.Handler` | +| `file_delete` | impure | `(path string) error` | +| `thumbnail_generate` | impure | `(srcPath string, dstPath string, maxWidth int, maxHeight int) error` | +| `file_validate_type` | pure | `(header []byte, allowedTypes []string) (string, bool)` | +| `file_unique_name` | pure | `(originalName string) string` | +| `s3_upload` | impure | `(cfg S3Config, key string, data io.Reader, contentType string) error` | +| `s3_download` | impure | `(cfg S3Config, key string, dst io.Writer) error` | +| `s3_presign_url` | impure | `(cfg S3Config, key string, expiry time.Duration) (string, error)` | + +### Detalle de cada funcion + +**`upload_handler`** β€” Handler HTTP completo para multipart upload. Recibe un `StorageConfig`, aplica limites de tamano, valida tipos MIME, guarda en disco con nombre unico, y responde con JSON del `UploadedFile`. Compone internamente `upload_parse`, `file_validate_type`, `file_unique_name`, y `file_save_disk`. + +**`upload_parse`** β€” Parsea el request multipart, extrae uno o mas archivos con su metadata (nombre original, tamano, content type, contenido). Aplica `http.MaxBytesReader` para limitar tamano. Retorna un slice de structs intermedios con el contenido listo para guardar. + +**`file_save_disk`** β€” Recibe un `io.Reader` con el contenido y lo escribe a disco en `baseDir` con un nombre unico generado por `file_unique_name`. Crea subdirectorios si no existen. Retorna el `UploadedFile` con la ruta final. + +**`file_serve`** β€” Retorna un `http.Handler` que sirve archivos estaticos desde un directorio. Setea headers de cache (`Cache-Control`, `ETag`). Usa `http.FileServer` internamente pero stripea el prefijo de path y anade seguridad contra path traversal. + +**`file_delete`** β€” Elimina un archivo del disco. Valida que el path es relativo al directorio de storage (no permite `../` traversal). Retorna error si el archivo no existe. + +**`thumbnail_generate`** β€” Lee una imagen del disco, la redimensiona manteniendo aspect ratio al tamano maximo indicado, y la guarda en `dstPath`. Usa `image`, `image/jpeg`, `image/png` de stdlib. Soporta JPEG y PNG. + +**`file_validate_type`** β€” Lee los primeros N bytes (magic bytes / file signature) de un `[]byte` y determina el MIME type real del archivo. Compara contra la lista de tipos permitidos. No confia en el Content-Type del request β€” siempre verifica los bytes. Retorna el MIME type detectado y si esta en la lista permitida. + +**`file_unique_name`** β€” Genera un nombre de archivo unico combinando un UUID v4 con la extension del archivo original. Ejemplo: `a1b2c3d4-e5f6-7890-abcd-ef1234567890.png`. Sanitiza la extension (solo alfanumericos). + +**`s3_upload`** β€” Sube un archivo a un bucket S3-compatible. Acepta `S3Config` para conectar a AWS S3, MinIO, o cualquier implementacion compatible. Usa el AWS SDK v2. + +**`s3_download`** β€” Descarga un archivo desde S3 a un `io.Writer`. Permite streaming directo a disco o a un HTTP response. + +**`s3_presign_url`** β€” Genera una URL presignada para upload o download directo sin pasar por el servidor. Util para uploads grandes donde el cliente sube directamente a S3. + +## Tareas + +### Fase 1: Tipos + +- [ ] **1.1** Crear tipo `UploadedFile` en `functions/infra/uploaded_file.go` con `.md` en `types/infra/` +- [ ] **1.2** Crear tipo `StorageConfig` en `functions/infra/storage_config.go` con `.md` en `types/infra/` +- [ ] **1.3** Crear tipo `S3Config` en `functions/infra/s3_config.go` con `.md` en `types/infra/` + +### Fase 2: Funciones puras (validacion y naming) + +- [ ] **2.1** `file_validate_type` β€” detectar MIME type por magic bytes, comparar contra lista permitida. Tabla interna de signatures: JPEG (`FF D8 FF`), PNG (`89 50 4E 47`), GIF (`47 49 46 38`), PDF (`25 50 44 46`), WebP (`52 49 46 46...57 45 42 50`), ZIP (`50 4B 03 04`) +- [ ] **2.2** `file_unique_name` β€” UUID v4 + extension sanitizada. Sin I/O, sin estado +- [ ] **2.3** Tests unitarios para ambas funciones puras + +### Fase 3: Almacenamiento en disco + +- [ ] **3.1** `file_save_disk` β€” escribir a disco con nombre unico, crear subdirectorios, retornar UploadedFile +- [ ] **3.2** `file_delete` β€” eliminar archivo, validar path traversal +- [ ] **3.3** `file_serve` β€” http.Handler con FileServer, cache headers, path traversal protection +- [ ] **3.4** `upload_parse` β€” parsear multipart form, extraer archivos con metadata +- [ ] **3.5** `upload_handler` β€” handler HTTP completo que compone parse + validate + save +- [ ] **3.6** `thumbnail_generate` β€” resize con image stdlib, mantener aspect ratio +- [ ] **3.7** Tests para funciones de disco con `os.MkdirTemp` + +### Fase 4: S3-compatible storage + +- [ ] **4.1** `s3_upload` β€” subir archivo a bucket con AWS SDK v2 +- [ ] **4.2** `s3_download` β€” descargar archivo desde bucket +- [ ] **4.3** `s3_presign_url` β€” generar URL presignada con expiracion configurable +- [ ] **4.4** Tests con stub/mock de S3 (o `go build` + `go vet` si no hay MinIO local) +- [ ] **4.5** `fn index` y verificar que todas las funciones aparecen en registry.db +- [ ] **4.6** Verificar `go vet -tags fts5` + +--- + +## Ejemplo de uso + +```go +// Configurar storage +cfg := infra.StorageConfig{ + BaseDir: "./uploads", + MaxFileSize: 10 << 20, // 10 MB + AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"}, +} + +// Montar rutas (usando funciones de 0009 HTTP Server Foundation) +routes := []infra.Route{ + {Method: "POST", Path: "/api/upload", Handler: infra.UploadHandler(cfg)}, +} + +mux := infra.HttpRouter(routes) + +// Servir archivos subidos +mux.Handle("/files/", infra.FileServe("./uploads", "/files/", 3600)) + +// Graceful shutdown +ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +defer cancel() +infra.HttpServe(":8080", mux, ctx) +``` + +```go +// Dentro de un handler custom (sin usar upload_handler): +func customUpload(w http.ResponseWriter, r *http.Request) { + files, err := infra.UploadParse(r, 10<<20) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "parse_error", Message: err.Error()}) + return + } + + for _, f := range files { + // Validar tipo real (no confiar en Content-Type del request) + mimeType, ok := infra.FileValidateType(f.Header, cfg.AllowedTypes) + if !ok { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 415, Code: "invalid_type", Message: "tipo no permitido"}) + return + } + + // Guardar en disco + uploaded, err := infra.FileSaveDisk(cfg.BaseDir, f.Filename, f.Content) + if err != nil { + infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "save_error", Message: err.Error()}) + return + } + + // Generar thumbnail si es imagen + if mimeType == "image/jpeg" || mimeType == "image/png" { + thumbPath := filepath.Join(cfg.BaseDir, "thumbs", uploaded.StoredName) + infra.ThumbnailGenerate(uploaded.Path, thumbPath, 200, 200) + } + } + + infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"}) +} +``` + +```go +// S3 upload (opcional, para apps que necesiten storage remoto) +s3cfg := infra.S3Config{ + Endpoint: "minio.local:9000", + Bucket: "uploads", + AccessKey: os.Getenv("S3_ACCESS_KEY"), + SecretKey: os.Getenv("S3_SECRET_KEY"), + Region: "us-east-1", + UseSSL: false, +} + +f, _ := os.Open("./uploads/a1b2c3d4.png") +defer f.Close() +err := infra.S3Upload(s3cfg, "images/a1b2c3d4.png", f, "image/png") + +// Generar URL presignada para download directo (1 hora) +url, err := infra.S3PresignUrl(s3cfg, "images/a1b2c3d4.png", time.Hour) +``` + +## Decisiones de diseno + +- **Disco local primero, S3 opcional:** la mayoria de apps del registry corren en un solo servidor. El almacenamiento en disco es suficiente y no requiere infraestructura adicional. S3 es para apps que escalan o necesitan storage distribuido. +- **Validacion por magic bytes, no por Content-Type:** el header Content-Type del request puede ser falso. Los primeros bytes del archivo son la fuente de verdad para determinar el tipo real. +- **UUID para nombres en disco:** evita colisiones de nombres y elimina problemas con caracteres especiales en nombres de archivo originales. Se preserva la extension para que el MIME type sea inferible por el filesystem. +- **Solo stdlib para imagenes:** `image/jpeg` y `image/png` de Go stdlib son suficientes para thumbnails basicos. Si se necesita soporte de mas formatos (WebP, AVIF), se puede extender despues sin romper la interfaz. +- **Sin base de datos de metadata:** las funciones manejan archivos en disco/S3 pero no mantienen un indice de archivos subidos. Cada app decide como trackear sus uploads (puede usar operations.db, una tabla SQL, o simplemente el filesystem). +- **S3Config como struct separado:** permite que apps que no usan S3 no tengan que importar el AWS SDK. Las funciones S3 son independientes del resto. + +## Riesgos + +- **Path traversal:** Un atacante podria intentar subir archivos con nombres como `../../etc/passwd` o acceder a archivos fuera del directorio de storage. Mitigado: `file_save_disk` ignora el nombre original y usa UUID, `file_serve` valida que el path resuelto esta dentro del directorio base, `file_delete` rechaza paths con `..`. +- **File size DoS:** Un cliente podria enviar archivos enormes para agotar disco o memoria. Mitigado: `upload_parse` usa `http.MaxBytesReader` para cortar la lectura al limite configurado, `upload_handler` rechaza antes de leer si `Content-Length` excede el maximo. +- **MIME type bypass:** Un archivo puede tener magic bytes validos pero contenido malicioso despues. `file_validate_type` solo verifica los primeros bytes β€” no es un antivirus. Documentar que para apps con requisitos de seguridad altos se necesita escaneo adicional. +- **Dependencia AWS SDK para S3:** Anade un arbol de dependencias significativo. Mitigado: las funciones S3 son opcionales y estan en archivos separados. Si una app no importa S3, no paga el costo. +- **Thumbnails con stdlib limitados:** `image/jpeg` y `image/png` no soportan formatos modernos (WebP, AVIF, HEIC). Para apps que necesiten mas formatos, habra que evaluar dependencias externas o delegar a herramientas de sistema (`imagemagick`). diff --git a/dev/issues/0016-rate-limiting.md b/dev/issues/0016-rate-limiting.md new file mode 100644 index 00000000..24833b6a --- /dev/null +++ b/dev/issues/0016-rate-limiting.md @@ -0,0 +1,174 @@ +# 0016 β€” Rate Limiting + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0016 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | feature | + +## Dependencias + +- **0009** (HTTP Server Foundation) β€” el middleware de rate limiting se integra via `http_middleware_chain`. + +--- + +## Objetivo + +Proteger cualquier API del registry contra abuso con rate limiting in-memory basado en token bucket, sin dependencias externas (no Redis). Funciones componibles que se enchufan al stack de middlewares de 0009. + +## Contexto + +- `sqlite_api` y futuras apps HTTP no tienen ninguna proteccion contra abuso. Un cliente puede hacer miles de requests por segundo sin limite. +- Con las funciones de HTTP server de 0009, integrar rate limiting es cuestion de componer un middleware mas en la chain. +- Token bucket es el algoritmo estandar para rate limiting HTTP: permite rafagas controladas (`burst`) mientras mantiene una tasa sostenida (`rate`). +- Go stdlib incluye `golang.org/x/time/rate` pero crear funciones propias permite control total sobre cleanup, headers y key extraction. + +## Arquitectura + +``` +functions/infra/ + rate_limiter_new.go β€” NEW: crea rate limiter in-memory + rate_limiter_new.md β€” NEW + rate_limiter_check.go β€” NEW: consulta si un request esta permitido + rate_limiter_check.md β€” NEW + rate_limit_middleware.go β€” NEW: middleware HTTP por IP + rate_limit_middleware.md β€” NEW + rate_limiter_by_key.go β€” NEW: rate limit por clave custom + rate_limiter_by_key.md β€” NEW + rate_limiter_cleanup.go β€” NEW: GC de entries stale + rate_limiter_cleanup.md β€” NEW + rate_limit_headers.go β€” NEW: construye headers estandar + rate_limit_headers.md β€” NEW + +types/infra/ + rate_limiter.md β€” NEW + rate_limit_config.md β€” NEW + rate_limit_result.md β€” NEW +``` + +## Diseno + +### Tipos + +```go +// RateLimiter mantiene estado de todos los clientes +type RateLimiter struct { + rate float64 // tokens por segundo + burst int // capacidad maxima del bucket + mu sync.Mutex + clients map[string]*clientEntry // key -> bucket state +} + +type clientEntry struct { + tokens float64 + lastSeen time.Time +} + +// RateLimitConfig configura el middleware +type RateLimitConfig struct { + RequestsPerSecond float64 // tasa sostenida + BurstSize int // rafaga maxima + KeyFunc func(r *http.Request) string // extractor de clave (nil = IP) + CleanupInterval time.Duration // frecuencia de GC +} + +// RateLimitResult es el resultado de un check +type RateLimitResult struct { + Allowed bool // request permitido + Remaining int // tokens restantes + ResetAt time.Time // cuando se rellena el bucket + RetryAfter float64 // segundos hasta que se pueda reintentar (0 si allowed) +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `rate_limiter_new` | impure | `(rate float64, burst int) *RateLimiter` | +| `rate_limiter_check` | impure | `(rl *RateLimiter, key string) RateLimitResult` | +| `rate_limit_middleware` | impure | `(rl *RateLimiter) Middleware` | +| `rate_limiter_by_key` | impure | `(rl *RateLimiter, keyFunc func(*http.Request) string) Middleware` | +| `rate_limiter_cleanup` | impure | `(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func()` | +| `rate_limit_headers` | pure | `(result RateLimitResult, limit int) http.Header` | + +### Token bucket + +Cada key tiene un bucket con `burst` tokens. Se recargan a `rate` tokens/segundo. Un request consume 1 token. Si no quedan tokens, se rechaza con 429. + +--- + +## Tareas + +### Fase 1: Core + tipos + +- [ ] **1.1** Crear tipos `RateLimiter`, `RateLimitConfig`, `RateLimitResult` en `functions/infra/` con `.md` en `types/infra/` +- [ ] **1.2** `rate_limiter_new` β€” inicializa `RateLimiter` con rate y burst +- [ ] **1.3** `rate_limiter_check` β€” evalua token bucket para una key, retorna `RateLimitResult` +- [ ] **1.4** `rate_limit_headers` β€” construye `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` a partir de `RateLimitResult` +- [ ] **1.5** `rate_limiter_cleanup` β€” goroutine que borra entries sin actividad reciente, retorna `func()` para cancelar + +### Fase 2: Middlewares + tests + +- [ ] **2.1** `rate_limit_middleware` β€” middleware que limita por IP del cliente (extrae de `RemoteAddr` / `X-Forwarded-For`) +- [ ] **2.2** `rate_limiter_by_key` β€” middleware configurable con `keyFunc` para limitar por API key, user ID, etc. +- [ ] **2.3** Tests de cada funcion con `httptest.NewRecorder` +- [ ] **2.4** `fn index` y verificar con `fn show` + +--- + +## Ejemplo de uso + +```go +// Crear limiter: 10 req/s con burst de 20 +rl := infra.RateLimiterNew(10, 20) + +// Arrancar cleanup cada 5 minutos, borra entries sin actividad en 10 min +stopCleanup := infra.RateLimiterCleanup(rl, 10*time.Minute, 5*time.Minute) +defer stopCleanup() + +// Componer con otros middlewares de 0009 +middleware := infra.HttpMiddlewareChain( + infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}), + infra.RateLimitMiddleware(rl), // por IP + infra.HttpLoggerMiddleware(os.Stdout), +) + +mux := infra.HttpRouter(routes) +infra.HttpServe(":8484", middleware(mux), ctx) +``` + +```go +// Rate limit por API key en vez de IP +keyMiddleware := infra.RateLimiterByKey(rl, func(r *http.Request) string { + return r.Header.Get("X-API-Key") +}) +``` + +```go +// Respuesta 429 automatica del middleware: +// HTTP/1.1 429 Too Many Requests +// X-RateLimit-Limit: 10 +// X-RateLimit-Remaining: 0 +// X-RateLimit-Reset: 1713045600 +// Retry-After: 1 +// Content-Type: application/json +// +// {"status":429,"code":"rate_limited","message":"too many requests"} +``` + +## Decisiones de diseno + +- **In-memory, no Redis:** para el scope del registry (single-process, pocas apps) un `sync.Mutex` + `map` es suficiente y evita una dependencia de infraestructura. +- **Token bucket sobre sliding window:** permite rafagas legitimas (burst) sin penalizar al cliente por picos puntuales, y es trivial de implementar. +- **Headers IETF draft:** sigue `draft-ietf-httpapi-ratelimit-headers` (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). Los clientes pueden adaptar su ritmo sin adivinar. +- **`rate_limit_headers` como funcion pura:** construir headers no requiere I/O, solo formateo. El middleware la usa internamente pero queda disponible para otros usos. +- **Cleanup explicito:** el GC goroutine se arranca con parametros configurables y se para con la funcion retornada, sin goroutine leaks. + +## Riesgos + +- **Memoria con muchas IPs unicas:** Mitigado con `rate_limiter_cleanup` que purga entries inactivas periodicamente. Para APIs con millones de IPs distintas habria que migrar a Redis, pero ese no es el caso del registry. +- **IP detras de proxy:** `X-Forwarded-For` puede ser spoofed. Para uso interno es aceptable; para exposicion publica real habria que validar el header contra trusted proxies. diff --git a/dev/issues/0019-structured-logging.md b/dev/issues/0019-structured-logging.md new file mode 100644 index 00000000..21fa8c1a --- /dev/null +++ b/dev/issues/0019-structured-logging.md @@ -0,0 +1,191 @@ +# 0019 β€” Structured Logging Go + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0019 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | feature | + +## Dependencias + +- `logger_middleware` depende de issue 0009 (HTTP Server Foundation) para el tipo `Middleware`. +- El resto de funciones no tiene dependencias externas. + +--- + +## Objetivo + +Funciones de structured logging en Go (dominio infra) basadas en `log/slog` de stdlib. Logs en JSON con niveles, campos contextuales y middleware HTTP, reemplazando el uso ad-hoc de `fmt.Println` y `log.Printf` en las apps. + +## Contexto + +- Python ya tiene `get_logger_py_infra` y `setup_logger_py_infra` con rotacion, dual output y niveles. +- Bash tiene `bash_log_bash_shell` con niveles y colores. +- Go tiene **cero** funciones de logging estructurado. Las apps (`deploy_server`, `sqlite_api`, `pipeline_launcher`) loguean con `fmt.Println` o `log.Printf` sin estructura, sin niveles, sin contexto. +- Go 1.21+ incluye `log/slog` en stdlib: JSON handler, niveles, campos key-value, groups. No se necesita zerolog ni zap. + +## Arquitectura + +``` +functions/infra/ +β”œβ”€β”€ logger_new.go β€” NEW: crea logger con nivel, output y formato +β”œβ”€β”€ logger_new.md β€” NEW +β”œβ”€β”€ logger_with.go β€” NEW: retorna copia del logger con campos adicionales +β”œβ”€β”€ logger_with.md β€” NEW +β”œβ”€β”€ logger_middleware.go β€” NEW: middleware HTTP que loguea requests +β”œβ”€β”€ logger_middleware.md β€” NEW +β”œβ”€β”€ log_debug.go β€” NEW: log a nivel debug +β”œβ”€β”€ log_debug.md β€” NEW +β”œβ”€β”€ log_info.go β€” NEW: log a nivel info +β”œβ”€β”€ log_info.md β€” NEW +β”œβ”€β”€ log_warn.go β€” NEW: log a nivel warn +β”œβ”€β”€ log_warn.md β€” NEW +β”œβ”€β”€ log_error.go β€” NEW: log a nivel error +β”œβ”€β”€ log_error.md β€” NEW + +types/infra/ +β”œβ”€β”€ logger.md β€” NEW: metadata del tipo Logger +β”œβ”€β”€ log_level.md β€” NEW: metadata del tipo LogLevel +β”œβ”€β”€ log_entry.md β€” NEW: metadata del tipo LogEntry +``` + +### Patron pure core / impure shell + +- **Pure:** `logger_with` (copia inmutable del logger con campos adicionales, sin I/O) +- **Impure:** `logger_new`, `log_debug`, `log_info`, `log_warn`, `log_error`, `logger_middleware` (escriben a un `io.Writer`) + +## Diseno + +### Tipos + +```go +// LogLevel representa los niveles de log soportados. +type LogLevel int + +const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError +) + +// Logger wrappea slog.Logger con config del registry. +type Logger struct { + Level LogLevel + Output io.Writer + Format string // "json" | "text" + Fields map[string]any + inner *slog.Logger +} + +// LogEntry representa una entrada de log estructurada. +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `logger_new` | impure | `(level LogLevel, output io.Writer, format string) (*Logger, error)` | +| `logger_with` | pure | `(logger *Logger, fields map[string]any) *Logger` | +| `log_debug` | impure | `(logger *Logger, msg string, fields ...any)` | +| `log_info` | impure | `(logger *Logger, msg string, fields ...any)` | +| `log_warn` | impure | `(logger *Logger, msg string, fields ...any)` | +| `log_error` | impure | `(logger *Logger, msg string, fields ...any)` | +| `logger_middleware` | impure | `(logger *Logger) Middleware` | + +## Tareas + +### Fase 1: Tipos y funciones core + +- [ ] **1.1** Crear tipos `Logger`, `LogLevel`, `LogEntry` en `functions/infra/` con `.md` en `types/infra/` +- [ ] **1.2** `logger_new` β€” crea `*Logger` con `slog.NewJSONHandler` o `slog.NewTextHandler` segun `format` +- [ ] **1.3** `logger_with` β€” clona el logger y anade campos al `slog.Logger` interno via `slog.With()` +- [ ] **1.4** `log_debug`, `log_info`, `log_warn`, `log_error` β€” delegan al `slog.Logger` interno con el nivel correspondiente +- [ ] **1.5** Tests unitarios: verificar output JSON, niveles filtrados, campos inyectados + +### Fase 2: Middleware HTTP (requiere 0009) + +- [ ] **2.1** `logger_middleware` β€” wrappea `http.Handler`, loguea method, path, status, duration_ms al completar cada request +- [ ] **2.2** Tests con `httptest.NewRecorder` +- [ ] **2.3** `fn index` y verificar todas las funciones en registry.db + +--- + +## Ejemplo de uso + +```go +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + + "github.com/fn_registry/functions/infra" +) + +func main() { + // Crear logger JSON a stdout, nivel info + logger, _ := infra.LoggerNew(infra.LogLevelInfo, os.Stdout, "json") + + // Logger con contexto de app + appLog := infra.LoggerWith(logger, map[string]any{ + "app": "sqlite_api", + "version": "1.0.0", + }) + + infra.LogInfo(appLog, "server starting", "port", 8484) + // {"time":"2026-04-13T...","level":"INFO","msg":"server starting","app":"sqlite_api","version":"1.0.0","port":8484} + + // Logger por request con campos adicionales + reqLog := infra.LoggerWith(appLog, map[string]any{"request_id": "abc-123"}) + infra.LogDebug(reqLog, "parsing body") // filtrado: nivel < info + infra.LogError(reqLog, "db query failed", "err", "connection refused", "table", "functions") + + // Middleware HTTP (compone con las funciones de 0009) + routes := []infra.Route{ + {Method: "GET", Path: "/health", Handler: healthHandler}, + } + mux := infra.HttpRouter(routes) + + middleware := infra.HttpMiddlewareChain( + infra.LoggerMiddleware(appLog), + infra.HttpCorsMiddleware([]string{"*"}, []string{"GET"}), + ) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + infra.HttpServe(":8484", middleware(mux), ctx) + // Cada request produce: + // {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1} +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"}) +} +``` + +## Decisiones de diseno + +- **`log/slog` de stdlib (Go 1.21+):** Zero dependencies. `slog` ya resuelve JSON structured logging, niveles, campos key-value y handlers extensibles. No se justifica zerolog ni zap para el scope de este registry. +- **Logger como struct, no global:** Cada app/componente crea su logger con su config. Sin `slog.SetDefault()` ni variables de paquete. Inyeccion explicita. +- **`logger_with` puro:** `slog.Logger.With()` retorna un nuevo logger sin mutar el original. Esto permite crear loggers contextuales (por request, por componente) sin side effects. +- **Funciones de nivel separadas (`log_info`, `log_error`...):** En vez de un unico `Log(level, msg)`, funciones dedicadas por nivel. Mas legibles en el call site y mas buscables en el registry. +- **Formato configurable (JSON/text):** JSON para produccion y pipelines de logs, text para desarrollo local. Un solo parametro en `logger_new`. + +## Riesgos + +- **Adopcion gradual:** Las apps existentes usan `fmt.Println`/`log.Printf`. Mitigado porque las funciones nuevas no rompen nada β€” las apps migran a su ritmo. +- **Middleware depende de 0009:** `logger_middleware` usa el tipo `Middleware` de 0009. Si 0009 no esta implementado, la fase 2 se pospone. La fase 1 es independiente. +- **Proliferacion de funciones de log:** 4 funciones de nivel + `logger_new` + `logger_with` = 6 funciones. Aceptable: cada una es trivial y atomica, preferible a una sola funcion con parametro de nivel. diff --git a/dev/issues/0021-crud-generator.md b/dev/issues/0021-crud-generator.md new file mode 100644 index 00000000..9c80508b --- /dev/null +++ b/dev/issues/0021-crud-generator.md @@ -0,0 +1,309 @@ +# 0021 β€” CRUD Generator + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0021 | +| **Estado** | pendiente | +| **Prioridad** | media | +| **Tipo** | feature | + +## Dependencias + +- **0009** (HTTP Server Foundation) β€” los handlers generados usan `http_json_response`, `http_error_response`, `http_parse_body` y se registran via `http_router`. +- **0015** (DB Migrations) β€” la tabla generada por `crud_generate_table_sql` se ejecuta como migracion. + +--- + +## Objetivo + +Generar handlers REST completos (list, get, create, update, delete) a partir de una definicion declarativa de recurso, sin escribir codigo repetitivo. El 80% de los endpoints de cualquier API son CRUD identico β€” solo cambian el nombre de la tabla y los campos. + +## Contexto + +- Ya existen `wails_bind_crud_go_infra` (genera bindings CRUD para Wails como string de codigo Go) y `crud_page_ts_ui` (layout CRUD en frontend). Pero no hay nada que genere **endpoints HTTP** CRUD funcionales. +- Las apps del registry (`sqlite_api`, `deploy_server`, futuras) construyen sus handlers CRUD a mano cada vez: misma estructura de list con paginacion, mismo get por ID, mismo create con validacion, mismo update parcial, mismo delete. +- Este issue NO genera archivos ni templates β€” genera **handler factories en runtime**. Le pasas una definicion de recurso y te devuelve `http.HandlerFunc` listas para usar. +- Especifico para SQLite (usa `database/sql` con mattn/go-sqlite3). No abstrae multiples motores de BD. + +## Arquitectura + +``` +functions/infra/ + crud_define_resource.go β€” NEW: construye CRUDResource a partir de nombre y campos + crud_define_resource.md β€” NEW + crud_generate_table_sql.go β€” NEW: genera CREATE TABLE a partir de CRUDResource + crud_generate_table_sql.md β€” NEW + crud_generate_handlers.go β€” NEW: genera los 5 handlers a partir de CRUDResource + *sql.DB + crud_generate_handlers.md β€” NEW + crud_register_routes.go β€” NEW: registra rutas CRUD en router + crud_register_routes.md β€” NEW + crud_list_handler.go β€” NEW: handler generico list con paginacion/filtro/sort + crud_list_handler.md β€” NEW + crud_get_handler.go β€” NEW: handler get por ID + crud_get_handler.md β€” NEW + crud_create_handler.go β€” NEW: handler create con validacion + crud_create_handler.md β€” NEW + crud_update_handler.go β€” NEW: handler update parcial por ID + crud_update_handler.md β€” NEW + crud_delete_handler.go β€” NEW: handler delete por ID + crud_delete_handler.md β€” NEW + +types/infra/ + crud_resource.md β€” NEW: metadata del tipo CRUDResource + crud_field.md β€” NEW: metadata del tipo CRUDField + crud_list_params.md β€” NEW: metadata del tipo CRUDListParams + crud_list_result.md β€” NEW: metadata del tipo CRUDListResult +``` + +### Patron pure core / impure shell + +- **Pure:** `crud_define_resource` (construye struct), `crud_generate_table_sql` (string SQL), `crud_generate_handlers` (retorna handlers sin ejecutar I/O, pero los handlers en si hacen I/O al invocarse β€” la funcion factory es pure, el handler resultante es impure) +- **Impure:** `crud_register_routes` (muta router), `crud_list_handler`, `crud_get_handler`, `crud_create_handler`, `crud_update_handler`, `crud_delete_handler` (todos hacen I/O contra SQLite y HTTP) + +**Nota sobre `crud_generate_handlers`:** La funcion que genera los handlers es pura (recibe definicion + db, retorna funciones). Pero los handlers retornados son closures impuros que leen/escriben BD y HTTP. Se clasifica como **pure** porque la funcion en si no hace I/O β€” solo construye closures. + +## Diseno + +### Tipos + +```go +// CRUDResource define un recurso CRUD completo +type CRUDResource struct { + Name string // nombre del recurso (singular, snake_case: "project") + Table string // nombre de la tabla SQLite ("projects") + Fields []CRUDField // campos del recurso (sin ID ni timestamps) + SoftDelete bool // si true, usa deleted_at en vez de DELETE real +} + +// CRUDField define un campo del recurso +type CRUDField struct { + Name string // nombre del campo (snake_case: "display_name") + Type string // tipo SQLite: TEXT, INTEGER, REAL, BLOB + Required bool // NOT NULL + validacion en create + Unique bool // UNIQUE constraint + Default string // valor por defecto en CREATE TABLE (vacio = sin default) + Validations map[string]string // reglas: "min_length":"3", "max_length":"255", "pattern":"^[a-z]+" +} + +// CRUDListParams parametros de paginacion, orden y filtro +type CRUDListParams struct { + Page int // pagina actual (1-based, default 1) + PerPage int // items por pagina (default 20, max 100) + SortBy string // campo por el que ordenar (default "created_at") + SortDir string // "asc" o "desc" (default "desc") + Filters map[string]string // campo -> valor para WHERE exacto +} + +// CRUDListResult resultado paginado +type CRUDListResult struct { + Items []map[string]any `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` +} +``` + +### Funciones + +| Funcion | Purity | Firma (simplificada) | +|---------|--------|---------------------| +| `crud_define_resource` | pure | `(name string, table string, fields []CRUDField, softDelete bool) CRUDResource` | +| `crud_generate_table_sql` | pure | `(res CRUDResource) string` | +| `crud_generate_handlers` | pure | `(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc` | +| `crud_register_routes` | impure | `(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)` | +| `crud_list_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | +| `crud_get_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | +| `crud_create_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | +| `crud_update_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | +| `crud_delete_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | + +### Comportamiento de cada handler + +**List** (`GET /basePath`): +- Query params: `page`, `per_page`, `sort_by`, `sort_dir`, `filter_{campo}={valor}` +- Genera `SELECT * FROM table WHERE ... ORDER BY ... LIMIT ... OFFSET ...` +- Cuenta total con `SELECT COUNT(*) FROM table WHERE ...` +- Retorna `CRUDListResult` como JSON +- Si `soft_delete`, agrega `WHERE deleted_at IS NULL` automaticamente + +**Get** (`GET /basePath/{id}`): +- Genera `SELECT * FROM table WHERE id = ?` +- 404 si no existe (o si `soft_delete` y tiene `deleted_at`) +- Retorna el registro como JSON + +**Create** (`POST /basePath`): +- Parsea body JSON con `http_parse_body` +- Valida campos required y validaciones de cada campo +- Genera UUID para `id`, timestamp para `created_at` y `updated_at` +- `INSERT INTO table (id, field1, ..., created_at, updated_at) VALUES (?, ?, ..., ?, ?)` +- Retorna 201 con el registro creado + +**Update** (`PUT /basePath/{id}`): +- Parsea body JSON β€” solo campos presentes se actualizan (partial update) +- Valida campos enviados contra sus reglas +- `UPDATE table SET field1=?, updated_at=? WHERE id=?` +- 404 si no existe +- Retorna el registro actualizado + +**Delete** (`DELETE /basePath/{id}`): +- Si `soft_delete`: `UPDATE table SET deleted_at=? WHERE id=?` +- Si no: `DELETE FROM table WHERE id=?` +- 404 si no existe +- Retorna 204 sin body + +### SQL generado por `crud_generate_table_sql` + +```sql +-- Para un recurso "projects" con campos name (TEXT, required, unique) y description (TEXT) +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +-- Si soft_delete: +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); +``` + +### Validaciones soportadas + +| Clave | Aplica a | Ejemplo | +|-------|----------|---------| +| `min_length` | TEXT | `"3"` β€” minimo 3 caracteres | +| `max_length` | TEXT | `"255"` β€” maximo 255 caracteres | +| `pattern` | TEXT | `"^[a-z_]+$"` β€” regex match | +| `min` | INTEGER, REAL | `"0"` β€” valor minimo | +| `max` | INTEGER, REAL | `"1000"` β€” valor maximo | +| `enum` | TEXT | `"active,inactive,archived"` β€” valores permitidos | + +--- + +## Tareas + +### Fase 1: Tipos y definicion + +- [ ] **1.1** Crear tipos `CRUDResource`, `CRUDField`, `CRUDListParams`, `CRUDListResult` en `functions/infra/` con `.md` en `types/infra/` +- [ ] **1.2** `crud_define_resource` β€” construye `CRUDResource` validando que haya al menos un campo y que los tipos sean validos (TEXT, INTEGER, REAL, BLOB) +- [ ] **1.3** `crud_generate_table_sql` β€” genera DDL `CREATE TABLE IF NOT EXISTS` con las constraints derivadas de los campos + +### Fase 2: Handlers individuales + +- [ ] **2.1** `crud_list_handler` β€” parsea query params a `CRUDListParams`, construye SQL dinamico con paginacion y filtros, retorna `CRUDListResult` +- [ ] **2.2** `crud_get_handler` β€” busca por ID, escanea columnas dinamicamente a `map[string]any`, responde 404 si no existe +- [ ] **2.3** `crud_create_handler` β€” valida input contra definicion, genera UUID, inserta, retorna 201 +- [ ] **2.4** `crud_update_handler` β€” partial update con solo los campos enviados, valida los presentes, 404 si no existe +- [ ] **2.5** `crud_delete_handler` β€” hard/soft delete segun config, 404 si no existe, retorna 204 + +### Fase 3: Composicion y tests + +- [ ] **3.1** `crud_generate_handlers` β€” llama a los 5 handlers individuales, retorna `map[string]http.HandlerFunc` con keys "list", "get", "create", "update", "delete" +- [ ] **3.2** `crud_register_routes` β€” registra en `http.ServeMux` las rutas `GET /base`, `GET /base/{id}`, `POST /base`, `PUT /base/{id}`, `DELETE /base/{id}` +- [ ] **3.3** Tests con `httptest.NewServer` + SQLite in-memory (`:memory:`) para cada handler +- [ ] **3.4** Test de integracion: define recurso, genera tabla, registra rutas, CRUD completo via HTTP +- [ ] **3.5** `fn index` y verificar con `fn show` que todas las funciones y tipos aparecen + +--- + +## Ejemplo de uso + +```go +// 1. Definir el recurso +resource := infra.CRUDDefineResource("project", "projects", []infra.CRUDField{ + {Name: "name", Type: "TEXT", Required: true, Unique: true, + Validations: map[string]string{"min_length": "1", "max_length": "100"}}, + {Name: "description", Type: "TEXT"}, + {Name: "status", Type: "TEXT", Required: true, Default: "'active'", + Validations: map[string]string{"enum": "active,archived,deleted"}}, + {Name: "priority", Type: "INTEGER", Default: "0", + Validations: map[string]string{"min": "0", "max": "10"}}, +}, false) // soft_delete = false + +// 2. Crear la tabla +ddl := infra.CRUDGenerateTableSQL(resource) +db.Exec(ddl) + +// 3. Registrar rutas (una linea) +mux := http.NewServeMux() +infra.CRUDRegisterRoutes(mux, "/api/projects", resource, db) + +// Listo. Endpoints disponibles: +// GET /api/projects β€” list con paginacion +// GET /api/projects/{id} β€” get por ID +// POST /api/projects β€” create +// PUT /api/projects/{id} β€” update parcial +// DELETE /api/projects/{id} β€” delete + +// 4. Multiples recursos en la misma API +infra.CRUDRegisterRoutes(mux, "/api/users", userResource, db) +infra.CRUDRegisterRoutes(mux, "/api/tasks", taskResource, db) + +// 5. Componer con middlewares de 0009 +middleware := infra.HttpMiddlewareChain( + infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST", "PUT", "DELETE"}), + infra.HttpLoggerMiddleware(os.Stdout), +) + +ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +defer cancel() + +infra.HttpServe(":8080", middleware(mux), ctx) +``` + +```bash +# Uso desde curl: + +# List con paginacion y filtros +curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active" + +# Get +curl "localhost:8080/api/projects/abc-123" + +# Create +curl -X POST localhost:8080/api/projects \ + -H 'Content-Type: application/json' \ + -d '{"name":"mi-proyecto","description":"Desc","status":"active","priority":5}' + +# Update parcial (solo cambia description) +curl -X PUT localhost:8080/api/projects/abc-123 \ + -H 'Content-Type: application/json' \ + -d '{"description":"Nueva descripcion"}' + +# Delete +curl -X DELETE localhost:8080/api/projects/abc-123 +``` + +## Decisiones de diseno + +- **Runtime handler factories, no code generation:** `wails_bind_crud_go_infra` genera codigo como string para Wails. Este issue toma el camino opuesto: las funciones **son** los handlers, no generan texto. Esto evita templates, archivos generados y el ciclo generate-compile. El tradeoff es que los handlers son genericos (`map[string]any`) en vez de tipados, pero para APIs REST sobre SQLite es aceptable. +- **`map[string]any` en vez de structs tipados:** Como los campos se definen en runtime, no es posible usar structs Go tipados. Toda la serializacion pasa por `map[string]any`. Esto es idiomatico para APIs JSON + SQLite donde el schema es dinamico. +- **SQLite especifico, no multi-motor:** Simplifica enormemente el SQL generado (TEXT para timestamps, sin SERIAL, sin esquemas). Si en el futuro se necesita Postgres, se crea una variante separada. +- **Validacion en Go, no en SQLite:** Las constraints de SQLite (CHECK, NOT NULL) son la ultima linea de defensa. La validacion principal ocurre en el handler antes del INSERT/UPDATE, con mensajes de error descriptivos para el cliente. +- **UUID como ID:** Todos los recursos usan `id TEXT PRIMARY KEY` con UUID generado server-side. No IDs autoincrement β€” evita problemas de concurrencia y es mas portable. +- **Timestamps como TEXT ISO 8601:** Consistente con como registry.db y operations.db ya almacenan timestamps. +- **Partial update en PUT:** Normalmente PATCH es para partial update y PUT para replace completo. Aqui se usa PUT con partial update por simplicidad β€” solo los campos presentes en el JSON se actualizan. Es pragmatico para APIs internas. + +## Relacion con funciones existentes + +| Funcion existente | Relacion | +|---|---| +| `wails_bind_crud_go_infra` | Genera **codigo Go** como string para desktop (Wails). Este issue genera **handlers HTTP** en runtime para REST APIs. Complementarios, no solapados. | +| `crud_page_ts_ui` | Frontend CRUD layout en React. Los handlers generados aqui serian el **backend** que esa pagina consume. Stack completo: `crud_define_resource` (definicion) + handlers (backend) + `crud_page` (frontend). | + +## Riesgos + +- **SQL injection en filtros:** Los filtros se pasan como query params y se inyectan en WHERE. Mitigado validando que el campo exista en la definicion del recurso y usando siempre `?` placeholders β€” nunca interpolacion de strings en SQL. +- **Performance en tablas grandes:** El `SELECT COUNT(*)` para paginacion se ejecuta en cada list request. Para tablas con millones de filas esto es lento. Mitigado: las apps del registry manejan miles de registros, no millones. Si se necesita, se anade cache de count como mejora futura. +- **Scope creep hacia un ORM:** Hay tentacion de agregar relaciones, joins, nested resources, hooks before/after. Mitigado limitando el scope a CRUD plano de una tabla. Relaciones y logica de negocio van en handlers custom, no en el generador. +- **Colision con 0009 si cambia la API:** Los handlers usan `http_json_response` y `http_error_response` de 0009. Si esas firmas cambian, hay que actualizar. Mitigado: las funciones de 0009 son primitivas estables con firmas simples. diff --git a/dev/issues/0022-init-pipelines.md b/dev/issues/0022-init-pipelines.md new file mode 100644 index 00000000..2a91f885 --- /dev/null +++ b/dev/issues/0022-init-pipelines.md @@ -0,0 +1,554 @@ +# 0022 β€” Init Pipelines + +## Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0022 | +| **Estado** | pendiente | +| **Prioridad** | alta | +| **Tipo** | feature | + +## Dependencias + +| ID | Titulo | Estado | Requerido | +|----|--------|--------|-----------| +| 0009 | HTTP Server Foundation | pendiente | si | +| 0010 | Auth System | pendiente | si | +| 0015 | Database Migrations | pendiente | si | + +**Bloqueada por:** `#0009` (http_serve, http_router, http_json_response, http_middleware_chain), `#0010` (jwt_middleware, password_hash, session_create), `#0015` (migration_create, migration_up, migration_status). Los pipelines de init generan boilerplate que importa y compone estas funciones β€” sin ellas, el codigo scaffoldeado no compilaria. + +**Desbloquea:** cualquier app nueva del registry se puede crear con un solo comando en vez de copiar y adaptar una app existente. + +--- + +## Objetivo + +Cuatro bash pipelines que scaffold apps completas en `apps/` con un solo comando. Cada uno genera la estructura de directorios, archivos boilerplate, `app.md` con frontmatter correcto, y verifica que el resultado compila. Misma filosofia que `init_jupyter_analysis_bash_pipelines`: componer funciones atomicas del registry para producir un entorno listo para trabajar. + +## Contexto + +- Existen tres init pipelines: `init_go_project_bash_pipelines` (repo Go generico), `init_go_module_bash_pipelines` (modulo Go simple), `init_jupyter_analysis_bash_pipelines` (analysis Jupyter). Todos scaffoldean estructuras basicas. +- **No existe ningun pipeline para scaffoldear apps del registry** con HTTP server, auth, DB, frontend, Wails o TUI. Cada app nueva se construye copiando otra y adaptando manualmente. +- Las apps existentes (`deploy_server`, `sqlite_api`, `rapid_dashboards`, `pipeline_launcher`) comparten patrones repetitivos: main.go con graceful shutdown, config desde env vars, health check, migrations, app.md. +- Con las funciones de 0009 (HTTP), 0010 (auth) y 0015 (migrations) disponibles, el boilerplate de una app API es predecible y automatizable. +- El registry ya tiene funciones Wails completas (`scaffold_wails_app_go_infra`, `install_wails_bash_infra`, `wails_build_go_infra`, hooks `use_wails_*_ts_ui`, `wails_provider_ts_ui`) y TUI (`new_base_model_go_tui`, `run_fullscreen_go_tui`, temas, spinners, listas). + +## Arquitectura + +``` +bash/functions/pipelines/ +β”œβ”€β”€ init_api_app.sh β€” NEW: scaffold Go HTTP API app +β”œβ”€β”€ init_api_app.md β€” NEW +β”œβ”€β”€ init_web_app.sh β€” NEW: scaffold full-stack app (Go API + React) +β”œβ”€β”€ init_web_app.md β€” NEW +β”œβ”€β”€ init_desktop_app.sh β€” NEW: scaffold Wails desktop app +β”œβ”€β”€ init_desktop_app.md β€” NEW +β”œβ”€β”€ init_cli_app.sh β€” NEW: scaffold Go CLI/TUI app +β”œβ”€β”€ init_cli_app.md β€” NEW +``` + +Todas son `kind: pipeline`, `purity: impure`, `lang: bash`, `domain: pipelines`. + +### Patron de composicion + +Cada pipeline sigue el mismo patron que `init_jupyter_analysis`: + +1. Source funciones atomicas del registry via `source "$REGISTRY_ROOT/bash/functions/..."` +2. Parsear argumentos (nombre obligatorio, flags opcionales) +3. Crear estructura de directorios con `mkdir -p` +4. Escribir archivos boilerplate con heredocs +5. Generar `app.md` con frontmatter correcto +6. Ejecutar `fn index` para registrar la app +7. Verificar con `go vet` / `pnpm build` / `wails build` segun corresponda + +--- + +## Diseno + +### Pipeline 1: `init_api_app` + +Scaffold de Go HTTP API app en `apps/`. + +**Uso:** + +```bash +fn run init_api_app my_service +fn run init_api_app my_service --port 8080 --with-auth --with-db +``` + +**Archivos generados:** + +``` +apps/{nombre}/ +β”œβ”€β”€ main.go β€” Entry point: config β†’ router β†’ middleware β†’ http_serve con graceful shutdown +β”œβ”€β”€ handlers.go β€” Handler GET /health + handler de ejemplo GET /api/v1/status +β”œβ”€β”€ config.go β€” Struct Config con tags + carga desde .env / env vars +β”œβ”€β”€ migrations/ +β”‚ └── 001_initial.sql β€” CREATE TABLE ejemplo con id, created_at, updated_at +β”œβ”€β”€ app.md β€” Frontmatter con tag service, uses_functions, dir_path +β”œβ”€β”€ Makefile β€” Targets: build, run, test, vet, clean +β”œβ”€β”€ .env.example β€” Variables de entorno documentadas (PORT, DB_PATH, etc.) +└── .gitignore β€” Binario, .env, *.db-shm, *.db-wal +``` + +**Funciones del registry compuestas:** + +| Funcion | Para que | +|---------|---------| +| `assert_command_exists_bash_shell` | Verificar que `go` esta instalado | +| `http_serve_go_infra` (0009) | Codigo de graceful shutdown en main.go | +| `http_router_go_infra` (0009) | Registro de rutas en main.go | +| `http_json_response_go_infra` (0009) | Helper en handlers.go | +| `http_error_response_go_infra` (0009) | Helper en handlers.go | +| `http_middleware_chain_go_infra` (0009) | Composicion de middlewares en main.go | +| `http_logger_middleware_go_infra` (0009) | Logging en main.go | +| `http_cors_middleware_go_infra` (0009) | CORS en main.go | +| `migration_up_go_infra` (0015) | Aplicar migrations en main.go al arrancar | +| `config_load_go_infra` (0018) | Carga de config en config.go (si existe) | + +**Flags opcionales:** + +| Flag | Efecto | +|------|--------| +| `--port N` | Puerto por defecto en config y .env.example (default: 8080) | +| `--with-auth` | Anade jwt_middleware, handlers de login/register, tabla users en migration | +| `--with-db` | Anade operations.db setup, store.go con helpers CRUD basicos | +| `--with-ops` | Anade `fn ops init` para crear operations.db con schema completo | + +**main.go generado (esquema):** + +```go +package main + +import ( + "context" + "log" + "os" + "os/signal" + + "fn_registry/functions/infra" +) + +func main() { + cfg := LoadConfig() + + // Migrations + if err := infra.MigrationUp(cfg.DBPath, "migrations"); err != nil { + log.Fatal(err) + } + + // Routes + routes := []infra.Route{ + {Method: "GET", Path: "/health", Handler: healthHandler}, + {Method: "GET", Path: "/api/v1/status", Handler: statusHandler}, + } + mux := infra.HttpRouter(routes) + + // Middleware + middleware := infra.HttpMiddlewareChain( + infra.HttpCorsMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE"}), + infra.HttpLoggerMiddleware(os.Stdout), + ) + + // Serve + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + log.Printf("starting %s on :%s", cfg.AppName, cfg.Port) + if err := infra.HttpServe(":"+cfg.Port, middleware(mux), ctx); err != nil { + log.Fatal(err) + } +} +``` + +--- + +### Pipeline 2: `init_web_app` + +Scaffold de full-stack app: Go API backend + React frontend con Mantine. + +**Uso:** + +```bash +fn run init_web_app my_dashboard +fn run init_web_app my_dashboard --port 8080 --with-auth +``` + +**Archivos generados:** + +``` +apps/{nombre}/ +β”œβ”€β”€ main.go β€” Igual que init_api_app + serve static files del frontend build +β”œβ”€β”€ handlers.go β€” Health + API handlers de ejemplo +β”œβ”€β”€ config.go β€” Config con FRONTEND_DIR +β”œβ”€β”€ migrations/ +β”‚ └── 001_initial.sql +β”œβ”€β”€ app.md β€” tag service, uses frontend +β”œβ”€β”€ Makefile β€” Targets: build, build-frontend, run, dev, test, clean +β”œβ”€β”€ .env.example +β”œβ”€β”€ .gitignore +β”œβ”€β”€ docker-compose.yml β€” Dev: Go API hot-reload + frontend dev server +└── frontend/ + β”œβ”€β”€ package.json β€” pnpm, vite, react, @mantine/core, @mantine/charts, @fn_library + β”œβ”€β”€ vite.config.ts β€” API proxy a localhost:${port} + β”œβ”€β”€ tsconfig.json + β”œβ”€β”€ index.html + β”œβ”€β”€ postcss.config.cjs + └── src/ + β”œβ”€β”€ main.tsx β€” FnMantineProvider + App mount + β”œβ”€β”€ App.tsx β€” Router basico con pagina de ejemplo + β”œβ”€β”€ theme.ts β€” createTheme() con colores del proyecto + └── pages/ + └── Home.tsx β€” Pagina de ejemplo usando crud_page_ts_ui o dashboard_layout_ts_ui +``` + +**Funciones adicionales compuestas (sobre init_api_app):** + +| Funcion | Para que | +|---------|---------| +| `mantine_provider_ts_ui` | Provider raiz en main.tsx | +| `crud_page_ts_ui` | Pagina de ejemplo funcional | +| `app_shell_ts_ui` | Layout con navbar y header | +| `data_table_ts_ui` | Tabla de datos en la pagina de ejemplo | + +**vite.config.ts generado:** + +```ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@fn_library": path.resolve(__dirname, "../../frontend/functions/ui"), + }, + }, + server: { + proxy: { + "/api": { + target: "http://localhost:${PORT}", + changeOrigin: true, + }, + "/health": { + target: "http://localhost:${PORT}", + }, + }, + }, +}); +``` + +**docker-compose.yml generado:** + +```yaml +services: + api: + build: . + ports: + - "${PORT}:${PORT}" + env_file: .env + volumes: + - ./migrations:/app/migrations + frontend: + image: node:20-alpine + working_dir: /app + command: sh -c "corepack enable && pnpm install && pnpm dev" + ports: + - "5173:5173" + volumes: + - ./frontend:/app +``` + +--- + +### Pipeline 3: `init_desktop_app` + +Scaffold de Wails desktop app con Go backend y React frontend con @fn_library. + +**Uso:** + +```bash +fn run init_desktop_app my_tool +fn run init_desktop_app my_tool --with-db +``` + +**Archivos generados:** + +``` +apps/{nombre}/ +β”œβ”€β”€ main.go β€” Wails entry point con embed del frontend +β”œβ”€β”€ app.go β€” Struct App con bindings base (Greet, GetVersion) +β”œβ”€β”€ wails.json β€” Config Wails apuntando a frontend/ +β”œβ”€β”€ go.mod +β”œβ”€β”€ app.md β€” framework: wails, uses wails hooks +β”œβ”€β”€ .gitignore +└── frontend/ + β”œβ”€β”€ package.json β€” pnpm, vite, react, @mantine/core, @fn_library + β”œβ”€β”€ vite.config.ts + β”œβ”€β”€ tsconfig.json + β”œβ”€β”€ index.html + └── src/ + β”œβ”€β”€ main.tsx β€” WailsProvider + FnMantineProvider + App + β”œβ”€β”€ App.tsx β€” Ejemplo usando useWailsQuery + data_table + └── theme.ts β€” createTheme() +``` + +**Funciones del registry compuestas:** + +| Funcion | Para que | +|---------|---------| +| `scaffold_wails_app_go_infra` | Genera estructura base Wails (main.go, app.go, wails.json, go.mod) | +| `install_wails_bash_infra` | Verifica/instala Wails CLI y deps de sistema | +| `wails_provider_ts_ui` | Provider React para IPC cache | +| `use_wails_query_ts_ui` | Hook de ejemplo en App.tsx | +| `mantine_provider_ts_ui` | Provider Mantine | +| `wails_bind_crud_go_infra` | Genera bindings CRUD si `--with-db` | + +**Flags opcionales:** + +| Flag | Efecto | +|------|--------| +| `--with-db` | Anade SQLite con migrations, bindings CRUD generados por `wails_bind_crud_go_infra` | + +--- + +### Pipeline 4: `init_cli_app` + +Scaffold de Go CLI app con subcomandos y componentes TUI de Bubbletea. + +**Uso:** + +```bash +fn run init_cli_app my_cli +fn run init_cli_app my_cli --with-tui +``` + +**Archivos generados:** + +``` +apps/{nombre}/ +β”œβ”€β”€ main.go β€” Entry point con subcommand routing (os.Args) +β”œβ”€β”€ cmd_version.go β€” Subcomando: version +β”œβ”€β”€ cmd_status.go β€” Subcomando de ejemplo: status (imprime info) +β”œβ”€β”€ app.md β€” framework vacio (CLI puro) o bubbletea (con --with-tui) +β”œβ”€β”€ Makefile β€” Targets: build, run, install, test, clean +β”œβ”€β”€ .gitignore +└── go.mod +``` + +**Con `--with-tui`:** + +``` +apps/{nombre}/ +β”œβ”€β”€ main.go β€” Entry point con run_fullscreen o run_model +β”œβ”€β”€ model.go β€” BaseModel + Update + View con tema oscuro +β”œβ”€β”€ cmd_version.go β€” Subcomando no-TUI +β”œβ”€β”€ app.md β€” framework: bubbletea +β”œβ”€β”€ Makefile +β”œβ”€β”€ .gitignore +└── go.mod +``` + +**Funciones del registry compuestas:** + +| Funcion | Para que | +|---------|---------| +| `assert_command_exists_bash_shell` | Verificar Go | +| `new_base_model_go_tui` | Modelo base en model.go | +| `dark_styles_go_tui` | Tema oscuro por defecto | +| `run_fullscreen_go_tui` | Arranque fullscreen en main.go | +| `new_spinner_go_tui` | Componente de ejemplo | +| `new_filtered_list_go_tui` | Componente de ejemplo | + +**main.go generado (sin TUI):** + +```go +package main + +import ( + "fmt" + "os" +) + +var version = "dev" + +func main() { + if len(os.Args) < 2 { + printUsage() + os.Exit(1) + } + + switch os.Args[1] { + case "version": + cmdVersion() + case "status": + cmdStatus() + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) + printUsage() + os.Exit(1) + } +} + +func printUsage() { + fmt.Println("Usage: {nombre} ") + fmt.Println() + fmt.Println("Commands:") + fmt.Println(" version Print version") + fmt.Println(" status Show status") +} +``` + +--- + +## Tareas + +### Fase 1: `init_api_app` (pipeline base) + +- [ ] **1.1** Crear `bash/functions/pipelines/init_api_app.sh` con source de funciones atomicas, parseo de argumentos, y generacion de estructura +- [ ] **1.2** Escribir heredocs para `main.go`, `handlers.go`, `config.go` que importen funciones de 0009 y 0015 +- [ ] **1.3** Generar `app.md` con frontmatter correcto (tag `service`, `uses_functions` con IDs reales, `dir_path`) +- [ ] **1.4** Generar `Makefile`, `.env.example`, `.gitignore`, `migrations/001_initial.sql` +- [ ] **1.5** Flag `--with-auth`: anadir imports de 0010, handlers de login/register, tabla users en migration +- [ ] **1.6** Flag `--with-db`: anadir `store.go` con helpers CRUD, setup de SQLite al arrancar +- [ ] **1.7** Ejecutar `go vet -tags fts5` al final como verificacion +- [ ] **1.8** Crear `init_api_app.md` con frontmatter de pipeline + +### Fase 2: `init_web_app` (extiende init_api_app) + +- [ ] **2.1** Crear `bash/functions/pipelines/init_web_app.sh` que primero invoca la logica de `init_api_app` y luego anade el frontend +- [ ] **2.2** Generar `frontend/` con `package.json` (pnpm, vite, react, mantine, @fn_library alias) +- [ ] **2.3** Generar `vite.config.ts` con proxy al backend y alias `@fn_library` +- [ ] **2.4** Generar `src/main.tsx` con `FnMantineProvider`, `src/App.tsx` con `AppShell`, `src/pages/Home.tsx` con ejemplo +- [ ] **2.5** Generar `docker-compose.yml` para desarrollo +- [ ] **2.6** Actualizar `main.go` para servir static files del frontend build +- [ ] **2.7** Ejecutar `pnpm install && pnpm build` como verificacion del frontend +- [ ] **2.8** Crear `init_web_app.md` con frontmatter de pipeline + +### Fase 3: `init_desktop_app` + +- [ ] **3.1** Crear `bash/functions/pipelines/init_desktop_app.sh` que invoca `scaffold_wails_app_go_infra` y anade frontend React +- [ ] **3.2** Verificar/instalar Wails con `install_wails_bash_infra` +- [ ] **3.3** Generar frontend con `WailsProvider` + `FnMantineProvider` y ejemplo con `useWailsQuery` +- [ ] **3.4** Flag `--with-db`: invocar `wails_bind_crud_go_infra` para generar bindings +- [ ] **3.5** Ejecutar `wails build` como verificacion +- [ ] **3.6** Crear `init_desktop_app.md` con frontmatter de pipeline + +### Fase 4: `init_cli_app` + +- [ ] **4.1** Crear `bash/functions/pipelines/init_cli_app.sh` con generacion de estructura CLI basica +- [ ] **4.2** Generar `main.go` con routing de subcomandos, `cmd_version.go`, `cmd_status.go` +- [ ] **4.3** Flag `--with-tui`: generar `model.go` con `new_base_model`, `dark_styles`, `run_fullscreen` +- [ ] **4.4** Ejecutar `go vet` como verificacion +- [ ] **4.5** Crear `init_cli_app.md` con frontmatter de pipeline + +### Fase 5: Integracion + +- [ ] **5.1** `fn index` y verificar que los 4 pipelines aparecen en registry.db con kind=pipeline, purity=impure +- [ ] **5.2** Verificar `fn run init_api_app test_app` end-to-end: genera, compila, limpia +- [ ] **5.3** Verificar `fn run init_web_app test_web` end-to-end +- [ ] **5.4** Verificar `fn run init_desktop_app test_desktop` end-to-end +- [ ] **5.5** Verificar `fn run init_cli_app test_cli` end-to-end + +### Fase 6: Documentacion de uso rapido + +Cada pipeline debe ser usable sin leer el issue completo. La documentacion va en dos niveles: el `.md` de cada funcion (fuente de verdad para `fn show`) y una guia consolidada. + +- [ ] **6.1** En cada `.md` de pipeline (`init_api_app.md`, etc.) documentar en la seccion `documentation` del frontmatter: + - Sinopsis: `fn run init_api_app [--port N] [--with-auth] [--with-db]` + - Descripcion de cada flag y su efecto concreto (que archivos anade, que imports genera) + - Listado de archivos generados con una linea de descripcion cada uno + - Post-setup: que comandos ejecutar despues (`make run`, `make dev`, `wails dev`, etc.) + - Ejemplo rapido: un bloque copy-paste de 3-4 lineas que crea la app y la arranca +- [ ] **6.2** En el campo `params` del frontmatter de cada pipeline, documentar cada argumento y flag con `name` y `desc` semantico para que `fn check params` pase y la info sea buscable via FTS5 +- [ ] **6.3** En el campo `example` del frontmatter, poner el caso de uso mas comun (una linea): + - `init_api_app`: `fn run init_api_app my_service --with-db` + - `init_web_app`: `fn run init_web_app my_dashboard --with-auth` + - `init_desktop_app`: `fn run init_desktop_app my_tool` + - `init_cli_app`: `fn run init_cli_app my_cli --with-tui` +- [ ] **6.4** Crear `docs/init-pipelines.md` como guia consolidada de referencia rapida con: + - Tabla resumen de los 4 pipelines (nombre, que genera, flags disponibles) + - Arbol de decision: "quiero una API" β†’ init_api_app, "quiero frontend" β†’ init_web_app, "quiero desktop" β†’ init_desktop_app, "quiero CLI" β†’ init_cli_app + - Seccion de combinaciones comunes (API + auth + DB, web dashboard, desktop con SQLite, CLI con TUI) + - FAQ: como anadir auth despues, como cambiar el puerto, como anadir operations.db, como agregar mas paginas al frontend +- [ ] **6.5** Verificar que `fn show init_api_app_bash_pipelines` (y los otros 3) muestra la documentacion completa con params, ejemplo y notas de uso + +--- + +## Ejemplo de uso + +```bash +# API service con auth y database +fn run init_api_app billing_api --port 8090 --with-auth --with-db +cd apps/billing_api +make run +# β†’ starting billing_api on :8090 +# β†’ curl localhost:8090/health β†’ {"status":"ok"} + +# Full-stack dashboard +fn run init_web_app inventory_dashboard --with-auth +cd apps/inventory_dashboard +make dev +# β†’ API en :8080, frontend en :5173 con proxy + +# Desktop app con base de datos +fn run init_desktop_app data_explorer --with-db +cd apps/data_explorer +wails dev +# β†’ App de escritorio con React + SQLite + +# CLI con TUI +fn run init_cli_app deploy_helper --with-tui +cd apps/deploy_helper +make run -- status +# β†’ TUI fullscreen con lista filtrable +``` + +**Cada pipeline genera su `app.md` listo para `fn index`:** + +```yaml +--- +name: billing_api +lang: go +domain: tools +description: "API de facturacion." +tags: [service] +uses_functions: + - http_serve_go_infra + - http_router_go_infra + - http_middleware_chain_go_infra + - http_cors_middleware_go_infra + - http_logger_middleware_go_infra + - http_json_response_go_infra + - http_error_response_go_infra + - migration_up_go_infra +uses_types: [] +framework: "net/http" +entry_point: "main.go" +dir_path: "apps/billing_api" +--- +``` + +--- + +## Decisiones de diseno + +- **Bash, no Go:** los init pipelines generan archivos con heredocs β€” bash es el lenguaje natural para esto. Go seria overengineering para scaffolding de texto. Coherente con `init_jupyter_analysis` y los demas init existentes. +- **Composicion sobre monolito:** cada pipeline sourcea funciones atomicas del registry (`assert_command_exists`, `scaffold_wails_app`, etc.) en vez de reimplementar. Si una funcion atomica mejora, todos los pipelines se benefician. +- **init_web_app extiende init_api_app:** el pipeline web reutiliza la logica del API (misma estructura backend) y anade la capa frontend encima. No duplica codigo. +- **Verificacion al final:** cada pipeline termina con `go vet`, `pnpm build`, o `wails build` para garantizar que el scaffold compila. Si falla, el pipeline reporta el error antes de declarar exito. +- **Flags opcionales con defaults sensatos:** el caso base (sin flags) genera una app funcional minima. `--with-auth`, `--with-db`, `--with-tui` anaden capas incrementales. El usuario no necesita decidir todo upfront. +- **@fn_library como alias, no copia:** el frontend generado referencia `@fn_library` via alias en `vite.config.ts` apuntando a `frontend/functions/ui/` del registry. Los componentes se comparten, no se duplican. +- **app.md generado automaticamente:** el frontmatter incluye `uses_functions` con los IDs reales de las funciones que el boilerplate importa. `fn index` los valida al registrar la app. +- **Sin framework CLI externo para init_cli_app:** routing de subcomandos con `os.Args` y switch β€” consistente con las apps existentes del registry que no usan cobra/urfave. Para TUI se usa Bubbletea que ya esta en el registry. + +## Riesgos + +- **Dependencias no implementadas:** los tres issues de dependencia (0009, 0010, 0015) estan pendientes. Si alguna funcion cambia de firma durante su implementacion, los heredocs de los pipelines necesitaran ajuste. **Mitigacion:** implementar los pipelines despues de que las dependencias esten merged, o mantener los heredocs parametricos para absorber cambios menores. +- **Heredocs fragiles:** generar Go/TS/YAML con heredocs bash es propenso a errores de indentacion, escape de variables y quoting. **Mitigacion:** cada pipeline incluye verificacion final (`go vet` / `pnpm build`) que detecta errores de sintaxis inmediatamente. Tests end-to-end en fase 5. +- **Frontend desactualizado respecto a @fn_library:** si los componentes de `frontend/functions/ui/` evolucionan, el boilerplate generado puede quedar desactualizado. **Mitigacion:** el boilerplate es minimo (un Provider, un AppShell, una pagina de ejemplo) β€” el usuario lo extiende con los componentes actuales del registry. +- **Wails como dependencia de sistema:** `init_desktop_app` requiere GTK3 + WebKit2GTK instalados en Linux. `install_wails_bash_infra` lo maneja, pero puede fallar en distros no soportadas. **Mitigacion:** el pipeline verifica la instalacion al inicio y falla rapido con mensaje descriptivo. +- **Colision de nombres:** si el usuario elige un nombre que ya existe en `apps/`, el pipeline sobreescribiria archivos. **Mitigacion:** verificar si `apps/{nombre}/` existe al inicio y abortar con error si ya existe. diff --git a/dev/issues/0024-dashboard-yaml-split-por-tab.md b/dev/issues/0024-dashboard-yaml-split-por-tab.md new file mode 100644 index 00000000..bc4d93af --- /dev/null +++ b/dev/issues/0024-dashboard-yaml-split-por-tab.md @@ -0,0 +1,225 @@ +# 0024 β€” Split dashboard YAMLs por tab + +## APP Metadata + +| Campo | Valor | +|-------|-------| +| **ID** | 0024 | +| **Estado** | pendiente | +| **Prioridad** | alta | +| **Tipo** | mejora β€” devex de auto_metabase | + +## Dependencias + +Ninguna. Modifica `apps/auto_metabase/sync_pull.py`, `sync_push.py`, `sync_validate.py` y `app.md`. + +**Desbloquea:** edicion fluida de dashboards grandes (aurgi id=734: 11.282 lineas, 90 dashcards, 11 tabs). Actualmente cada cambio obliga a scrollear el monolitico; tras esta issue cada tab es un archivo independiente. + +--- + +## Objetivo + +Dividir el YAML monolitico de cada dashboard en un directorio `{slug}/` con un archivo `_dashboard.yaml` (metadata, parametros, tabs) y un archivo por tab (`tab_{slug_tab}.yaml`) con sus dashcards. El push compone todo de vuelta a un unico body JSON antes de enviar a Metabase β€” cambio 100% cosmetico del sync local, el API no se entera. + +## Contexto + +**Situacion actual:** +- `pull_dashboard()` escribe `projects/{name}/dashboards/{slug}.yaml` con todo: metadata + parametros + tabs + 90+ dashcards en un solo archivo. +- Aurgi (proyecto real) tiene un dashboard `bi_ventas_portfolio_producto_en_construccion` que genera **11.282 lineas YAML**. Para editar una dashcard en el tab "Foto Categorias" hay que encontrarla entre 90 entries via `dashboard_tab_id` (que recientemente volvimos a preservar β€” ver commit de sync_pull que re-inyecta ids de tabs tras el strip). +- Los diffs de git son ilegibles cuando se reordenan dashcards: cambiar 1 dashcard puede mover 40 bloques de YAML y romper el review. +- `validate` corre sobre el archivo completo aunque solo hayas editado una tab. + +**Alternativas consideradas:** +- **One file per dashcard** (muy granular) β€” descartado: demasiados archivos para un dashboard mediano, el tab se pierde como unidad semantica. +- **One file per tab** β€” elegido: coincide con la unidad semantica que un humano edita ("trabajar en la tab 5 Min View"), diffs acotados a una tab, tamaΓ±o manejable (~1000 lineas por tab en el peor caso). +- **Mantener monolitico + jq/yq helpers** β€” descartado: no resuelve el problema del diff ni de la navegacion cognitiva. + +## Arquitectura + +``` +apps/auto_metabase/ +β”œβ”€β”€ sync_pull.py # MOD: pull_dashboard -> split en directorio +β”œβ”€β”€ sync_push.py # MOD: load_dashboard -> compose desde directorio +β”œβ”€β”€ sync_validate.py # MOD: resolver path a dashboard (file o directory) +β”œβ”€β”€ payload.py # MOD si hace falta (builders actuales) +β”œβ”€β”€ app.md # MOD: documentar nuevo layout +└── projects/{name}/ + └── dashboards/ + β”œβ”€β”€ {slug}.yaml # LEGACY: sigue soportado (backward-compat) + └── {slug}/ # NEW: nueva estructura directory-based + β”œβ”€β”€ _dashboard.yaml # NEW: _meta + _refs + name + description + parameters + tabs (solo names/ids) + dashcards_globales (sin tab_id) + β”œβ”€β”€ tab_5_min_view.yaml # NEW: dashcards con dashboard_tab_id=191 + β”œβ”€β”€ tab_foto_centros_compara_semana.yaml # NEW + └── ... +``` + +### Pure core / impure shell + +- **Pure (testable sin Metabase):** + - `split_dashboard_payload(payload: dict) -> dict[str, dict]` β€” toma el dict de un dashboard tal y como lo escribe el pull actual y devuelve `{"_dashboard": {...}, "tab_xxx": {...}, ...}`. Input β†’ output puro. En `sync_pull.py` o en un nuevo modulo `dashboard_split.py`. + - `merge_dashboard_payload(parts: dict[str, dict]) -> dict` β€” inverso: toma el dict de partes y reconstruye el payload unico para push. Tambien puro. + - `tab_slug(tab_name: str) -> str` β€” slugify, reusar la que ya existe en `sync_pull._slugify()`. +- **Impure (I/O):** + - `pull_dashboard()` β€” escribe directorio en disco tras el split. + - `load_dashboard_from_disk(dir_path)` β€” lee todos los `tab_*.yaml` + `_dashboard.yaml` y pasa a `merge_dashboard_payload()`. + +### Formato de `_dashboard.yaml` (directory-based) + +```yaml +_meta: + kind: dashboard + id: 734 + slug: bi_ventas_portfolio_producto_en_construccion + synced_at: '2026-04-13T16:04:34Z' + remote_updated_at: '2026-04-09T15:11:20Z' + dashcards_count: 90 + tabs_count: 11 + parameters_count: 26 + split_version: 1 # NEW: marcador de formato splitteado +_refs: + collection: repositorio_central +payload: + archived: false + enable_embedding: false + name: "🚧BI - VENTAS - PORTFOLIO PRODUCTO 🚧 En construccion" + description: ... + width: full + parameters: [...] # 26 parametros + tabs: # lista con nombre + id (para mapear a files) + - id: 191 + name: 5 Min View + file: tab_5_min_view.yaml + - id: 192 + name: Foto Centros - Compara Semana + file: tab_foto_centros_compara_semana.yaml + - ... + dashcards_globales: [] # dashcards SIN dashboard_tab_id (headings root-level) +``` + +### Formato de `tab_{slug}.yaml` + +```yaml +_meta: + kind: dashboard_tab + parent_slug: bi_ventas_portfolio_producto_en_construccion + tab_id: 191 + tab_name: 5 Min View +payload: + dashcards: + - size_x: 6 + size_y: 2 + col: 0 + row: 0 + card: ventas_totales_2 # slug resuelto (como ya lo hace R15) + parameter_mappings: [...] + visualization_settings: {} + series: [] + - ... +``` + +## Tareas + +### Fase 1: Helpers puros + +- [ ] **1.1** Crear `dashboard_split.py` (modulo nuevo) con `split_dashboard_payload()` y `merge_dashboard_payload()`. Input/output puros, sin I/O. +- [ ] **1.2** Implementar `tab_file_slug(tab_name: str) -> str` reusando `_slugify()` de `sync_pull.py`. Prefijo `tab_` obligatorio para reconocer archivos. +- [ ] **1.3** Tests unitarios en `dashboard_split_test.py`: round-trip `split β†’ merge == original`, caso con tabs, sin tabs, con dashcards globales, con parameter_mappings, con series. + +### Fase 2: Pull (escribe directorio) + +- [ ] **2.1** En `sync_pull.py::pull_dashboard()`, tras construir `body`, llamar `split_dashboard_payload(body)` y escribir: + - `dashboards/{slug}/_dashboard.yaml` + - `dashboards/{slug}/tab_{tab_slug}.yaml` por cada tab +- [ ] **2.2** Si existe el legacy `dashboards/{slug}.yaml` en disco, dejarlo sin tocar pero preferir siempre el directory-based al leer (warning de migracion). +- [ ] **2.3** Log de pull imprime los archivos escritos (cantidad de tabs + globales). + +### Fase 3: Push (lee directorio y compone) + +- [ ] **3.1** En `sync_push.py`, resolver path del item dashboard: si existe `dashboards/{slug}/_dashboard.yaml` usar directory-based, sino fallback a `dashboards/{slug}.yaml`. +- [ ] **3.2** Cargar todos los `tab_*.yaml` del directorio. Para cada uno: + - Verificar `_meta.parent_slug == slug` + - Leer `payload.dashcards`, inyectar `dashboard_tab_id` desde el mapeo en `_dashboard.yaml::payload.tabs[*].id` +- [ ] **3.3** Llamar `merge_dashboard_payload()` con `_dashboard.yaml` + todos los `tab_*.yaml` β†’ payload unificado igual al del pull monolitico. +- [ ] **3.4** Todo lo demas (freshness R17, count R18, R6 backup, `--patch` diff) sigue funcionando sin cambios. + +### Fase 4: Validate + diff + +- [ ] **4.1** `sync_validate.py` y `cmd_diff` resuelven el mismo path unificado via un helper `resolve_dashboard_path(project, slug) -> tuple[str, Path]` (`"legacy"|"split"`, path). +- [ ] **4.2** Si es `split`, validar: + - `_dashboard.yaml` existe y tiene `_meta.kind == 'dashboard'` + - Cada `tab_*.yaml` referenciado en `tabs[*].file` existe en el directorio + - Cada archivo de tab tiene `_meta.parent_slug == slug` y `_meta.tab_id` coincide con `_dashboard.yaml` + - No hay archivos `tab_*.yaml` huerfanos (sin entrada en `tabs`) + +### Fase 5: Backward-compat + migracion + +- [ ] **5.1** Flag opcional en `pull`: `--split` (default true en configs nuevos). Variable de config `split_dashboards: true` en `config.yaml` del proyecto. Si `false`, sigue escribiendo monolitico (solo para casos legacy). +- [ ] **5.2** Script `scripts/migrate_dashboards_to_split.py`: itera `dashboards/*.yaml` de un proyecto y los convierte a directory-based (usando `split_dashboard_payload()` sobre el YAML cargado β€” no toca Metabase). +- [ ] **5.3** Probar migracion sobre aurgi: `dashboards/bi_ventas_portfolio_producto_en_construccion.yaml` β†’ directorio. + +### Fase 6: Tests end-to-end + docs + +- [ ] **6.1** Test e2e con dashboard 734 (aurgi): pull (nueva estructura) β†’ commit β†’ editar una celda en `tab_5_min_view.yaml` (p.ej. renombrar description de una dashcard) β†’ `push --patch --apply` β†’ re-pull β†’ diff debe mostrar SOLO la edicion deliberada. +- [ ] **6.2** Actualizar `app.md` seccion "Crear cards y dashboards desde cero" con el nuevo layout de directorio. +- [ ] **6.3** AΓ±adir troubleshooting row a la tabla del readme: "archivo tab_xxx.yaml huerfano" / "tab en `_dashboard.yaml::tabs` sin archivo correspondiente". + +## Ejemplo de uso + +```bash +# Pull bajo nuevo formato +./main.py -p aurgi pull dashboard bi_ventas_portfolio_producto_en_construccion +# [aurgi] pull dashboard bi_ventas_portfolio_producto_en_construccion (id=734) -> +# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/ +# _dashboard.yaml (26 parametros, 11 tabs) +# tab_5_min_view.yaml (7 dashcards) +# tab_foto_centros_compara_semana.yaml (7 dashcards) +# tab_foto_categorias_compara_semana.yaml (9 dashcards) +# ... (8 mas) + +# Edicion quirurgica: abrir solo la tab +$EDITOR projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml + +# Diff acotado +git diff --stat +# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml | 12 ++++++------ + +# Push atomic, solo lo que cambio +./main.py -p aurgi push dashboard bi_ventas_portfolio_producto_en_construccion --patch --apply +# (patch) enviando 1 keys: ['dashcards'] +# aplicado. +``` + +## Decisiones de diseno + +- **Prefijo `tab_` obligatorio** en archivos de tab: permite que `ls dashboards/{slug}/tab_*.yaml` liste exactamente los tabs, separandolos de `_dashboard.yaml`. Sin prefijo seria ambiguo. +- **`_dashboard.yaml` con guion bajo**: se ordena primero alfabeticamente al hacer `ls`, lo cual ayuda a la navegacion. +- **`_meta.split_version: 1`** en `_dashboard.yaml`: abre la puerta a evoluciones del formato sin romper dashboards antiguos. Pull puede detectar versiones antiguas y migrar al vuelo. +- **Mapping `tabs[*].file`** en `_dashboard.yaml` en vez de deducirlo del nombre: protege contra renames manuales y permite nombres de tab con caracteres raros. +- **Dashcards globales en `_dashboard.yaml::payload.dashcards_globales`**: algunos dashboards (sobre todo legacy) tienen dashcards sin `dashboard_tab_id`. Separarlos del array de tabs evita perderlos. +- **NO cambiar el schema del API de Metabase**: merge reconstruye el mismo JSON que el pull monolitico actual. Byte-a-byte equivalente. +- **Legacy YAML monolitico sigue soportado**: un proyecto viejo con `dashboards/x.yaml` sigue pushable. La migracion es opcional / script-assisted. + +## Prerequisitos + +- Commit actual de `sync_pull.py` que preserva `id` en tabs (ya aplicado en esta sesion). +- `state/index.json` con mapeos de slugβ†’id intactos. + +## Riesgos + +| Riesgo | Impacto | Mitigacion | +|---|---|---| +| Dashboard con 2 tabs del mismo nombre | Colision de archivo | Slugify + sufijo `_2`, `_3` como en `_slug_for()`; registrar el file mapping en `_dashboard.yaml::tabs[*].file`, no deducirlo | +| Usuario edita `_dashboard.yaml::tabs` y borra una entrada pero no el archivo `tab_*.yaml` | Archivo huerfano queda en disco | Validate avisa "archivo tab_xxx.yaml huerfano" en fase 4.2 | +| Orden de dashcards en YAML no determinista entre pulls | Diffs falsos en git | Ordenar dashcards por `(row, col)` antes de escribir β€” idempotente | +| Migracion masiva rompe dashboards de otros proyectos | Push falla en `push-all` | Migracion opcional via script, no automatica. Legacy sigue funcionando | +| Dashboard con 0 tabs (solo dashcards root) | Solo `_dashboard.yaml` sin tabs | Soportado via `dashcards_globales`; validate acepta `tabs: []` | +| Rename de tab en Metabase entre pulls | `tab_slug` cambia, archivo anterior queda huerfano | Detectar por `tab_id` en remoto: si coincide pero nombre cambio, renombrar archivo local (no borrar) | + +## Verificacion final + +- [ ] Pull de dashboard 734 (aurgi) genera el directorio con 11 tabs + `_dashboard.yaml` +- [ ] `git diff` de una edicion en 1 tab solo muestra cambios en ese archivo +- [ ] Push roundtrip: pull β†’ push (sin cambios) β†’ pull β†’ diff vacio +- [ ] Legacy monolitico (`dashboards/x.yaml` antiguo) sigue validable y pushable +- [ ] `app.md` actualizado con el nuevo layout + ejemplo +- [ ] Tests de `dashboard_split` pasan (round-trip + casos edge) diff --git a/dev/issues/README.md b/dev/issues/README.md index 24515adb..f98e7d1f 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -29,3 +29,4 @@ | [0021](0021-crud-generator.md) | CRUD Generator | pendiente | media | feature | β€” | | [0022](0022-init-pipelines.md) | Init Pipelines (scaffolding) | pendiente | alta | feature | β€” | | [0023](completed/0023-testing-utils.md) | Testing Utilities Go | completado | media | feature | β€” | +| [0024](0024-dashboard-yaml-split-por-tab.md) | auto_metabase: split dashboard YAMLs por tab | pendiente | alta | mejora | β€” |