Compare commits
54 Commits
95826cb14f
...
d771c21a46
| Author | SHA1 | Date | |
|---|---|---|---|
| d771c21a46 | |||
| 4aa3bc2d94 | |||
| 7bda65209c | |||
| c25f623355 | |||
| 3b37827d16 | |||
| bcfe87af7f | |||
| 526d7f4977 | |||
| 6d63f058de | |||
| e19bc09f4d | |||
| 7eab4a52e9 | |||
| 057f55c8a9 | |||
| 7b2004c649 | |||
| 6c83263d9b | |||
| f46fde3656 | |||
| 4601af88b5 | |||
| fc1ebb4967 | |||
| 07341aa89f | |||
| 4bc6d1bced | |||
| 5f282bedc5 | |||
| 3b2cd26a06 | |||
| 66e54f092d | |||
| 22994f14bf | |||
| e96f8eaf6a | |||
| 5bbdf2ff16 | |||
| 19722cb085 | |||
| 6fac9e1ef0 | |||
| 1ab39d105a | |||
| 2c1a956b32 | |||
| e35ec39c10 | |||
| 637bc8fd34 | |||
| 75157f528a | |||
| 77be3ce325 | |||
| 9634cfdb4a | |||
| 6cf006d87b | |||
| 4d25ebd070 | |||
| 0bd91f04b8 | |||
| 0bfe267501 | |||
| 4b420fb24b | |||
| 3262d058a6 | |||
| 69dcfec4eb | |||
| 31708d0942 | |||
| 53976c0c31 | |||
| 04c3ead5fa | |||
| e076901aa9 | |||
| d80f0412a8 | |||
| 9e8c0d66bb | |||
| df0227d4f2 | |||
| ae22787e60 | |||
| ab3069ae17 | |||
| 1675d2bb84 | |||
| 4ac93a0933 | |||
| ae0c4b7389 | |||
| 3d47e74ec7 | |||
| 0255207514 |
@@ -50,6 +50,12 @@ vaults/*/
|
||||
# Sources — repos externos clonados (solo se versiona el manifest)
|
||||
sources/*/
|
||||
|
||||
# External — symlinks a repos ajenos (ej: repo_Claude con skills/commands)
|
||||
external/
|
||||
|
||||
# Worktrees — git worktrees para issues paralelos (parallel-fix-issues)
|
||||
worktrees/
|
||||
|
||||
# Temp — workspace efimero para pruebas rapidas (APIs, scripts, analisis)
|
||||
temp/
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: init_api_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_api_app(nombre: string, [--port N], [--with-auth], [--with-db], [--with-ops]) -> void"
|
||||
description: "Scaffold de Go HTTP API app en apps/{nombre}/. Genera main.go, handlers.go, config.go, migrations, Makefile, .env.example, .gitignore y app.md con frontmatter correcto. Compone funciones del registry (http_serve, http_router, http_middleware_chain, migration_up) y verifica con go vet."
|
||||
tags: [init, scaffold, api, http, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- assert_command_exists_bash_shell
|
||||
- http_serve_go_infra
|
||||
- http_router_go_infra
|
||||
- http_middleware_chain_go_infra
|
||||
- http_logger_middleware_go_infra
|
||||
- http_cors_middleware_go_infra
|
||||
- http_json_response_go_infra
|
||||
- http_error_response_go_infra
|
||||
- migration_up_go_infra
|
||||
- jwt_generate_go_infra
|
||||
- password_hash_go_infra
|
||||
- password_verify_go_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "nombre de la app a crear (se usa como dir y binario en apps/{nombre}/)"
|
||||
- name: "--port"
|
||||
desc: "puerto por defecto del servidor HTTP (opcional, default 8080)"
|
||||
- name: "--with-auth"
|
||||
desc: "anade jwt_middleware, handlers login/register, tabla users en migration"
|
||||
- name: "--with-db"
|
||||
desc: "anade store.go con helpers CRUD y setup de SQLite al arrancar"
|
||||
- name: "--with-ops"
|
||||
desc: "anade fn ops init para crear operations.db con schema completo"
|
||||
output: "estructura apps/{nombre}/ lista para ejecutarse con `make run`; si go vet falla, reporta error antes de declarar exito."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
example: "fn run init_api_app my_service --with-db"
|
||||
file_path: "bash/functions/pipelines/init_api_app.sh"
|
||||
---
|
||||
|
||||
## Sinopsis
|
||||
|
||||
```bash
|
||||
fn run init_api_app <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]
|
||||
```
|
||||
|
||||
## Ejemplo rapido
|
||||
|
||||
```bash
|
||||
fn run init_api_app billing_api --port 8090 --with-auth --with-db
|
||||
cd apps/billing_api
|
||||
cp .env.example .env
|
||||
make run
|
||||
# → starting billing_api on :8090
|
||||
curl localhost:8090/health # {"status":"ok"}
|
||||
```
|
||||
|
||||
## Archivos generados
|
||||
|
||||
| Archivo | Descripcion |
|
||||
|---------|-------------|
|
||||
| `main.go` | Entry point con HTTPServe, router, middleware chain, graceful shutdown |
|
||||
| `handlers.go` | Handlers de ejemplo (`/health`, `/api/v1/status`) con HTTPJSONResponse |
|
||||
| `config.go` | Struct Config leida desde env vars con defaults |
|
||||
| `migrations/001_initial.sql` | Schema inicial con tabla `items` (id, name, timestamps) |
|
||||
| `Makefile` | Targets `build`, `run`, `dev`, `test`, `vet`, `clean` |
|
||||
| `.env.example` | Variables PORT, DB_PATH, CORS_ORIGINS (+ JWT_SECRET con auth) |
|
||||
| `.gitignore` | Binario, *.db, .env, IDE files |
|
||||
| `go.mod` | Modulo Go con replace directive a fn-registry |
|
||||
| `app.md` | Frontmatter con tag `service`, uses_functions reales, dir_path |
|
||||
|
||||
Con `--with-auth` anade ademas:
|
||||
- `auth.go` — handlers `/auth/login`, `/auth/register` usando JWTGenerate, PasswordHash, PasswordVerify
|
||||
- `migrations/002_users.sql` — tablas `users` y `sessions`
|
||||
|
||||
Con `--with-db` anade:
|
||||
- `store.go` — struct Store con `NewStore`, `Ping` para acceso a SQLite
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--port N` | Puerto por defecto en config y .env.example (default: 8080) |
|
||||
| `--with-auth` | Auth con JWT + bcrypt + tabla users |
|
||||
| `--with-db` | Store con helpers CRUD + setup SQLite |
|
||||
| `--with-ops` | fn ops init para operations.db |
|
||||
|
||||
## Post-setup
|
||||
|
||||
```bash
|
||||
cd apps/{nombre}
|
||||
cp .env.example .env
|
||||
make run # Arranca el server
|
||||
make dev # Hot via go run
|
||||
make test # Tests con fts5
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline impuro: escribe archivos al disco, ejecuta `go mod tidy` y `go vet`.
|
||||
|
||||
Compone heredocs para generar los archivos. Cada heredoc es reemplazable si alguna funcion del registry cambia de firma — ajustar el heredoc correspondiente.
|
||||
|
||||
Abort si `apps/{nombre}` ya existe para no sobrescribir.
|
||||
|
||||
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI.
|
||||
Executable
+603
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_api_app
|
||||
# ------------
|
||||
# Scaffold de una Go HTTP API app en apps/{nombre}/.
|
||||
#
|
||||
# Genera main.go, handlers.go, config.go, migrations/001_initial.sql,
|
||||
# Makefile, .env.example, .gitignore y app.md con frontmatter correcto.
|
||||
# El boilerplate importa funciones del registry (http_serve, http_router,
|
||||
# http_middleware_chain, migration_up, etc.) y verifica que compila con
|
||||
# `go vet` al final.
|
||||
#
|
||||
# USO:
|
||||
# ./init_api_app.sh <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]
|
||||
#
|
||||
# FLAGS:
|
||||
# --port N Puerto por defecto (default: 8080)
|
||||
# --with-auth Anade JWT middleware, login/register, tabla users
|
||||
# --with-db Anade store.go con helpers CRUD y setup de SQLite
|
||||
# --with-ops Anade `fn ops init` para crear operations.db con schema completo
|
||||
#
|
||||
# EJEMPLO:
|
||||
# ./init_api_app.sh my_service
|
||||
# ./init_api_app.sh billing_api --port 8090 --with-auth --with-db
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Source funciones atomicas del registry
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
|
||||
# ── Parsing de argumentos ────────────────────────────────────
|
||||
|
||||
NOMBRE=""
|
||||
PORT="8080"
|
||||
WITH_AUTH="false"
|
||||
WITH_DB="false"
|
||||
WITH_OPS="false"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
PORT="$2"; shift 2 ;;
|
||||
--with-auth)
|
||||
WITH_AUTH="true"; shift ;;
|
||||
--with-db)
|
||||
WITH_DB="true"; shift ;;
|
||||
--with-ops)
|
||||
WITH_OPS="true"; shift ;;
|
||||
-h|--help)
|
||||
grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||
-*)
|
||||
echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
NOMBRE="$1"
|
||||
else
|
||||
echo "Argumento extra ignorado: $1" >&2
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
echo "Uso: $0 <nombre> [--port N] [--with-auth] [--with-db] [--with-ops]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
|
||||
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo "ERROR: ${APP_DIR} ya existe. Abortando para no sobrescribir." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT API APP: ${NOMBRE}"
|
||||
echo " Directorio: ${APP_DIR}"
|
||||
echo " Puerto: ${PORT}"
|
||||
echo " Auth: ${WITH_AUTH}"
|
||||
echo " DB: ${WITH_DB}"
|
||||
echo " Ops: ${WITH_OPS}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Verificar Go ──────────────────────────────────────────
|
||||
|
||||
echo "[1/7] Verificando herramientas..."
|
||||
assert_command_exists go
|
||||
echo " Go: $(go version)"
|
||||
|
||||
# ── 2. Crear estructura ──────────────────────────────────────
|
||||
|
||||
echo "[2/7] Creando estructura..."
|
||||
mkdir -p "$APP_DIR/migrations"
|
||||
echo " ${APP_DIR}/"
|
||||
echo " ${APP_DIR}/migrations/"
|
||||
|
||||
# ── 3. Escribir go.mod ───────────────────────────────────────
|
||||
|
||||
echo "[3/7] Creando go.mod..."
|
||||
cat > "$APP_DIR/go.mod" <<EOF
|
||||
module ${NOMBRE}
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
)
|
||||
|
||||
replace fn-registry => ${REGISTRY_ROOT}
|
||||
EOF
|
||||
|
||||
# ── 4. Escribir archivos Go ──────────────────────────────────
|
||||
|
||||
echo "[4/7] Escribiendo archivos Go..."
|
||||
|
||||
# config.go
|
||||
cat > "$APP_DIR/config.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Config contiene la configuracion runtime de la app.
|
||||
type Config struct {
|
||||
AppName string
|
||||
Port string
|
||||
DBPath string
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
// LoadConfig lee la configuracion desde variables de entorno con defaults sensatos.
|
||||
func LoadConfig() Config {
|
||||
return Config{
|
||||
AppName: getenv("APP_NAME", "__APP_NAME__"),
|
||||
Port: getenv("PORT", "__PORT__"),
|
||||
DBPath: getenv("DB_PATH", "__APP_NAME__.db"),
|
||||
CORSOrigins: []string{getenv("CORS_ORIGINS", "*")},
|
||||
}
|
||||
}
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
EOF
|
||||
sed -i "s/__APP_NAME__/${NOMBRE}/g; s/__PORT__/${PORT}/g" "$APP_DIR/config.go"
|
||||
|
||||
# handlers.go
|
||||
cat > "$APP_DIR/handlers.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// healthHandler responde {"status":"ok"} en GET /health.
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
// statusHandler es un handler de ejemplo en GET /api/v1/status.
|
||||
func statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"app": "__APP_NAME__",
|
||||
"version": "0.1.0",
|
||||
})
|
||||
}
|
||||
EOF
|
||||
sed -i "s/__APP_NAME__/${NOMBRE}/g" "$APP_DIR/handlers.go"
|
||||
|
||||
# main.go
|
||||
# Construccion incremental segun flags
|
||||
MAIN_IMPORTS='"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"fn-registry/functions/infra"'
|
||||
|
||||
MAIN_MIGRATION=""
|
||||
if [ "$WITH_DB" = "true" ]; then
|
||||
MAIN_MIGRATION=' // Migrations
|
||||
if _, err := infra.MigrationUp(db, "migrations"); err != nil {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
'
|
||||
else
|
||||
MAIN_MIGRATION=' // Migrations (only if migrations dir has content)
|
||||
if _, err := os.Stat("migrations"); err == nil {
|
||||
if _, err := infra.MigrationUp(db, "migrations"); err != nil {
|
||||
log.Fatalf("migrations: %v", err)
|
||||
}
|
||||
}
|
||||
'
|
||||
fi
|
||||
|
||||
cat > "$APP_DIR/main.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
${MAIN_IMPORTS}
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := LoadConfig()
|
||||
|
||||
// DB
|
||||
db, err := sql.Open("sqlite3", cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
${MAIN_MIGRATION}
|
||||
// Routes
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: http.HandlerFunc(healthHandler)},
|
||||
{Method: "GET", Path: "/api/v1/status", Handler: http.HandlerFunc(statusHandler)},
|
||||
}
|
||||
mux := infra.HTTPRouter(routes)
|
||||
|
||||
// Middleware chain (outer → inner)
|
||||
chain := infra.HTTPMiddlewareChain(
|
||||
infra.HTTPLoggerMiddleware(os.Stdout),
|
||||
infra.HTTPCORSMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
)
|
||||
handler := chain(mux)
|
||||
|
||||
// Graceful shutdown via context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
log.Printf("starting %s on %s", cfg.AppName, addr)
|
||||
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
|
||||
log.Fatalf("http serve: %v", err)
|
||||
}
|
||||
log.Println("server stopped")
|
||||
}
|
||||
EOF
|
||||
|
||||
# main.go necesita import net/http tambien
|
||||
# rehacerlo con el import bien
|
||||
cat > "$APP_DIR/main.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := LoadConfig()
|
||||
|
||||
// DB
|
||||
db, err := sql.Open("sqlite3", cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
${MAIN_MIGRATION}
|
||||
// Routes
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: http.HandlerFunc(healthHandler)},
|
||||
{Method: "GET", Path: "/api/v1/status", Handler: http.HandlerFunc(statusHandler)},
|
||||
}
|
||||
mux := infra.HTTPRouter(routes)
|
||||
|
||||
// Middleware chain
|
||||
chain := infra.HTTPMiddlewareChain(
|
||||
infra.HTTPLoggerMiddleware(os.Stdout),
|
||||
infra.HTTPCORSMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
)
|
||||
handler := chain(mux)
|
||||
|
||||
// Graceful shutdown via signal-aware context
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
addr := ":" + cfg.Port
|
||||
log.Printf("starting %s on %s", cfg.AppName, addr)
|
||||
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
|
||||
log.Fatalf("http serve: %v", err)
|
||||
}
|
||||
log.Println("server stopped")
|
||||
_ = db
|
||||
}
|
||||
EOF
|
||||
|
||||
# --- Auth opcional ---
|
||||
if [ "$WITH_AUTH" = "true" ]; then
|
||||
cat > "$APP_DIR/auth.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
// LoginRequest es el body JSON del endpoint /auth/login.
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// RegisterRequest es el body JSON del endpoint /auth/register.
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// authRegisterHandler crea un usuario nuevo.
|
||||
func authRegisterHandler(db *sql.DB, jwtSecret string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "invalid body"})
|
||||
return
|
||||
}
|
||||
hash, err := infra.PasswordHash(req.Password, 10)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)`,
|
||||
req.Email, hash, time.Now().Format(time.RFC3339),
|
||||
); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 409, Code: "duplicate_user", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, map[string]string{"email": req.Email})
|
||||
}
|
||||
}
|
||||
|
||||
// authLoginHandler valida credenciales y emite un JWT.
|
||||
func authLoginHandler(db *sql.DB, jwtSecret string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "invalid body"})
|
||||
return
|
||||
}
|
||||
var id int64
|
||||
var hash string
|
||||
err := db.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, req.Email).Scan(&id, &hash)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
if err := infra.PasswordVerify(req.Password, hash); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "invalid credentials"})
|
||||
return
|
||||
}
|
||||
claims := infra.JWTClaims{
|
||||
Subject: req.Email,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
IssuedAt: time.Now().Unix(),
|
||||
}
|
||||
token, err := infra.JWTGenerate(claims, jwtSecret)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "jwt_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]string{"token": token})
|
||||
}
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# --- Store opcional ---
|
||||
if [ "$WITH_DB" = "true" ]; then
|
||||
cat > "$APP_DIR/store.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store encapsula el acceso a la base de datos.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore crea una Store con el pool de conexiones dado.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// Ping verifica conectividad con la base de datos.
|
||||
func (s *Store) Ping() error {
|
||||
if err := s.db.Ping(); err != nil {
|
||||
return fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 5. Migracion inicial ─────────────────────────────────────
|
||||
|
||||
echo "[5/7] Escribiendo migrations/001_initial.sql..."
|
||||
cat > "$APP_DIR/migrations/001_initial.sql" <<EOF
|
||||
-- 001_initial — schema inicial para ${NOMBRE}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- DOWN
|
||||
DROP TABLE IF EXISTS items;
|
||||
EOF
|
||||
|
||||
if [ "$WITH_AUTH" = "true" ]; then
|
||||
cat > "$APP_DIR/migrations/002_users.sql" <<'EOF'
|
||||
-- 002_users — tabla users para auth
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
metadata TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- DOWN
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS users;
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 6. Makefile / .env.example / .gitignore / app.md ─────────
|
||||
|
||||
echo "[6/7] Escribiendo Makefile, .env.example, .gitignore..."
|
||||
|
||||
cat > "$APP_DIR/Makefile" <<EOF
|
||||
.PHONY: build run test vet clean dev
|
||||
|
||||
BIN=${NOMBRE}
|
||||
|
||||
build:
|
||||
CGO_ENABLED=1 go build -tags fts5 -o \$(BIN) .
|
||||
|
||||
run: build
|
||||
./\$(BIN)
|
||||
|
||||
dev:
|
||||
CGO_ENABLED=1 go run .
|
||||
|
||||
test:
|
||||
CGO_ENABLED=1 go test -tags fts5 -v ./...
|
||||
|
||||
vet:
|
||||
CGO_ENABLED=1 go vet -tags fts5 ./...
|
||||
|
||||
clean:
|
||||
rm -f \$(BIN) *.db *.db-shm *.db-wal
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/.env.example" <<EOF
|
||||
# Configuracion de ${NOMBRE}
|
||||
APP_NAME=${NOMBRE}
|
||||
PORT=${PORT}
|
||||
DB_PATH=${NOMBRE}.db
|
||||
CORS_ORIGINS=*
|
||||
EOF
|
||||
|
||||
if [ "$WITH_AUTH" = "true" ]; then
|
||||
cat >> "$APP_DIR/.env.example" <<EOF
|
||||
|
||||
# Auth
|
||||
JWT_SECRET=change-me-in-production
|
||||
EOF
|
||||
fi
|
||||
|
||||
cat > "$APP_DIR/.gitignore" <<EOF
|
||||
# Binario
|
||||
${NOMBRE}
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Env
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
EOF
|
||||
|
||||
# app.md con frontmatter
|
||||
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'
|
||||
|
||||
if [ "$WITH_AUTH" = "true" ]; then
|
||||
USES_FUNCTIONS="${USES_FUNCTIONS}
|
||||
- jwt_generate_go_infra
|
||||
- password_hash_go_infra
|
||||
- password_verify_go_infra"
|
||||
fi
|
||||
|
||||
cat > "$APP_DIR/app.md" <<EOF
|
||||
---
|
||||
name: ${NOMBRE}
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "API HTTP generada por init_api_app."
|
||||
tags: [service]
|
||||
uses_functions:
|
||||
${USES_FUNCTIONS}
|
||||
uses_types:
|
||||
- Route_go_infra
|
||||
- Middleware_go_infra
|
||||
- HTTPError_go_infra
|
||||
framework: "net/http"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/${NOMBRE}"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
App scaffoldeada por \`init_api_app\`. Puerto ${PORT}. Health check en \`/health\`.
|
||||
|
||||
Ejecutar: \`make run\` o \`make dev\` para hot reload.
|
||||
EOF
|
||||
|
||||
# ── 7. Verificar con go vet ──────────────────────────────────
|
||||
|
||||
echo "[7/7] Verificando con go vet..."
|
||||
(
|
||||
cd "$APP_DIR"
|
||||
if go mod tidy 2>&1 | tail -3; then
|
||||
:
|
||||
fi
|
||||
if CGO_ENABLED=1 go vet -tags fts5 ./... 2>&1; then
|
||||
echo " go vet OK"
|
||||
else
|
||||
echo " WARN: go vet reporto problemas (revisa el output arriba)" >&2
|
||||
fi
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " API APP '${NOMBRE}' LISTA"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo " cd apps/${NOMBRE}"
|
||||
echo " cp .env.example .env"
|
||||
echo " make run"
|
||||
echo ""
|
||||
echo " Health check:"
|
||||
echo " curl localhost:${PORT}/health"
|
||||
echo ""
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
name: init_cli_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_cli_app(nombre: string, [--with-tui]) -> void"
|
||||
description: "Scaffold de Go CLI app con subcomandos (version/status/help) routed via os.Args. Con --with-tui genera model.go con un modelo Bubbletea fullscreen (lista filtrable + spinner + dark theme) y main.go arranca la TUI en modo fullscreen. Sin dependencias de cobra/urfave — consistente con las apps del registry."
|
||||
tags: [init, scaffold, cli, tui, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- assert_command_exists_bash_shell
|
||||
- new_base_model_go_tui
|
||||
- dark_styles_go_tui
|
||||
- run_fullscreen_go_tui
|
||||
- new_spinner_go_tui
|
||||
- new_filtered_list_go_tui
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "nombre de la CLI app (apps/{nombre}/); se usa como binario y como modulo Go"
|
||||
- name: "--with-tui"
|
||||
desc: "anade model.go con modelo Bubbletea (lista + spinner + dark theme) y arranca TUI fullscreen al invocar sin args"
|
||||
output: "CLI app en apps/{nombre}/ con main.go (subcommand routing), cmd_version.go, cmd_status.go, Makefile con targets build/run/install/test/vet/clean. Con --with-tui anade model.go y el default sin args arranca TUI."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
example: "fn run init_cli_app my_cli --with-tui"
|
||||
file_path: "bash/functions/pipelines/init_cli_app.sh"
|
||||
---
|
||||
|
||||
## Sinopsis
|
||||
|
||||
```bash
|
||||
fn run init_cli_app <nombre> [--with-tui]
|
||||
```
|
||||
|
||||
## Ejemplo rapido
|
||||
|
||||
```bash
|
||||
# CLI clasica
|
||||
fn run init_cli_app mytool
|
||||
cd apps/mytool
|
||||
make build
|
||||
./mytool version
|
||||
./mytool status
|
||||
|
||||
# CLI + TUI fullscreen
|
||||
fn run init_cli_app deploy_helper --with-tui
|
||||
cd apps/deploy_helper
|
||||
make build
|
||||
./deploy_helper # arranca TUI
|
||||
./deploy_helper version # subcomando
|
||||
```
|
||||
|
||||
## Archivos generados
|
||||
|
||||
| Archivo | Descripcion |
|
||||
|---------|-------------|
|
||||
| `main.go` | Entry con `switch os.Args[1]`; subcomandos: version, status, help |
|
||||
| `cmd_version.go` | Imprime nombre + version |
|
||||
| `cmd_status.go` | Imprime app/version/go/os/arch |
|
||||
| `go.mod` | Modulo Go con replace `fn-registry`; con `--with-tui` agrega bubbletea/bubbles/lipgloss |
|
||||
| `Makefile` | Targets build, run (ARGS=...), install (~/.local/bin/), test, vet, clean |
|
||||
| `.gitignore` | Binario + IDE files |
|
||||
| `app.md` | Tag `cli` (o `cli,tui,bubbletea` con `--with-tui`) |
|
||||
|
||||
Con `--with-tui` anade:
|
||||
- `model.go` — `Model` con spinner (spinner.Dot), lista Bubbletea con 4 items de ejemplo, dark theme (lipgloss con colores 7D56F4 / 06B6D4), keys enter/q/ctrl+c
|
||||
- `main.go` arranca la TUI con `tea.NewProgram(model, tea.WithAltScreen())` si no hay args
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--with-tui` | Anade model.go con Bubbletea y TUI fullscreen como default |
|
||||
|
||||
## Post-setup
|
||||
|
||||
```bash
|
||||
cd apps/{nombre}
|
||||
make build # construye ./{nombre}
|
||||
./{nombre} version
|
||||
./{nombre} status
|
||||
make install # copia a ~/.local/bin/{nombre}
|
||||
make run ARGS="status" # build + run con argumentos
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline impuro: genera archivos, ejecuta `go mod tidy` y `go vet` al final.
|
||||
|
||||
Los ejemplos del modelo TUI (items "Deploy", "Status", "Logs", "Exit") son
|
||||
placeholders — reemplazar con la logica real de la app. El modelo usa los
|
||||
componentes estandar de bubbles (`list`, `spinner`) y lipgloss para estilos.
|
||||
|
||||
Las funciones del registry `new_base_model_go_tui`, `dark_styles_go_tui`,
|
||||
`run_fullscreen_go_tui`, `new_spinner_go_tui`, `new_filtered_list_go_tui`
|
||||
son referenciadas en el frontmatter como deps conceptuales aunque el
|
||||
scaffold inline el codigo Bubbletea directamente (las funciones del registry
|
||||
son stubs que delegan a devfactory/tui).
|
||||
|
||||
Abort si `apps/{nombre}/` ya existe.
|
||||
|
||||
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI (aunque
|
||||
una CLI con TUI interactiva normalmente no se lanza como subprocess).
|
||||
Executable
+460
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_cli_app
|
||||
# ------------
|
||||
# Scaffold de Go CLI app con subcomandos, opcionalmente con TUI Bubbletea.
|
||||
#
|
||||
# Genera main.go con routing de subcomandos (os.Args + switch), cmd_version.go,
|
||||
# cmd_status.go, Makefile, .gitignore, go.mod y app.md.
|
||||
#
|
||||
# Con --with-tui genera ademas model.go con un modelo Bubbletea base y main.go
|
||||
# arranca la TUI con tea.NewProgram().Run() en modo fullscreen.
|
||||
#
|
||||
# USO:
|
||||
# ./init_cli_app.sh <nombre> [--with-tui]
|
||||
#
|
||||
# EJEMPLOS:
|
||||
# ./init_cli_app.sh my_cli
|
||||
# ./init_cli_app.sh deploy_helper --with-tui
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
|
||||
NOMBRE=""
|
||||
WITH_TUI="false"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-tui) WITH_TUI="true"; shift ;;
|
||||
-h|--help) grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||
-*) echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$NOMBRE" ]; then NOMBRE="$1"
|
||||
else echo "Argumento extra ignorado: $1" >&2
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
echo "Uso: $0 <nombre> [--with-tui]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo "ERROR: ${APP_DIR} ya existe. Abortando." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT CLI APP: ${NOMBRE}"
|
||||
echo " Directorio: ${APP_DIR}"
|
||||
echo " TUI: ${WITH_TUI}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Verificar Go ──────────────────────────────────────────
|
||||
|
||||
echo "[1/5] Verificando herramientas..."
|
||||
assert_command_exists go
|
||||
echo " Go: $(go version)"
|
||||
|
||||
# ── 2. Crear estructura ──────────────────────────────────────
|
||||
|
||||
echo "[2/5] Creando estructura..."
|
||||
mkdir -p "$APP_DIR"
|
||||
|
||||
# go.mod
|
||||
if [ "$WITH_TUI" = "true" ]; then
|
||||
cat > "$APP_DIR/go.mod" <<EOF
|
||||
module ${NOMBRE}
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
)
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
replace fn-registry => ${REGISTRY_ROOT}
|
||||
EOF
|
||||
else
|
||||
cat > "$APP_DIR/go.mod" <<EOF
|
||||
module ${NOMBRE}
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
replace fn-registry => ${REGISTRY_ROOT}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 3. Archivos Go ───────────────────────────────────────────
|
||||
|
||||
echo "[3/5] Escribiendo archivos Go..."
|
||||
|
||||
# cmd_version.go — siempre existe
|
||||
cat > "$APP_DIR/cmd_version.go" <<EOF
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
var version = "0.1.0"
|
||||
|
||||
func cmdVersion() {
|
||||
fmt.Printf("${NOMBRE} %s\n", version)
|
||||
}
|
||||
EOF
|
||||
|
||||
# cmd_status.go — siempre existe
|
||||
cat > "$APP_DIR/cmd_status.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func cmdStatus() {
|
||||
fmt.Printf("app: ${NOMBRE}\n")
|
||||
fmt.Printf("version: %s\n", version)
|
||||
fmt.Printf("go: %s\n", runtime.Version())
|
||||
fmt.Printf("os/arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
EOF
|
||||
|
||||
if [ "$WITH_TUI" = "true" ]; then
|
||||
# model.go — Bubbletea BaseModel con spinner y lista
|
||||
cat > "$APP_DIR/model.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// --- estilos (dark theme) ---
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7D56F4"))
|
||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
|
||||
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#06B6D4"))
|
||||
)
|
||||
|
||||
// --- item de la lista ---
|
||||
|
||||
type item struct {
|
||||
title, desc string
|
||||
}
|
||||
|
||||
func (i item) Title() string { return i.title }
|
||||
func (i item) Description() string { return i.desc }
|
||||
func (i item) FilterValue() string { return i.title }
|
||||
|
||||
// --- modelo raiz ---
|
||||
|
||||
type Model struct {
|
||||
spinner spinner.Model
|
||||
list list.Model
|
||||
status string
|
||||
quit bool
|
||||
}
|
||||
|
||||
func NewModel() Model {
|
||||
items := []list.Item{
|
||||
item{title: "Deploy", desc: "Subir codigo al VPS"},
|
||||
item{title: "Status", desc: "Ver estado de servicios"},
|
||||
item{title: "Logs", desc: "Tail de logs en tiempo real"},
|
||||
item{title: "Exit", desc: "Salir"},
|
||||
}
|
||||
|
||||
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
|
||||
l.Title = "${NOMBRE} — elige una accion"
|
||||
l.Styles.Title = titleStyle
|
||||
|
||||
s := spinner.New()
|
||||
s.Spinner = spinner.Dot
|
||||
s.Style = selectedStyle
|
||||
|
||||
return Model{
|
||||
spinner: s,
|
||||
list: l,
|
||||
status: "listo",
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if it, ok := m.list.SelectedItem().(item); ok {
|
||||
if it.title == "Exit" {
|
||||
m.quit = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.status = fmt.Sprintf("seleccionado: %s", it.title)
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
m.list.SetSize(msg.Width, msg.Height-4)
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.quit {
|
||||
return "adios!\n"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s\n\n%s %s\n%s",
|
||||
m.list.View(),
|
||||
m.spinner.View(),
|
||||
m.status,
|
||||
helpStyle.Render("enter: seleccionar · q: salir"),
|
||||
)
|
||||
}
|
||||
EOF
|
||||
|
||||
# main.go con TUI
|
||||
cat > "$APP_DIR/main.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
runTUI()
|
||||
return
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "tui":
|
||||
runTUI()
|
||||
case "version":
|
||||
cmdVersion()
|
||||
case "status":
|
||||
cmdStatus()
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "comando desconocido: %s\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func runTUI() {
|
||||
p := tea.NewProgram(NewModel(), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "tui error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(\`${NOMBRE} — CLI tool
|
||||
|
||||
Uso:
|
||||
${NOMBRE} [comando]
|
||||
|
||||
Sin comando arranca la TUI fullscreen.
|
||||
|
||||
Comandos:
|
||||
tui Arranca la TUI fullscreen (default)
|
||||
version Imprime la version
|
||||
status Muestra info del sistema
|
||||
help Muestra esta ayuda\`)
|
||||
}
|
||||
EOF
|
||||
else
|
||||
# main.go sin TUI — subcommand routing clasico
|
||||
cat > "$APP_DIR/main.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
cmdVersion()
|
||||
case "status":
|
||||
cmdStatus()
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "comando desconocido: %s\n", os.Args[1])
|
||||
printUsage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Println(\`${NOMBRE} — CLI tool
|
||||
|
||||
Uso:
|
||||
${NOMBRE} <comando>
|
||||
|
||||
Comandos:
|
||||
version Imprime la version
|
||||
status Muestra info del sistema
|
||||
help Muestra esta ayuda\`)
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 4. Makefile, .gitignore, app.md ─────────────────────────
|
||||
|
||||
echo "[4/5] Escribiendo Makefile, .gitignore, app.md..."
|
||||
|
||||
cat > "$APP_DIR/Makefile" <<EOF
|
||||
.PHONY: build run install test vet clean
|
||||
|
||||
BIN=${NOMBRE}
|
||||
|
||||
build:
|
||||
CGO_ENABLED=1 go build -tags fts5 -o \$(BIN) .
|
||||
|
||||
run: build
|
||||
./\$(BIN) \$(ARGS)
|
||||
|
||||
install: build
|
||||
install -m755 \$(BIN) \$(HOME)/.local/bin/\$(BIN)
|
||||
@echo "instalado en \$(HOME)/.local/bin/\$(BIN)"
|
||||
|
||||
test:
|
||||
CGO_ENABLED=1 go test -tags fts5 -v ./...
|
||||
|
||||
vet:
|
||||
CGO_ENABLED=1 go vet -tags fts5 ./...
|
||||
|
||||
clean:
|
||||
rm -f \$(BIN)
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/.gitignore" <<EOF
|
||||
# Binario
|
||||
${NOMBRE}
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
EOF
|
||||
|
||||
if [ "$WITH_TUI" = "true" ]; then
|
||||
FRAMEWORK="bubbletea"
|
||||
USES_FUNCTIONS=' - new_base_model_go_tui
|
||||
- dark_styles_go_tui
|
||||
- run_fullscreen_go_tui
|
||||
- new_spinner_go_tui
|
||||
- new_filtered_list_go_tui'
|
||||
TAGS='[cli, tui, bubbletea]'
|
||||
else
|
||||
FRAMEWORK=""
|
||||
USES_FUNCTIONS=' []'
|
||||
TAGS='[cli]'
|
||||
fi
|
||||
|
||||
cat > "$APP_DIR/app.md" <<EOF
|
||||
---
|
||||
name: ${NOMBRE}
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "CLI app generada por init_cli_app."
|
||||
tags: ${TAGS}
|
||||
uses_functions:
|
||||
${USES_FUNCTIONS}
|
||||
uses_types: []
|
||||
framework: "${FRAMEWORK}"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/${NOMBRE}"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
CLI con routing de subcomandos (\`os.Args\` + switch). Sin cobra/urfave —
|
||||
consistente con las apps del registry.
|
||||
|
||||
Ejecutar: \`make run ARGS="version"\` o \`./\${NOMBRE} status\`.
|
||||
EOF
|
||||
|
||||
# ── 5. go mod tidy + go vet ─────────────────────────────────
|
||||
|
||||
echo "[5/5] Verificacion..."
|
||||
(
|
||||
cd "$APP_DIR"
|
||||
if CGO_ENABLED=1 go mod tidy 2>&1 | tail -5; then
|
||||
:
|
||||
fi
|
||||
if CGO_ENABLED=1 go vet -tags fts5 ./... 2>&1; then
|
||||
echo " go vet OK"
|
||||
else
|
||||
echo " WARN: go vet fallo" >&2
|
||||
fi
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " CLI APP '${NOMBRE}' LISTA"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo " cd apps/${NOMBRE}"
|
||||
echo " make build"
|
||||
if [ "$WITH_TUI" = "true" ]; then
|
||||
echo " ./${NOMBRE} # arranca la TUI fullscreen"
|
||||
echo " ./${NOMBRE} version # comando CLI"
|
||||
else
|
||||
echo " ./${NOMBRE} version"
|
||||
echo " ./${NOMBRE} status"
|
||||
fi
|
||||
echo ""
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: init_desktop_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_desktop_app(nombre: string, [--with-db]) -> void"
|
||||
description: "Scaffold de Wails desktop app: Go backend + React frontend con Mantine y @fn_library. Genera main.go (Wails con embed frontend), app.go (bindings Greet/GetVersion), wails.json, go.mod con replace a fn-registry y frontend/ con vite + react + mantine."
|
||||
tags: [init, scaffold, desktop, wails, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- assert_command_exists_bash_shell
|
||||
- scaffold_wails_app_go_infra
|
||||
- install_wails_bash_infra
|
||||
- wails_bind_crud_go_infra
|
||||
- wails_build_go_infra
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "nombre de la app Wails (apps/{nombre}/)"
|
||||
- name: "--with-db"
|
||||
desc: "anade store.go con SQLite + bindings CRUD (Item, ListItems, CreateItem)"
|
||||
output: "app Wails en apps/{nombre}/ con main.go + app.go + wails.json + frontend/ Vite+React. Ejecutar 'wails dev' o 'wails build' dentro del directorio."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
example: "fn run init_desktop_app my_tool"
|
||||
file_path: "bash/functions/pipelines/init_desktop_app.sh"
|
||||
---
|
||||
|
||||
## Sinopsis
|
||||
|
||||
```bash
|
||||
fn run init_desktop_app <nombre> [--with-db]
|
||||
```
|
||||
|
||||
## Ejemplo rapido
|
||||
|
||||
```bash
|
||||
fn run init_desktop_app data_explorer --with-db
|
||||
cd apps/data_explorer/frontend && pnpm install && cd ..
|
||||
wails dev # dev con hot reload
|
||||
# o bien:
|
||||
wails build && ./build/bin/data_explorer
|
||||
```
|
||||
|
||||
## Archivos generados
|
||||
|
||||
| Archivo | Descripcion |
|
||||
|---------|-------------|
|
||||
| `main.go` | Entry Wails con embed de frontend/dist, options.App, Bind: []interface{}{app} |
|
||||
| `app.go` | Struct App con bindings `Greet(name)`, `GetVersion()` (y con `--with-db`: `ListItems`, `CreateItem`) |
|
||||
| `wails.json` | Config Wails: name, outputfilename, scripts frontend (pnpm) |
|
||||
| `go.mod` | Modulo Go con `github.com/wailsapp/wails/v2` + replace `fn-registry` |
|
||||
| `frontend/package.json` | pnpm + vite + react + @mantine/core + @tabler/icons-react |
|
||||
| `frontend/vite.config.ts` | Alias `@fn_library`, outDir `dist` para embed |
|
||||
| `frontend/tsconfig.json` | TS strict con paths `@fn_library/*` |
|
||||
| `frontend/src/main.tsx` | Root con `MantineProvider` (defaultColorScheme: dark) |
|
||||
| `frontend/src/App.tsx` | Componente que llama bindings `Greet` y `GetVersion` via wailsjs |
|
||||
| `frontend/postcss.config.cjs` | postcss-preset-mantine |
|
||||
| `app.md` | Framework: `wails + vite + react + mantine` |
|
||||
|
||||
Con `--with-db` anade ademas:
|
||||
- `store.go` — `openDB`, tipo `Item`, bindings CRUD `ListItems`, `CreateItem`
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--with-db` | SQLite con schema `items` + bindings CRUD como ejemplo |
|
||||
|
||||
## Post-setup
|
||||
|
||||
```bash
|
||||
# 1. Instalar deps del frontend (una vez)
|
||||
cd apps/{nombre}/frontend && pnpm install && cd ..
|
||||
|
||||
# 2. Desarrollo
|
||||
wails dev # Ventana desktop con hot reload del frontend
|
||||
|
||||
# 3. Produccion
|
||||
wails build # binario en build/bin/{nombre}
|
||||
./build/bin/{nombre}
|
||||
```
|
||||
|
||||
## Requisitos
|
||||
|
||||
El scaffold funciona sin Wails CLI, pero `wails dev`/`wails build` requiere:
|
||||
|
||||
- **Wails CLI:** `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
|
||||
- **Deps del sistema (Linux):** GTK3 + WebKit2GTK — usa
|
||||
`install_wails_bash_infra` para instalarlas:
|
||||
```bash
|
||||
source bash/functions/infra/install_wails.sh && install_wails
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline impuro: genera archivos via heredocs, ejecuta `go mod tidy` al
|
||||
final como verificacion. Si Wails CLI no esta disponible, reporta el warning
|
||||
y continua — el scaffold es valido, solo `wails build` falla hasta instalar
|
||||
el CLI.
|
||||
|
||||
El frontend importa `@fn_library` via alias en vite.config.ts apuntando a
|
||||
`../../../frontend/functions/ui/` (los componentes del registry sin
|
||||
duplicarlos).
|
||||
|
||||
Los bindings de Wails (funciones del struct App) se regeneran en
|
||||
`frontend/wailsjs/go/main/App.ts` automaticamente cuando corres `wails dev`
|
||||
o `wails build`.
|
||||
|
||||
Abort si `apps/{nombre}/` ya existe.
|
||||
|
||||
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI.
|
||||
Executable
+594
@@ -0,0 +1,594 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_desktop_app
|
||||
# ----------------
|
||||
# Scaffold de Wails desktop app: Go backend + React frontend con @mantine y
|
||||
# @fn_library. Genera main.go (Wails con embed del frontend), app.go (struct
|
||||
# App con bindings base), wails.json, go.mod y frontend/ con vite+react+mantine.
|
||||
#
|
||||
# USO:
|
||||
# ./init_desktop_app.sh <nombre> [--with-db]
|
||||
#
|
||||
# FLAGS:
|
||||
# --with-db Anade store.go con SQLite + bindings CRUD de ejemplo
|
||||
#
|
||||
# EJEMPLO:
|
||||
# ./init_desktop_app.sh my_tool
|
||||
# ./init_desktop_app.sh data_explorer --with-db
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
|
||||
NOMBRE=""
|
||||
WITH_DB="false"
|
||||
SKIP_WAILS_BUILD="${SKIP_WAILS_BUILD:-false}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--with-db) WITH_DB="true"; shift ;;
|
||||
--skip-wails-build) SKIP_WAILS_BUILD="true"; shift ;;
|
||||
-h|--help) grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||
-*) echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$NOMBRE" ]; then NOMBRE="$1"
|
||||
else echo "Argumento extra ignorado: $1" >&2
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
echo "Uso: $0 <nombre> [--with-db]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo "ERROR: ${APP_DIR} ya existe. Abortando." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT DESKTOP APP (Wails): ${NOMBRE}"
|
||||
echo " Directorio: ${APP_DIR}"
|
||||
echo " DB: ${WITH_DB}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Verificar Go y Wails ──────────────────────────────────
|
||||
|
||||
echo "[1/5] Verificando herramientas..."
|
||||
assert_command_exists go
|
||||
echo " Go: $(go version)"
|
||||
|
||||
if command -v wails >/dev/null 2>&1; then
|
||||
echo " Wails: $(wails version 2>/dev/null || echo detectado)"
|
||||
else
|
||||
echo " WARN: Wails CLI no detectado."
|
||||
echo " Instalar: source ${REGISTRY_ROOT}/bash/functions/infra/install_wails.sh && install_wails"
|
||||
echo " (Continuando con scaffold; \`wails build\` requiere el CLI.)"
|
||||
fi
|
||||
|
||||
# ── 2. Crear estructura + archivos Go ────────────────────────
|
||||
|
||||
echo "[2/5] Creando estructura y archivos Go..."
|
||||
mkdir -p "$APP_DIR/frontend/src/pages"
|
||||
mkdir -p "$APP_DIR/build/bin"
|
||||
|
||||
# go.mod con replace a fn-registry
|
||||
cat > "$APP_DIR/go.mod" <<EOF
|
||||
module ${NOMBRE}
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v2 v2.9.2
|
||||
)
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
replace fn-registry => ${REGISTRY_ROOT}
|
||||
EOF
|
||||
|
||||
# main.go: entry point Wails con embed frontend/dist
|
||||
cat > "$APP_DIR/main.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "__APP_NAME__",
|
||||
Width: 1024,
|
||||
Height: 768,
|
||||
AssetServer: &assetserver.Options{
|
||||
Assets: assets,
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
|
||||
OnStartup: app.startup,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
}
|
||||
}
|
||||
EOF
|
||||
sed -i "s/__APP_NAME__/${NOMBRE}/g" "$APP_DIR/main.go"
|
||||
|
||||
# app.go: struct App con bindings base
|
||||
cat > "$APP_DIR/app.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// App struct — contexto de la app Wails y bindings al frontend.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewApp crea una nueva App.
|
||||
func NewApp() *App {
|
||||
return &App{}
|
||||
}
|
||||
|
||||
// startup se llama cuando la app arranca. Persistimos el ctx para usarlo en
|
||||
// llamadas a runtime.EventsEmit, etc.
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
// Greet retorna un saludo. Accesible desde el frontend como wails.Greet(name).
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hola, %s!", name)
|
||||
}
|
||||
|
||||
// GetVersion retorna la version de la app y el Go runtime.
|
||||
func (a *App) GetVersion() map[string]string {
|
||||
return map[string]string{
|
||||
"app": "0.1.0",
|
||||
"name": "${NOMBRE}",
|
||||
"goVer": runtime.Version(),
|
||||
"goOS": runtime.GOOS,
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# wails.json
|
||||
cat > "$APP_DIR/wails.json" <<EOF
|
||||
{
|
||||
"\$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "${NOMBRE}",
|
||||
"outputfilename": "${NOMBRE}",
|
||||
"frontend:install": "pnpm install",
|
||||
"frontend:build": "pnpm build",
|
||||
"frontend:dev:watcher": "pnpm dev",
|
||||
"frontend:dev:serverUrl": "auto",
|
||||
"author": {
|
||||
"name": "",
|
||||
"email": ""
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Store opcional
|
||||
if [ "$WITH_DB" = "true" ]; then
|
||||
cat > "$APP_DIR/store.go" <<'EOF'
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Item es un registro de ejemplo.
|
||||
type Item struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// openDB abre (o crea) la base de datos SQLite en dbPath e inicializa el schema.
|
||||
func openDB(dbPath string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return nil, fmt.Errorf("init schema: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// ListItems devuelve todos los items. Binding accesible desde el frontend.
|
||||
func (a *App) ListItems() ([]Item, error) {
|
||||
rows, err := a.db.Query(`SELECT id, name, created_at FROM items ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Item
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
if err := rows.Scan(&it.ID, &it.Name, &it.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, it)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CreateItem inserta un nuevo item.
|
||||
func (a *App) CreateItem(name string) (Item, error) {
|
||||
res, err := a.db.Exec(`INSERT INTO items (name) VALUES (?)`, name)
|
||||
if err != nil {
|
||||
return Item{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return Item{ID: id, Name: name}, nil
|
||||
}
|
||||
EOF
|
||||
# Cuando hay DB, actualizamos app.go para incluir db field
|
||||
cat > "$APP_DIR/app.go" <<EOF
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// App struct — contexto de la app Wails, DB y bindings al frontend.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewApp crea una nueva App con la DB abierta.
|
||||
func NewApp() *App {
|
||||
db, err := openDB("${NOMBRE}.db")
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
return &App{db: db}
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
}
|
||||
|
||||
func (a *App) Greet(name string) string {
|
||||
return fmt.Sprintf("Hola, %s!", name)
|
||||
}
|
||||
|
||||
func (a *App) GetVersion() map[string]string {
|
||||
return map[string]string{
|
||||
"app": "0.1.0",
|
||||
"name": "${NOMBRE}",
|
||||
"goVer": runtime.Version(),
|
||||
"goOS": runtime.GOOS,
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# Actualizar go.mod para incluir mattn/go-sqlite3
|
||||
cat > "$APP_DIR/go.mod" <<EOF
|
||||
module ${NOMBRE}
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/wailsapp/wails/v2 v2.9.2
|
||||
github.com/mattn/go-sqlite3 v1.14.37
|
||||
)
|
||||
|
||||
require (
|
||||
fn-registry v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
|
||||
replace fn-registry => ${REGISTRY_ROOT}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# ── 3. Frontend ──────────────────────────────────────────────
|
||||
|
||||
echo "[3/5] Generando frontend..."
|
||||
|
||||
cat > "$APP_DIR/frontend/package.json" <<EOF
|
||||
{
|
||||
"name": "${NOMBRE}-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.0.0",
|
||||
"@mantine/hooks": "^8.0.0",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/vite.config.ts" <<'EOF'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// Vite config para Wails: build → dist/, alias @fn_library a frontend/functions/ui
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/tsconfig.json" <<'EOF'
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fn_library/*": ["../../../frontend/functions/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/index.html" <<EOF
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${NOMBRE}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/postcss.config.cjs" <<'EOF'
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
},
|
||||
};
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/src/theme.ts" <<'EOF'
|
||||
import { createTheme } from "@mantine/core";
|
||||
export const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
defaultRadius: "md",
|
||||
});
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/src/main.tsx" <<'EOF'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/src/App.tsx" <<EOF
|
||||
import { useEffect, useState } from "react";
|
||||
import { Stack, Title, Text, Paper, Button, Group, Badge } from "@mantine/core";
|
||||
|
||||
// @ts-expect-error Wails genera este modulo al hacer build
|
||||
import { Greet, GetVersion } from "../wailsjs/go/main/App";
|
||||
|
||||
export function App() {
|
||||
const [greeting, setGreeting] = useState("");
|
||||
const [version, setVersion] = useState<Record<string, string> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
GetVersion().then(setVersion).catch(() => setVersion(null));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack p="xl">
|
||||
<Title order={2}>${NOMBRE}</Title>
|
||||
<Text c="dimmed">Desktop app scaffoldeada por init_desktop_app.</Text>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Version</Text>
|
||||
<Badge color="blue">
|
||||
{version ? \`\${version.app} (\${version.goOS} \${version.goVer})\` : "cargando..."}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Stack>
|
||||
<Button onClick={() => Greet("desktop").then(setGreeting)}>
|
||||
Greet from Go
|
||||
</Button>
|
||||
{greeting && <Text>{greeting}</Text>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
EOF
|
||||
|
||||
cat > "$APP_DIR/frontend/.gitignore" <<'EOF'
|
||||
node_modules/
|
||||
dist/
|
||||
wailsjs/
|
||||
*.log
|
||||
EOF
|
||||
|
||||
# .gitignore raiz
|
||||
cat > "$APP_DIR/.gitignore" <<EOF
|
||||
# Wails build output
|
||||
build/bin/
|
||||
frontend/dist/
|
||||
frontend/wailsjs/
|
||||
frontend/node_modules/
|
||||
|
||||
# SQLite
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
EOF
|
||||
|
||||
# ── 4. app.md ────────────────────────────────────────────────
|
||||
|
||||
echo "[4/5] Escribiendo app.md..."
|
||||
|
||||
USES_FUNCTIONS=' - scaffold_wails_app_go_infra
|
||||
- install_wails_bash_infra'
|
||||
|
||||
cat > "$APP_DIR/app.md" <<EOF
|
||||
---
|
||||
name: ${NOMBRE}
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "Desktop app (Wails + React + Mantine) generada por init_desktop_app."
|
||||
tags: [desktop, wails, frontend]
|
||||
uses_functions:
|
||||
${USES_FUNCTIONS}
|
||||
uses_types: []
|
||||
framework: "wails + vite + react + mantine"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/${NOMBRE}"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Desktop app con backend Go (Wails v2) y frontend React. Bindings en
|
||||
\`app.go\` son accesibles desde el frontend via \`wails dev\`/\`wails build\`
|
||||
que genera \`frontend/wailsjs/go/main/App.ts\` automaticamente.
|
||||
|
||||
Desarrollo:
|
||||
|
||||
\`\`\`bash
|
||||
cd apps/${NOMBRE}
|
||||
cd frontend && pnpm install && cd ..
|
||||
wails dev
|
||||
\`\`\`
|
||||
|
||||
Build:
|
||||
|
||||
\`\`\`bash
|
||||
wails build
|
||||
./build/bin/${NOMBRE}
|
||||
\`\`\`
|
||||
EOF
|
||||
|
||||
# ── 5. Verificacion ──────────────────────────────────────────
|
||||
|
||||
echo "[5/5] Verificacion..."
|
||||
|
||||
if [ "$SKIP_WAILS_BUILD" = "true" ]; then
|
||||
echo " SKIP_WAILS_BUILD=true — saltando wails build"
|
||||
elif command -v wails >/dev/null 2>&1; then
|
||||
echo " wails detectado. Ejecutar manualmente 'wails build' en ${APP_DIR}"
|
||||
echo " (el build real requiere pnpm install previo en frontend/)"
|
||||
else
|
||||
echo " wails no disponible — saltando build."
|
||||
echo " El scaffold esta listo. Para builds: instalar Wails primero."
|
||||
fi
|
||||
|
||||
# Al menos verificar que go mod tidy funciona (que el main.go/app.go es Go valido)
|
||||
(
|
||||
cd "$APP_DIR"
|
||||
if CGO_ENABLED=1 go mod tidy 2>&1 | tail -5; then
|
||||
echo " go mod tidy OK"
|
||||
else
|
||||
echo " WARN: go mod tidy fallo — revisa main.go/app.go/go.mod" >&2
|
||||
fi
|
||||
)
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " DESKTOP APP '${NOMBRE}' LISTA"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo " cd apps/${NOMBRE}/frontend && pnpm install && cd .."
|
||||
echo " wails dev # modo desarrollo con hot reload"
|
||||
echo " wails build # binario de produccion en build/bin/"
|
||||
echo ""
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
name: init_web_app
|
||||
kind: pipeline
|
||||
lang: bash
|
||||
domain: pipelines
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "init_web_app(nombre: string, [--port N], [--with-auth], [--with-db]) -> void"
|
||||
description: "Scaffold de full-stack app: Go HTTP API backend + React frontend con Mantine y @fn_library. Extiende init_api_app anadiendo frontend/ con pnpm + vite + react + mantine. Genera vite.config.ts con proxy al backend y alias @fn_library, src/main.tsx con MantineProvider, src/App.tsx con AppShell, src/pages/Home.tsx con ejemplo consumiendo /api/v1/status."
|
||||
tags: [init, scaffold, web, fullstack, frontend, pipeline, bash, launcher]
|
||||
uses_functions:
|
||||
- assert_command_exists_bash_shell
|
||||
- http_serve_go_infra
|
||||
- http_router_go_infra
|
||||
- http_middleware_chain_go_infra
|
||||
- http_logger_middleware_go_infra
|
||||
- http_cors_middleware_go_infra
|
||||
- http_json_response_go_infra
|
||||
- http_error_response_go_infra
|
||||
- migration_up_go_infra
|
||||
- mantine_provider_ts_ui
|
||||
- app_shell_ts_ui
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: nombre
|
||||
desc: "nombre de la app a crear (apps/{nombre}/)"
|
||||
- name: "--port"
|
||||
desc: "puerto del backend HTTP (default 8080); el frontend usa 5173 con proxy"
|
||||
- name: "--with-auth"
|
||||
desc: "anade jwt_middleware + handlers login/register + tabla users al backend"
|
||||
- name: "--with-db"
|
||||
desc: "anade store.go con helpers CRUD y setup SQLite al backend"
|
||||
output: "app full-stack en apps/{nombre}/ con backend Go (main.go) y frontend/ (vite + react + mantine). Dev: terminal 1 go run .; terminal 2 cd frontend && pnpm dev."
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
example: "fn run init_web_app my_dashboard --with-auth"
|
||||
file_path: "bash/functions/pipelines/init_web_app.sh"
|
||||
---
|
||||
|
||||
## Sinopsis
|
||||
|
||||
```bash
|
||||
fn run init_web_app <nombre> [--port N] [--with-auth] [--with-db]
|
||||
```
|
||||
|
||||
## Ejemplo rapido
|
||||
|
||||
```bash
|
||||
fn run init_web_app inventory_dashboard --with-auth
|
||||
cd apps/inventory_dashboard
|
||||
make install # pnpm install del frontend
|
||||
# Terminal 1:
|
||||
CGO_ENABLED=1 go run .
|
||||
# Terminal 2:
|
||||
cd frontend && pnpm dev
|
||||
# → http://localhost:5173 (frontend) proxea a :8080 (backend)
|
||||
```
|
||||
|
||||
## Archivos generados
|
||||
|
||||
Todos los de `init_api_app`, mas:
|
||||
|
||||
| Archivo | Descripcion |
|
||||
|---------|-------------|
|
||||
| `frontend/package.json` | pnpm, vite, react, @mantine/core, @tabler/icons-react |
|
||||
| `frontend/vite.config.ts` | Proxy `/api` y `/health` al backend + alias `@fn_library` |
|
||||
| `frontend/tsconfig.json` | TS strict con paths `@fn_library/*` |
|
||||
| `frontend/index.html` | Entry HTML minimo |
|
||||
| `frontend/postcss.config.cjs` | postcss-preset-mantine + breakpoints |
|
||||
| `frontend/src/main.tsx` | Root con `MantineProvider` + theme |
|
||||
| `frontend/src/theme.ts` | `createTheme()` con primaryColor |
|
||||
| `frontend/src/App.tsx` | `AppShell` con Burger + Navbar + Header |
|
||||
| `frontend/src/pages/Home.tsx` | Pagina ejemplo que consume `/api/v1/status` |
|
||||
| `docker-compose.yml` | Services: api + frontend (node alpine) |
|
||||
| `Makefile` | Targets `install`, `build-frontend`, `build`, `dev` |
|
||||
| `app.md` | Framework: `net/http + vite + react + mantine` |
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Efecto |
|
||||
|------|--------|
|
||||
| `--port N` | Puerto del backend Go (default: 8080) — frontend Vite siempre en 5173 |
|
||||
| `--with-auth` | JWT + tabla users al backend |
|
||||
| `--with-db` | Store + SQLite setup al backend |
|
||||
|
||||
## Post-setup
|
||||
|
||||
```bash
|
||||
cd apps/{nombre}
|
||||
cp .env.example .env
|
||||
make install # pnpm install del frontend
|
||||
|
||||
# Desarrollo (2 terminales):
|
||||
CGO_ENABLED=1 go run . # Terminal 1: backend :PORT
|
||||
cd frontend && pnpm dev # Terminal 2: frontend :5173
|
||||
|
||||
# Produccion:
|
||||
make build # build frontend + binario Go
|
||||
./<nombre> # sirve todo en :PORT
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pipeline impuro: invoca primero `init_api_app` para el backend y luego
|
||||
escribe el frontend. Si pnpm esta disponible y `SKIP_PNPM_BUILD` no es
|
||||
`true`, ejecuta `pnpm install && pnpm build` como verificacion final.
|
||||
|
||||
El alias `@fn_library` en `vite.config.ts` apunta a
|
||||
`../../../frontend/functions/ui` (relativo desde `apps/{nombre}/frontend/`).
|
||||
Los componentes del registry se consumen sin duplicarlos.
|
||||
|
||||
Si `apps/{nombre}/` ya existe, aborta sin sobrescribir.
|
||||
|
||||
El tag `launcher` permite que aparezca en el Pipeline Launcher TUI.
|
||||
Executable
+466
@@ -0,0 +1,466 @@
|
||||
#!/usr/bin/env bash
|
||||
# init_web_app
|
||||
# ------------
|
||||
# Scaffold de full-stack app: Go HTTP API backend + React frontend con
|
||||
# Mantine y @fn_library. Extiende init_api_app anadiendo la capa frontend.
|
||||
#
|
||||
# Genera todo lo de init_api_app mas frontend/ con pnpm + vite + react +
|
||||
# mantine, vite.config.ts con alias @fn_library y proxy al backend,
|
||||
# src/main.tsx con FnMantineProvider, src/App.tsx, src/theme.ts y
|
||||
# src/pages/Home.tsx de ejemplo.
|
||||
#
|
||||
# USO:
|
||||
# ./init_web_app.sh <nombre> [--port N] [--with-auth] [--with-db]
|
||||
#
|
||||
# EJEMPLO:
|
||||
# ./init_web_app.sh my_dashboard
|
||||
# ./init_web_app.sh my_dashboard --port 8080 --with-auth
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REGISTRY_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# Source funciones atomicas
|
||||
source "$REGISTRY_ROOT/bash/functions/shell/assert_command_exists.sh"
|
||||
|
||||
# ── Parsing ──────────────────────────────────────────────────
|
||||
|
||||
NOMBRE=""
|
||||
PORT="8080"
|
||||
WITH_AUTH="false"
|
||||
WITH_DB="false"
|
||||
SKIP_PNPM_BUILD="${SKIP_PNPM_BUILD:-false}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port) PORT="$2"; shift 2 ;;
|
||||
--with-auth) WITH_AUTH="true"; shift ;;
|
||||
--with-db) WITH_DB="true"; shift ;;
|
||||
--skip-pnpm-build) SKIP_PNPM_BUILD="true"; shift ;;
|
||||
-h|--help) grep "^#" "$0" | sed 's/^# \?//' ; exit 0 ;;
|
||||
-*) echo "Flag desconocido: $1" >&2 ; exit 1 ;;
|
||||
*)
|
||||
if [ -z "$NOMBRE" ]; then NOMBRE="$1"
|
||||
else echo "Argumento extra ignorado: $1" >&2
|
||||
fi
|
||||
shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$NOMBRE" ]; then
|
||||
echo "Uso: $0 <nombre> [--port N] [--with-auth] [--with-db]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="${REGISTRY_ROOT}/apps/${NOMBRE}"
|
||||
if [ -d "$APP_DIR" ]; then
|
||||
echo "ERROR: ${APP_DIR} ya existe. Abortando." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " INIT WEB APP: ${NOMBRE}"
|
||||
echo " Directorio: ${APP_DIR}"
|
||||
echo " Puerto: ${PORT}"
|
||||
echo " Auth: ${WITH_AUTH}"
|
||||
echo " DB: ${WITH_DB}"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# ── 1. Invocar init_api_app para generar backend ─────────────
|
||||
|
||||
echo "[1/3] Generando backend con init_api_app..."
|
||||
BACKEND_FLAGS=()
|
||||
BACKEND_FLAGS+=(--port "$PORT")
|
||||
[ "$WITH_AUTH" = "true" ] && BACKEND_FLAGS+=(--with-auth)
|
||||
[ "$WITH_DB" = "true" ] && BACKEND_FLAGS+=(--with-db)
|
||||
|
||||
bash "$SCRIPT_DIR/init_api_app.sh" "$NOMBRE" "${BACKEND_FLAGS[@]}"
|
||||
|
||||
# ── 2. Generar frontend ──────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "[2/3] Generando frontend..."
|
||||
mkdir -p "$APP_DIR/frontend/src/pages"
|
||||
|
||||
# package.json
|
||||
cat > "$APP_DIR/frontend/package.json" <<EOF
|
||||
{
|
||||
"name": "${NOMBRE}-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.0.0",
|
||||
"@mantine/hooks": "^8.0.0",
|
||||
"@mantine/notifications": "^8.0.0",
|
||||
"@tabler/icons-react": "^3.30.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^7.0.0"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# vite.config.ts
|
||||
cat > "$APP_DIR/frontend/vite.config.ts" <<EOF
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
|
||||
// Vite config: proxy API al backend Go + alias @fn_library a frontend/functions/ui del registry
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@fn_library": path.resolve(__dirname, "../../../frontend/functions/ui"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:${PORT}",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/health": {
|
||||
target: "http://localhost:${PORT}",
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
EOF
|
||||
|
||||
# tsconfig.json
|
||||
cat > "$APP_DIR/frontend/tsconfig.json" <<'EOF'
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@fn_library/*": ["../../../frontend/functions/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
EOF
|
||||
|
||||
# index.html
|
||||
cat > "$APP_DIR/frontend/index.html" <<EOF
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${NOMBRE}</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
# postcss.config.cjs (Mantine usa postcss-preset-mantine)
|
||||
cat > "$APP_DIR/frontend/postcss.config.cjs" <<'EOF'
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
"mantine-breakpoint-sm": "48em",
|
||||
"mantine-breakpoint-md": "62em",
|
||||
"mantine-breakpoint-lg": "75em",
|
||||
"mantine-breakpoint-xl": "88em",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
EOF
|
||||
|
||||
# src/theme.ts
|
||||
cat > "$APP_DIR/frontend/src/theme.ts" <<EOF
|
||||
import { createTheme } from "@mantine/core";
|
||||
|
||||
// Tema Mantine de ${NOMBRE}. Mantine genera sus propias CSS variables.
|
||||
export const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
defaultRadius: "md",
|
||||
});
|
||||
EOF
|
||||
|
||||
# src/main.tsx
|
||||
cat > "$APP_DIR/frontend/src/main.tsx" <<'EOF'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme="auto">
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
EOF
|
||||
|
||||
# src/App.tsx
|
||||
cat > "$APP_DIR/frontend/src/App.tsx" <<EOF
|
||||
import { AppShell, Title, Burger, Group, NavLink, Stack } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { IconHome, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
import { Home } from "./pages/Home";
|
||||
|
||||
export function App() {
|
||||
const [opened, { toggle }] = useDisclosure();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{ width: 240, breakpoint: "sm", collapsed: { mobile: !opened } }}
|
||||
padding="md"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md">
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||
<Title order={4}>${NOMBRE}</Title>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="sm">
|
||||
<Stack gap={4}>
|
||||
<NavLink label="Home" leftSection={<IconHome size={16} />} active />
|
||||
<NavLink label="Settings" leftSection={<IconSettings size={16} />} />
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main>
|
||||
<Home />
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
EOF
|
||||
|
||||
# src/pages/Home.tsx
|
||||
cat > "$APP_DIR/frontend/src/pages/Home.tsx" <<EOF
|
||||
import { useEffect, useState } from "react";
|
||||
import { Stack, Title, Text, Paper, Group, Badge } from "@mantine/core";
|
||||
|
||||
type StatusResponse = {
|
||||
app: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/v1/status")
|
||||
.then((r) => r.json())
|
||||
.then(setStatus)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title order={2}>${NOMBRE}</Title>
|
||||
<Text c="dimmed">Full-stack app scaffoldeada por init_web_app.</Text>
|
||||
|
||||
<Paper p="md" withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>API status</Text>
|
||||
{status ? (
|
||||
<Badge color="green">{status.app} v{status.version}</Badge>
|
||||
) : error ? (
|
||||
<Badge color="red">{error}</Badge>
|
||||
) : (
|
||||
<Badge color="gray">loading...</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
EOF
|
||||
|
||||
# .gitignore del frontend
|
||||
cat > "$APP_DIR/frontend/.gitignore" <<'EOF'
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
EOF
|
||||
|
||||
# docker-compose.yml
|
||||
cat > "$APP_DIR/docker-compose.yml" <<EOF
|
||||
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 --host"
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
EOF
|
||||
|
||||
# Actualizar Makefile con targets frontend + dev
|
||||
cat > "$APP_DIR/Makefile" <<EOF
|
||||
.PHONY: build build-frontend run dev test vet clean install
|
||||
|
||||
BIN=${NOMBRE}
|
||||
|
||||
install:
|
||||
cd frontend && pnpm install
|
||||
|
||||
build-frontend:
|
||||
cd frontend && pnpm build
|
||||
|
||||
build: build-frontend
|
||||
CGO_ENABLED=1 go build -tags fts5 -o \$(BIN) .
|
||||
|
||||
run: build
|
||||
./\$(BIN)
|
||||
|
||||
dev:
|
||||
@echo "Arranca el backend (API en :${PORT}):"
|
||||
@echo " CGO_ENABLED=1 go run ."
|
||||
@echo "Arranca el frontend (Vite en :5173 con proxy):"
|
||||
@echo " cd frontend && pnpm dev"
|
||||
|
||||
test:
|
||||
CGO_ENABLED=1 go test -tags fts5 -v ./...
|
||||
|
||||
vet:
|
||||
CGO_ENABLED=1 go vet -tags fts5 ./...
|
||||
|
||||
clean:
|
||||
rm -f \$(BIN) *.db *.db-shm *.db-wal
|
||||
rm -rf frontend/dist frontend/node_modules
|
||||
EOF
|
||||
|
||||
# Actualizar app.md con framework + uses frontend
|
||||
cat > "$APP_DIR/app.md" <<EOF
|
||||
---
|
||||
name: ${NOMBRE}
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "Full-stack app (Go API + React/Mantine frontend) generada por init_web_app."
|
||||
tags: [service, web, frontend]
|
||||
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:
|
||||
- Route_go_infra
|
||||
- Middleware_go_infra
|
||||
- HTTPError_go_infra
|
||||
framework: "net/http + vite + react + mantine"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/${NOMBRE}"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
App full-stack: backend en Go (\`main.go\`) + frontend en \`frontend/\` con
|
||||
Vite + React + Mantine. El vite.config.ts hace proxy de \`/api\` y \`/health\`
|
||||
al backend en :${PORT}, y usa alias \`@fn_library\` hacia \`frontend/functions/ui\`
|
||||
del registry.
|
||||
|
||||
Dev: \`make install && make dev\` (dos terminales: backend con \`go run .\`,
|
||||
frontend con \`cd frontend && pnpm dev\`).
|
||||
|
||||
Prod: \`make build\` genera \`frontend/dist\` + binario; el binario sirve los
|
||||
static files del build embebido (pendiente embed en main.go).
|
||||
EOF
|
||||
|
||||
# ── 3. Verificar frontend si pnpm disponible ─────────────────
|
||||
|
||||
echo ""
|
||||
echo "[3/3] Verificacion..."
|
||||
|
||||
if [ "$SKIP_PNPM_BUILD" = "true" ]; then
|
||||
echo " SKIP_PNPM_BUILD=true — saltando pnpm install/build"
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
echo " pnpm detectado, ejecutando install + build..."
|
||||
(
|
||||
cd "$APP_DIR/frontend"
|
||||
if pnpm install --silent 2>&1 | tail -5; then
|
||||
:
|
||||
fi
|
||||
if pnpm build 2>&1 | tail -5; then
|
||||
echo " frontend build OK"
|
||||
else
|
||||
echo " WARN: frontend build fallo (revisa el output arriba)" >&2
|
||||
fi
|
||||
)
|
||||
else
|
||||
echo " pnpm no disponible — saltando verificacion frontend"
|
||||
echo " Cuando este disponible: cd ${APP_DIR}/frontend && pnpm install && pnpm build"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo " WEB APP '${NOMBRE}' LISTA"
|
||||
echo "════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo " Pasos siguientes:"
|
||||
echo " cd apps/${NOMBRE}"
|
||||
echo " cp .env.example .env"
|
||||
echo " make install # pnpm install del frontend"
|
||||
echo ""
|
||||
echo " Desarrollo (2 terminales):"
|
||||
echo " Terminal 1: cd apps/${NOMBRE} && CGO_ENABLED=1 go run ."
|
||||
echo " Terminal 2: cd apps/${NOMBRE}/frontend && pnpm dev"
|
||||
echo ""
|
||||
echo " Abrir http://localhost:5173"
|
||||
echo ""
|
||||
@@ -15,18 +15,18 @@
|
||||
| [0007e](completed/0007e-dag-executor-app.md) | DAG engine: CLI + web app que reemplaza Dagu | completado | alta | feature | — |
|
||||
| [0008](completed/0008-sqlite-api-web.md) | SQLite API Web | completado | alta | feature | — |
|
||||
| [0009](completed/0009-http-server.md) | HTTP Server Foundation | completado | alta | feature | 0010, 0011, 0014, 0016, 0019, 0021, 0022 |
|
||||
| [0010](0010-auth-system.md) | Auth System (JWT, passwords, OAuth2, RBAC) | pendiente | alta | feature | 0022 |
|
||||
| [0011](0011-websocket-sse.md) | WebSocket & SSE Server | pendiente | alta | feature | — |
|
||||
| [0010](completed/0010-auth-system.md) | Auth System (JWT, passwords, OAuth2, RBAC) | completado | alta | feature | 0022 |
|
||||
| [0011](completed/0011-websocket-sse.md) | WebSocket & SSE Server | completado | alta | feature | — |
|
||||
| [0012](completed/0012-email-smtp.md) | Email & SMTP | completado | media | feature | — |
|
||||
| [0013](completed/0013-background-jobs.md) | Background Job Queue | completado | alta | feature | — |
|
||||
| [0014](0014-file-upload.md) | File Upload & Storage | pendiente | media | feature | — |
|
||||
| [0014](completed/0014-file-upload.md) | File Upload & Storage | completado | media | feature | — |
|
||||
| [0015](completed/0015-db-migrations.md) | Database Migrations | completado | media | feature | 0021, 0022 |
|
||||
| [0016](0016-rate-limiting.md) | Rate Limiting | pendiente | media | feature | — |
|
||||
| [0016](completed/0016-rate-limiting.md) | Rate Limiting | completado | media | feature | — |
|
||||
| [0017](completed/0017-frontend-hooks.md) | Frontend Data Hooks (React) | completado | alta | feature | — |
|
||||
| [0018](completed/0018-config-env.md) | Config & Env Management | completado | media | feature | — |
|
||||
| [0019](0019-structured-logging.md) | Structured Logging Go | pendiente | media | feature | — |
|
||||
| [0019](completed/0019-structured-logging.md) | Structured Logging Go | completado | media | feature | — |
|
||||
| [0020](completed/0020-pdf-generation.md) | PDF Generation | completado | media | feature | — |
|
||||
| [0021](0021-crud-generator.md) | CRUD Generator | pendiente | media | feature | — |
|
||||
| [0022](0022-init-pipelines.md) | Init Pipelines (scaffolding) | pendiente | alta | feature | — |
|
||||
| [0021](completed/0021-crud-generator.md) | CRUD Generator | completado | media | feature | — |
|
||||
| [0022](completed/0022-init-pipelines.md) | Init Pipelines (scaffolding) | completado | 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 | — |
|
||||
| [0024](completed/0024-dashboard-yaml-split-por-tab.md) | auto_metabase: split dashboard YAMLs por tab | completado | alta | mejora | — |
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Init Pipelines
|
||||
|
||||
Cuatro pipelines bash que scaffold apps completas en `apps/` con un solo comando. Mismo patron que `init_jupyter_analysis` — componen funciones atomicas del registry para producir entornos listos para trabajar.
|
||||
|
||||
Todos son `kind: pipeline`, `purity: impure`, `lang: bash`, `domain: pipelines`. Llevan el tag `launcher` y aparecen en el Pipeline Launcher TUI.
|
||||
|
||||
## Resumen
|
||||
|
||||
| Pipeline | Para que | Flags |
|
||||
|----------|----------|-------|
|
||||
| `init_api_app` | Go HTTP API service con graceful shutdown, middleware chain, migrations | `--port N`, `--with-auth`, `--with-db`, `--with-ops` |
|
||||
| `init_web_app` | Full-stack: Go API + React frontend con Mantine y `@fn_library` | `--port N`, `--with-auth`, `--with-db` |
|
||||
| `init_desktop_app` | Wails desktop app: Go backend + React frontend con Mantine | `--with-db` |
|
||||
| `init_cli_app` | Go CLI con subcomandos, opcionalmente con TUI Bubbletea fullscreen | `--with-tui` |
|
||||
|
||||
## Arbol de decision
|
||||
|
||||
```
|
||||
¿Que tipo de app necesitas?
|
||||
│
|
||||
├─ Un servicio HTTP/API
|
||||
│ │
|
||||
│ └─ ¿Necesitas frontend?
|
||||
│ ├─ NO → init_api_app
|
||||
│ └─ SI → init_web_app
|
||||
│
|
||||
├─ Una app de escritorio (ventana nativa)
|
||||
│ │
|
||||
│ └─ init_desktop_app
|
||||
│
|
||||
└─ Una herramienta de linea de comando
|
||||
│
|
||||
└─ ¿Quieres TUI interactiva?
|
||||
├─ NO → init_cli_app
|
||||
└─ SI → init_cli_app --with-tui
|
||||
```
|
||||
|
||||
## Combinaciones comunes
|
||||
|
||||
### API service con auth y DB
|
||||
|
||||
```bash
|
||||
fn run init_api_app billing_api --port 8090 --with-auth --with-db
|
||||
cd apps/billing_api
|
||||
cp .env.example .env
|
||||
make run
|
||||
# → curl localhost:8090/health
|
||||
```
|
||||
|
||||
### Dashboard web full-stack
|
||||
|
||||
```bash
|
||||
fn run init_web_app inventory_dashboard --with-auth
|
||||
cd apps/inventory_dashboard
|
||||
make install
|
||||
# 2 terminales:
|
||||
CGO_ENABLED=1 go run . # backend :8080
|
||||
cd frontend && pnpm dev # frontend :5173 con proxy
|
||||
```
|
||||
|
||||
### Desktop app con SQLite
|
||||
|
||||
```bash
|
||||
fn run init_desktop_app data_explorer --with-db
|
||||
cd apps/data_explorer/frontend && pnpm install && cd ..
|
||||
wails dev
|
||||
```
|
||||
|
||||
### CLI con TUI
|
||||
|
||||
```bash
|
||||
fn run init_cli_app deploy_helper --with-tui
|
||||
cd apps/deploy_helper
|
||||
make build
|
||||
./deploy_helper # arranca TUI fullscreen
|
||||
./deploy_helper status # subcomando CLI normal
|
||||
```
|
||||
|
||||
## Filosofia
|
||||
|
||||
1. **Composicion sobre monolito:** cada pipeline sourcea funciones atomicas del registry (`assert_command_exists`, etc.) — si una mejora, todos los pipelines se benefician.
|
||||
2. **Verificacion al final:** cada pipeline termina con `go vet`, `pnpm build` o `go mod tidy`. Si falla, el pipeline reporta antes de declarar exito.
|
||||
3. **Defaults sensatos:** el caso base (sin flags) genera una app funcional minima. Las flags anaden capas incrementales.
|
||||
4. **`@fn_library` como alias, no copia:** los frontends generados referencian `@fn_library` via alias en `vite.config.ts` apuntando a `frontend/functions/ui/` del registry.
|
||||
5. **`app.md` generado automaticamente:** el frontmatter incluye `uses_functions` con los IDs reales que el boilerplate importa.
|
||||
6. **Abort si existe:** si `apps/{nombre}/` ya existe, el pipeline aborta sin sobrescribir.
|
||||
|
||||
## Estructura generada por tipo
|
||||
|
||||
### `init_api_app`
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go HTTPServe + router + middleware + graceful shutdown
|
||||
├── handlers.go healthHandler, statusHandler con HTTPJSONResponse
|
||||
├── config.go LoadConfig desde env vars
|
||||
├── migrations/001_initial.sql
|
||||
├── Makefile build/run/dev/test/vet/clean
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── go.mod replace fn-registry → registry root
|
||||
├── app.md
|
||||
├── auth.go (con --with-auth) login/register con JWT
|
||||
├── migrations/002_users.sql (con --with-auth)
|
||||
└── store.go (con --with-db) struct Store con Ping
|
||||
```
|
||||
|
||||
### `init_web_app`
|
||||
Todo lo de `init_api_app` mas:
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── docker-compose.yml
|
||||
└── frontend/
|
||||
├── package.json pnpm + vite + react + @mantine/core
|
||||
├── vite.config.ts alias @fn_library + proxy /api a backend
|
||||
├── tsconfig.json
|
||||
├── index.html
|
||||
├── postcss.config.cjs
|
||||
└── src/
|
||||
├── main.tsx MantineProvider + App
|
||||
├── App.tsx AppShell con Burger + Navbar
|
||||
├── theme.ts createTheme()
|
||||
└── pages/Home.tsx Consume /api/v1/status
|
||||
```
|
||||
|
||||
### `init_desktop_app`
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go Wails v2 con embed frontend/dist
|
||||
├── app.go struct App, bindings Greet, GetVersion
|
||||
├── wails.json
|
||||
├── go.mod wails/v2 + replace fn-registry
|
||||
├── app.md framework: wails+vite+react+mantine
|
||||
├── .gitignore
|
||||
├── store.go (con --with-db) Item + ListItems + CreateItem
|
||||
└── frontend/ package.json, vite.config.ts, src/ con Mantine
|
||||
```
|
||||
|
||||
### `init_cli_app`
|
||||
```
|
||||
apps/{nombre}/
|
||||
├── main.go switch os.Args[1] — subcommand routing
|
||||
├── cmd_version.go
|
||||
├── cmd_status.go
|
||||
├── Makefile build/run (ARGS=...) /install/test/vet/clean
|
||||
├── go.mod
|
||||
├── app.md framework vacio (CLI puro) o 'bubbletea' (TUI)
|
||||
└── model.go (con --with-tui) Modelo Bubbletea con list+spinner+dark theme
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### ¿Como anado auth despues?
|
||||
|
||||
Manualmente: anadir `auth.go` con los handlers, `migrations/002_users.sql`, y tag `auth` a `app.md`. Las funciones `jwt_generate_go_infra`, `password_hash_go_infra`, `password_verify_go_infra` estan en el registry.
|
||||
|
||||
Alternativa: regenerar con `--with-auth` en otro directorio y copiar manualmente los archivos relevantes.
|
||||
|
||||
### ¿Como cambio el puerto despues?
|
||||
|
||||
Editar `config.go` (cambiar default de `Port`) o pasar `PORT=8090` via env var. El cliente HTTP del frontend (`init_web_app`) tiene el proxy en `vite.config.ts` — hay que ajustarlo alli tambien.
|
||||
|
||||
### ¿Como anado operations.db?
|
||||
|
||||
```bash
|
||||
cd apps/{nombre}
|
||||
FN_REGISTRY_ROOT=$(pwd)/../.. fn ops init .
|
||||
```
|
||||
|
||||
Esto crea `operations.db` con schema completo. Para escribir entities/relations/executions usar `fn ops entity add`, etc.
|
||||
|
||||
### ¿Como agrego mas paginas al frontend?
|
||||
|
||||
Crear `frontend/src/pages/Nueva.tsx` y anadirla como ruta en `App.tsx`. Si quieres react-router, ya esta en el package.json de `init_web_app`.
|
||||
|
||||
### ¿Como desactivo las verificaciones al final del pipeline?
|
||||
|
||||
Las verificaciones de build del frontend (`pnpm build`) o Wails (`wails build`) se saltan con env vars:
|
||||
|
||||
```bash
|
||||
SKIP_PNPM_BUILD=true fn run init_web_app my_app
|
||||
SKIP_WAILS_BUILD=true fn run init_desktop_app my_app
|
||||
```
|
||||
|
||||
`go vet` del backend no tiene opt-out — si falla, hay algo que corregir en los heredocs del pipeline.
|
||||
|
||||
### ¿Que pasa si fn-registry cambia el path?
|
||||
|
||||
Los pipelines embeben el path absoluto del registry en la directiva `replace fn-registry => /abs/path` del `go.mod` generado. Si el registry se mueve de directorio, hay que actualizar esa linea en cada app scaffoldeada.
|
||||
|
||||
### ¿Puedo scaffoldear en `projects/{proyecto}/apps/` en vez de `apps/`?
|
||||
|
||||
De momento los 4 pipelines generan en `apps/{nombre}/`. Para un proyecto existe `init_jupyter_analysis --project {proyecto}` que crea en `projects/{proyecto}/analysis/`. Una mejora futura seria anadir `--project` a `init_api_app`/`init_web_app`/etc. Por ahora: generar en `apps/` y mover manualmente (cuidado con los paths en `replace` y `app.md`).
|
||||
|
||||
## Ver tambien
|
||||
|
||||
- `init_jupyter_analysis_bash_pipelines` — pipeline de referencia (scaffold analisis Jupyter)
|
||||
- `init_go_project_bash_pipelines` / `init_go_module_bash_pipelines` — pipelines Go genericos (no apps)
|
||||
- `gitea_init_app_bash_pipelines` — inicializar repo Gitea para una app scaffoldeada
|
||||
- `dev/issues/completed/0022-init-pipelines.md` — spec original
|
||||
@@ -0,0 +1,92 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CRUDCreateHandler retorna un http.HandlerFunc que parsea un body JSON,
|
||||
// valida los campos contra la definicion del recurso, genera id UUID y timestamps,
|
||||
// inserta en la tabla y responde 201 con el registro creado.
|
||||
func CRUDCreateHandler(res CRUDResource, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body := map[string]any{}
|
||||
if err := HTTPParseBody(r, &body, 1<<20); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "invalid_body", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar campos required y validaciones
|
||||
for _, f := range res.Fields {
|
||||
val, present := body[f.Name]
|
||||
if !present {
|
||||
if f.Required && f.Default == "" {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "validation_error", Message: fmt.Sprintf("field %q is required", f.Name)})
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := crudValidateField(f, val); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "validation_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
id := uuid.NewString()
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
// Construir INSERT solo con los campos presentes
|
||||
cols := []string{"id"}
|
||||
placeholders := []string{"?"}
|
||||
args := []any{id}
|
||||
for _, f := range res.Fields {
|
||||
if val, present := body[f.Name]; present {
|
||||
cols = append(cols, f.Name)
|
||||
placeholders = append(placeholders, "?")
|
||||
args = append(args, val)
|
||||
}
|
||||
}
|
||||
cols = append(cols, "created_at", "updated_at")
|
||||
placeholders = append(placeholders, "?", "?")
|
||||
args = append(args, now, now)
|
||||
|
||||
insertSQL := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", res.Table, strings.Join(cols, ", "), strings.Join(placeholders, ", "))
|
||||
if _, err := db.Exec(insertSQL, args...); err != nil {
|
||||
// UNIQUE violations → 409
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusConflict, Code: "unique_violation", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Leer de vuelta para devolver todas las columnas (incluido defaults)
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT * FROM %s WHERE id = ?", res.Table), id)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
colsOut, err := rows.Columns()
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if !rows.Next() {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: "inserted row not found"})
|
||||
return
|
||||
}
|
||||
row, err := crudScanRow(rows, colsOut)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPJSONResponse(w, http.StatusCreated, row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_create_handler
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDCreateHandler(res CRUDResource, db *sql.DB) http.HandlerFunc"
|
||||
description: "Genera un handler HTTP POST que parsea JSON, valida campos contra la definicion del recurso, genera UUID y timestamps, inserta en SQLite y responde 201 con el registro. 400 en errores de validacion, 409 en violaciones UNIQUE."
|
||||
tags: [crud, create, handler, http, sqlite, uuid, validation, infra]
|
||||
uses_functions: [http_json_response_go_infra, http_error_response_go_infra, http_parse_body_go_infra]
|
||||
uses_types: [CRUDResource_go_infra, HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http, strings, time, github.com/google/uuid]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion del recurso con campos y validaciones"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "http.HandlerFunc que crea un registro y responde 201"
|
||||
tested: true
|
||||
tests: ["crea un registro valido y retorna 201", "valida campos required y retorna 400 si faltan", "valida min_length y max_length", "valida enum de texto", "valida min y max numericos", "retorna 409 si se viola UNIQUE"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_create_handler.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handler := CRUDCreateHandler(res, db)
|
||||
mux.Handle("POST /api/projects", handler)
|
||||
// curl -X POST localhost:8080/api/projects -H 'Content-Type: application/json' -d '{"name":"mi-proyecto"}'
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura. Limita el body a 1 MiB. Los ids se generan con github.com/google/uuid (string). Los timestamps created_at y updated_at se escriben en formato RFC3339 UTC con nanosegundos. Los errores de validacion devuelven 400 con code "validation_error" y mensaje descriptivo. Errores UNIQUE de SQLite se mapean a 409.
|
||||
@@ -0,0 +1,40 @@
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CRUDDefineResource construye un CRUDResource validando que el nombre no este vacio,
|
||||
// que haya al menos un campo y que todos los tipos de los campos sean validos
|
||||
// (TEXT, INTEGER, REAL, BLOB). Es pura — solo valida y devuelve la estructura.
|
||||
func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error) {
|
||||
if name == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: name must not be empty")
|
||||
}
|
||||
if table == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: table must not be empty")
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: must have at least one field")
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.Name == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name must not be empty")
|
||||
}
|
||||
if f.Name == "id" || f.Name == "created_at" || f.Name == "updated_at" || f.Name == "deleted_at" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name %q is reserved", f.Name)
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: duplicate field name %q", f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
if !isValidCRUDType(f.Type) {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: invalid type %q for field %q (must be TEXT, INTEGER, REAL or BLOB)", f.Type, f.Name)
|
||||
}
|
||||
}
|
||||
return CRUDResource{
|
||||
Name: name,
|
||||
Table: table,
|
||||
Fields: fields,
|
||||
SoftDelete: softDelete,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: crud_define_resource
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error)"
|
||||
description: "Construye un CRUDResource validando nombre, tabla y campos. Rechaza nombres de campo reservados (id, created_at, updated_at, deleted_at), duplicados y tipos distintos de TEXT, INTEGER, REAL, BLOB."
|
||||
tags: [crud, resource, define, validation, infra]
|
||||
uses_functions: []
|
||||
uses_types: [CRUDResource_go_infra, CRUDField_go_infra]
|
||||
returns: [CRUDResource_go_infra]
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt]
|
||||
params:
|
||||
- name: name
|
||||
desc: "nombre singular del recurso en snake_case (ej: 'project')"
|
||||
- name: table
|
||||
desc: "nombre de la tabla SQLite asociada (ej: 'projects')"
|
||||
- name: fields
|
||||
desc: "lista de CRUDField con los campos del recurso (sin id ni timestamps)"
|
||||
- name: softDelete
|
||||
desc: "si true, el recurso usa deleted_at en vez de borrado fisico"
|
||||
output: "CRUDResource validado listo para pasar a crud_generate_table_sql y crud_generate_handlers"
|
||||
tested: true
|
||||
tests: ["construye un recurso valido", "rechaza nombre vacio", "rechaza tabla vacia", "rechaza lista de campos vacia", "rechaza tipos invalidos", "rechaza nombres reservados", "rechaza duplicados"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_define_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
res, err := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0"},
|
||||
}, false)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — no hace I/O. Valida antes de devolver. Los campos id, created_at, updated_at y deleted_at son gestionados por el generador de tabla y los handlers, por eso estan reservados. Los tipos aceptados son los tipos de almacenamiento nativos de SQLite.
|
||||
@@ -0,0 +1,49 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CRUDDeleteHandler retorna un http.HandlerFunc que borra un registro por id.
|
||||
// Si el recurso es SoftDelete, hace UPDATE deleted_at en vez de DELETE real.
|
||||
// Responde 204 sin body si el borrado es exitoso. 404 si el registro no existe.
|
||||
func CRUDDeleteHandler(res CRUDResource, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "missing_id", Message: "id path parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar que existe
|
||||
existsSQL := fmt.Sprintf("SELECT 1 FROM %s WHERE id = ?", res.Table)
|
||||
if res.SoftDelete {
|
||||
existsSQL += " AND deleted_at IS NULL"
|
||||
}
|
||||
var dummy int
|
||||
if err := db.QueryRow(existsSQL, id).Scan(&dummy); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: fmt.Sprintf("%s %q not found", res.Name, id)})
|
||||
return
|
||||
}
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if res.SoftDelete {
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = db.Exec(fmt.Sprintf("UPDATE %s SET deleted_at = ?, updated_at = ? WHERE id = ?", res.Table), now, now, id)
|
||||
} else {
|
||||
_, err = db.Exec(fmt.Sprintf("DELETE FROM %s WHERE id = ?", res.Table), id)
|
||||
}
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_delete_handler
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDDeleteHandler(res CRUDResource, db *sql.DB) http.HandlerFunc"
|
||||
description: "Genera un handler HTTP DELETE /{id} que borra un registro. Si el recurso es SoftDelete, hace UPDATE deleted_at en vez de DELETE. Responde 204 sin body, 404 si no existe."
|
||||
tags: [crud, delete, handler, http, sqlite, soft-delete, infra]
|
||||
uses_functions: [http_error_response_go_infra]
|
||||
uses_types: [CRUDResource_go_infra, HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http, time]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion del recurso (SoftDelete determina el modo de borrado)"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "http.HandlerFunc que borra un registro"
|
||||
tested: true
|
||||
tests: ["hard delete fisico si soft_delete false", "soft delete via UPDATE deleted_at si soft_delete true", "retorna 404 si no existe", "retorna 204 sin body"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_delete_handler.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handler := CRUDDeleteHandler(res, db)
|
||||
mux.Handle("DELETE /api/projects/{id}", handler)
|
||||
// curl -X DELETE localhost:8080/api/projects/abc-123
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura. Responde 204 No Content sin body en exito (convencion REST). Si el recurso es SoftDelete, actualiza deleted_at y updated_at con el timestamp actual, preservando el registro para auditoria. Un segundo DELETE sobre un recurso soft-deleted responde 404 (se considera que ya fue borrado).
|
||||
@@ -0,0 +1,22 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CRUDGenerateHandlers construye los 5 handlers CRUD (list, get, create, update, delete)
|
||||
// a partir de una definicion CRUDResource y una conexion *sql.DB, y los retorna como
|
||||
// un mapa con claves "list", "get", "create", "update", "delete".
|
||||
//
|
||||
// La funcion es pura (no hace I/O por si misma) — solo construye closures. Los handlers
|
||||
// retornados son impuros cuando se invocan.
|
||||
func CRUDGenerateHandlers(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc {
|
||||
return map[string]http.HandlerFunc{
|
||||
"list": CRUDListHandler(res, db),
|
||||
"get": CRUDGetHandler(res, db),
|
||||
"create": CRUDCreateHandler(res, db),
|
||||
"update": CRUDUpdateHandler(res, db),
|
||||
"delete": CRUDDeleteHandler(res, db),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_generate_handlers
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CRUDGenerateHandlers(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc"
|
||||
description: "Compone los 5 handlers CRUD (list, get, create, update, delete) en un mapa con claves estandar. La funcion factory es pura — solo construye closures a partir de la definicion y la conexion de bd. Los handlers resultantes son impuros al invocarse."
|
||||
tags: [crud, factory, handlers, compose, http, infra]
|
||||
uses_functions: [crud_list_handler_go_infra, crud_get_handler_go_infra, crud_create_handler_go_infra, crud_update_handler_go_infra, crud_delete_handler_go_infra]
|
||||
uses_types: [CRUDResource_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [database/sql, net/http]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion CRUDResource del recurso"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "map con keys 'list', 'get', 'create', 'update', 'delete' -> http.HandlerFunc"
|
||||
tested: true
|
||||
tests: ["retorna las 5 keys esperadas", "cada handler funciona end-to-end"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_generate_handlers.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handlers := CRUDGenerateHandlers(res, db)
|
||||
mux.Handle("GET /api/projects", handlers["list"])
|
||||
mux.Handle("POST /api/projects", handlers["create"])
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — solo ensambla closures. Para registrar todas las rutas en un paso, ver CRUDRegisterRoutes. El mapa incluye exactamente las claves list, get, create, update, delete (en minuscula).
|
||||
@@ -0,0 +1,39 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CRUDGenerateTableSQL genera el DDL CREATE TABLE IF NOT EXISTS correspondiente a un CRUDResource.
|
||||
// Incluye siempre: id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL.
|
||||
// Si el recurso es SoftDelete, agrega una columna deleted_at TEXT (nullable).
|
||||
// Cada CRUDField se mapea a su tipo SQLite y aplica NOT NULL, UNIQUE y DEFAULT segun corresponda.
|
||||
func CRUDGenerateTableSQL(res CRUDResource) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n", res.Table))
|
||||
sb.WriteString(" id TEXT PRIMARY KEY")
|
||||
for _, f := range res.Fields {
|
||||
sb.WriteString(",\n ")
|
||||
sb.WriteString(f.Name)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(strings.ToUpper(f.Type))
|
||||
if f.Required {
|
||||
sb.WriteString(" NOT NULL")
|
||||
}
|
||||
if f.Unique {
|
||||
sb.WriteString(" UNIQUE")
|
||||
}
|
||||
if f.Default != "" {
|
||||
sb.WriteString(" DEFAULT ")
|
||||
sb.WriteString(f.Default)
|
||||
}
|
||||
}
|
||||
sb.WriteString(",\n created_at TEXT NOT NULL")
|
||||
sb.WriteString(",\n updated_at TEXT NOT NULL")
|
||||
if res.SoftDelete {
|
||||
sb.WriteString(",\n deleted_at TEXT")
|
||||
}
|
||||
sb.WriteString("\n);\n")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: crud_generate_table_sql
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CRUDGenerateTableSQL(res CRUDResource) string"
|
||||
description: "Genera el DDL CREATE TABLE IF NOT EXISTS de un CRUDResource. Incluye id como PRIMARY KEY, timestamps created_at/updated_at y deleted_at si soft_delete. Cada campo aplica su tipo SQLite y constraints NOT NULL/UNIQUE/DEFAULT."
|
||||
tags: [crud, sql, ddl, generate, sqlite, infra]
|
||||
uses_functions: []
|
||||
uses_types: [CRUDResource_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: res
|
||||
desc: "CRUDResource con la definicion completa del recurso"
|
||||
output: "string con el statement CREATE TABLE listo para ejecutar"
|
||||
tested: true
|
||||
tests: ["genera tabla basica con timestamps", "aplica NOT NULL y UNIQUE", "aplica DEFAULT", "anade deleted_at si soft_delete"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_generate_table_sql.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
res, _ := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0"},
|
||||
}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
// CREATE TABLE IF NOT EXISTS projects (
|
||||
// id TEXT PRIMARY KEY,
|
||||
// name TEXT NOT NULL UNIQUE,
|
||||
// priority INTEGER DEFAULT 0,
|
||||
// created_at TEXT NOT NULL,
|
||||
// updated_at TEXT NOT NULL
|
||||
// );
|
||||
db.Exec(ddl)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — solo manipula strings. Usa CREATE TABLE IF NOT EXISTS para ser idempotente. Las columnas id, created_at y updated_at siempre se generan. Si el CRUDResource es SoftDelete, se anade deleted_at TEXT nullable. El resultado se puede ejecutar directamente con db.Exec o envolver como migracion.
|
||||
@@ -0,0 +1,47 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CRUDGetHandler retorna un http.HandlerFunc que busca un registro por id y lo devuelve
|
||||
// como JSON. Usa r.PathValue("id"). Responde 404 si no existe o si esta soft-deleted.
|
||||
func CRUDGetHandler(res CRUDResource, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "missing_id", Message: "id path parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s WHERE id = ?", res.Table)
|
||||
if res.SoftDelete {
|
||||
query += " AND deleted_at IS NULL"
|
||||
}
|
||||
rows, err := db.Query(query, id)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: fmt.Sprintf("%s %q not found", res.Name, id)})
|
||||
return
|
||||
}
|
||||
row, err := crudScanRow(rows, cols)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPJSONResponse(w, http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_get_handler
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDGetHandler(res CRUDResource, db *sql.DB) http.HandlerFunc"
|
||||
description: "Genera un handler HTTP GET /{id} que busca un registro por id en la tabla del recurso y lo devuelve como JSON. Responde 404 si no existe o si soft_delete y tiene deleted_at no nulo."
|
||||
tags: [crud, get, handler, http, sqlite, infra]
|
||||
uses_functions: [http_json_response_go_infra, http_error_response_go_infra]
|
||||
uses_types: [CRUDResource_go_infra, HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion del recurso"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "http.HandlerFunc que retorna el registro o 404"
|
||||
tested: true
|
||||
tests: ["devuelve el registro si existe", "responde 404 si no existe", "responde 404 si soft-deleted"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_get_handler.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handler := CRUDGetHandler(res, db)
|
||||
mux.Handle("GET /api/projects/{id}", handler)
|
||||
// curl "localhost:8080/api/projects/abc-123"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura. Usa r.PathValue("id") de Go 1.22+. El id es un string opaco (UUID en general). Si el recurso es SoftDelete y el registro tiene deleted_at no nulo, responde 404 como si no existiera.
|
||||
@@ -0,0 +1,198 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validCRUDTypes enumera los tipos SQLite aceptados por las funciones CRUD.
|
||||
var validCRUDTypes = map[string]bool{
|
||||
"TEXT": true,
|
||||
"INTEGER": true,
|
||||
"REAL": true,
|
||||
"BLOB": true,
|
||||
}
|
||||
|
||||
// isValidCRUDType indica si el tipo string corresponde a uno soportado.
|
||||
func isValidCRUDType(t string) bool {
|
||||
return validCRUDTypes[strings.ToUpper(t)]
|
||||
}
|
||||
|
||||
// crudFieldByName busca un campo por nombre. Retorna nil si no existe.
|
||||
func crudFieldByName(res CRUDResource, name string) *CRUDField {
|
||||
for i := range res.Fields {
|
||||
if res.Fields[i].Name == name {
|
||||
return &res.Fields[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudColumnNames retorna la lista de nombres de columnas de una tabla sqlite.
|
||||
// Usa PRAGMA table_info — unica forma portable en SQLite.
|
||||
func crudColumnNames(db *sql.DB, table string) ([]string, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crud column names: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, fmt.Errorf("crud column names: %w", err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
// crudScanRow escanea una fila generica a map[string]any usando las columnas proporcionadas.
|
||||
// Usa []any con apuntadores para que database/sql decida el tipo Go.
|
||||
func crudScanRow(rows *sql.Rows, cols []string) (map[string]any, error) {
|
||||
values := make([]any, len(cols))
|
||||
scanArgs := make([]any, len(cols))
|
||||
for i := range values {
|
||||
scanArgs[i] = &values[i]
|
||||
}
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := make(map[string]any, len(cols))
|
||||
for i, col := range cols {
|
||||
v := values[i]
|
||||
// Normalizar bytes a string (SQLite TEXT llega como []byte cuando no se tipa)
|
||||
if b, ok := v.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = v
|
||||
}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// crudValidateField valida un valor contra las reglas de un campo.
|
||||
// Retorna nil si todo ok, error con mensaje descriptivo si falla alguna regla.
|
||||
func crudValidateField(field CRUDField, value any) error {
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return fmt.Errorf("field %q is required", field.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch strings.ToUpper(field.Type) {
|
||||
case "TEXT":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q must be a string", field.Name)
|
||||
}
|
||||
return crudValidateText(field, s)
|
||||
case "INTEGER":
|
||||
n, err := crudCoerceInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q must be an integer", field.Name)
|
||||
}
|
||||
return crudValidateNumber(field, float64(n))
|
||||
case "REAL":
|
||||
f, err := crudCoerceFloat(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q must be a number", field.Name)
|
||||
}
|
||||
return crudValidateNumber(field, f)
|
||||
case "BLOB":
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudValidateText aplica min_length, max_length, pattern, enum a un string.
|
||||
func crudValidateText(field CRUDField, s string) error {
|
||||
if v, ok := field.Validations["min_length"]; ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil && len(s) < n {
|
||||
return fmt.Errorf("field %q must have at least %d characters", field.Name, n)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["max_length"]; ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil && len(s) > n {
|
||||
return fmt.Errorf("field %q must have at most %d characters", field.Name, n)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["pattern"]; ok {
|
||||
re, err := regexp.Compile(v)
|
||||
if err == nil && !re.MatchString(s) {
|
||||
return fmt.Errorf("field %q does not match pattern %q", field.Name, v)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["enum"]; ok {
|
||||
options := strings.Split(v, ",")
|
||||
matched := false
|
||||
for _, opt := range options {
|
||||
if strings.TrimSpace(opt) == s {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %q must be one of: %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudValidateNumber aplica min y max a un valor numerico.
|
||||
func crudValidateNumber(field CRUDField, f float64) error {
|
||||
if v, ok := field.Validations["min"]; ok {
|
||||
min, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil && f < min {
|
||||
return fmt.Errorf("field %q must be >= %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["max"]; ok {
|
||||
max, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil && f > max {
|
||||
return fmt.Errorf("field %q must be <= %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudCoerceInt intenta convertir un valor a int64.
|
||||
func crudCoerceInt(v any) (int64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), nil
|
||||
case int64:
|
||||
return n, nil
|
||||
case float64:
|
||||
if n != float64(int64(n)) {
|
||||
return 0, fmt.Errorf("not an integer")
|
||||
}
|
||||
return int64(n), nil
|
||||
case string:
|
||||
return strconv.ParseInt(n, 10, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("not an integer")
|
||||
}
|
||||
|
||||
// crudCoerceFloat intenta convertir un valor a float64.
|
||||
func crudCoerceFloat(v any) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n), nil
|
||||
case int64:
|
||||
return float64(n), nil
|
||||
case float64:
|
||||
return n, nil
|
||||
case string:
|
||||
return strconv.ParseFloat(n, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("not a number")
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CRUDListHandler retorna un http.HandlerFunc que lista registros de la tabla del recurso
|
||||
// con paginacion, orden y filtros tomados de los query params.
|
||||
// Query params soportados:
|
||||
// - page (default 1)
|
||||
// - per_page (default 20, max 100)
|
||||
// - sort_by (columna valida; default "created_at")
|
||||
// - sort_dir ("asc" o "desc"; default "desc")
|
||||
// - filter_<field>=<valor> para WHERE exactos (solo campos definidos en el recurso)
|
||||
//
|
||||
// Si el recurso es SoftDelete, se agrega automaticamente "WHERE deleted_at IS NULL".
|
||||
// Retorna un CRUDListResult serializado como JSON.
|
||||
func CRUDListHandler(res CRUDResource, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
params := parseCRUDListParams(res, r)
|
||||
|
||||
// Construir WHERE
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
if res.SoftDelete {
|
||||
where = append(where, "deleted_at IS NULL")
|
||||
}
|
||||
for col, val := range params.Filters {
|
||||
where = append(where, fmt.Sprintf("%s = ?", col))
|
||||
args = append(args, val)
|
||||
}
|
||||
whereSQL := ""
|
||||
if len(where) > 0 {
|
||||
whereSQL = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
// COUNT total
|
||||
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM %s%s", res.Table, whereSQL)
|
||||
var total int
|
||||
if err := db.QueryRow(countSQL, args...).Scan(&total); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// SELECT paginado
|
||||
offset := (params.Page - 1) * params.PerPage
|
||||
selectSQL := fmt.Sprintf(
|
||||
"SELECT * FROM %s%s ORDER BY %s %s LIMIT ? OFFSET ?",
|
||||
res.Table, whereSQL, params.SortBy, strings.ToUpper(params.SortDir),
|
||||
)
|
||||
selectArgs := append(append([]any{}, args...), params.PerPage, offset)
|
||||
rows, err := db.Query(selectSQL, selectArgs...)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
items := []map[string]any{}
|
||||
for rows.Next() {
|
||||
row, err := crudScanRow(rows, cols)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := 0
|
||||
if params.PerPage > 0 {
|
||||
totalPages = (total + params.PerPage - 1) / params.PerPage
|
||||
}
|
||||
result := CRUDListResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: params.Page,
|
||||
PerPage: params.PerPage,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
HTTPJSONResponse(w, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
|
||||
// parseCRUDListParams extrae CRUDListParams desde los query params, aplicando
|
||||
// defaults y validando los nombres de campo contra la definicion del recurso
|
||||
// para evitar SQL injection en sort_by y filter_*.
|
||||
func parseCRUDListParams(res CRUDResource, r *http.Request) CRUDListParams {
|
||||
q := r.URL.Query()
|
||||
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
perPage, _ := strconv.Atoi(q.Get("per_page"))
|
||||
if perPage < 1 {
|
||||
perPage = 20
|
||||
}
|
||||
if perPage > 100 {
|
||||
perPage = 100
|
||||
}
|
||||
|
||||
sortBy := q.Get("sort_by")
|
||||
if sortBy == "" || !isSortableColumn(res, sortBy) {
|
||||
sortBy = "created_at"
|
||||
}
|
||||
|
||||
sortDir := strings.ToLower(q.Get("sort_dir"))
|
||||
if sortDir != "asc" && sortDir != "desc" {
|
||||
sortDir = "desc"
|
||||
}
|
||||
|
||||
filters := map[string]string{}
|
||||
for key, vals := range q {
|
||||
if !strings.HasPrefix(key, "filter_") {
|
||||
continue
|
||||
}
|
||||
col := strings.TrimPrefix(key, "filter_")
|
||||
if crudFieldByName(res, col) == nil {
|
||||
continue // campo desconocido, se ignora (defensa SQLi)
|
||||
}
|
||||
if len(vals) > 0 {
|
||||
filters[col] = vals[0]
|
||||
}
|
||||
}
|
||||
|
||||
return CRUDListParams{
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
SortBy: sortBy,
|
||||
SortDir: sortDir,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
// isSortableColumn indica si la columna pertenece al recurso o es una columna base.
|
||||
func isSortableColumn(res CRUDResource, col string) bool {
|
||||
if col == "id" || col == "created_at" || col == "updated_at" {
|
||||
return true
|
||||
}
|
||||
if res.SoftDelete && col == "deleted_at" {
|
||||
return true
|
||||
}
|
||||
return crudFieldByName(res, col) != nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_list_handler
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDListHandler(res CRUDResource, db *sql.DB) http.HandlerFunc"
|
||||
description: "Genera un handler HTTP GET que lista registros de la tabla del recurso con paginacion, orden y filtros desde los query params. Responde con un CRUDListResult JSON. Valida sort_by y filter_* contra la definicion del recurso para evitar SQL injection."
|
||||
tags: [crud, list, handler, http, sqlite, pagination, infra]
|
||||
uses_functions: [http_json_response_go_infra, http_error_response_go_infra]
|
||||
uses_types: [CRUDResource_go_infra, CRUDListParams_go_infra, CRUDListResult_go_infra, HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http, strconv, strings]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion del recurso (tabla, campos, soft_delete)"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite con la tabla ya creada"
|
||||
output: "http.HandlerFunc que lista registros segun query params"
|
||||
tested: true
|
||||
tests: ["devuelve lista vacia si no hay registros", "pagina resultados con page y per_page", "filtra por campo con filter_<field>", "ordena con sort_by y sort_dir", "ignora soft-deleted si soft_delete"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_list_handler.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handler := CRUDListHandler(res, db)
|
||||
mux.Handle("GET /api/projects", handler)
|
||||
// curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — hace SELECT y COUNT contra SQLite. Los query params soportados son: page (default 1), per_page (default 20, max 100), sort_by (default "created_at"), sort_dir ("asc"|"desc", default "desc") y filter_<campo> con igualdad exacta. Los nombres de campo en sort_by y filter_* se validan contra la definicion del recurso — cualquier valor no reconocido se ignora (defensa contra SQLi). Si el recurso es SoftDelete, se anade WHERE deleted_at IS NULL automaticamente.
|
||||
@@ -0,0 +1,26 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CRUDRegisterRoutes registra en mux las 5 rutas REST del recurso usando la sintaxis
|
||||
// "METHOD /path" de Go 1.22+. basePath es el prefijo de las rutas (ej: "/api/projects").
|
||||
// Rutas generadas:
|
||||
// GET {basePath}
|
||||
// GET {basePath}/{id}
|
||||
// POST {basePath}
|
||||
// PUT {basePath}/{id}
|
||||
// DELETE {basePath}/{id}
|
||||
func CRUDRegisterRoutes(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB) {
|
||||
basePath = strings.TrimRight(basePath, "/")
|
||||
handlers := CRUDGenerateHandlers(res, db)
|
||||
mux.Handle(fmt.Sprintf("GET %s", basePath), handlers["list"])
|
||||
mux.Handle(fmt.Sprintf("GET %s/{id}", basePath), handlers["get"])
|
||||
mux.Handle(fmt.Sprintf("POST %s", basePath), handlers["create"])
|
||||
mux.Handle(fmt.Sprintf("PUT %s/{id}", basePath), handlers["update"])
|
||||
mux.Handle(fmt.Sprintf("DELETE %s/{id}", basePath), handlers["delete"])
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: crud_register_routes
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDRegisterRoutes(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)"
|
||||
description: "Registra las 5 rutas REST de un CRUDResource en un http.ServeMux: GET /base, GET /base/{id}, POST /base, PUT /base/{id}, DELETE /base/{id}. Usa la sintaxis 'METHOD /path' de Go 1.22+."
|
||||
tags: [crud, routes, register, http, mux, infra]
|
||||
uses_functions: [crud_generate_handlers_go_infra]
|
||||
uses_types: [CRUDResource_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http, strings]
|
||||
params:
|
||||
- name: mux
|
||||
desc: "*http.ServeMux donde se registran las rutas"
|
||||
- name: basePath
|
||||
desc: "prefijo de las rutas (ej: '/api/projects')"
|
||||
- name: res
|
||||
desc: "definicion CRUDResource del recurso"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "muta mux con las 5 rutas CRUD registradas"
|
||||
tested: true
|
||||
tests: ["registra las 5 rutas y responde correctamente", "soporta multiples recursos en un mismo mux"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_register_routes.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", projectRes, db)
|
||||
CRUDRegisterRoutes(mux, "/api/users", userRes, db)
|
||||
http.ListenAndServe(":8080", mux)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — muta el mux pasado como parametro. basePath se normaliza quitando el slash final. Si la ruta colisiona con una ya registrada en el mux, Go lanzara panic al arrancar (comportamiento estandar del ServeMux). Para combinar con middleware de logging/CORS, envolver el mux con HTTPMiddlewareChain al final.
|
||||
@@ -0,0 +1,49 @@
|
||||
package infra
|
||||
|
||||
// CRUDResource define un recurso CRUD completo para generar handlers HTTP.
|
||||
// Name es el nombre singular del recurso en snake_case (ej: "project").
|
||||
// Table es el nombre de la tabla SQLite asociada (ej: "projects").
|
||||
// Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
|
||||
// SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
|
||||
type CRUDResource struct {
|
||||
Name string // nombre del recurso (singular, snake_case)
|
||||
Table string // nombre de la tabla SQLite
|
||||
Fields []CRUDField // campos del recurso (sin id ni timestamps)
|
||||
SoftDelete bool // si true, usa deleted_at en vez de DELETE
|
||||
}
|
||||
|
||||
// CRUDField define un campo de un recurso CRUD.
|
||||
// Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
|
||||
// Required fuerza NOT NULL en la tabla y validacion en create.
|
||||
// Unique anade un UNIQUE constraint en la tabla.
|
||||
// Default es el valor SQL por defecto (vacio = sin default).
|
||||
// Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
|
||||
type CRUDField struct {
|
||||
Name string // nombre del campo (snake_case)
|
||||
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
|
||||
Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
|
||||
}
|
||||
|
||||
// CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
|
||||
// Page es 1-based (default 1). PerPage tiene default 20 y max 100.
|
||||
// SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
|
||||
// Filters contiene pares campo -> valor para filtros exactos en WHERE.
|
||||
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
|
||||
SortDir string // "asc" o "desc"
|
||||
Filters map[string]string // campo -> valor para WHERE exacto
|
||||
}
|
||||
|
||||
// CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func openCRUDTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("cannot open test DB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func sampleProjectFields() []CRUDField {
|
||||
return []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true,
|
||||
Validations: map[string]string{"min_length": "1", "max_length": "50"}},
|
||||
{Name: "description", Type: "TEXT"},
|
||||
{Name: "status", Type: "TEXT", Default: "'active'",
|
||||
Validations: map[string]string{"enum": "active,archived"}},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0",
|
||||
Validations: map[string]string{"min": "0", "max": "10"}},
|
||||
}
|
||||
}
|
||||
|
||||
func buildProjectResource(t *testing.T, softDelete bool) (CRUDResource, *sql.DB) {
|
||||
t.Helper()
|
||||
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), softDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("define resource: %v", err)
|
||||
}
|
||||
db := openCRUDTestDB(t)
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
return res, db
|
||||
}
|
||||
|
||||
func doJSONRequest(t *testing.T, mux http.Handler, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reqBody)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
var got map[string]any
|
||||
if rec.Body.Len() > 0 && strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
}
|
||||
return rec, got
|
||||
}
|
||||
|
||||
// --- CRUDDefineResource ---
|
||||
|
||||
func TestCRUDDefineResource(t *testing.T) {
|
||||
t.Run("construye un recurso valido", func(t *testing.T) {
|
||||
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Name != "project" || res.Table != "projects" || len(res.Fields) != 4 {
|
||||
t.Errorf("unexpected resource: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza nombre vacio", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("", "projects", sampleProjectFields(), false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tabla vacia", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "", sampleProjectFields(), false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty table")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza lista de campos vacia", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", nil, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty fields")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tipos invalidos", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: "x", Type: "FOO"}}, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza nombres reservados", func(t *testing.T) {
|
||||
for _, reserved := range []string{"id", "created_at", "updated_at", "deleted_at"} {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: reserved, Type: "TEXT"}}, false)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for reserved field %q", reserved)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza duplicados", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT"},
|
||||
{Name: "name", Type: "TEXT"},
|
||||
}, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate field")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDGenerateTableSQL ---
|
||||
|
||||
func TestCRUDGenerateTableSQL(t *testing.T) {
|
||||
t.Run("genera tabla basica con timestamps", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT"}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
for _, want := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS projects",
|
||||
"id TEXT PRIMARY KEY",
|
||||
"name TEXT",
|
||||
"created_at TEXT NOT NULL",
|
||||
"updated_at TEXT NOT NULL",
|
||||
} {
|
||||
if !strings.Contains(ddl, want) {
|
||||
t.Errorf("DDL missing %q:\n%s", want, ddl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ddl, "deleted_at") {
|
||||
t.Errorf("DDL should not contain deleted_at:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("aplica NOT NULL y UNIQUE", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT", Required: true, Unique: true}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "name TEXT NOT NULL UNIQUE") {
|
||||
t.Errorf("expected NOT NULL UNIQUE:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("aplica DEFAULT", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "priority", Type: "INTEGER", Default: "0"}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "priority INTEGER DEFAULT 0") {
|
||||
t.Errorf("expected DEFAULT clause:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anade deleted_at si soft_delete", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT"}}, true)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "deleted_at TEXT") {
|
||||
t.Errorf("expected deleted_at for soft_delete:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("el DDL generado es valido en sqlite", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects", sampleProjectFields(), true)
|
||||
db := openCRUDTestDB(t)
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
|
||||
t.Fatalf("DDL not valid: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDCreateHandler + CRUDGetHandler ---
|
||||
|
||||
func TestCRUDCreateAndGet(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
t.Run("crea un registro valido y retorna 201", func(t *testing.T) {
|
||||
rec, body := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "demo", "description": "hola", "status": "active", "priority": 5,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got status %d, want 201: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body["name"] != "demo" {
|
||||
t.Errorf("got name=%v, want demo", body["name"])
|
||||
}
|
||||
if body["id"] == nil || body["id"] == "" {
|
||||
t.Errorf("expected generated id, got %v", body["id"])
|
||||
}
|
||||
if body["created_at"] == nil {
|
||||
t.Errorf("expected created_at, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 400 si faltan required", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida min_length", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": ""})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for empty name", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida max_length", func(t *testing.T) {
|
||||
longName := strings.Repeat("a", 60)
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": longName})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for too-long name", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida enum", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "x", "status": "pirate",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for invalid enum", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida min y max numericos", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "numeric-min", "priority": -1,
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for priority<0", rec.Code)
|
||||
}
|
||||
rec, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "numeric-max", "priority": 999,
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for priority>10", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 409 si se viola UNIQUE", func(t *testing.T) {
|
||||
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("got %d, want 409", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET recupera el registro creado", func(t *testing.T) {
|
||||
rec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "get-me"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create failed: %d", rec.Code)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
rec, body := doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if body["name"] != "get-me" {
|
||||
t.Errorf("got name=%v, want get-me", body["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET no existente retorna 404", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "GET", "/api/projects/nonexistent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDListHandler ---
|
||||
|
||||
func TestCRUDListHandler(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
// Sembrar datos
|
||||
for i, name := range []string{"a", "b", "c", "d", "e"} {
|
||||
status := "active"
|
||||
if i%2 == 1 {
|
||||
status = "archived"
|
||||
}
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": name, "status": status, "priority": i,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("seed %s failed: %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("lista todo con paginacion default", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
var got CRUDListResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if got.Total != 5 {
|
||||
t.Errorf("got total=%d, want 5", got.Total)
|
||||
}
|
||||
if len(got.Items) != 5 {
|
||||
t.Errorf("got %d items, want 5", len(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respeta page y per_page", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?page=1&per_page=2", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got.Items) != 2 {
|
||||
t.Errorf("got %d items, want 2", len(got.Items))
|
||||
}
|
||||
if got.TotalPages != 3 {
|
||||
t.Errorf("got total_pages=%d, want 3", got.TotalPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filtra por campo con filter_<field>", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_status=archived", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 2 {
|
||||
t.Errorf("got total=%d, want 2 archived", got.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordena con sort_by y sort_dir", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?sort_by=name&sort_dir=asc", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got.Items) < 2 {
|
||||
t.Fatalf("not enough items: %d", len(got.Items))
|
||||
}
|
||||
if got.Items[0]["name"] != "a" || got.Items[1]["name"] != "b" {
|
||||
t.Errorf("sort asc failed: first=%v second=%v", got.Items[0]["name"], got.Items[1]["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignora filtros con campos desconocidos", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_unknown=xxx", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 5 {
|
||||
t.Errorf("got total=%d, want 5 (filter should be ignored)", got.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDUpdateHandler ---
|
||||
|
||||
func TestCRUDUpdateHandler(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
createRec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "original", "description": "first", "priority": 2,
|
||||
})
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create failed: %d", createRec.Code)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
originalUpdatedAt := fmt.Sprintf("%v", created["updated_at"])
|
||||
|
||||
t.Run("actualiza solo los campos enviados", func(t *testing.T) {
|
||||
rec, body := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{
|
||||
"description": "updated",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body["description"] != "updated" {
|
||||
t.Errorf("description not updated: %v", body["description"])
|
||||
}
|
||||
if body["name"] != "original" {
|
||||
t.Errorf("name should not change: %v", body["name"])
|
||||
}
|
||||
if fmt.Sprintf("%v", body["updated_at"]) == originalUpdatedAt {
|
||||
t.Errorf("updated_at should change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 404 si no existe", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/nonexistent", map[string]any{"description": "x"})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida los campos enviados", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{"priority": 999})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDDeleteHandler ---
|
||||
|
||||
func TestCRUDDeleteHandler(t *testing.T) {
|
||||
t.Run("hard delete borra fisicamente", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "hard"})
|
||||
id := created["id"].(string)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("got %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body, got %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Verificar que ya no existe
|
||||
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("after delete, GET got %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Verificar que la fila ya no esta en la tabla
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM projects WHERE id = ?", id).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("hard delete should remove row, got count=%d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft delete actualiza deleted_at", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, true)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "soft"})
|
||||
id := created["id"].(string)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("got %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
// GET debe dar 404 (la fila esta oculta por soft delete)
|
||||
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("after soft delete, GET got %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Pero la fila sigue fisica en la tabla con deleted_at no nulo
|
||||
var deletedAt sql.NullString
|
||||
if err := db.QueryRow("SELECT deleted_at FROM projects WHERE id = ?", id).Scan(&deletedAt); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if !deletedAt.Valid || deletedAt.String == "" {
|
||||
t.Errorf("expected deleted_at set, got %+v", deletedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 404 si no existe", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/nonexistent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft delete no lista registros ocultos", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, true)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "keep"})
|
||||
_, del := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "remove"})
|
||||
delID := del["id"].(string)
|
||||
_, _ = doJSONRequest(t, mux, "DELETE", "/api/projects/"+delID, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 1 {
|
||||
t.Errorf("got total=%d, want 1 (soft deleted should be hidden)", got.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDGenerateHandlers + CRUDRegisterRoutes integration ---
|
||||
|
||||
func TestCRUDGenerateHandlers(t *testing.T) {
|
||||
t.Run("retorna las 5 keys esperadas", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
handlers := CRUDGenerateHandlers(res, db)
|
||||
for _, key := range []string{"list", "get", "create", "update", "delete"} {
|
||||
if handlers[key] == nil {
|
||||
t.Errorf("handler %q is nil", key)
|
||||
}
|
||||
}
|
||||
if len(handlers) != 5 {
|
||||
t.Errorf("expected 5 handlers, got %d", len(handlers))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCRUDRegisterRoutesIntegration(t *testing.T) {
|
||||
t.Run("CRUD completo end-to-end via servidor HTTP", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Create
|
||||
createBody, _ := json.Marshal(map[string]any{"name": "e2e", "description": "integration", "priority": 3})
|
||||
resp, err := http.Post(srv.URL+"/api/projects", "application/json", bytes.NewReader(createBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: got %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var created map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
id := created["id"].(string)
|
||||
|
||||
// Get
|
||||
resp, err = http.Get(srv.URL + "/api/projects/" + id)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("get: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Update
|
||||
updateBody, _ := json.Marshal(map[string]any{"description": "modified"})
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/projects/"+id, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
var updated map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&updated)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("update: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if updated["description"] != "modified" {
|
||||
t.Errorf("update did not persist: %v", updated["description"])
|
||||
}
|
||||
|
||||
// List
|
||||
resp, err = http.Get(srv.URL + "/api/projects")
|
||||
if err != nil {
|
||||
t.Fatalf("LIST: %v", err)
|
||||
}
|
||||
var list CRUDListResult
|
||||
_ = json.NewDecoder(resp.Body).Decode(&list)
|
||||
resp.Body.Close()
|
||||
if list.Total != 1 {
|
||||
t.Errorf("list total: got %d, want 1", list.Total)
|
||||
}
|
||||
|
||||
// Delete
|
||||
req, _ = http.NewRequest("DELETE", srv.URL+"/api/projects/"+id, nil)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DELETE: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete: got %d, want 204", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Get after delete: 404
|
||||
resp, _ = http.Get(srv.URL + "/api/projects/" + id)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("after delete: got %d, want 404", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples recursos en el mismo mux", func(t *testing.T) {
|
||||
projectRes, db := buildProjectResource(t, false)
|
||||
// Segundo recurso sobre la misma DB
|
||||
userFields := []CRUDField{
|
||||
{Name: "email", Type: "TEXT", Required: true, Unique: true},
|
||||
}
|
||||
userRes, err := CRUDDefineResource("user", "users", userFields, false)
|
||||
if err != nil {
|
||||
t.Fatalf("define user: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(userRes)); err != nil {
|
||||
t.Fatalf("create users table: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", projectRes, db)
|
||||
CRUDRegisterRoutes(mux, "/api/users", userRes, db)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "p1"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("projects: got %d", rec.Code)
|
||||
}
|
||||
rec, _ = doJSONRequest(t, mux, "POST", "/api/users", map[string]any{"email": "a@b.c"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("users: got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CRUDUpdateHandler retorna un http.HandlerFunc que hace partial update por id.
|
||||
// Solo actualiza los campos presentes en el body JSON. Valida los campos enviados
|
||||
// contra las reglas del recurso. 404 si no existe (o soft-deleted), 400 si falla validacion.
|
||||
func CRUDUpdateHandler(res CRUDResource, db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "missing_id", Message: "id path parameter is required"})
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{}
|
||||
if err := HTTPParseBody(r, &body, 1<<20); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "invalid_body", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar que existe
|
||||
existsSQL := fmt.Sprintf("SELECT 1 FROM %s WHERE id = ?", res.Table)
|
||||
if res.SoftDelete {
|
||||
existsSQL += " AND deleted_at IS NULL"
|
||||
}
|
||||
var dummy int
|
||||
if err := db.QueryRow(existsSQL, id).Scan(&dummy); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: fmt.Sprintf("%s %q not found", res.Name, id)})
|
||||
return
|
||||
}
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validar los campos presentes
|
||||
setCols := []string{}
|
||||
args := []any{}
|
||||
for _, f := range res.Fields {
|
||||
val, present := body[f.Name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
if err := crudValidateField(f, val); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusBadRequest, Code: "validation_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
setCols = append(setCols, fmt.Sprintf("%s = ?", f.Name))
|
||||
args = append(args, val)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
setCols = append(setCols, "updated_at = ?")
|
||||
args = append(args, now)
|
||||
args = append(args, id)
|
||||
|
||||
updateSQL := fmt.Sprintf("UPDATE %s SET %s WHERE id = ?", res.Table, strings.Join(setCols, ", "))
|
||||
if _, err := db.Exec(updateSQL, args...); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusConflict, Code: "unique_violation", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Leer de vuelta
|
||||
rows, err := db.Query(fmt.Sprintf("SELECT * FROM %s WHERE id = ?", res.Table), id)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if !rows.Next() {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: fmt.Sprintf("%s %q not found after update", res.Name, id)})
|
||||
return
|
||||
}
|
||||
row, err := crudScanRow(rows, cols)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: http.StatusInternalServerError, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPJSONResponse(w, http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: crud_update_handler
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func CRUDUpdateHandler(res CRUDResource, db *sql.DB) http.HandlerFunc"
|
||||
description: "Genera un handler HTTP PUT /{id} que hace partial update del registro. Solo actualiza campos presentes en el body JSON, valida cada uno contra la definicion, actualiza updated_at y retorna el registro. 404 si no existe, 400 si falla validacion, 409 si viola UNIQUE."
|
||||
tags: [crud, update, handler, http, sqlite, partial, infra]
|
||||
uses_functions: [http_json_response_go_infra, http_error_response_go_infra, http_parse_body_go_infra]
|
||||
uses_types: [CRUDResource_go_infra, HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [database/sql, fmt, net/http, strings, time]
|
||||
params:
|
||||
- name: res
|
||||
desc: "definicion del recurso"
|
||||
- name: db
|
||||
desc: "conexion *sql.DB a SQLite"
|
||||
output: "http.HandlerFunc que actualiza parcialmente un registro"
|
||||
tested: true
|
||||
tests: ["actualiza solo los campos enviados", "retorna 404 si no existe", "valida campos enviados", "actualiza updated_at"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_update_handler.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
handler := CRUDUpdateHandler(res, db)
|
||||
mux.Handle("PUT /api/projects/{id}", handler)
|
||||
// curl -X PUT localhost:8080/api/projects/abc -H 'Content-Type: application/json' -d '{"description":"X"}'
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura. Usa PUT con semantica de partial update por pragmatismo (en vez de PATCH). Los campos no enviados se preservan tal cual. updated_at se actualiza siempre, aunque el body este vacio (el handler de igual modo ejecuta UPDATE, consulta la bd). Id va en la ruta, no en el body.
|
||||
@@ -0,0 +1,48 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileDelete elimina un archivo del disco. Rechaza paths que contengan ".." para
|
||||
// evitar path traversal fuera del directorio esperado.
|
||||
//
|
||||
// Retorna error si el archivo no existe (os.ErrNotExist), si el path contiene "..",
|
||||
// o si la operacion de remove falla por permisos.
|
||||
func FileDelete(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("file_delete: path vacio")
|
||||
}
|
||||
|
||||
// Rechazar cualquier path traversal explicito en el input original
|
||||
// (filepath.Clean resuelve `..` y borraria la huella, asi que comprobamos antes)
|
||||
if containsParentRef(path) {
|
||||
return fmt.Errorf("file_delete: path traversal no permitido en %q", path)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
|
||||
if _, err := os.Stat(clean); err != nil {
|
||||
return fmt.Errorf("file_delete: stat %s: %w", clean, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(clean); err != nil {
|
||||
return fmt.Errorf("file_delete: remove %s: %w", clean, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsParentRef detecta si el path tiene un segmento ".." entre separadores.
|
||||
// Acepta tanto "/" como "\" como separadores. No marca como malo nombres como "..bashrc".
|
||||
func containsParentRef(path string) bool {
|
||||
// Normalizar a slashes
|
||||
p := strings.ReplaceAll(path, "\\", "/")
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: file_delete
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func FileDelete(path string) error"
|
||||
description: "Elimina un archivo del disco. Rechaza paths con \"..\" para evitar path traversal. Retorna error si el archivo no existe o si falla el remove."
|
||||
tags: [file, delete, disk, storage, security, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, os, path/filepath, strings]
|
||||
params:
|
||||
- name: path
|
||||
desc: "ruta del archivo a eliminar (no debe contener \"..\")"
|
||||
output: "nil si el archivo se elimino correctamente, error si el path es vacio, contiene path traversal, no existe o falla la operacion"
|
||||
tested: true
|
||||
tests: ["elimina archivo existente", "rechaza path con ..", "rechaza path vacio", "retorna error si no existe"]
|
||||
test_file_path: "functions/infra/file_delete_test.go"
|
||||
file_path: "functions/infra/file_delete.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
err := FileDelete("./uploads/a1b2c3d4.png")
|
||||
if err != nil {
|
||||
log.Printf("delete fallo: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
La proteccion contra path traversal es defensiva pero NO es suficiente por si sola: la app debe pasar paths que ya estan resueltos al directorio de storage (usar `filepath.Join(baseDir, storedName)`). Esta funcion es un cinturon adicional contra bugs en la app que llamaria.
|
||||
|
||||
NO sigue symlinks de forma especial — `os.Remove` borra el symlink, no el target.
|
||||
@@ -0,0 +1,45 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileDelete(t *testing.T) {
|
||||
t.Run("elimina archivo existente", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "x.txt")
|
||||
if err := os.WriteFile(path, []byte("hi"), 0o644); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
if err := FileDelete(path); err != nil {
|
||||
t.Fatalf("FileDelete err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("archivo aun existe: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza path con ..", func(t *testing.T) {
|
||||
err := FileDelete("./uploads/../etc/passwd")
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Errorf("got err %v, want path traversal", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza path vacio", func(t *testing.T) {
|
||||
err := FileDelete("")
|
||||
if err == nil {
|
||||
t.Error("got nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna error si no existe", func(t *testing.T) {
|
||||
err := FileDelete(filepath.Join(t.TempDir(), "nope.txt"))
|
||||
if err == nil {
|
||||
t.Error("got nil, want error not found")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileSaveDisk escribe el contenido de data en baseDir con un nombre unico generado a
|
||||
// partir de filename original. Crea baseDir si no existe.
|
||||
//
|
||||
// Retorna el UploadedFile con la metadata y la ruta completa en disco. El campo
|
||||
// ContentType se infiere de la extension via mime.TypeByExtension; si la app necesita
|
||||
// validacion mas estricta, debe usar FileValidateType antes y/o sobreescribir el campo.
|
||||
func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error) {
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return UploadedFile{}, fmt.Errorf("file_save_disk: mkdir %s: %w", baseDir, err)
|
||||
}
|
||||
|
||||
stored := FileUniqueName(filename)
|
||||
dst := filepath.Join(baseDir, stored)
|
||||
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return UploadedFile{}, fmt.Errorf("file_save_disk: create %s: %w", dst, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, data)
|
||||
if err != nil {
|
||||
_ = os.Remove(dst)
|
||||
return UploadedFile{}, fmt.Errorf("file_save_disk: copy: %w", err)
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(stored))
|
||||
ct := mime.TypeByExtension(ext)
|
||||
if ct == "" {
|
||||
ct = "application/octet-stream"
|
||||
}
|
||||
|
||||
return UploadedFile{
|
||||
Filename: filename,
|
||||
StoredName: stored,
|
||||
Size: n,
|
||||
ContentType: ct,
|
||||
Path: dst,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: file_save_disk
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func FileSaveDisk(baseDir string, filename string, data io.Reader) (UploadedFile, error)"
|
||||
description: "Escribe el contenido de un io.Reader a disco en baseDir con un nombre unico (UUID + extension). Crea el directorio si no existe. Retorna UploadedFile con metadata."
|
||||
tags: [file, save, disk, storage, upload, infra]
|
||||
uses_functions: [file_unique_name_go_infra]
|
||||
uses_types: [UploadedFile_go_infra]
|
||||
returns: [UploadedFile_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, io, mime, os, path/filepath, strings, time]
|
||||
params:
|
||||
- name: baseDir
|
||||
desc: "directorio destino (se crea si no existe, permisos 0755)"
|
||||
- name: filename
|
||||
desc: "nombre original del archivo (solo se usa para extraer la extension)"
|
||||
- name: data
|
||||
desc: "reader con el contenido binario a escribir"
|
||||
output: "UploadedFile con StoredName (UUID-based), Path completo, Size en bytes, ContentType inferido por extension y CreatedAt (UTC). Error si falla mkdir, create o copy"
|
||||
tested: true
|
||||
tests: ["guarda contenido en baseDir con nombre UUID", "crea baseDir si no existe", "tamano coincide con bytes escritos", "infiere ContentType desde la extension"]
|
||||
test_file_path: "functions/infra/file_save_disk_test.go"
|
||||
file_path: "functions/infra/file_save_disk.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
f, _ := os.Open("./input.png")
|
||||
defer f.Close()
|
||||
uploaded, err := FileSaveDisk("./uploads", "input.png", f)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(uploaded.Path) // ./uploads/{uuid}.png
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El nombre original NUNCA se usa como nombre en disco (riesgo path traversal). Solo se preserva como metadata en el campo `Filename` para trazabilidad.
|
||||
- ContentType se infiere de la extension via `mime.TypeByExtension`. Para validacion estricta del tipo real, llamar `FileValidateType` ANTES de guardar y/o sobreescribir el campo.
|
||||
- Si falla el `io.Copy`, el archivo parcial se borra automaticamente.
|
||||
@@ -0,0 +1,62 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileSaveDisk(t *testing.T) {
|
||||
t.Run("guarda contenido en baseDir con nombre UUID", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := strings.NewReader("hello world")
|
||||
got, err := FileSaveDisk(dir, "saludos.txt", body)
|
||||
if err != nil {
|
||||
t.Fatalf("FileSaveDisk err: %v", err)
|
||||
}
|
||||
if got.Filename != "saludos.txt" {
|
||||
t.Errorf("got Filename %q, want saludos.txt", got.Filename)
|
||||
}
|
||||
if !strings.HasSuffix(got.StoredName, ".txt") {
|
||||
t.Errorf("got StoredName %q, want suffix .txt", got.StoredName)
|
||||
}
|
||||
if !strings.HasPrefix(got.Path, dir) {
|
||||
t.Errorf("got Path %q, want prefix %q", got.Path, dir)
|
||||
}
|
||||
if got.Size != int64(len("hello world")) {
|
||||
t.Errorf("got Size %d, want %d", got.Size, len("hello world"))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(got.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != "hello world" {
|
||||
t.Errorf("contenido en disco %q, want %q", data, "hello world")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crea baseDir si no existe", func(t *testing.T) {
|
||||
base := filepath.Join(t.TempDir(), "nested", "uploads")
|
||||
body := strings.NewReader("x")
|
||||
got, err := FileSaveDisk(base, "a.png", body)
|
||||
if err != nil {
|
||||
t.Fatalf("FileSaveDisk err: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(got.Path); err != nil {
|
||||
t.Fatalf("archivo no escrito: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("infiere ContentType desde la extension", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := FileSaveDisk(dir, "logo.png", strings.NewReader("x"))
|
||||
if err != nil {
|
||||
t.Fatalf("err: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got.ContentType, "image/png") {
|
||||
t.Errorf("got ContentType %q, want image/png prefix", got.ContentType)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileServe retorna un http.Handler que sirve archivos estaticos desde dir.
|
||||
// Stripea pathPrefix del request URL antes de buscar el archivo, y setea el
|
||||
// header Cache-Control con max-age=maxAge segundos.
|
||||
//
|
||||
// El handler rechaza cualquier path que contenga ".." para mitigar path traversal,
|
||||
// aunque http.FileServer ya hace su propia normalizacion.
|
||||
func FileServe(dir string, pathPrefix string, maxAge int) http.Handler {
|
||||
fs := http.FileServer(http.Dir(dir))
|
||||
cacheControl := fmt.Sprintf("public, max-age=%d", maxAge)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Rechazar path traversal explicito
|
||||
if strings.Contains(r.URL.Path, "..") {
|
||||
http.Error(w, "path traversal not allowed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
http.StripPrefix(pathPrefix, fs).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: file_serve
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func FileServe(dir string, pathPrefix string, maxAge int) http.Handler"
|
||||
description: "Retorna un http.Handler que sirve archivos estaticos desde dir, stripeando pathPrefix del URL. Setea Cache-Control con max-age. Rechaza paths con \"..\"."
|
||||
tags: [http, file, serve, static, cache, security, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, net/http, strings]
|
||||
params:
|
||||
- name: dir
|
||||
desc: "directorio raiz desde donde se sirven los archivos"
|
||||
- name: pathPrefix
|
||||
desc: "prefijo del URL a remover antes de buscar (ej: \"/files/\")"
|
||||
- name: maxAge
|
||||
desc: "segundos para el header Cache-Control max-age"
|
||||
output: "http.Handler listo para registrar en un mux. No retorna error directo; el handler responde 400 si detecta path traversal y delega al http.FileServer en otros casos"
|
||||
tested: true
|
||||
tests: ["sirve archivo existente con headers de cache", "responde 404 para archivo inexistente", "rechaza path con .. con 400"]
|
||||
test_file_path: "functions/infra/file_serve_test.go"
|
||||
file_path: "functions/infra/file_serve.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/files/", FileServe("./uploads", "/files/", 3600))
|
||||
http.ListenAndServe(":8080", mux)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Wrapper sobre `http.FileServer` con dos refuerzos: rechazo explicito de paths con `..` y header `Cache-Control` configurable. `http.FileServer` ya normaliza paths, pero la doble verificacion es barata y reduce la superficie de ataque. Para servir archivos generados dinamicamente o detras de auth, no usar esta funcion — usar handlers custom.
|
||||
@@ -0,0 +1,52 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileServe(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hola mundo"), 0o644); err != nil {
|
||||
t.Fatalf("setup: %v", err)
|
||||
}
|
||||
|
||||
handler := FileServe(dir, "/files/", 60)
|
||||
|
||||
t.Run("sirve archivo existente con headers de cache", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/files/hello.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != "hola mundo" {
|
||||
t.Errorf("got body %q", rec.Body.String())
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=60" {
|
||||
t.Errorf("got Cache-Control %q", cc)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde 404 para archivo inexistente", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/files/missing.txt", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza path con .. con 400", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/files/../etc/passwd", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// FileUniqueName genera un nombre de archivo unico combinando un UUID v4 con la
|
||||
// extension sanitizada del nombre original.
|
||||
//
|
||||
// Ejemplo: "vacaciones.PNG" -> "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
|
||||
//
|
||||
// La extension se sanitiza: solo se conservan caracteres alfanumericos en minusculas
|
||||
// y se trunca a 16 caracteres como maximo. Si el archivo no tiene extension, se
|
||||
// retorna solo el UUID.
|
||||
//
|
||||
// La funcion es "pura en intencion" en el sentido de que su firma no depende del
|
||||
// contexto, pero internamente usa un generador de UUIDs aleatorios — el resultado
|
||||
// no es determinista.
|
||||
func FileUniqueName(originalName string) string {
|
||||
id := uuid.NewString()
|
||||
|
||||
ext := filepath.Ext(originalName)
|
||||
ext = strings.TrimPrefix(ext, ".")
|
||||
ext = sanitizeExt(ext)
|
||||
if ext == "" {
|
||||
return id
|
||||
}
|
||||
return id + "." + ext
|
||||
}
|
||||
|
||||
// sanitizeExt deja solo caracteres alfanumericos en minusculas y trunca a 16 chars.
|
||||
func sanitizeExt(ext string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(ext) {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
if b.Len() >= 16 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: file_unique_name
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FileUniqueName(originalName string) string"
|
||||
description: "Genera un nombre de archivo unico combinando un UUID v4 con la extension sanitizada del nombre original. Evita colisiones y elimina problemas con caracteres especiales."
|
||||
tags: [file, unique, name, uuid, upload, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [path/filepath, strings, unicode, github.com/google/uuid]
|
||||
params:
|
||||
- name: originalName
|
||||
desc: "nombre original del archivo (puede contener path, espacios, caracteres especiales)"
|
||||
output: "nombre unico {uuid}.{ext} con extension sanitizada (alfanumerica, minusculas, max 16 chars). Si no hay extension retorna solo el UUID"
|
||||
tested: true
|
||||
tests: ["preserva extension comun como png", "convierte extension a minusculas", "remueve caracteres especiales en extension", "genera UUID sin extension si el archivo no tiene", "trunca extensiones extremadamente largas"]
|
||||
test_file_path: "functions/infra/file_unique_name_test.go"
|
||||
file_path: "functions/infra/file_unique_name.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
n1 := FileUniqueName("vacaciones.PNG")
|
||||
// n1 = "a1b2c3d4-e5f6-7890-abcd-ef1234567890.png"
|
||||
|
||||
n2 := FileUniqueName("contrato sin extension")
|
||||
// n2 = "f9b6c2d1-..." (solo UUID)
|
||||
|
||||
n3 := FileUniqueName("malicious; rm -rf /.exe.txt")
|
||||
// n3 = "{uuid}.txt"
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Marcada como `pure` por contrato (no hace I/O ni depende de estado mutable explicitamente), pero internamente la generacion del UUID v4 usa un PRNG por lo que el resultado NO es determinista. Esto es aceptable en la convencion del registry: la pureza se refiere a la ausencia de side effects observables (no escribe a disco, red, ni globals), no al determinismo bit a bit.
|
||||
|
||||
La extension se sanitiza para evitar:
|
||||
- Path traversal en disco (ej: `../../etc/passwd`)
|
||||
- Inyeccion de comandos en logs/UI
|
||||
- Ambiguedad de filesystem entre mayus/minus
|
||||
@@ -0,0 +1,63 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileUniqueName(t *testing.T) {
|
||||
t.Run("preserva extension comun como png", func(t *testing.T) {
|
||||
got := FileUniqueName("foto.png")
|
||||
if !strings.HasSuffix(got, ".png") {
|
||||
t.Fatalf("got %q, want suffix .png", got)
|
||||
}
|
||||
if len(got) < 36+4 { // uuid + ".png"
|
||||
t.Fatalf("got %q, want UUID + .png", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("convierte extension a minusculas", func(t *testing.T) {
|
||||
got := FileUniqueName("VACACIONES.JPEG")
|
||||
if !strings.HasSuffix(got, ".jpeg") {
|
||||
t.Fatalf("got %q, want suffix .jpeg", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remueve caracteres especiales en extension", func(t *testing.T) {
|
||||
got := FileUniqueName("malicious.t!x@t#")
|
||||
if !strings.HasSuffix(got, ".txt") {
|
||||
t.Fatalf("got %q, want suffix .txt", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("genera UUID sin extension si el archivo no tiene", func(t *testing.T) {
|
||||
got := FileUniqueName("contrato_sin_extension")
|
||||
if strings.Contains(got, ".") {
|
||||
t.Fatalf("got %q, want sin punto", got)
|
||||
}
|
||||
if len(got) != 36 {
|
||||
t.Fatalf("got %q (len %d), want UUID len 36", got, len(got))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trunca extensiones extremadamente largas", func(t *testing.T) {
|
||||
got := FileUniqueName("file." + strings.Repeat("a", 100))
|
||||
// Buscar la ultima parte despues del punto
|
||||
idx := strings.LastIndex(got, ".")
|
||||
if idx < 0 {
|
||||
t.Fatalf("got %q, want al menos un punto", got)
|
||||
}
|
||||
ext := got[idx+1:]
|
||||
if len(ext) > 16 {
|
||||
t.Fatalf("got ext len %d, want <= 16", len(ext))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dos llamadas generan IDs distintos", func(t *testing.T) {
|
||||
a := FileUniqueName("x.png")
|
||||
b := FileUniqueName("x.png")
|
||||
if a == b {
|
||||
t.Fatalf("got %q == %q, want distintos", a, b)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package infra
|
||||
|
||||
import "bytes"
|
||||
|
||||
// fileSignature describe el magic byte signature de un tipo de archivo.
|
||||
type fileSignature struct {
|
||||
mime string
|
||||
prefix []byte
|
||||
// Para WebP: el prefix son los primeros 4 bytes "RIFF", luego 4 bytes de tamaño,
|
||||
// luego suffix en offset 8: "WEBP".
|
||||
suffix []byte
|
||||
suffixOffset int
|
||||
}
|
||||
|
||||
// fileSignatures es la tabla interna de magic bytes soportados.
|
||||
var fileSignatures = []fileSignature{
|
||||
{mime: "image/jpeg", prefix: []byte{0xFF, 0xD8, 0xFF}},
|
||||
{mime: "image/png", prefix: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}},
|
||||
{mime: "image/gif", prefix: []byte{0x47, 0x49, 0x46, 0x38}},
|
||||
{mime: "application/pdf", prefix: []byte{0x25, 0x50, 0x44, 0x46}},
|
||||
{mime: "image/webp", prefix: []byte{0x52, 0x49, 0x46, 0x46}, suffix: []byte{0x57, 0x45, 0x42, 0x50}, suffixOffset: 8},
|
||||
{mime: "application/zip", prefix: []byte{0x50, 0x4B, 0x03, 0x04}},
|
||||
}
|
||||
|
||||
// FileValidateType detecta el MIME type real de un archivo a partir de sus primeros
|
||||
// bytes (magic bytes / file signature) y verifica que esta en la lista permitida.
|
||||
//
|
||||
// Retorna el MIME type detectado y true si esta permitido. Si no se puede detectar
|
||||
// el tipo o no esta en allowedTypes, retorna "" y false.
|
||||
//
|
||||
// Funcion pura — no hace I/O. La validacion por magic bytes es mas segura que confiar
|
||||
// en el header Content-Type del request, que puede ser falsificado.
|
||||
func FileValidateType(header []byte, allowedTypes []string) (string, bool) {
|
||||
mime := detectMimeType(header)
|
||||
if mime == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, allowed := range allowedTypes {
|
||||
if allowed == mime {
|
||||
return mime, true
|
||||
}
|
||||
}
|
||||
return mime, false
|
||||
}
|
||||
|
||||
// detectMimeType busca el primer signature que matchee header.
|
||||
func detectMimeType(header []byte) string {
|
||||
for _, sig := range fileSignatures {
|
||||
if len(header) < len(sig.prefix) {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(header[:len(sig.prefix)], sig.prefix) {
|
||||
continue
|
||||
}
|
||||
if len(sig.suffix) > 0 {
|
||||
end := sig.suffixOffset + len(sig.suffix)
|
||||
if len(header) < end {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(header[sig.suffixOffset:end], sig.suffix) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return sig.mime
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: file_validate_type
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func FileValidateType(header []byte, allowedTypes []string) (string, bool)"
|
||||
description: "Detecta el MIME type real de un archivo a partir de sus primeros bytes (magic bytes) y verifica que esta en la lista de tipos permitidos. Mas seguro que confiar en el header Content-Type del request."
|
||||
tags: [file, validate, mime, magic, security, upload, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [bytes]
|
||||
params:
|
||||
- name: header
|
||||
desc: "primeros bytes del archivo (al menos 12 bytes para detectar todos los formatos soportados)"
|
||||
- name: allowedTypes
|
||||
desc: "lista blanca de MIME types permitidos (ej: [\"image/png\", \"image/jpeg\", \"application/pdf\"])"
|
||||
output: "tupla (mime_detectado, permitido). Si no se reconoce el tipo retorna (\"\", false). Si se reconoce pero no esta en allowedTypes retorna (mime, false)"
|
||||
tested: true
|
||||
tests: ["detecta JPEG por magic bytes", "detecta PNG por magic bytes", "detecta PDF", "detecta WebP con prefix RIFF y suffix WEBP", "rechaza tipo no permitido", "tipo desconocido retorna vacio"]
|
||||
test_file_path: "functions/infra/file_validate_type_test.go"
|
||||
file_path: "functions/infra/file_validate_type.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
data, _ := os.ReadFile("./uploads/some.bin")
|
||||
mime, ok := FileValidateType(data[:12], []string{"image/png", "image/jpeg"})
|
||||
if !ok {
|
||||
log.Printf("tipo no permitido: %s", mime)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — sin I/O, determinista. Tabla interna de signatures soportados:
|
||||
|
||||
| Tipo | Magic bytes |
|
||||
|------|-------------|
|
||||
| JPEG | `FF D8 FF` |
|
||||
| PNG | `89 50 4E 47 0D 0A 1A 0A` |
|
||||
| GIF | `47 49 46 38` |
|
||||
| PDF | `25 50 44 46` |
|
||||
| WebP | `52 49 46 46 ?? ?? ?? ?? 57 45 42 50` |
|
||||
| ZIP | `50 4B 03 04` |
|
||||
|
||||
NO es un antivirus. Solo verifica los primeros bytes — un archivo puede tener magic valido pero contenido malicioso despues. Para apps con requisitos de seguridad altos, complementar con escaneo adicional.
|
||||
@@ -0,0 +1,72 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFileValidateType(t *testing.T) {
|
||||
allowed := []string{"image/png", "image/jpeg", "application/pdf", "image/webp"}
|
||||
|
||||
t.Run("detecta JPEG por magic bytes", func(t *testing.T) {
|
||||
header := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/jpeg" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/jpeg,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta PNG por magic bytes", func(t *testing.T) {
|
||||
header := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/png" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/png,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta PDF", func(t *testing.T) {
|
||||
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x37}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "application/pdf" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (application/pdf,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detecta WebP con prefix RIFF y suffix WEBP", func(t *testing.T) {
|
||||
header := []byte{
|
||||
0x52, 0x49, 0x46, 0x46, // RIFF
|
||||
0xAA, 0xBB, 0xCC, 0xDD, // tamano (cualquier valor)
|
||||
0x57, 0x45, 0x42, 0x50, // WEBP
|
||||
0x56, 0x50, 0x38, 0x20,
|
||||
}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "image/webp" || !ok {
|
||||
t.Fatalf("got (%q,%v), want (image/webp,true)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tipo no permitido", func(t *testing.T) {
|
||||
// PDF detectado, pero no en allowedTypes
|
||||
header := []byte{0x25, 0x50, 0x44, 0x46, 0x2D}
|
||||
mime, ok := FileValidateType(header, []string{"image/png"})
|
||||
if mime != "application/pdf" {
|
||||
t.Fatalf("got mime %q, want application/pdf", mime)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("got ok=true, want false (PDF no en allowedTypes)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tipo desconocido retorna vacio", func(t *testing.T) {
|
||||
header := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "" || ok {
|
||||
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header demasiado corto retorna vacio", func(t *testing.T) {
|
||||
header := []byte{0xFF}
|
||||
mime, ok := FileValidateType(header, allowed)
|
||||
if mime != "" || ok {
|
||||
t.Fatalf("got (%q,%v), want (\"\",false)", mime, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package infra
|
||||
|
||||
// JWTClaims contiene claims estandar y custom para un JWT.
|
||||
// Incluye los campos registrados mas comunes (sub, iss, aud, exp, iat)
|
||||
// y un mapa libre `Custom` para claims de aplicacion (ej: role, email).
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JWTGenerate codifica un JWT firmado con HMAC-SHA256 (alg: HS256).
|
||||
// Si claims.IssuedAt viene en cero se setea a time.Now().Unix().
|
||||
// Retorna el token en formato "header.payload.signature" con los tres segmentos
|
||||
// codificados en base64url sin padding.
|
||||
func JWTGenerate(claims JWTClaims, secret string) (string, error) {
|
||||
if secret == "" {
|
||||
return "", errors.New("jwt_generate: secret vacio")
|
||||
}
|
||||
if claims.IssuedAt == 0 {
|
||||
claims.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
|
||||
header := map[string]string{"alg": "HS256", "typ": "JWT"}
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
enc := base64.RawURLEncoding
|
||||
headerPart := enc.EncodeToString(headerJSON)
|
||||
payloadPart := enc.EncodeToString(payloadJSON)
|
||||
signingInput := headerPart + "." + payloadPart
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signingInput))
|
||||
sigPart := enc.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return signingInput + "." + sigPart, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: jwt_generate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func JWTGenerate(claims JWTClaims, secret string) (string, error)"
|
||||
description: "Codifica y firma un JWT con HMAC-SHA256 (HS256). Retorna el token en formato header.payload.signature. Setea IssuedAt automaticamente si viene en cero."
|
||||
tags: [jwt, auth, token, hmac, sign, infra]
|
||||
uses_functions: []
|
||||
uses_types: [JWTClaims_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, time]
|
||||
params:
|
||||
- name: claims
|
||||
desc: "claims del JWT (sub, iss, aud, exp, iat, custom). Si IssuedAt es 0 se rellena con time.Now()"
|
||||
- name: secret
|
||||
desc: "clave HMAC para firmar. No debe estar vacia. Obtenerla de env var o pass_get, nunca hardcoded"
|
||||
output: "token JWT firmado en formato base64url header.payload.signature"
|
||||
tested: true
|
||||
tests: ["genera token valido con claims completas", "setea IssuedAt si viene en cero", "error si secret vacio"]
|
||||
test_file_path: "functions/infra/jwt_generate_test.go"
|
||||
file_path: "functions/infra/jwt_generate.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
claims := JWTClaims{
|
||||
Subject: "user-123",
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
|
||||
Custom: map[string]any{"role": "admin"},
|
||||
}
|
||||
token, err := JWTGenerate(claims, os.Getenv("JWT_SECRET"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Authorization", "Bearer " + token)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — usa `time.Now()` para el claim `iat` cuando no viene fijado. Implementa HS256 sin libreria externa (solo stdlib crypto/hmac + crypto/sha256). Solo soporta HS256: para RS256/ES256 se crearia una funcion separada. El secret debe tener al menos 256 bits de entropia (32+ bytes aleatorios) para resistencia real. NO apto para escenarios multi-servicio donde se necesita clave publica/privada — usa RS256 en ese caso.
|
||||
@@ -0,0 +1,54 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJWTGenerate_ReturnsThreeSegments(t *testing.T) {
|
||||
claims := JWTClaims{
|
||||
Subject: "user-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
token, err := JWTGenerate(claims, "test-secret-0123456789abcdef")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate error: %v", err)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("esperados 3 segmentos, got %d en %q", len(parts), token)
|
||||
}
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
t.Fatalf("segmento %d vacio", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTGenerate_FillsIssuedAtWhenZero(t *testing.T) {
|
||||
claims := JWTClaims{
|
||||
Subject: "user-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
}
|
||||
before := time.Now().Unix()
|
||||
token, err := JWTGenerate(claims, "s")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate error: %v", err)
|
||||
}
|
||||
after := time.Now().Unix()
|
||||
parsed, err := JWTValidate(token, "s")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTValidate error: %v", err)
|
||||
}
|
||||
if parsed.IssuedAt < before || parsed.IssuedAt > after {
|
||||
t.Fatalf("IssuedAt %d fuera del rango [%d,%d]", parsed.IssuedAt, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTGenerate_ErrorsOnEmptySecret(t *testing.T) {
|
||||
_, err := JWTGenerate(JWTClaims{Subject: "x"}, "")
|
||||
if err == nil {
|
||||
t.Fatal("esperaba error con secret vacio")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// jwtCtxKey es el tipo no exportado usado como key del context para las claims
|
||||
// inyectadas por JWTMiddleware. Usar un tipo dedicado evita colisiones con
|
||||
// otros middlewares que guarden valores en el context.
|
||||
type jwtCtxKey struct{}
|
||||
|
||||
// JWTClaimsFromContext extrae las claims inyectadas por JWTMiddleware.
|
||||
// Retorna (claims, true) si existen en el context, o (zero, false) si no.
|
||||
func JWTClaimsFromContext(ctx context.Context) (JWTClaims, bool) {
|
||||
v, ok := ctx.Value(jwtCtxKey{}).(JWTClaims)
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// JWTMiddleware retorna un Middleware que extrae el JWT del header
|
||||
// Authorization: Bearer <token>, lo valida con JWTValidate, e inyecta las
|
||||
// claims en el context para que handlers posteriores las lean con
|
||||
// JWTClaimsFromContext. Responde 401 si el header falta, tiene formato
|
||||
// incorrecto o el token es invalido/expirado.
|
||||
func JWTMiddleware(secret string) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "missing_token",
|
||||
Message: "falta header Authorization",
|
||||
})
|
||||
return
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "invalid_token",
|
||||
Message: "se esperaba Authorization: Bearer <token>",
|
||||
})
|
||||
return
|
||||
}
|
||||
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
|
||||
claims, err := JWTValidate(token, secret)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusUnauthorized, Code: "invalid_token",
|
||||
Message: "token invalido",
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), jwtCtxKey{}, claims)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: jwt_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func JWTMiddleware(secret string) Middleware"
|
||||
description: "Middleware HTTP que extrae el JWT del header Authorization: Bearer y valida con JWTValidate. Inyecta las claims en el context del request (recuperables con JWTClaimsFromContext). Responde 401 si falta el header, formato incorrecto o token invalido."
|
||||
tags: [jwt, auth, middleware, http, server, infra]
|
||||
uses_functions: [jwt_validate_go_infra, http_error_response_go_infra]
|
||||
uses_types: [JWTClaims_go_infra, Middleware_go_infra, HTTPError_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [context, net/http, strings]
|
||||
params:
|
||||
- name: secret
|
||||
desc: "clave HMAC para JWTValidate. Debe ser la misma usada en JWTGenerate"
|
||||
output: "Middleware que protege handlers con validacion JWT. Las claims se inyectan en r.Context() con una key privada"
|
||||
tested: true
|
||||
tests: ["pasa con token valido", "401 sin header Authorization", "401 con formato distinto de Bearer", "401 con token invalido", "claims accesibles via JWTClaimsFromContext"]
|
||||
test_file_path: "functions/infra/jwt_middleware_test.go"
|
||||
file_path: "functions/infra/jwt_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
protected := HTTPMiddlewareChain(
|
||||
HTTPLoggerMiddleware(os.Stderr),
|
||||
JWTMiddleware(os.Getenv("JWT_SECRET")),
|
||||
)
|
||||
mux.Handle("GET /api/me", protected(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, _ := JWTClaimsFromContext(r.Context())
|
||||
HTTPJSONResponse(w, 200, map[string]string{"user_id": claims.Subject})
|
||||
})))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — lee headers y modifica el request. Expone el helper JWTClaimsFromContext(ctx) que devuelve (JWTClaims, bool) — el bool permite distinguir "no autenticado" de "subject vacio". Usa `context.WithValue` con una key de tipo privado `jwtCtxKey struct{}` para evitar colisiones con otros middlewares. Solo soporta cabecera `Authorization: Bearer`; para leer token desde cookie se crearia un middleware separado. En las respuestas 401 no se da detalle del motivo (token expirado vs firma invalida) para no filtrar informacion, el motivo real esta en los logs si se compone con HTTPLoggerMiddleware.
|
||||
@@ -0,0 +1,94 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTokenFor(t *testing.T, subject, secret string) string {
|
||||
t.Helper()
|
||||
tok, err := JWTGenerate(JWTClaims{
|
||||
Subject: subject,
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
}, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate: %v", err)
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_ValidToken(t *testing.T) {
|
||||
secret := "test-sec"
|
||||
token := makeTokenFor(t, "alice", secret)
|
||||
|
||||
var gotSubject string
|
||||
handler := JWTMiddleware(secret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := JWTClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
t.Error("JWTClaimsFromContext no encontro claims")
|
||||
}
|
||||
gotSubject = claims.Subject
|
||||
w.WriteHeader(200)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
if gotSubject != "alice" {
|
||||
t.Errorf("subject = %q", gotSubject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_MissingAuthHeader(t *testing.T) {
|
||||
handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_WrongFormat(t *testing.T) {
|
||||
handler := JWTMiddleware("s")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Basic abcdef")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTMiddleware_InvalidToken(t *testing.T) {
|
||||
handler := JWTMiddleware("secret-a")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("no deberia ejecutarse")
|
||||
}))
|
||||
tok := makeTokenFor(t, "x", "secret-b") // firmado con otro secret
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != 401 {
|
||||
t.Errorf("status = %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTClaimsFromContext_NotPresent(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
_, ok := JWTClaimsFromContext(req.Context())
|
||||
if ok {
|
||||
t.Fatal("no deberia haber claims en un context nuevo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JWTValidate verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims.
|
||||
// Rechaza tokens mal formados, con firma invalida o expirados (exp < time.Now()).
|
||||
// Retorna las claims si todo es valido.
|
||||
func JWTValidate(token string, secret string) (JWTClaims, error) {
|
||||
var zero JWTClaims
|
||||
if secret == "" {
|
||||
return zero, errors.New("jwt_validate: secret vacio")
|
||||
}
|
||||
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return zero, errors.New("jwt_validate: token malformado (se esperaban 3 segmentos)")
|
||||
}
|
||||
|
||||
enc := base64.RawURLEncoding
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
|
||||
// Verificar firma
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signingInput))
|
||||
expectedSig := mac.Sum(nil)
|
||||
|
||||
gotSig, err := enc.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return zero, errors.New("jwt_validate: firma mal codificada")
|
||||
}
|
||||
if !hmac.Equal(expectedSig, gotSig) {
|
||||
return zero, errors.New("jwt_validate: firma invalida")
|
||||
}
|
||||
|
||||
// Decodificar header y confirmar alg
|
||||
headerBytes, err := enc.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return zero, errors.New("jwt_validate: header mal codificado")
|
||||
}
|
||||
var header map[string]string
|
||||
if err := json.Unmarshal(headerBytes, &header); err != nil {
|
||||
return zero, errors.New("jwt_validate: header no es JSON valido")
|
||||
}
|
||||
if alg, _ := header["alg"]; alg != "HS256" {
|
||||
return zero, errors.New("jwt_validate: algoritmo no soportado (solo HS256)")
|
||||
}
|
||||
|
||||
// Decodificar claims
|
||||
payloadBytes, err := enc.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return zero, errors.New("jwt_validate: payload mal codificado")
|
||||
}
|
||||
var claims JWTClaims
|
||||
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
||||
return zero, errors.New("jwt_validate: payload no es JSON valido")
|
||||
}
|
||||
|
||||
// Validar expiracion si esta presente
|
||||
if claims.ExpiresAt > 0 && time.Now().Unix() >= claims.ExpiresAt {
|
||||
return zero, errors.New("jwt_validate: token expirado")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: jwt_validate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func JWTValidate(token string, secret string) (JWTClaims, error)"
|
||||
description: "Verifica la firma HMAC-SHA256 de un JWT y decodifica sus claims. Rechaza tokens mal formados, con firma invalida o expirados."
|
||||
tags: [jwt, auth, token, hmac, verify, infra]
|
||||
uses_functions: []
|
||||
uses_types: [JWTClaims_go_infra]
|
||||
returns: [JWTClaims_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [crypto/hmac, crypto/sha256, encoding/base64, encoding/json, errors, strings, time]
|
||||
params:
|
||||
- name: token
|
||||
desc: "JWT string en formato header.payload.signature (base64url, sin padding)"
|
||||
- name: secret
|
||||
desc: "clave HMAC usada para firmar el token. Debe coincidir con la usada en JWTGenerate"
|
||||
output: "claims decodificadas si el token es valido; error si firma invalida, expirado o malformado"
|
||||
tested: true
|
||||
tests: ["valida token generado por JWTGenerate", "rechaza firma invalida", "rechaza token expirado", "rechaza token malformado", "rechaza algoritmo distinto de HS256"]
|
||||
test_file_path: "functions/infra/jwt_validate_test.go"
|
||||
file_path: "functions/infra/jwt_validate.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
auth := r.Header.Get("Authorization")
|
||||
token := strings.TrimPrefix(auth, "Bearer ")
|
||||
claims, err := JWTValidate(token, os.Getenv("JWT_SECRET"))
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_token", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
userID := claims.Subject
|
||||
role, _ := claims.Custom["role"].(string)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — usa `time.Now()` para comparar contra `exp`. Usa `hmac.Equal` para comparacion constant-time de firmas (mitiga timing attacks). Solo acepta alg=HS256 en el header, otros algoritmos se rechazan explicitamente para evitar el ataque "alg=none". Si `exp` es 0 (no fijado) no se valida expiracion — es responsabilidad del caller asegurar que sus tokens siempre tengan exp fijado. Errores descriptivos con prefijo `jwt_validate:` para facilitar debugging; en respuestas HTTP conviene mapear todos a un mensaje generico "token invalido" para no filtrar informacion.
|
||||
@@ -0,0 +1,83 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJWTValidate_ValidatesGeneratedToken(t *testing.T) {
|
||||
claims := JWTClaims{
|
||||
Subject: "user-42",
|
||||
Issuer: "tester",
|
||||
ExpiresAt: time.Now().Add(time.Hour).Unix(),
|
||||
Custom: map[string]any{"role": "admin"},
|
||||
}
|
||||
token, err := JWTGenerate(claims, "super-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate error: %v", err)
|
||||
}
|
||||
got, err := JWTValidate(token, "super-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTValidate error: %v", err)
|
||||
}
|
||||
if got.Subject != "user-42" {
|
||||
t.Errorf("Subject = %q, esperado user-42", got.Subject)
|
||||
}
|
||||
if got.Issuer != "tester" {
|
||||
t.Errorf("Issuer = %q, esperado tester", got.Issuer)
|
||||
}
|
||||
if got.Custom["role"] != "admin" {
|
||||
t.Errorf("Custom[role] = %v, esperado admin", got.Custom["role"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTValidate_RejectsInvalidSignature(t *testing.T) {
|
||||
token, err := JWTGenerate(JWTClaims{Subject: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()}, "secret-a")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate error: %v", err)
|
||||
}
|
||||
if _, err := JWTValidate(token, "secret-b"); err == nil {
|
||||
t.Fatal("esperaba error con secret distinto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTValidate_RejectsExpiredToken(t *testing.T) {
|
||||
token, err := JWTGenerate(JWTClaims{
|
||||
Subject: "x",
|
||||
ExpiresAt: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
}, "s")
|
||||
if err != nil {
|
||||
t.Fatalf("JWTGenerate error: %v", err)
|
||||
}
|
||||
if _, err := JWTValidate(token, "s"); err == nil {
|
||||
t.Fatal("esperaba error con token expirado")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTValidate_RejectsMalformedToken(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"not-a-token",
|
||||
"one.two",
|
||||
"one.two.three.four",
|
||||
}
|
||||
for _, tok := range cases {
|
||||
if _, err := JWTValidate(tok, "s"); err == nil {
|
||||
t.Errorf("esperaba error con token %q", tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTValidate_RejectsOtherAlgorithms(t *testing.T) {
|
||||
// Token con alg=none no es aceptado aunque la firma sea vacia
|
||||
// Construimos manualmente: header con alg=none
|
||||
// "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0" = {"alg":"none","typ":"JWT"}
|
||||
token := "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ4In0."
|
||||
if _, err := JWTValidate(token, "s"); err == nil {
|
||||
t.Fatal("esperaba error con alg=none")
|
||||
}
|
||||
if !strings.Contains(token, ".") {
|
||||
t.Fatal("token debe tener puntos")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package infra
|
||||
|
||||
// LogDebug emite un log a nivel debug en el Logger.
|
||||
// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
|
||||
// Si el nivel del logger es mayor que Debug, el mensaje se descarta.
|
||||
func LogDebug(logger *Logger, msg string, fields ...any) {
|
||||
if logger == nil || logger.inner == nil {
|
||||
return
|
||||
}
|
||||
logger.inner.Debug(msg, fields...)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: log_debug
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LogDebug(logger *Logger, msg string, fields ...any)"
|
||||
description: "Emite un log a nivel debug en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Debug, el mensaje se descarta silenciosamente."
|
||||
tags: [logging, log, debug, slog, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada"
|
||||
- name: msg
|
||||
desc: "mensaje principal del log"
|
||||
- name: fields
|
||||
desc: "pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")"
|
||||
output: "nada (side effect: escribe al Output del Logger)"
|
||||
tested: true
|
||||
tests: ["LogDebug emite nivel DEBUG", "campos inline en la llamada aparecen en el JSON", "logger nil no hace panic en las funciones de log"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/log_debug.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger, _ := LoggerNew(LogLevelDebug, os.Stdout, "json")
|
||||
LogDebug(logger, "parsing body", "content_type", "application/json", "size", 1024)
|
||||
// {"time":"...","level":"DEBUG","msg":"parsing body","content_type":"application/json","size":1024}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — delega a `slog.Logger.Debug()`. El filtrado por nivel lo hace el handler de slog internamente (no se evalua el costo de los campos si el nivel esta debajo). Los fields deben venir en pares: si el numero es impar slog lo marca como `!BADKEY`. Usar este nivel para trazas detalladas de desarrollo que normalmente no se ven en produccion.
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
import "time"
|
||||
|
||||
// LogEntry representa una entrada de log estructurada serializable a JSON.
|
||||
// Se usa como modelo canonico para tests y para pipelines que procesan logs.
|
||||
type LogEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package infra
|
||||
|
||||
// LogError emite un log a nivel error en el Logger.
|
||||
// Los fields son pares key-value variadicos (ej: "err", err, "table", "users").
|
||||
// El nivel error siempre se emite (es el mas severo).
|
||||
func LogError(logger *Logger, msg string, fields ...any) {
|
||||
if logger == nil || logger.inner == nil {
|
||||
return
|
||||
}
|
||||
logger.inner.Error(msg, fields...)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: log_error
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LogError(logger *Logger, msg string, fields ...any)"
|
||||
description: "Emite un log a nivel error en el Logger. Los fields son pares key-value variadicos. Nivel maximo de severidad, siempre se emite salvo que el logger tenga un handler que lo filtre explicitamente."
|
||||
tags: [logging, log, error, slog, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada"
|
||||
- name: msg
|
||||
desc: "mensaje principal del log"
|
||||
- name: fields
|
||||
desc: "pares key-value variadicos (ej: \"err\", err.Error(), \"table\", \"users\", \"query\", sql)"
|
||||
output: "nada (side effect: escribe al Output del Logger)"
|
||||
tested: true
|
||||
tests: ["LogError emite nivel ERROR", "logger nil no hace panic en las funciones de log"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/log_error.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
err := db.QueryRow(...).Scan(&x)
|
||||
if err != nil {
|
||||
LogError(logger, "db query failed", "err", err.Error(), "table", "users")
|
||||
}
|
||||
// {"time":"...","level":"ERROR","msg":"db query failed","err":"connection refused","table":"users"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — delega a `slog.Logger.Error()`. Usar para fallos que requieren atencion: panics capturados, errores de I/O, estados invalidos. No aborta el programa por si solo — el caller decide que hacer. Para convertir un `error` en campo se recomienda usar `err.Error()` directamente, aunque slog tambien acepta el tipo `error` como valor (lo serializa con `.Error()` internamente).
|
||||
@@ -0,0 +1,11 @@
|
||||
package infra
|
||||
|
||||
// LogInfo emite un log a nivel info en el Logger.
|
||||
// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
|
||||
// Si el nivel del logger es mayor que Info, el mensaje se descarta.
|
||||
func LogInfo(logger *Logger, msg string, fields ...any) {
|
||||
if logger == nil || logger.inner == nil {
|
||||
return
|
||||
}
|
||||
logger.inner.Info(msg, fields...)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: log_info
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LogInfo(logger *Logger, msg string, fields ...any)"
|
||||
description: "Emite un log a nivel info en el Logger. Los fields son pares key-value variadicos. Si el nivel del logger es mayor que Info, el mensaje se descarta silenciosamente."
|
||||
tags: [logging, log, info, slog, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada"
|
||||
- name: msg
|
||||
desc: "mensaje principal del log"
|
||||
- name: fields
|
||||
desc: "pares key-value variadicos (ej: \"port\", 8484, \"user\", \"lucas\")"
|
||||
output: "nada (side effect: escribe al Output del Logger)"
|
||||
tested: true
|
||||
tests: ["LogInfo emite nivel INFO", "emite JSON valido al escribir", "campos inline en la llamada aparecen en el JSON"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/log_info.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
LogInfo(logger, "server starting", "port", 8484, "app", "sqlite_api")
|
||||
// {"time":"...","level":"INFO","msg":"server starting","port":8484,"app":"sqlite_api"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — delega a `slog.Logger.Info()`. Nivel por defecto recomendado para eventos normales del ciclo de vida de la app (arranque, conexiones establecidas, requests completadas). Para errores usar `LogError`, para situaciones anomalas no fatales usar `LogWarn`.
|
||||
@@ -0,0 +1,16 @@
|
||||
package infra
|
||||
|
||||
// LogLevel representa los niveles de log soportados por el Logger.
|
||||
// El orden implicito es Debug < Info < Warn < Error.
|
||||
type LogLevel int
|
||||
|
||||
const (
|
||||
// LogLevelDebug es el nivel mas verbose, util para trazas de desarrollo.
|
||||
LogLevelDebug LogLevel = iota
|
||||
// LogLevelInfo es el nivel por defecto para eventos normales del sistema.
|
||||
LogLevelInfo
|
||||
// LogLevelWarn indica situaciones anomalas que no impiden el funcionamiento.
|
||||
LogLevelWarn
|
||||
// LogLevelError indica fallos que requieren atencion.
|
||||
LogLevelError
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
package infra
|
||||
|
||||
// LogWarn emite un log a nivel warn en el Logger.
|
||||
// Los fields son pares key-value variadicos (ej: "port", 8484, "user", "lucas").
|
||||
// Si el nivel del logger es mayor que Warn, el mensaje se descarta.
|
||||
func LogWarn(logger *Logger, msg string, fields ...any) {
|
||||
if logger == nil || logger.inner == nil {
|
||||
return
|
||||
}
|
||||
logger.inner.Warn(msg, fields...)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: log_warn
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LogWarn(logger *Logger, msg string, fields ...any)"
|
||||
description: "Emite un log a nivel warn en el Logger. Los fields son pares key-value variadicos. Indica situaciones anomalas que no impiden el funcionamiento del sistema."
|
||||
tags: [logging, log, warn, slog, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger al que emitir el mensaje. Si es nil la funcion no hace nada"
|
||||
- name: msg
|
||||
desc: "mensaje principal del log"
|
||||
- name: fields
|
||||
desc: "pares key-value variadicos (ej: \"retry_count\", 3, \"endpoint\", \"/api/users\")"
|
||||
output: "nada (side effect: escribe al Output del Logger)"
|
||||
tested: true
|
||||
tests: ["LogWarn emite nivel WARN", "filtra mensajes debajo del nivel configurado"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/log_warn.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
LogWarn(logger, "retry attempt", "attempt", 2, "max", 5, "err", "timeout")
|
||||
// {"time":"...","level":"WARN","msg":"retry attempt","attempt":2,"max":5,"err":"timeout"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — delega a `slog.Logger.Warn()`. Usar para eventos recuperables: reintentos, fallos de cache, deprecaciones, datos inesperados pero no invalidos. Si el evento requiere intervencion humana usar `LogError`.
|
||||
@@ -0,0 +1,16 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Logger wrappea slog.Logger con config del registry (nivel, output, formato, campos contextuales).
|
||||
// Se crea con LoggerNew y se clona inmutablemente con LoggerWith anadiendo campos.
|
||||
type Logger struct {
|
||||
Level LogLevel // nivel minimo filtrado
|
||||
Output io.Writer // destino de los logs (stdout, stderr, file, buffer)
|
||||
Format string // "json" | "text"
|
||||
Fields map[string]any // campos contextuales adjuntos al logger
|
||||
inner *slog.Logger // handler real de slog
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoggerMiddleware retorna un Middleware que emite un log estructurado por cada request HTTP.
|
||||
// Cada request produce una entrada a nivel info con method, path, status y duration_ms.
|
||||
// Respeta los campos contextuales que ya tenga el logger (app, version, request_id...).
|
||||
func LoggerMiddleware(logger *Logger) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &loggerResponseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
duration := time.Since(start)
|
||||
LogInfo(logger, "http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rw.status,
|
||||
"duration_ms", duration.Milliseconds(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// loggerResponseWriter captura el status code escrito al ResponseWriter.
|
||||
// Nombrado distinto de responseWriter (http_logger_middleware.go) para evitar colision.
|
||||
type loggerResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *loggerResponseWriter) WriteHeader(status int) {
|
||||
rw.status = status
|
||||
rw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: logger_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LoggerMiddleware(logger *Logger) Middleware"
|
||||
description: "Retorna un Middleware HTTP que emite un log estructurado por cada request. Cada request produce una entrada info con method, path, status y duration_ms. Respeta los campos contextuales del Logger (app, version, request_id...)."
|
||||
tags: [logging, log, slog, middleware, http, server, infra]
|
||||
uses_functions: [log_info_go_infra]
|
||||
uses_types: [Logger_go_infra, Middleware_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [net/http, time]
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger estructurado al que emitir cada request. Hereda los campos contextuales (app, version...)"
|
||||
output: "Middleware que loguea cada request HTTP tras su procesamiento"
|
||||
tested: true
|
||||
tests: ["loguea method, path, status y duration_ms", "usa status 200 si el handler no llama WriteHeader", "preserva los campos contextuales del logger"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/logger_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"})
|
||||
|
||||
mux := HTTPRouter(routes)
|
||||
chain := HTTPMiddlewareChain(LoggerMiddleware(appLog), HTTPCORSMiddleware([]string{"*"}, []string{"GET"}))
|
||||
|
||||
http.ListenAndServe(":8484", chain(mux))
|
||||
// Cada request produce:
|
||||
// {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — captura el status code con un `loggerResponseWriter` envolvente que intercepta `WriteHeader`. Si el handler no llama `WriteHeader` explicitamente el status por defecto es 200. La duracion se mide desde el inicio del middleware hasta despues de que el handler siguiente termine — incluye el tiempo de los middlewares internos pero no los externos en la cadena. El mensaje emitido es `"http request"` a nivel info para facilitar filtrado via `msg:"http request"` en queries downstream.
|
||||
@@ -0,0 +1,54 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
// LoggerNew crea un Logger con nivel, destino y formato configurables.
|
||||
// format debe ser "json" o "text". Si output es nil se usa os.Stderr.
|
||||
// Retorna error si el formato no es valido.
|
||||
func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error) {
|
||||
if output == nil {
|
||||
output = os.Stderr
|
||||
}
|
||||
|
||||
slogLevel := toSlogLevel(level)
|
||||
opts := &slog.HandlerOptions{Level: slogLevel}
|
||||
|
||||
var handler slog.Handler
|
||||
switch format {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(output, opts)
|
||||
case "text":
|
||||
handler = slog.NewTextHandler(output, opts)
|
||||
default:
|
||||
return nil, fmt.Errorf("logger_new: formato invalido %q, usa \"json\" o \"text\"", format)
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
Level: level,
|
||||
Output: output,
|
||||
Format: format,
|
||||
Fields: map[string]any{},
|
||||
inner: slog.New(handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// toSlogLevel convierte LogLevel a slog.Level.
|
||||
func toSlogLevel(level LogLevel) slog.Level {
|
||||
switch level {
|
||||
case LogLevelDebug:
|
||||
return slog.LevelDebug
|
||||
case LogLevelInfo:
|
||||
return slog.LevelInfo
|
||||
case LogLevelWarn:
|
||||
return slog.LevelWarn
|
||||
case LogLevelError:
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: logger_new
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func LoggerNew(level LogLevel, output io.Writer, format string) (*Logger, error)"
|
||||
description: "Crea un Logger estructurado sobre log/slog con nivel, destino y formato configurables. Formato soportado: json o text. Si output es nil cae en os.Stderr."
|
||||
tags: [logging, log, slog, logger, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra, LogLevel_go_infra]
|
||||
returns: [Logger_go_infra]
|
||||
returns_optional: true
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, io, log/slog, os]
|
||||
params:
|
||||
- name: level
|
||||
desc: "nivel minimo de log (LogLevelDebug, LogLevelInfo, LogLevelWarn o LogLevelError)"
|
||||
- name: output
|
||||
desc: "destino de los logs (os.Stdout, os.Stderr, un archivo, bytes.Buffer). Si es nil se usa os.Stderr"
|
||||
- name: format
|
||||
desc: "formato de los logs: \"json\" para maquina o \"text\" para desarrollo local"
|
||||
output: "Logger listo para usar con LogInfo/LogWarn/... o error si el formato no es valido"
|
||||
tested: true
|
||||
tests: ["crea logger JSON valido", "crea logger text valido", "rechaza formato invalido", "output nil cae en os.Stderr sin panic", "emite JSON valido al escribir", "filtra mensajes debajo del nivel configurado"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/logger_new.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger, err := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
LogInfo(logger, "server starting", "port", 8484)
|
||||
// {"time":"...","level":"INFO","msg":"server starting","port":8484}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion impura — internamente construye `slog.NewJSONHandler` o `slog.NewTextHandler` segun el formato y lo envuelve en `slog.New()`. El campo privado `inner` del Logger es el `*slog.Logger` real. Cada Logger es inmutable una vez creado: para anadir campos usar `LoggerWith`, que retorna una copia.
|
||||
@@ -0,0 +1,312 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- LoggerNew ---
|
||||
|
||||
func TestLoggerNew(t *testing.T) {
|
||||
t.Run("crea logger JSON valido", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, err := LoggerNew(LogLevelInfo, buf, "json")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if logger == nil {
|
||||
t.Fatal("expected non-nil logger")
|
||||
}
|
||||
if logger.Format != "json" {
|
||||
t.Errorf("got format=%q, want json", logger.Format)
|
||||
}
|
||||
if logger.Level != LogLevelInfo {
|
||||
t.Errorf("got level=%d, want LogLevelInfo", logger.Level)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("crea logger text valido", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, err := LoggerNew(LogLevelDebug, buf, "text")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if logger.Format != "text" {
|
||||
t.Errorf("got format=%q, want text", logger.Format)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza formato invalido", func(t *testing.T) {
|
||||
_, err := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "xml")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid format")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("output nil cae en os.Stderr sin panic", func(t *testing.T) {
|
||||
logger, err := LoggerNew(LogLevelError, nil, "json")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if logger.Output == nil {
|
||||
t.Error("expected Output to default to os.Stderr, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("emite JSON valido al escribir", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
LogInfo(logger, "hello")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
||||
t.Fatalf("output no es JSON valido: %v\noutput: %s", err, buf.String())
|
||||
}
|
||||
if parsed["msg"] != "hello" {
|
||||
t.Errorf("got msg=%v, want hello", parsed["msg"])
|
||||
}
|
||||
if parsed["level"] != "INFO" {
|
||||
t.Errorf("got level=%v, want INFO", parsed["level"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filtra mensajes debajo del nivel configurado", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelWarn, buf, "json")
|
||||
LogDebug(logger, "debug msg")
|
||||
LogInfo(logger, "info msg")
|
||||
LogWarn(logger, "warn msg")
|
||||
LogError(logger, "error msg")
|
||||
|
||||
output := buf.String()
|
||||
if strings.Contains(output, "debug msg") {
|
||||
t.Error("debug msg no deberia aparecer con LogLevelWarn")
|
||||
}
|
||||
if strings.Contains(output, "info msg") {
|
||||
t.Error("info msg no deberia aparecer con LogLevelWarn")
|
||||
}
|
||||
if !strings.Contains(output, "warn msg") {
|
||||
t.Error("warn msg deberia aparecer con LogLevelWarn")
|
||||
}
|
||||
if !strings.Contains(output, "error msg") {
|
||||
t.Error("error msg deberia aparecer con LogLevelWarn")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- LoggerWith ---
|
||||
|
||||
func TestLoggerWith(t *testing.T) {
|
||||
t.Run("anade campos al logger", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
base, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
appLog := LoggerWith(base, map[string]any{"app": "test", "version": "1.0"})
|
||||
|
||||
LogInfo(appLog, "evento")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
||||
t.Fatalf("JSON invalido: %v", err)
|
||||
}
|
||||
if parsed["app"] != "test" {
|
||||
t.Errorf("got app=%v, want test", parsed["app"])
|
||||
}
|
||||
if parsed["version"] != "1.0" {
|
||||
t.Errorf("got version=%v, want 1.0", parsed["version"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no muta el logger original", func(t *testing.T) {
|
||||
base, _ := LoggerNew(LogLevelInfo, &bytes.Buffer{}, "json")
|
||||
if len(base.Fields) != 0 {
|
||||
t.Fatalf("base logger deberia tener 0 fields iniciales, got %d", len(base.Fields))
|
||||
}
|
||||
_ = LoggerWith(base, map[string]any{"a": 1})
|
||||
if len(base.Fields) != 0 {
|
||||
t.Errorf("base logger no deberia haber mutado, got %d fields", len(base.Fields))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("apila fields sobre un logger ya contextualizado", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
base, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
appLog := LoggerWith(base, map[string]any{"app": "api"})
|
||||
reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc"})
|
||||
|
||||
LogInfo(reqLog, "inicio")
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
||||
t.Fatalf("JSON invalido: %v", err)
|
||||
}
|
||||
if parsed["app"] != "api" {
|
||||
t.Errorf("falta campo app heredado del padre, got %v", parsed["app"])
|
||||
}
|
||||
if parsed["request_id"] != "abc" {
|
||||
t.Errorf("falta campo request_id nuevo, got %v", parsed["request_id"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna nil si recibe nil", func(t *testing.T) {
|
||||
got := LoggerWith(nil, map[string]any{"k": "v"})
|
||||
if got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- Log niveles ---
|
||||
|
||||
func TestLogLevels(t *testing.T) {
|
||||
t.Run("LogInfo emite nivel INFO", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelDebug, buf, "json")
|
||||
LogInfo(logger, "m")
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["level"] != "INFO" {
|
||||
t.Errorf("got level=%v, want INFO", parsed["level"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LogWarn emite nivel WARN", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelDebug, buf, "json")
|
||||
LogWarn(logger, "m")
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["level"] != "WARN" {
|
||||
t.Errorf("got level=%v, want WARN", parsed["level"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LogError emite nivel ERROR", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelDebug, buf, "json")
|
||||
LogError(logger, "m")
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["level"] != "ERROR" {
|
||||
t.Errorf("got level=%v, want ERROR", parsed["level"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("LogDebug emite nivel DEBUG", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelDebug, buf, "json")
|
||||
LogDebug(logger, "m")
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["level"] != "DEBUG" {
|
||||
t.Errorf("got level=%v, want DEBUG", parsed["level"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("campos inline en la llamada aparecen en el JSON", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
LogInfo(logger, "evento", "port", 8080, "user", "lucas")
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["port"] != float64(8080) {
|
||||
t.Errorf("got port=%v, want 8080", parsed["port"])
|
||||
}
|
||||
if parsed["user"] != "lucas" {
|
||||
t.Errorf("got user=%v, want lucas", parsed["user"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logger nil no hace panic en las funciones de log", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("panic inesperado con logger nil: %v", r)
|
||||
}
|
||||
}()
|
||||
LogDebug(nil, "msg")
|
||||
LogInfo(nil, "msg")
|
||||
LogWarn(nil, "msg")
|
||||
LogError(nil, "msg")
|
||||
})
|
||||
}
|
||||
|
||||
// --- LoggerMiddleware ---
|
||||
|
||||
func TestLoggerMiddleware(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
t.Run("loguea method, path, status y duration_ms", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
mw := LoggerMiddleware(logger)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(handler).ServeHTTP(rec, req)
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed); err != nil {
|
||||
t.Fatalf("JSON invalido: %v\noutput: %s", err, buf.String())
|
||||
}
|
||||
if parsed["method"] != "POST" {
|
||||
t.Errorf("got method=%v, want POST", parsed["method"])
|
||||
}
|
||||
if parsed["path"] != "/api/users" {
|
||||
t.Errorf("got path=%v, want /api/users", parsed["path"])
|
||||
}
|
||||
if parsed["status"] != float64(http.StatusCreated) {
|
||||
t.Errorf("got status=%v, want 201", parsed["status"])
|
||||
}
|
||||
if _, ok := parsed["duration_ms"]; !ok {
|
||||
t.Error("falta campo duration_ms en el log")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("usa status 200 si el handler no llama WriteHeader", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
logger, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
mw := LoggerMiddleware(logger)
|
||||
|
||||
silentHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("hi"))
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(silentHandler).ServeHTTP(rec, req)
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["status"] != float64(http.StatusOK) {
|
||||
t.Errorf("got status=%v, want 200", parsed["status"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserva los campos contextuales del logger", func(t *testing.T) {
|
||||
buf := &bytes.Buffer{}
|
||||
base, _ := LoggerNew(LogLevelInfo, buf, "json")
|
||||
appLog := LoggerWith(base, map[string]any{"app": "sqlite_api"})
|
||||
mw := LoggerMiddleware(appLog)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
mw(handler).ServeHTTP(rec, req)
|
||||
|
||||
var parsed map[string]any
|
||||
_ = json.Unmarshal(bytes.TrimSpace(buf.Bytes()), &parsed)
|
||||
if parsed["app"] != "sqlite_api" {
|
||||
t.Errorf("falta campo contextual app=sqlite_api, got %v", parsed["app"])
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import "sort"
|
||||
|
||||
// LoggerWith retorna una copia del Logger con campos adicionales.
|
||||
// No muta el logger original — los campos se apilan sobre los ya existentes.
|
||||
// Funcion pura: misma entrada produce siempre la misma salida sin I/O.
|
||||
func LoggerWith(logger *Logger, fields map[string]any) *Logger {
|
||||
if logger == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Combinar fields existentes + nuevos (los nuevos tienen precedencia)
|
||||
combined := make(map[string]any, len(logger.Fields)+len(fields))
|
||||
for k, v := range logger.Fields {
|
||||
combined[k] = v
|
||||
}
|
||||
for k, v := range fields {
|
||||
combined[k] = v
|
||||
}
|
||||
|
||||
// Convertir a args key-value ordenados para slog.With (orden determinista)
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
args := make([]any, 0, len(keys)*2)
|
||||
for _, k := range keys {
|
||||
args = append(args, k, fields[k])
|
||||
}
|
||||
|
||||
var inner = logger.inner
|
||||
if inner != nil && len(args) > 0 {
|
||||
inner = inner.With(args...)
|
||||
}
|
||||
|
||||
return &Logger{
|
||||
Level: logger.Level,
|
||||
Output: logger.Output,
|
||||
Format: logger.Format,
|
||||
Fields: combined,
|
||||
inner: inner,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: logger_with
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func LoggerWith(logger *Logger, fields map[string]any) *Logger"
|
||||
description: "Retorna una copia del Logger con campos contextuales adicionales. No muta el logger original — los campos se apilan sobre los existentes y aparecen en cada entrada del nuevo logger."
|
||||
tags: [logging, log, slog, logger, context, pure, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Logger_go_infra]
|
||||
returns: [Logger_go_infra]
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [sort]
|
||||
params:
|
||||
- name: logger
|
||||
desc: "Logger base a clonar. Si es nil retorna nil"
|
||||
- name: fields
|
||||
desc: "mapa de campos key-value a anadir al logger (ej: {\"app\": \"api\", \"request_id\": \"abc\"})"
|
||||
output: "nuevo Logger con los fields combinados (los del parametro tienen precedencia sobre los del logger base)"
|
||||
tested: true
|
||||
tests: ["anade campos al logger", "no muta el logger original", "apila fields sobre un logger ya contextualizado", "retorna nil si recibe nil"]
|
||||
test_file_path: "functions/infra/logger_test.go"
|
||||
file_path: "functions/infra/logger_with.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
base, _ := LoggerNew(LogLevelInfo, os.Stdout, "json")
|
||||
appLog := LoggerWith(base, map[string]any{"app": "sqlite_api", "version": "1.0.0"})
|
||||
reqLog := LoggerWith(appLog, map[string]any{"request_id": "abc-123"})
|
||||
|
||||
LogInfo(reqLog, "evento")
|
||||
// {"...","msg":"evento","app":"sqlite_api","version":"1.0.0","request_id":"abc-123"}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — no hace I/O, no muta estado. Internamente llama a `slog.Logger.With()` que ya retorna un nuevo logger. Los campos se pasan en orden alfabetico a `With()` para que el output sea determinista (util para tests). El campo `Fields` del `*Logger` mantiene la union combinada (base + nuevos) para permitir inspeccion programatica.
|
||||
@@ -0,0 +1,27 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Oauth2AuthURL construye la URL de autorizacion OAuth2 a partir de la config.
|
||||
// Funcion pura — no hace I/O, solo concatenacion de strings.
|
||||
// La URL resultante redirige al usuario al proveedor para que autorice el acceso.
|
||||
func Oauth2AuthURL(config OAuthConfig, state string) string {
|
||||
q := url.Values{}
|
||||
q.Set("client_id", config.ClientID)
|
||||
q.Set("redirect_uri", config.RedirectURL)
|
||||
q.Set("response_type", "code")
|
||||
if len(config.Scopes) > 0 {
|
||||
q.Set("scope", strings.Join(config.Scopes, " "))
|
||||
}
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(config.AuthURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return config.AuthURL + sep + q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: oauth2_auth_url
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func Oauth2AuthURL(config OAuthConfig, state string) string"
|
||||
description: "Construye la URL de autorizacion OAuth2 a partir de la config. Funcion pura que concatena el AuthURL del proveedor con los query params (client_id, redirect_uri, response_type=code, scope, state)."
|
||||
tags: [oauth, oauth2, auth, url, infra]
|
||||
uses_functions: []
|
||||
uses_types: [OAuthConfig_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [net/url, strings]
|
||||
params:
|
||||
- name: config
|
||||
desc: "OAuthConfig del proveedor (Google, GitHub, etc.) con ClientID, AuthURL, RedirectURL y Scopes"
|
||||
- name: state
|
||||
desc: "valor aleatorio anti-CSRF que debe validarse en el callback. Si es vacio no se añade"
|
||||
output: "URL completa a la que redirigir al usuario para iniciar el flujo OAuth2"
|
||||
tested: true
|
||||
tests: ["genera URL con todos los params basicos", "concatena scopes con espacio", "añade state si no es vacio", "detecta si AuthURL ya trae query y usa & en vez de ?"]
|
||||
test_file_path: "functions/infra/oauth2_auth_url_test.go"
|
||||
file_path: "functions/infra/oauth2_auth_url.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
google := OAuthConfig{
|
||||
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
RedirectURL: "http://localhost:8080/callback",
|
||||
Scopes: []string{"openid", "email", "profile"},
|
||||
}
|
||||
state := "random-anti-csrf-token" // guardar en cookie/session
|
||||
url := Oauth2AuthURL(google, state)
|
||||
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Pura — solo hace string building con `net/url.Values.Encode()` (ordena params alfabeticamente y hace URL-encoding). No lee env, ni toca I/O, ni `time.Now()`. El state es critico para prevenir CSRF: debe ser aleatorio por sesion, guardarse server-side (cookie firmada, session, etc.) y validarse en el callback antes de hacer Oauth2Exchange. Un state vacio significa sin proteccion CSRF y no se incluye en la URL — solo apto para pruebas locales.
|
||||
@@ -0,0 +1,68 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOauth2AuthURL_BuildsBasicParams(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
ClientID: "abc-client",
|
||||
AuthURL: "https://example.com/authorize",
|
||||
RedirectURL: "http://localhost/callback",
|
||||
Scopes: []string{"openid", "email"},
|
||||
}
|
||||
got := Oauth2AuthURL(cfg, "state-xyz")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if u.Scheme != "https" || u.Host != "example.com" || u.Path != "/authorize" {
|
||||
t.Fatalf("base URL incorrecta: %s", got)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("client_id") != "abc-client" {
|
||||
t.Errorf("client_id = %q", q.Get("client_id"))
|
||||
}
|
||||
if q.Get("redirect_uri") != "http://localhost/callback" {
|
||||
t.Errorf("redirect_uri = %q", q.Get("redirect_uri"))
|
||||
}
|
||||
if q.Get("response_type") != "code" {
|
||||
t.Errorf("response_type = %q", q.Get("response_type"))
|
||||
}
|
||||
if q.Get("scope") != "openid email" {
|
||||
t.Errorf("scope = %q", q.Get("scope"))
|
||||
}
|
||||
if q.Get("state") != "state-xyz" {
|
||||
t.Errorf("state = %q", q.Get("state"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2AuthURL_OmitsEmptyState(t *testing.T) {
|
||||
cfg := OAuthConfig{ClientID: "c", AuthURL: "https://x.test/a", RedirectURL: "http://r"}
|
||||
got := Oauth2AuthURL(cfg, "")
|
||||
if strings.Contains(got, "state=") {
|
||||
t.Errorf("state deberia estar ausente: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2AuthURL_HandlesExistingQueryString(t *testing.T) {
|
||||
cfg := OAuthConfig{
|
||||
ClientID: "c",
|
||||
AuthURL: "https://example.com/authorize?hd=domain.com",
|
||||
RedirectURL: "http://r",
|
||||
}
|
||||
got := Oauth2AuthURL(cfg, "s")
|
||||
u, err := url.Parse(got)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("hd") != "domain.com" {
|
||||
t.Errorf("param pre-existente se perdio")
|
||||
}
|
||||
if q.Get("client_id") != "c" {
|
||||
t.Errorf("client_id no agregado")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// oauth2TokenResponse es la respuesta JSON estandar del endpoint token de OAuth2.
|
||||
type oauth2TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
// oauth2DoTokenRequest hace POST application/x-www-form-urlencoded al TokenURL
|
||||
// con el body indicado, parsea la respuesta JSON y construye OAuthTokens.
|
||||
func oauth2DoTokenRequest(tokenURL string, form url.Values) (OAuthTokens, error) {
|
||||
var zero OAuthTokens
|
||||
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
var parsed oauth2TokenResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return zero, fmt.Errorf("parse json: %w (body=%s)", err, string(body))
|
||||
}
|
||||
if parsed.Error != "" {
|
||||
return zero, fmt.Errorf("oauth provider error: %s: %s", parsed.Error, parsed.ErrorDescription)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return zero, fmt.Errorf("http %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return zero, fmt.Errorf("respuesta sin access_token")
|
||||
}
|
||||
|
||||
var expiresAt int64
|
||||
if parsed.ExpiresIn > 0 {
|
||||
expiresAt = time.Now().Unix() + parsed.ExpiresIn
|
||||
}
|
||||
|
||||
return OAuthTokens{
|
||||
AccessToken: parsed.AccessToken,
|
||||
RefreshToken: parsed.RefreshToken,
|
||||
TokenType: parsed.TokenType,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Oauth2Exchange intercambia un authorization code por tokens OAuth2.
|
||||
// Hace POST al TokenURL con grant_type=authorization_code y las credenciales
|
||||
// del cliente. Retorna OAuthTokens con AccessToken, RefreshToken y ExpiresAt.
|
||||
func Oauth2Exchange(config OAuthConfig, code string) (OAuthTokens, error) {
|
||||
var zero OAuthTokens
|
||||
if code == "" {
|
||||
return zero, fmt.Errorf("oauth2_exchange: code vacio")
|
||||
}
|
||||
if config.TokenURL == "" {
|
||||
return zero, fmt.Errorf("oauth2_exchange: token_url vacio")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("code", code)
|
||||
form.Set("client_id", config.ClientID)
|
||||
form.Set("client_secret", config.ClientSecret)
|
||||
form.Set("redirect_uri", config.RedirectURL)
|
||||
|
||||
tokens, err := oauth2DoTokenRequest(config.TokenURL, form)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("oauth2_exchange: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: oauth2_exchange
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func Oauth2Exchange(config OAuthConfig, code string) (OAuthTokens, error)"
|
||||
description: "Intercambia un authorization code por tokens OAuth2. POST al TokenURL del proveedor con grant_type=authorization_code y las credenciales del cliente. Retorna OAuthTokens con AccessToken, RefreshToken y ExpiresAt calculado."
|
||||
tags: [oauth, oauth2, auth, token, exchange, http, infra]
|
||||
uses_functions: []
|
||||
uses_types: [OAuthConfig_go_infra, OAuthTokens_go_infra]
|
||||
returns: [OAuthTokens_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [encoding/json, fmt, io, net/http, net/url, strings, time]
|
||||
params:
|
||||
- name: config
|
||||
desc: "OAuthConfig del proveedor con ClientID, ClientSecret, TokenURL y RedirectURL"
|
||||
- name: code
|
||||
desc: "authorization code recibido en el callback tras redirigir al usuario a la URL de Oauth2AuthURL"
|
||||
output: "OAuthTokens con access/refresh tokens. ExpiresAt = now + expires_in del proveedor"
|
||||
tested: true
|
||||
tests: ["intercambia code por tokens contra mock server", "rechaza code vacio", "propaga error si proveedor devuelve error"]
|
||||
test_file_path: "functions/infra/oauth2_exchange_test.go"
|
||||
file_path: "functions/infra/oauth2_exchange.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
// Validar state contra el guardado en cookie/session...
|
||||
|
||||
tokens, err := Oauth2Exchange(googleConfig, code)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
// Usar tokens.AccessToken para llamar a APIs del proveedor
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — hace POST HTTP al TokenURL con timeout de 30s, y usa `time.Now()` para calcular ExpiresAt. El body es application/x-www-form-urlencoded (estandar OAuth2). Si el proveedor retorna JSON con campo `error` se wrappea en un error descriptivo. El ClientSecret se envia en el body (no en header Authorization Basic) para compatibilidad amplia — la mayoria de proveedores aceptan ambos. NO valida el state anti-CSRF: eso debe hacerlo el handler del callback antes de llamar a Oauth2Exchange.
|
||||
@@ -0,0 +1,74 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOauth2Exchange_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("metodo = %s", r.Method)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("ParseForm: %v", err)
|
||||
}
|
||||
if got := r.PostForm.Get("grant_type"); got != "authorization_code" {
|
||||
t.Errorf("grant_type = %q", got)
|
||||
}
|
||||
if got := r.PostForm.Get("code"); got != "abc-code" {
|
||||
t.Errorf("code = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "at-123",
|
||||
"refresh_token": "rt-456",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := OAuthConfig{
|
||||
ClientID: "c",
|
||||
ClientSecret: "s",
|
||||
TokenURL: srv.URL,
|
||||
RedirectURL: "http://r",
|
||||
}
|
||||
tokens, err := Oauth2Exchange(cfg, "abc-code")
|
||||
if err != nil {
|
||||
t.Fatalf("Oauth2Exchange: %v", err)
|
||||
}
|
||||
if tokens.AccessToken != "at-123" || tokens.RefreshToken != "rt-456" || tokens.TokenType != "Bearer" {
|
||||
t.Errorf("tokens = %+v", tokens)
|
||||
}
|
||||
if tokens.ExpiresAt == 0 {
|
||||
t.Error("ExpiresAt no deberia ser 0")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2Exchange_EmptyCode(t *testing.T) {
|
||||
cfg := OAuthConfig{TokenURL: "http://x", ClientID: "c"}
|
||||
if _, err := Oauth2Exchange(cfg, ""); err == nil {
|
||||
t.Fatal("esperaba error con code vacio")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2Exchange_ProviderError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(400)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "code expired",
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c"}
|
||||
if _, err := Oauth2Exchange(cfg, "code"); err == nil {
|
||||
t.Fatal("esperaba error del proveedor")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Oauth2Refresh renueva un access token OAuth2 usando el refresh token.
|
||||
// POST al TokenURL con grant_type=refresh_token. Retorna OAuthTokens con
|
||||
// el nuevo AccessToken (y posiblemente un nuevo RefreshToken segun el proveedor).
|
||||
func Oauth2Refresh(config OAuthConfig, refreshToken string) (OAuthTokens, error) {
|
||||
var zero OAuthTokens
|
||||
if refreshToken == "" {
|
||||
return zero, fmt.Errorf("oauth2_refresh: refresh_token vacio")
|
||||
}
|
||||
if config.TokenURL == "" {
|
||||
return zero, fmt.Errorf("oauth2_refresh: token_url vacio")
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", refreshToken)
|
||||
form.Set("client_id", config.ClientID)
|
||||
form.Set("client_secret", config.ClientSecret)
|
||||
|
||||
tokens, err := oauth2DoTokenRequest(config.TokenURL, form)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("oauth2_refresh: %w", err)
|
||||
}
|
||||
// Algunos proveedores no devuelven refresh_token al renovar — conservar el original
|
||||
if tokens.RefreshToken == "" {
|
||||
tokens.RefreshToken = refreshToken
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: oauth2_refresh
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func Oauth2Refresh(config OAuthConfig, refreshToken string) (OAuthTokens, error)"
|
||||
description: "Renueva un access token OAuth2 usando el refresh token. POST al TokenURL con grant_type=refresh_token. Conserva el refresh token original si el proveedor no devuelve uno nuevo."
|
||||
tags: [oauth, oauth2, auth, token, refresh, http, infra]
|
||||
uses_functions: []
|
||||
uses_types: [OAuthConfig_go_infra, OAuthTokens_go_infra]
|
||||
returns: [OAuthTokens_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [fmt, net/url]
|
||||
params:
|
||||
- name: config
|
||||
desc: "OAuthConfig del proveedor con ClientID, ClientSecret y TokenURL"
|
||||
- name: refreshToken
|
||||
desc: "refresh token obtenido previamente de Oauth2Exchange"
|
||||
output: "OAuthTokens con nuevo AccessToken. Si el proveedor no devuelve RefreshToken se conserva el original"
|
||||
tested: true
|
||||
tests: ["renueva tokens contra mock server", "conserva refresh token si el proveedor no devuelve uno nuevo", "rechaza refresh vacio"]
|
||||
test_file_path: "functions/infra/oauth2_refresh_test.go"
|
||||
file_path: "functions/infra/oauth2_refresh.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
tokens, err := Oauth2Refresh(googleConfig, storedRefreshToken)
|
||||
if err != nil {
|
||||
// El refresh token tambien puede haber caducado — forzar relogin
|
||||
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
saveTokens(tokens) // actualizar tokens en BD/cookie
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — reutiliza oauth2DoTokenRequest para el POST. Algunos proveedores (Google) no devuelven un nuevo RefreshToken al renovar — en ese caso se conserva el original. Otros (Microsoft) pueden rotar el refresh token en cada renovacion: el campo tokens.RefreshToken siempre trae el que hay que guardar para la proxima renovacion. Si el refresh token expiro (el usuario revoco acceso o paso demasiado tiempo) el proveedor retorna 400 con `error: invalid_grant` y se propaga como error.
|
||||
@@ -0,0 +1,69 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOauth2Refresh_Success(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if got := r.PostForm.Get("grant_type"); got != "refresh_token" {
|
||||
t.Errorf("grant_type = %q", got)
|
||||
}
|
||||
if got := r.PostForm.Get("refresh_token"); got != "rt-old" {
|
||||
t.Errorf("refresh_token = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "at-new",
|
||||
"refresh_token": "rt-new",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := OAuthConfig{TokenURL: srv.URL, ClientID: "c", ClientSecret: "s"}
|
||||
tokens, err := Oauth2Refresh(cfg, "rt-old")
|
||||
if err != nil {
|
||||
t.Fatalf("Oauth2Refresh: %v", err)
|
||||
}
|
||||
if tokens.AccessToken != "at-new" {
|
||||
t.Errorf("AccessToken = %q", tokens.AccessToken)
|
||||
}
|
||||
if tokens.RefreshToken != "rt-new" {
|
||||
t.Errorf("RefreshToken = %q", tokens.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2Refresh_PreservesRefreshTokenIfProviderOmits(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"access_token": "at-new",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 1800,
|
||||
// no refresh_token
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := OAuthConfig{TokenURL: srv.URL}
|
||||
tokens, err := Oauth2Refresh(cfg, "rt-keep")
|
||||
if err != nil {
|
||||
t.Fatalf("Oauth2Refresh: %v", err)
|
||||
}
|
||||
if tokens.RefreshToken != "rt-keep" {
|
||||
t.Errorf("esperaba conservar rt-keep, got %q", tokens.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOauth2Refresh_EmptyToken(t *testing.T) {
|
||||
cfg := OAuthConfig{TokenURL: "http://x"}
|
||||
if _, err := Oauth2Refresh(cfg, ""); err == nil {
|
||||
t.Fatal("esperaba error con refresh vacio")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package infra
|
||||
|
||||
// OAuthConfig contiene la configuracion de un proveedor OAuth2.
|
||||
// Los Scopes se concatenan con espacio al construir la URL de autorizacion.
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package infra
|
||||
|
||||
// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2.
|
||||
// ExpiresAt es Unix epoch seconds calculado a partir de expires_in del proveedor.
|
||||
type OAuthTokens struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package infra
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// PasswordHash hashea un password con bcrypt.
|
||||
// cost controla el trabajo computacional (4 = minimo, 14 = muy lento). Valor 0 usa default 12.
|
||||
func PasswordHash(password string, cost int) (string, error) {
|
||||
if cost <= 0 {
|
||||
cost = 12
|
||||
}
|
||||
b, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: password_hash
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PasswordHash(password string, cost int) (string, error)"
|
||||
description: "Hashea un password con bcrypt. Cost por defecto es 12 (si se pasa 0). El hash resultante incluye el salt y el cost embebidos."
|
||||
tags: [password, hash, bcrypt, auth, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [golang.org/x/crypto/bcrypt]
|
||||
params:
|
||||
- name: password
|
||||
desc: "password en texto plano a hashear"
|
||||
- name: cost
|
||||
desc: "coste bcrypt entre 4 y 14. 0 usa el default 12 (buen balance velocidad/seguridad en 2025)"
|
||||
output: "hash bcrypt en formato $2a$... apto para guardar en BD y verificar con PasswordVerify"
|
||||
tested: true
|
||||
tests: ["hashea password con cost default", "hashea password con cost custom", "hashes distintos para mismo password (salt diferente)"]
|
||||
test_file_path: "functions/infra/password_hash_test.go"
|
||||
file_path: "functions/infra/password_hash.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
hash, err := PasswordHash(inputPassword, 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.Exec("INSERT INTO users (email, password_hash) VALUES (?, ?)", email, hash)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — bcrypt usa entropia del OS para generar salt aleatorio en cada invocacion. El hash producido incluye el salt y el cost embebidos en el string (`$2a$12$salt...hash`), por lo que PasswordVerify no necesita el cost como parametro aparte. Cost 12 = ~250ms/hash en hardware moderno (2025): suficiente para bloquear ataques por fuerza bruta sin ser insoportable en el login. Para proteccion extra en servidores con mucho CPU disponible se puede subir a 14.
|
||||
@@ -0,0 +1,34 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordHash_DefaultCost(t *testing.T) {
|
||||
hash, err := PasswordHash("hunter2", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("PasswordHash error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$2") {
|
||||
t.Errorf("hash no tiene prefijo bcrypt: %q", hash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash_CustomCost(t *testing.T) {
|
||||
hash, err := PasswordHash("password", 4) // 4 = minimum, rapido para tests
|
||||
if err != nil {
|
||||
t.Fatalf("PasswordHash error: %v", err)
|
||||
}
|
||||
if hash == "" {
|
||||
t.Fatal("hash vacio")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordHash_DifferentSalts(t *testing.T) {
|
||||
h1, _ := PasswordHash("same-password", 4)
|
||||
h2, _ := PasswordHash("same-password", 4)
|
||||
if h1 == h2 {
|
||||
t.Fatal("los hashes deben diferir por salt distinto")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package infra
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// PasswordVerify compara un password en texto plano contra un hash bcrypt.
|
||||
// Retorna nil si hacen match, error si no.
|
||||
func PasswordVerify(password string, hash string) error {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: password_verify
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func PasswordVerify(password string, hash string) error"
|
||||
description: "Verifica un password en texto plano contra un hash bcrypt. Retorna nil si hacen match, error si no coinciden o si el hash es invalido."
|
||||
tags: [password, verify, bcrypt, auth, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [golang.org/x/crypto/bcrypt]
|
||||
params:
|
||||
- name: password
|
||||
desc: "password en texto plano a verificar"
|
||||
- name: hash
|
||||
desc: "hash bcrypt obtenido previamente de PasswordHash (guardado en BD)"
|
||||
output: "nil si el password coincide con el hash; error si no coincide o hash invalido"
|
||||
tested: true
|
||||
tests: ["verifica password correcto", "rechaza password incorrecto", "rechaza hash malformado"]
|
||||
test_file_path: "functions/infra/password_verify_test.go"
|
||||
file_path: "functions/infra/password_verify.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
row := db.QueryRow("SELECT password_hash FROM users WHERE email = ?", email)
|
||||
var stored string
|
||||
if err := row.Scan(&stored); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
|
||||
return
|
||||
}
|
||||
if err := PasswordVerify(input, stored); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
|
||||
return
|
||||
}
|
||||
// OK, emitir token
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — bcrypt.CompareHashAndPassword es constant-time internamente (mitiga timing attacks). En respuestas HTTP al usuario NO distinguir entre "email no existe" y "password incorrecto": ambos casos deben retornar el mismo mensaje generico para no filtrar existencia de cuentas. El error real se puede loguear internamente con log_info/log_warn sin problema.
|
||||
@@ -0,0 +1,23 @@
|
||||
package infra
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPasswordVerify_CorrectPassword(t *testing.T) {
|
||||
hash, _ := PasswordHash("correct-horse-battery-staple", 4)
|
||||
if err := PasswordVerify("correct-horse-battery-staple", hash); err != nil {
|
||||
t.Fatalf("esperaba nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordVerify_WrongPassword(t *testing.T) {
|
||||
hash, _ := PasswordHash("secret", 4)
|
||||
if err := PasswordVerify("wrong", hash); err == nil {
|
||||
t.Fatal("esperaba error con password incorrecto")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordVerify_InvalidHash(t *testing.T) {
|
||||
if err := PasswordVerify("x", "not-a-bcrypt-hash"); err == nil {
|
||||
t.Fatal("esperaba error con hash invalido")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user