fix(fn-run): propagar stdout/stderr de bash functions library-style #1

Open
dataforge wants to merge 537 commits from auto/0077-fn-run-bash-mudo into master
14 changed files with 770 additions and 0 deletions
Showing only changes of commit 69dcfec4eb - Show all commits
+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.
+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).
+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.
+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.
+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.