28599436e5
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.
160 lines
4.4 KiB
Go
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
|
|
}
|