--- id: "0021" title: "CRUD Generator" status: completado type: feature domain: [] scope: multi-app priority: media depends: [] blocks: [] related: [] created: 2026-05-17 updated: 2026-05-17 tags: [] --- # 0021 — CRUD Generator ## Metadata | Campo | Valor | |-------|-------| | **ID** | 0021 | | **Estado** | pendiente | | **Prioridad** | media | | **Tipo** | feature | ## Dependencias - **0009** (HTTP Server Foundation) — los handlers generados usan `http_json_response`, `http_error_response`, `http_parse_body` y se registran via `http_router`. - **0015** (DB Migrations) — la tabla generada por `crud_generate_table_sql` se ejecuta como migracion. --- ## Objetivo Generar handlers REST completos (list, get, create, update, delete) a partir de una definicion declarativa de recurso, sin escribir codigo repetitivo. El 80% de los endpoints de cualquier API son CRUD identico — solo cambian el nombre de la tabla y los campos. ## Contexto - Ya existen `wails_bind_crud_go_infra` (genera bindings CRUD para Wails como string de codigo Go) y `crud_page_ts_ui` (layout CRUD en frontend). Pero no hay nada que genere **endpoints HTTP** CRUD funcionales. - Las apps del registry (`sqlite_api`, `deploy_server`, futuras) construyen sus handlers CRUD a mano cada vez: misma estructura de list con paginacion, mismo get por ID, mismo create con validacion, mismo update parcial, mismo delete. - Este issue NO genera archivos ni templates — genera **handler factories en runtime**. Le pasas una definicion de recurso y te devuelve `http.HandlerFunc` listas para usar. - Especifico para SQLite (usa `database/sql` con mattn/go-sqlite3). No abstrae multiples motores de BD. ## Arquitectura ``` functions/infra/ crud_define_resource.go — NEW: construye CRUDResource a partir de nombre y campos crud_define_resource.md — NEW crud_generate_table_sql.go — NEW: genera CREATE TABLE a partir de CRUDResource crud_generate_table_sql.md — NEW crud_generate_handlers.go — NEW: genera los 5 handlers a partir de CRUDResource + *sql.DB crud_generate_handlers.md — NEW crud_register_routes.go — NEW: registra rutas CRUD en router crud_register_routes.md — NEW crud_list_handler.go — NEW: handler generico list con paginacion/filtro/sort crud_list_handler.md — NEW crud_get_handler.go — NEW: handler get por ID crud_get_handler.md — NEW crud_create_handler.go — NEW: handler create con validacion crud_create_handler.md — NEW crud_update_handler.go — NEW: handler update parcial por ID crud_update_handler.md — NEW crud_delete_handler.go — NEW: handler delete por ID crud_delete_handler.md — NEW types/infra/ crud_resource.md — NEW: metadata del tipo CRUDResource crud_field.md — NEW: metadata del tipo CRUDField crud_list_params.md — NEW: metadata del tipo CRUDListParams crud_list_result.md — NEW: metadata del tipo CRUDListResult ``` ### Patron pure core / impure shell - **Pure:** `crud_define_resource` (construye struct), `crud_generate_table_sql` (string SQL), `crud_generate_handlers` (retorna handlers sin ejecutar I/O, pero los handlers en si hacen I/O al invocarse — la funcion factory es pure, el handler resultante es impure) - **Impure:** `crud_register_routes` (muta router), `crud_list_handler`, `crud_get_handler`, `crud_create_handler`, `crud_update_handler`, `crud_delete_handler` (todos hacen I/O contra SQLite y HTTP) **Nota sobre `crud_generate_handlers`:** La funcion que genera los handlers es pura (recibe definicion + db, retorna funciones). Pero los handlers retornados son closures impuros que leen/escriben BD y HTTP. Se clasifica como **pure** porque la funcion en si no hace I/O — solo construye closures. ## Diseno ### Tipos ```go // CRUDResource define un recurso CRUD completo type CRUDResource struct { Name string // nombre del recurso (singular, snake_case: "project") Table string // nombre de la tabla SQLite ("projects") Fields []CRUDField // campos del recurso (sin ID ni timestamps) SoftDelete bool // si true, usa deleted_at en vez de DELETE real } // CRUDField define un campo del recurso type CRUDField struct { Name string // nombre del campo (snake_case: "display_name") 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 (vacio = sin default) Validations map[string]string // reglas: "min_length":"3", "max_length":"255", "pattern":"^[a-z]+" } // CRUDListParams parametros de paginacion, orden y filtro 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 (default "created_at") SortDir string // "asc" o "desc" (default "desc") Filters map[string]string // campo -> valor para WHERE exacto } // CRUDListResult resultado paginado 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"` } ``` ### Funciones | Funcion | Purity | Firma (simplificada) | |---------|--------|---------------------| | `crud_define_resource` | pure | `(name string, table string, fields []CRUDField, softDelete bool) CRUDResource` | | `crud_generate_table_sql` | pure | `(res CRUDResource) string` | | `crud_generate_handlers` | pure | `(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc` | | `crud_register_routes` | impure | `(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)` | | `crud_list_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | | `crud_get_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | | `crud_create_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | | `crud_update_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | | `crud_delete_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` | ### Comportamiento de cada handler **List** (`GET /basePath`): - Query params: `page`, `per_page`, `sort_by`, `sort_dir`, `filter_{campo}={valor}` - Genera `SELECT * FROM table WHERE ... ORDER BY ... LIMIT ... OFFSET ...` - Cuenta total con `SELECT COUNT(*) FROM table WHERE ...` - Retorna `CRUDListResult` como JSON - Si `soft_delete`, agrega `WHERE deleted_at IS NULL` automaticamente **Get** (`GET /basePath/{id}`): - Genera `SELECT * FROM table WHERE id = ?` - 404 si no existe (o si `soft_delete` y tiene `deleted_at`) - Retorna el registro como JSON **Create** (`POST /basePath`): - Parsea body JSON con `http_parse_body` - Valida campos required y validaciones de cada campo - Genera UUID para `id`, timestamp para `created_at` y `updated_at` - `INSERT INTO table (id, field1, ..., created_at, updated_at) VALUES (?, ?, ..., ?, ?)` - Retorna 201 con el registro creado **Update** (`PUT /basePath/{id}`): - Parsea body JSON — solo campos presentes se actualizan (partial update) - Valida campos enviados contra sus reglas - `UPDATE table SET field1=?, updated_at=? WHERE id=?` - 404 si no existe - Retorna el registro actualizado **Delete** (`DELETE /basePath/{id}`): - Si `soft_delete`: `UPDATE table SET deleted_at=? WHERE id=?` - Si no: `DELETE FROM table WHERE id=?` - 404 si no existe - Retorna 204 sin body ### SQL generado por `crud_generate_table_sql` ```sql -- Para un recurso "projects" con campos name (TEXT, required, unique) y description (TEXT) CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- Si soft_delete: CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT ); ``` ### Validaciones soportadas | Clave | Aplica a | Ejemplo | |-------|----------|---------| | `min_length` | TEXT | `"3"` — minimo 3 caracteres | | `max_length` | TEXT | `"255"` — maximo 255 caracteres | | `pattern` | TEXT | `"^[a-z_]+$"` — regex match | | `min` | INTEGER, REAL | `"0"` — valor minimo | | `max` | INTEGER, REAL | `"1000"` — valor maximo | | `enum` | TEXT | `"active,inactive,archived"` — valores permitidos | --- ## Tareas ### Fase 1: Tipos y definicion - [ ] **1.1** Crear tipos `CRUDResource`, `CRUDField`, `CRUDListParams`, `CRUDListResult` en `functions/infra/` con `.md` en `types/infra/` - [ ] **1.2** `crud_define_resource` — construye `CRUDResource` validando que haya al menos un campo y que los tipos sean validos (TEXT, INTEGER, REAL, BLOB) - [ ] **1.3** `crud_generate_table_sql` — genera DDL `CREATE TABLE IF NOT EXISTS` con las constraints derivadas de los campos ### Fase 2: Handlers individuales - [ ] **2.1** `crud_list_handler` — parsea query params a `CRUDListParams`, construye SQL dinamico con paginacion y filtros, retorna `CRUDListResult` - [ ] **2.2** `crud_get_handler` — busca por ID, escanea columnas dinamicamente a `map[string]any`, responde 404 si no existe - [ ] **2.3** `crud_create_handler` — valida input contra definicion, genera UUID, inserta, retorna 201 - [ ] **2.4** `crud_update_handler` — partial update con solo los campos enviados, valida los presentes, 404 si no existe - [ ] **2.5** `crud_delete_handler` — hard/soft delete segun config, 404 si no existe, retorna 204 ### Fase 3: Composicion y tests - [ ] **3.1** `crud_generate_handlers` — llama a los 5 handlers individuales, retorna `map[string]http.HandlerFunc` con keys "list", "get", "create", "update", "delete" - [ ] **3.2** `crud_register_routes` — registra en `http.ServeMux` las rutas `GET /base`, `GET /base/{id}`, `POST /base`, `PUT /base/{id}`, `DELETE /base/{id}` - [ ] **3.3** Tests con `httptest.NewServer` + SQLite in-memory (`:memory:`) para cada handler - [ ] **3.4** Test de integracion: define recurso, genera tabla, registra rutas, CRUD completo via HTTP - [ ] **3.5** `fn index` y verificar con `fn show` que todas las funciones y tipos aparecen --- ## Ejemplo de uso ```go // 1. Definir el recurso resource := infra.CRUDDefineResource("project", "projects", []infra.CRUDField{ {Name: "name", Type: "TEXT", Required: true, Unique: true, Validations: map[string]string{"min_length": "1", "max_length": "100"}}, {Name: "description", Type: "TEXT"}, {Name: "status", Type: "TEXT", Required: true, Default: "'active'", Validations: map[string]string{"enum": "active,archived,deleted"}}, {Name: "priority", Type: "INTEGER", Default: "0", Validations: map[string]string{"min": "0", "max": "10"}}, }, false) // soft_delete = false // 2. Crear la tabla ddl := infra.CRUDGenerateTableSQL(resource) db.Exec(ddl) // 3. Registrar rutas (una linea) mux := http.NewServeMux() infra.CRUDRegisterRoutes(mux, "/api/projects", resource, db) // Listo. Endpoints disponibles: // GET /api/projects — list con paginacion // GET /api/projects/{id} — get por ID // POST /api/projects — create // PUT /api/projects/{id} — update parcial // DELETE /api/projects/{id} — delete // 4. Multiples recursos en la misma API infra.CRUDRegisterRoutes(mux, "/api/users", userResource, db) infra.CRUDRegisterRoutes(mux, "/api/tasks", taskResource, db) // 5. Componer con middlewares de 0009 middleware := infra.HttpMiddlewareChain( infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST", "PUT", "DELETE"}), infra.HttpLoggerMiddleware(os.Stdout), ) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() infra.HttpServe(":8080", middleware(mux), ctx) ``` ```bash # Uso desde curl: # List con paginacion y filtros curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active" # Get curl "localhost:8080/api/projects/abc-123" # Create curl -X POST localhost:8080/api/projects \ -H 'Content-Type: application/json' \ -d '{"name":"mi-proyecto","description":"Desc","status":"active","priority":5}' # Update parcial (solo cambia description) curl -X PUT localhost:8080/api/projects/abc-123 \ -H 'Content-Type: application/json' \ -d '{"description":"Nueva descripcion"}' # Delete curl -X DELETE localhost:8080/api/projects/abc-123 ``` ## Decisiones de diseno - **Runtime handler factories, no code generation:** `wails_bind_crud_go_infra` genera codigo como string para Wails. Este issue toma el camino opuesto: las funciones **son** los handlers, no generan texto. Esto evita templates, archivos generados y el ciclo generate-compile. El tradeoff es que los handlers son genericos (`map[string]any`) en vez de tipados, pero para APIs REST sobre SQLite es aceptable. - **`map[string]any` en vez de structs tipados:** Como los campos se definen en runtime, no es posible usar structs Go tipados. Toda la serializacion pasa por `map[string]any`. Esto es idiomatico para APIs JSON + SQLite donde el schema es dinamico. - **SQLite especifico, no multi-motor:** Simplifica enormemente el SQL generado (TEXT para timestamps, sin SERIAL, sin esquemas). Si en el futuro se necesita Postgres, se crea una variante separada. - **Validacion en Go, no en SQLite:** Las constraints de SQLite (CHECK, NOT NULL) son la ultima linea de defensa. La validacion principal ocurre en el handler antes del INSERT/UPDATE, con mensajes de error descriptivos para el cliente. - **UUID como ID:** Todos los recursos usan `id TEXT PRIMARY KEY` con UUID generado server-side. No IDs autoincrement — evita problemas de concurrencia y es mas portable. - **Timestamps como TEXT ISO 8601:** Consistente con como registry.db y operations.db ya almacenan timestamps. - **Partial update en PUT:** Normalmente PATCH es para partial update y PUT para replace completo. Aqui se usa PUT con partial update por simplicidad — solo los campos presentes en el JSON se actualizan. Es pragmatico para APIs internas. ## Relacion con funciones existentes | Funcion existente | Relacion | |---|---| | `wails_bind_crud_go_infra` | Genera **codigo Go** como string para desktop (Wails). Este issue genera **handlers HTTP** en runtime para REST APIs. Complementarios, no solapados. | | `crud_page_ts_ui` | Frontend CRUD layout en React. Los handlers generados aqui serian el **backend** que esa pagina consume. Stack completo: `crud_define_resource` (definicion) + handlers (backend) + `crud_page` (frontend). | ## Riesgos - **SQL injection en filtros:** Los filtros se pasan como query params y se inyectan en WHERE. Mitigado validando que el campo exista en la definicion del recurso y usando siempre `?` placeholders — nunca interpolacion de strings en SQL. - **Performance en tablas grandes:** El `SELECT COUNT(*)` para paginacion se ejecuta en cada list request. Para tablas con millones de filas esto es lento. Mitigado: las apps del registry manejan miles de registros, no millones. Si se necesita, se anade cache de count como mejora futura. - **Scope creep hacia un ORM:** Hay tentacion de agregar relaciones, joins, nested resources, hooks before/after. Mitigado limitando el scope a CRUD plano de una tabla. Relaciones y logica de negocio van en handlers custom, no en el generador. - **Colision con 0009 si cambia la API:** Los handlers usan `http_json_response` y `http_error_response` de 0009. Si esas firmas cambian, hay que actualizar. Mitigado: las funciones de 0009 son primitivas estables con firmas simples.