Files
fn_registry/functions/infra/crud_list_handler.go
T
egutierrez 28599436e5 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

160 lines
4.4 KiB
Go

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
}