Files
fn_registry/bash/functions/pipelines/init_api_app.sh
T
egutierrez da58501723 feat: init_api_app bash pipeline — scaffold Go HTTP API app
Genera apps/{nombre}/ con main.go (http_serve + router + middleware chain +
graceful shutdown), handlers.go (HTTPJSONResponse), config.go (env vars),
migrations/001_initial.sql, Makefile, .env.example, .gitignore, go.mod y
app.md con frontmatter correcto.

Flags opcionales:
  --port N      puerto default del server (default 8080)
  --with-auth   jwt_middleware + login/register + tabla users/sessions
  --with-db     store.go con helpers CRUD y setup SQLite
  --with-ops    stub para fn ops init

Compone 8+ funciones del registry (http_*, migration_up, password_*, jwt_*).
Verifica con go vet al final.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:35 +02:00

604 lines
15 KiB
Bash
Executable File

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