54 Commits

Author SHA1 Message Date
egutierrez d771c21a46 docs: actualizar README de issues — marcar 8 issues como completados (wave 1-3)
Issues integrados a master en esta serie:
- 0010 Auth System (JWT, passwords, OAuth2, RBAC, sessions)
- 0011 WebSocket & SSE Server
- 0014 File Upload & Storage (+ S3 stubs)
- 0016 Rate Limiting (token bucket)
- 0019 Structured Logging (slog-based)
- 0021 CRUD Generator generico
- 0022 Init Pipelines (scaffolding api/web/desktop/cli)
- 0024 Dashboard YAML split por tab

Total aprox: 54 funciones y 23 tipos nuevos en functions/infra/
+ 4 pipelines bash en bash/functions/pipelines/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:00:57 +02:00
egutierrez 4aa3bc2d94 chore: regenerar registry.db tras integración 0022 2026-04-18 18:00:17 +02:00
egutierrez 7bda65209c merge: issue/0022-init-pipelines — scaffolding pipelines bash (api, web, desktop, cli) 2026-04-18 18:00:10 +02:00
egutierrez c25f623355 docs: cerrar issue 0022
Los 4 init pipelines (init_api_app, init_web_app, init_desktop_app,
init_cli_app) estan implementados, verificados end-to-end con `fn run`, e
indexados en registry.db como kind=pipeline + tag=launcher. Guia consolidada
en docs/init-pipelines.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:59:04 +02:00
egutierrez 3b37827d16 docs: guia consolidada docs/init-pipelines.md + regenerar registry.db
docs/init-pipelines.md: referencia rapida de los 4 init pipelines con tabla
resumen, arbol de decision, combinaciones comunes, FAQ y estructura
generada por tipo.

registry.db: re-indexado para registrar los 4 nuevos pipelines como
kind=pipeline, purity=impure, domain=pipelines, tag=launcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:58:53 +02:00
egutierrez bcfe87af7f feat: init_cli_app bash pipeline — scaffold Go CLI app con TUI opcional
Genera apps/{nombre}/ con main.go (subcommand routing via os.Args + switch),
cmd_version.go, cmd_status.go, Makefile (build/run/install/test/vet/clean),
.gitignore, go.mod y app.md. Sin cobra/urfave — consistente con el resto de
apps del registry.

Flag --with-tui anade model.go con un modelo Bubbletea fullscreen (lista
filtrable con bubbles/list, spinner con bubbles/spinner, dark theme con
lipgloss). main.go arranca la TUI con tea.NewProgram(m, WithAltScreen) si no
hay args; sino hace subcommand routing normal.

Verifica con go mod tidy + go vet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:56:55 +02:00
egutierrez 526d7f4977 feat: init_desktop_app bash pipeline — scaffold Wails desktop app
Genera apps/{nombre}/ con Wails v2 backend (main.go con embed frontend/dist,
app.go con struct App y bindings Greet/GetVersion) + frontend Vite+React+Mantine
con alias @fn_library.

Flag --with-db anade store.go con SQLite (schema items) y bindings CRUD
(ListItems, CreateItem); app.go se regenera con campo db.

wails.json con scripts pnpm, go.mod con replace a fn-registry, app.md con
framework wails+vite+react+mantine y dir_path correcto.

Verifica con go mod tidy al final. wails build requiere CLI instalado pero
el scaffold funciona sin el.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:54:55 +02:00
egutierrez 6d63f058de feat: init_web_app bash pipeline — scaffold Go API + React frontend
Extiende init_api_app anadiendo la capa frontend: pnpm + vite + react +
@mantine/core. Genera frontend/ con vite.config.ts (proxy /api y /health al
backend + alias @fn_library a frontend/functions/ui), src/main.tsx con
MantineProvider, src/App.tsx con AppShell y src/pages/Home.tsx consumiendo
/api/v1/status.

Flags: --port N, --with-auth, --with-db (delegadas a init_api_app).

Docker compose para desarrollo. Verifica con pnpm install && pnpm build si
pnpm esta disponible (skippable con SKIP_PNPM_BUILD=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:52:34 +02:00
egutierrez e19bc09f4d 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
egutierrez 7eab4a52e9 chore: regenerar registry.db tras integración 0010 2026-04-18 17:45:44 +02:00
egutierrez 057f55c8a9 merge: issue/0010-auth-system — JWT, passwords, OAuth2, RBAC, sessions (13 fns, 6 tipos) 2026-04-18 17:45:30 +02:00
egutierrez 7b2004c649 docs: cerrar issue 0010 2026-04-18 17:44:41 +02:00
egutierrez 6c83263d9b chore: regenerar registry.db con auth system (13 funciones, 6 tipos nuevos) 2026-04-18 17:44:40 +02:00
egutierrez f46fde3656 feat: rbac_check (pure), jwt_middleware, rbac_middleware
Fase 5 del issue 0010 — RBAC y middlewares de auth.
- rbac_check es pura: solo recorre la matriz roles/permisos
- jwt_middleware extrae token del header Authorization: Bearer, valida e
  inyecta claims en el context con una key privada (jwtCtxKey struct{})
- rbac_middleware requiere jwt_middleware antes; lee role de claims.Custom
- Helper JWTClaimsFromContext para acceder a las claims desde handlers
- 401 claro si RBAC se usa sin JWT antes (code: no_claims)
2026-04-18 17:44:04 +02:00
egutierrez 4601af88b5 feat: oauth2_auth_url (pure), oauth2_exchange, oauth2_refresh
Fase 4 del issue 0010 — cliente OAuth2 sin golang.org/x/oauth2.
- Oauth2AuthURL es pura: solo construye la URL con net/url
- Oauth2Exchange/Refresh hacen POST application/x-www-form-urlencoded
- ExpiresAt calculado como now + expires_in del proveedor
- Refresh conserva el token original si el proveedor no devuelve uno nuevo
- Tests con httptest.NewServer como mock del proveedor
2026-04-18 17:41:42 +02:00
egutierrez fc1ebb4967 feat: session_create, session_validate, session_cleanup
Fase 3 del issue 0010 — sesiones SQLite como alternativa a JWT.
- Tabla sessions creada con CREATE TABLE IF NOT EXISTS (autosetup)
- Tokens de 32 bytes aleatorios (crypto/rand) codificados en hex (256 bits)
- Indices en user_id y expires_at
- Prepared statements para evitar SQL injection
- SessionCleanup para eliminar expiradas periodicamente
2026-04-18 17:40:13 +02:00
egutierrez 07341aa89f feat: jwt_generate, jwt_validate, password_hash, password_verify
Fase 2 del issue 0010 — auth core:
- jwt_generate/validate: HS256 manual con crypto/hmac + crypto/sha256
- password_hash/verify: wrappers de golang.org/x/crypto/bcrypt (cost 12 default)
- JWT rechaza alg != HS256 para mitigar ataque 'alg=none'
- hmac.Equal para comparacion constant-time de firmas
2026-04-18 17:39:00 +02:00
egutierrez 4bc6d1bced feat: tipos auth (JWTClaims, Session, OAuthConfig, OAuthTokens, Permission, Role)
Fase 1 del issue 0010 — tipos base del sistema de auth en dominio infra.
Define las estructuras que usaran jwt_*, session_*, oauth2_* y rbac_*.

Añade dep golang.org/x/crypto/bcrypt para el hashing de passwords.
2026-04-18 17:37:19 +02:00
egutierrez 5f282bedc5 chore: regenerar registry.db tras integración wave 1 (0011, 0014, 0016, 0019, 0021, 0024) 2026-04-18 17:33:44 +02:00
egutierrez 3b2cd26a06 merge: issue/0011-websocket-sse — WebSocket + SSE (8 fns, 4 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:32 +02:00
egutierrez 66e54f092d merge: issue/0014-file-upload — file upload/storage + S3 stubs (11 fns, 3 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:28 +02:00
egutierrez 22994f14bf merge: issue/0021-crud-generator — CRUD generator genérico (9 fns, 4 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:23 +02:00
egutierrez e96f8eaf6a merge: issue/0019-structured-logging — slog-based structured logging (7 fns, 3 tipos)
# Conflicts:
#	registry.db
2026-04-18 17:33:18 +02:00
egutierrez 5bbdf2ff16 merge: issue/0016-rate-limiting — token bucket rate limiter (6 fns, 3 tipos) 2026-04-18 17:33:07 +02:00
egutierrez 19722cb085 merge: issue/0024-dashboard-yaml-split-por-tab — cerrar issue (código en repo dataforge/auto_metabase) 2026-04-18 17:33:03 +02:00
egutierrez 6fac9e1ef0 chore: gitignorear external/ y worktrees/
external/ contiene symlink a repo_Claude (skills).
worktrees/ es el directorio que usa parallel-fix-issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:32:54 +02:00
egutierrez 1ab39d105a docs: cerrar issue 0011 2026-04-18 17:30:28 +02:00
egutierrez 2c1a956b32 chore: regenerar registry.db con WS/SSE (issue 0011) 2026-04-18 17:30:24 +02:00
egutierrez e35ec39c10 feat: WebSocket upgrader, hub, send, broadcast, handler con tests (issue 0011 fase 3-4) 2026-04-18 17:29:37 +02:00
egutierrez 637bc8fd34 feat: SSE handler, send y keepalive (issue 0011 fase 2) 2026-04-18 17:25:03 +02:00
egutierrez 75157f528a docs: cerrar issue 0021 2026-04-18 17:18:11 +02:00
egutierrez 77be3ce325 chore: regenerar registry.db con funciones y tipos CRUD
Indexa las 9 funciones crud_* y los 4 tipos CRUDResource/CRUDField/
CRUDListParams/CRUDListResult del issue 0021.
2026-04-18 17:18:04 +02:00
egutierrez 9634cfdb4a docs: cerrar issue 0014 2026-04-18 17:17:49 +02:00
egutierrez 6cf006d87b chore: regenerar registry.db con file upload + s3 stubs 2026-04-18 17:17:46 +02:00
egutierrez 4d25ebd070 test(crud): cobertura completa de los handlers y generadores CRUD
Anade tests unitarios e integracion sobre SQLite in-memory:
- CRUDDefineResource: casos felices y rechazo de inputs invalidos
  (nombre vacio, tipos no soportados, nombres reservados, duplicados).
- CRUDGenerateTableSQL: columnas base, NOT NULL/UNIQUE/DEFAULT, deleted_at
  con soft_delete y verificacion de que el DDL es ejecutable en sqlite.
- Create + Get: creacion feliz, validaciones required/min_length/max_length/
  enum/min/max, 409 en UNIQUE, GET 200/404.
- List: paginacion, filtros, orden ascendente, campos desconocidos ignorados.
- Update: partial update, 404 y validacion de campos enviados.
- Delete: hard delete, soft delete, 404, ocultar soft-deleted en list.
- Integracion end-to-end con httptest.NewServer cubriendo CRUD completo y
  multiples recursos registrados en el mismo mux.
2026-04-18 17:17:42 +02:00
egutierrez 0bd91f04b8 feat: stubs s3_upload, s3_download, s3_presign_url (issue 0014 fase 4) 2026-04-18 17:17:19 +02:00
egutierrez 0bfe267501 docs: cerrar issue 0019 structured logging
Funciones, tipos y tests implementados. Registry actualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:45 +02:00
egutierrez 4b420fb24b feat: file_save_disk, file_delete, file_serve, upload_parse, upload_handler, thumbnail_generate (issue 0014 fase 3) 2026-04-18 17:15:39 +02:00
egutierrez 3262d058a6 chore: regenerar registry.db con funciones y tipos de structured logging
fn index registra:
- 7 funciones: logger_new, logger_with, log_debug, log_info, log_warn, log_error, logger_middleware
- 3 tipos: Logger, LogLevel, LogEntry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:15:37 +02:00
egutierrez 69dcfec4eb feat(crud): handlers HTTP y registro de rutas para recursos CRUD
Anade los 5 handlers CRUD genericos (list, get, create, update, delete) a
partir de un CRUDResource y *sql.DB, la factory crud_generate_handlers que
compone los 5 en un mapa, y crud_register_routes que registra todas las
rutas REST en un http.ServeMux con la sintaxis METHOD /path de Go 1.22+.

Caracteristicas:
- List con paginacion (page, per_page), orden (sort_by, sort_dir) y
  filtros exactos (filter_<campo>), validando nombres de columna contra
  la definicion del recurso para evitar SQL injection.
- Create valida required y validaciones (min/max, min_length/max_length,
  pattern, enum) antes de insertar; mapea UNIQUE violations a 409.
- Update hace partial update — solo los campos presentes en el JSON.
- Delete hace hard delete o soft delete segun CRUDResource.SoftDelete.
- UUIDs generados via github.com/google/uuid; timestamps en RFC3339Nano UTC.

Los handlers usan las funciones HTTP del registry (http_json_response,
http_error_response, http_parse_body) y se pueden componer con el mux
via http_router.
2026-04-18 17:15:33 +02:00
egutierrez 31708d0942 feat(crud): tipos y generador de DDL para recursos CRUD
Anade los tipos CRUDResource, CRUDField, CRUDListParams y CRUDListResult
que modelan un recurso CRUD sobre SQLite, junto con dos funciones puras:
- crud_define_resource valida nombre, tabla y campos (tipos SQLite validos,
  nombres reservados, duplicados) antes de retornar el CRUDResource.
- crud_generate_table_sql genera el DDL CREATE TABLE IF NOT EXISTS con
  id TEXT PRIMARY KEY, timestamps estandar y, si aplica, deleted_at para
  soft delete.

Primera capa de 0021 — el resto (handlers + registro de rutas) se apoya
sobre estas estructuras.
2026-04-18 17:15:21 +02:00
egutierrez 53976c0c31 docs: cerrar issue 0016 (rate limiting) 2026-04-18 17:14:50 +02:00
egutierrez 04c3ead5fa chore: regenerar registry.db con funciones y tipos de rate limiting 2026-04-18 17:14:49 +02:00
egutierrez e076901aa9 test: cobertura de structured logging (logger_new, logger_with, log_*, logger_middleware)
- LoggerNew: formatos validos e invalidos, output nil, filtrado por nivel
- LoggerWith: anadir fields, no mutacion del base, apilamiento, nil-safe
- LogDebug/Info/Warn/Error: niveles correctos en JSON, campos variadicos, logger nil no panic
- LoggerMiddleware: method/path/status/duration_ms, default 200, preserva campos del logger

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:41 +02:00
egutierrez d80f0412a8 feat: logger_middleware — middleware HTTP con logs estructurados (infra)
Middleware que envuelve cualquier http.Handler y emite un log info por
cada request con method, path, status y duration_ms. Hereda los campos
contextuales del Logger (app, version, request_id...) y se compone con
HTTPMiddlewareChain + HTTPCORSMiddleware.

Diferencia con http_logger_middleware: este escribe JSON estructurado via
slog en vez de texto plano a un io.Writer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:36 +02:00
egutierrez 9e8c0d66bb feat: log_debug, log_info, log_warn, log_error (infra)
Funciones de nivel que delegan al *slog.Logger interno del Logger.
Todas son impuras y soportan logger nil sin panic (no-op).
Los fields se pasan como pares key-value variadicos estilo slog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:29 +02:00
egutierrez df0227d4f2 feat(infra): rate limit middlewares HTTP por IP y por key + tests
Implementa fase 2 del issue 0016:
- rate_limit_middleware: limita por IP (X-Forwarded-For > X-Real-IP > RemoteAddr)
- rate_limiter_by_key: middleware configurable con keyFunc custom (API key, user ID...)
- Cuando se rechaza responde 429 con HTTPError JSON y headers Retry-After + X-RateLimit-*
- Tests con httptest.NewRecorder cubriendo: limite, burst, IPs independientes, XFF prioritario,
  recarga temporal, JSON body, headers IETF, GC stop idempotente, key vacia salta limit
2026-04-18 17:14:25 +02:00
egutierrez ae22787e60 feat: logger_new y logger_with sobre log/slog (infra)
- logger_new (impuro): construye *Logger con handler JSON/text segun formato
- logger_with (puro): clona el Logger anadiendo campos contextuales via slog.With

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:23 +02:00
egutierrez ab3069ae17 feat: tipos Logger, LogLevel y LogEntry para structured logging (infra)
Tipos base para las funciones de structured logging sobre log/slog:
- LogLevel: suma enum Debug/Info/Warn/Error
- Logger: wrapper producto con nivel, output, formato y fields contextuales
- LogEntry: modelo canonico JSON para tests y pipelines de logs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:14:19 +02:00
egutierrez 1675d2bb84 feat: file_validate_type y file_unique_name puras (issue 0014 fase 2) 2026-04-18 17:12:05 +02:00
egutierrez 4ac93a0933 feat(infra): rate limiter token-bucket in-memory + tipos y core funcs
Implementa fase 1 del issue 0016:
- Tipos RateLimiter, RateLimitConfig y RateLimitResult en types/infra/
- rate_limiter_new: constructor con validacion rate/burst > 0
- rate_limiter_check: evalua token bucket por key, calcula Allowed/Remaining/ResetAt/RetryAfter
- rate_limit_headers (pure): construye headers IETF X-RateLimit-* y Retry-After
- rate_limiter_cleanup: goroutine GC de entries inactivas con stop idempotente

Sin dependencias externas (no Redis). sync.Mutex + map. Algoritmo token bucket
estandar con recarga lineal proporcional al tiempo transcurrido.
2026-04-18 17:11:22 +02:00
egutierrez ae0c4b7389 docs: cerrar issue 0024
El split de dashboards YAML por tab ya esta implementado y committeado en
apps/auto_metabase (repo dataforge/auto_metabase, commit 47b5f89):

- dashboard_split.py: helpers puros split_dashboard_payload() y merge_dashboard_parts()
- dashboard_split_test.py: 23 tests passing (round-trip, edge cases, real aurgi YAML)
- sync_pull.py: escribe directorio {slug}/_dashboard.yaml + tab_*.yaml
- sync_push.py: lee directorio o legacy monolitico, reconstruye payload unificado
- sync_validate.py: valida parent_slug, tab_ids y detecta archivos huerfanos
- payload.py: resolve dashboards/{slug}/_dashboard.yaml preferentemente
- app.md: documenta nuevo layout con seccion "Dashboard split por tab"
- Backward-compat con legacy dashboards/{slug}.yaml
- Aplicado a aurgi (dashboard id=734) con 9 tabs separados

Las apps viven en repos Gitea independientes (dataforge/auto_metabase), por
lo que los cambios de codigo no quedan reflejados en el historial de
fn_registry, solo el cierre del issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:10:36 +02:00
egutierrez 3d47e74ec7 feat: tipos UploadedFile, StorageConfig, S3Config en infra (issue 0014 fase 1) 2026-04-18 17:10:31 +02:00
egutierrez 0255207514 feat: tipos WSHub, WSClient, WSMessage, SSEEvent (issue 0011 fase 1) 2026-04-18 17:10:28 +02:00
200 changed files with 12658 additions and 37 deletions
+6
View File
@@ -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/
+113
View File
@@ -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.
+603
View File
@@ -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 ""
+110
View File
@@ -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).
+460
View File
@@ -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.
+594
View File
@@ -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 ""
+119
View File
@@ -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.
+466
View File
@@ -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 ""
+8 -8
View File
@@ -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 | — |
+199
View File
@@ -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
+92
View File
@@ -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)
}
}
+39
View File
@@ -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.
+40
View File
@@ -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
}
+44
View File
@@ -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.
+49
View File
@@ -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)
}
}
+39
View File
@@ -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).
+22
View File
@@ -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),
}
}
+39
View File
@@ -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.
+47
View File
@@ -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)
}
}
+39
View File
@@ -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.
+198
View File
@@ -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, &notnull, &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")
}
+159
View File
@@ -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
}
+39
View File
@@ -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.
+26
View File
@@ -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"])
}
+44
View File
@@ -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.
+49
View File
@@ -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"`
}
+653
View File
@@ -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)
}
})
}
+97
View File
@@ -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)
}
}
+39
View File
@@ -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.
+48
View File
@@ -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
}
+40
View File
@@ -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.
+45
View File
@@ -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")
}
})
}
+53
View File
@@ -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
}
+47
View File
@@ -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.
+62
View File
@@ -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)
}
})
}
+28
View File
@@ -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)
})
}
+41
View File
@@ -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.
+52
View File
@@ -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)
}
})
}
+47
View File
@@ -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()
}
+47
View File
@@ -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
+63
View File
@@ -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)
}
})
}
+67
View File
@@ -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 ""
}
+52
View File
@@ -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)
}
})
}
+13
View File
@@ -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"`
}
+44
View File
@@ -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
}
+46
View File
@@ -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.
+54
View File
@@ -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")
}
}
+58
View File
@@ -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))
})
}
}
+42
View File
@@ -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.
+94
View File
@@ -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")
}
}
+72
View File
@@ -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
}
+45
View File
@@ -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.
+83
View File
@@ -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")
}
}
+11
View File
@@ -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...)
}
+41
View File
@@ -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.
+12
View File
@@ -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"`
}
+11
View File
@@ -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...)
}
+44
View File
@@ -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).
+11
View File
@@ -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...)
}
+41
View File
@@ -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`.
+16
View File
@@ -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
)
+11
View File
@@ -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...)
}
+41
View File
@@ -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`.
+16
View File
@@ -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
}
+38
View File
@@ -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)
}
+43
View File
@@ -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.
+54
View File
@@ -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
}
}
+44
View File
@@ -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.
+312
View File
@@ -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"])
}
})
}
+46
View File
@@ -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,
}
}
+42
View File
@@ -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.
+27
View File
@@ -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()
}
+45
View File
@@ -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.
+68
View File
@@ -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")
}
}
+95
View File
@@ -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
}
+46
View File
@@ -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.
+74
View File
@@ -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")
}
}
+34
View File
@@ -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
}
+43
View File
@@ -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.
+69
View File
@@ -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")
}
}
+12
View File
@@ -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"`
}
+10
View File
@@ -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"`
}
+16
View File
@@ -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
}
+41
View File
@@ -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.
+34
View File
@@ -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")
}
}
+9
View File
@@ -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))
}
+47
View File
@@ -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.
+23
View File
@@ -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