fix(fn-run): propagar stdout/stderr de bash functions library-style #1
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user