#!/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 ""