diff --git a/bash/functions/pipelines/init_api_app.md b/bash/functions/pipelines/init_api_app.md new file mode 100644 index 00000000..197a49a2 --- /dev/null +++ b/bash/functions/pipelines/init_api_app.md @@ -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 [--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. diff --git a/bash/functions/pipelines/init_api_app.sh b/bash/functions/pipelines/init_api_app.sh new file mode 100755 index 00000000..fe3a06af --- /dev/null +++ b/bash/functions/pipelines/init_api_app.sh @@ -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 [--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 [--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" < ${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" < "$APP_DIR/main.go" < "$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" < "$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" < "$APP_DIR/.env.example" <> "$APP_DIR/.env.example" < "$APP_DIR/.gitignore" < "$APP_DIR/app.md" <&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 ""