fix(infra): gradle_run detecta android-sdk — issue 0076 #2
@@ -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,40 @@
|
||||
package infra
|
||||
|
||||
import "fmt"
|
||||
|
||||
// CRUDDefineResource construye un CRUDResource validando que el nombre no este vacio,
|
||||
// que haya al menos un campo y que todos los tipos de los campos sean validos
|
||||
// (TEXT, INTEGER, REAL, BLOB). Es pura — solo valida y devuelve la estructura.
|
||||
func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error) {
|
||||
if name == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: name must not be empty")
|
||||
}
|
||||
if table == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: table must not be empty")
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: must have at least one field")
|
||||
}
|
||||
seen := make(map[string]bool, len(fields))
|
||||
for _, f := range fields {
|
||||
if f.Name == "" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name must not be empty")
|
||||
}
|
||||
if f.Name == "id" || f.Name == "created_at" || f.Name == "updated_at" || f.Name == "deleted_at" {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: field name %q is reserved", f.Name)
|
||||
}
|
||||
if seen[f.Name] {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: duplicate field name %q", f.Name)
|
||||
}
|
||||
seen[f.Name] = true
|
||||
if !isValidCRUDType(f.Type) {
|
||||
return CRUDResource{}, fmt.Errorf("crud_define_resource: invalid type %q for field %q (must be TEXT, INTEGER, REAL or BLOB)", f.Type, f.Name)
|
||||
}
|
||||
}
|
||||
return CRUDResource{
|
||||
Name: name,
|
||||
Table: table,
|
||||
Fields: fields,
|
||||
SoftDelete: softDelete,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: crud_define_resource
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CRUDDefineResource(name string, table string, fields []CRUDField, softDelete bool) (CRUDResource, error)"
|
||||
description: "Construye un CRUDResource validando nombre, tabla y campos. Rechaza nombres de campo reservados (id, created_at, updated_at, deleted_at), duplicados y tipos distintos de TEXT, INTEGER, REAL, BLOB."
|
||||
tags: [crud, resource, define, validation, infra]
|
||||
uses_functions: []
|
||||
uses_types: [CRUDResource_go_infra, CRUDField_go_infra]
|
||||
returns: [CRUDResource_go_infra]
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt]
|
||||
params:
|
||||
- name: name
|
||||
desc: "nombre singular del recurso en snake_case (ej: 'project')"
|
||||
- name: table
|
||||
desc: "nombre de la tabla SQLite asociada (ej: 'projects')"
|
||||
- name: fields
|
||||
desc: "lista de CRUDField con los campos del recurso (sin id ni timestamps)"
|
||||
- name: softDelete
|
||||
desc: "si true, el recurso usa deleted_at en vez de borrado fisico"
|
||||
output: "CRUDResource validado listo para pasar a crud_generate_table_sql y crud_generate_handlers"
|
||||
tested: true
|
||||
tests: ["construye un recurso valido", "rechaza nombre vacio", "rechaza tabla vacia", "rechaza lista de campos vacia", "rechaza tipos invalidos", "rechaza nombres reservados", "rechaza duplicados"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_define_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
res, err := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0"},
|
||||
}, false)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — no hace I/O. Valida antes de devolver. Los campos id, created_at, updated_at y deleted_at son gestionados por el generador de tabla y los handlers, por eso estan reservados. Los tipos aceptados son los tipos de almacenamiento nativos de SQLite.
|
||||
@@ -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,39 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CRUDGenerateTableSQL genera el DDL CREATE TABLE IF NOT EXISTS correspondiente a un CRUDResource.
|
||||
// Incluye siempre: id TEXT PRIMARY KEY, created_at TEXT NOT NULL, updated_at TEXT NOT NULL.
|
||||
// Si el recurso es SoftDelete, agrega una columna deleted_at TEXT (nullable).
|
||||
// Cada CRUDField se mapea a su tipo SQLite y aplica NOT NULL, UNIQUE y DEFAULT segun corresponda.
|
||||
func CRUDGenerateTableSQL(res CRUDResource) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n", res.Table))
|
||||
sb.WriteString(" id TEXT PRIMARY KEY")
|
||||
for _, f := range res.Fields {
|
||||
sb.WriteString(",\n ")
|
||||
sb.WriteString(f.Name)
|
||||
sb.WriteString(" ")
|
||||
sb.WriteString(strings.ToUpper(f.Type))
|
||||
if f.Required {
|
||||
sb.WriteString(" NOT NULL")
|
||||
}
|
||||
if f.Unique {
|
||||
sb.WriteString(" UNIQUE")
|
||||
}
|
||||
if f.Default != "" {
|
||||
sb.WriteString(" DEFAULT ")
|
||||
sb.WriteString(f.Default)
|
||||
}
|
||||
}
|
||||
sb.WriteString(",\n created_at TEXT NOT NULL")
|
||||
sb.WriteString(",\n updated_at TEXT NOT NULL")
|
||||
if res.SoftDelete {
|
||||
sb.WriteString(",\n deleted_at TEXT")
|
||||
}
|
||||
sb.WriteString("\n);\n")
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: crud_generate_table_sql
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func CRUDGenerateTableSQL(res CRUDResource) string"
|
||||
description: "Genera el DDL CREATE TABLE IF NOT EXISTS de un CRUDResource. Incluye id como PRIMARY KEY, timestamps created_at/updated_at y deleted_at si soft_delete. Cada campo aplica su tipo SQLite y constraints NOT NULL/UNIQUE/DEFAULT."
|
||||
tags: [crud, sql, ddl, generate, sqlite, infra]
|
||||
uses_functions: []
|
||||
uses_types: [CRUDResource_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [fmt, strings]
|
||||
params:
|
||||
- name: res
|
||||
desc: "CRUDResource con la definicion completa del recurso"
|
||||
output: "string con el statement CREATE TABLE listo para ejecutar"
|
||||
tested: true
|
||||
tests: ["genera tabla basica con timestamps", "aplica NOT NULL y UNIQUE", "aplica DEFAULT", "anade deleted_at si soft_delete"]
|
||||
test_file_path: "functions/infra/crud_test.go"
|
||||
file_path: "functions/infra/crud_generate_table_sql.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
res, _ := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0"},
|
||||
}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
// CREATE TABLE IF NOT EXISTS projects (
|
||||
// id TEXT PRIMARY KEY,
|
||||
// name TEXT NOT NULL UNIQUE,
|
||||
// priority INTEGER DEFAULT 0,
|
||||
// created_at TEXT NOT NULL,
|
||||
// updated_at TEXT NOT NULL
|
||||
// );
|
||||
db.Exec(ddl)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — solo manipula strings. Usa CREATE TABLE IF NOT EXISTS para ser idempotente. Las columnas id, created_at y updated_at siempre se generan. Si el CRUDResource es SoftDelete, se anade deleted_at TEXT nullable. El resultado se puede ejecutar directamente con db.Exec o envolver como migracion.
|
||||
@@ -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,198 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// validCRUDTypes enumera los tipos SQLite aceptados por las funciones CRUD.
|
||||
var validCRUDTypes = map[string]bool{
|
||||
"TEXT": true,
|
||||
"INTEGER": true,
|
||||
"REAL": true,
|
||||
"BLOB": true,
|
||||
}
|
||||
|
||||
// isValidCRUDType indica si el tipo string corresponde a uno soportado.
|
||||
func isValidCRUDType(t string) bool {
|
||||
return validCRUDTypes[strings.ToUpper(t)]
|
||||
}
|
||||
|
||||
// crudFieldByName busca un campo por nombre. Retorna nil si no existe.
|
||||
func crudFieldByName(res CRUDResource, name string) *CRUDField {
|
||||
for i := range res.Fields {
|
||||
if res.Fields[i].Name == name {
|
||||
return &res.Fields[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudColumnNames retorna la lista de nombres de columnas de una tabla sqlite.
|
||||
// Usa PRAGMA table_info — unica forma portable en SQLite.
|
||||
func crudColumnNames(db *sql.DB, table string) ([]string, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%q)", table))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("crud column names: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var cols []string
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return nil, fmt.Errorf("crud column names: %w", err)
|
||||
}
|
||||
cols = append(cols, name)
|
||||
}
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
// crudScanRow escanea una fila generica a map[string]any usando las columnas proporcionadas.
|
||||
// Usa []any con apuntadores para que database/sql decida el tipo Go.
|
||||
func crudScanRow(rows *sql.Rows, cols []string) (map[string]any, error) {
|
||||
values := make([]any, len(cols))
|
||||
scanArgs := make([]any, len(cols))
|
||||
for i := range values {
|
||||
scanArgs[i] = &values[i]
|
||||
}
|
||||
if err := rows.Scan(scanArgs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := make(map[string]any, len(cols))
|
||||
for i, col := range cols {
|
||||
v := values[i]
|
||||
// Normalizar bytes a string (SQLite TEXT llega como []byte cuando no se tipa)
|
||||
if b, ok := v.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = v
|
||||
}
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// crudValidateField valida un valor contra las reglas de un campo.
|
||||
// Retorna nil si todo ok, error con mensaje descriptivo si falla alguna regla.
|
||||
func crudValidateField(field CRUDField, value any) error {
|
||||
if value == nil {
|
||||
if field.Required {
|
||||
return fmt.Errorf("field %q is required", field.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch strings.ToUpper(field.Type) {
|
||||
case "TEXT":
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("field %q must be a string", field.Name)
|
||||
}
|
||||
return crudValidateText(field, s)
|
||||
case "INTEGER":
|
||||
n, err := crudCoerceInt(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q must be an integer", field.Name)
|
||||
}
|
||||
return crudValidateNumber(field, float64(n))
|
||||
case "REAL":
|
||||
f, err := crudCoerceFloat(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q must be a number", field.Name)
|
||||
}
|
||||
return crudValidateNumber(field, f)
|
||||
case "BLOB":
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudValidateText aplica min_length, max_length, pattern, enum a un string.
|
||||
func crudValidateText(field CRUDField, s string) error {
|
||||
if v, ok := field.Validations["min_length"]; ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil && len(s) < n {
|
||||
return fmt.Errorf("field %q must have at least %d characters", field.Name, n)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["max_length"]; ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err == nil && len(s) > n {
|
||||
return fmt.Errorf("field %q must have at most %d characters", field.Name, n)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["pattern"]; ok {
|
||||
re, err := regexp.Compile(v)
|
||||
if err == nil && !re.MatchString(s) {
|
||||
return fmt.Errorf("field %q does not match pattern %q", field.Name, v)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["enum"]; ok {
|
||||
options := strings.Split(v, ",")
|
||||
matched := false
|
||||
for _, opt := range options {
|
||||
if strings.TrimSpace(opt) == s {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return fmt.Errorf("field %q must be one of: %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudValidateNumber aplica min y max a un valor numerico.
|
||||
func crudValidateNumber(field CRUDField, f float64) error {
|
||||
if v, ok := field.Validations["min"]; ok {
|
||||
min, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil && f < min {
|
||||
return fmt.Errorf("field %q must be >= %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
if v, ok := field.Validations["max"]; ok {
|
||||
max, err := strconv.ParseFloat(v, 64)
|
||||
if err == nil && f > max {
|
||||
return fmt.Errorf("field %q must be <= %s", field.Name, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// crudCoerceInt intenta convertir un valor a int64.
|
||||
func crudCoerceInt(v any) (int64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return int64(n), nil
|
||||
case int64:
|
||||
return n, nil
|
||||
case float64:
|
||||
if n != float64(int64(n)) {
|
||||
return 0, fmt.Errorf("not an integer")
|
||||
}
|
||||
return int64(n), nil
|
||||
case string:
|
||||
return strconv.ParseInt(n, 10, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("not an integer")
|
||||
}
|
||||
|
||||
// crudCoerceFloat intenta convertir un valor a float64.
|
||||
func crudCoerceFloat(v any) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n), nil
|
||||
case int64:
|
||||
return float64(n), nil
|
||||
case float64:
|
||||
return n, nil
|
||||
case string:
|
||||
return strconv.ParseFloat(n, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("not a number")
|
||||
}
|
||||
@@ -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,49 @@
|
||||
package infra
|
||||
|
||||
// CRUDResource define un recurso CRUD completo para generar handlers HTTP.
|
||||
// Name es el nombre singular del recurso en snake_case (ej: "project").
|
||||
// Table es el nombre de la tabla SQLite asociada (ej: "projects").
|
||||
// Fields son las columnas del recurso sin contar id, created_at, updated_at y deleted_at.
|
||||
// SoftDelete si es true, el handler delete hace UPDATE deleted_at en vez de DELETE real.
|
||||
type CRUDResource struct {
|
||||
Name string // nombre del recurso (singular, snake_case)
|
||||
Table string // nombre de la tabla SQLite
|
||||
Fields []CRUDField // campos del recurso (sin id ni timestamps)
|
||||
SoftDelete bool // si true, usa deleted_at en vez de DELETE
|
||||
}
|
||||
|
||||
// CRUDField define un campo de un recurso CRUD.
|
||||
// Type debe ser uno de: TEXT, INTEGER, REAL, BLOB.
|
||||
// Required fuerza NOT NULL en la tabla y validacion en create.
|
||||
// Unique anade un UNIQUE constraint en la tabla.
|
||||
// Default es el valor SQL por defecto (vacio = sin default).
|
||||
// Validations define reglas de validacion: min_length, max_length, pattern, min, max, enum.
|
||||
type CRUDField struct {
|
||||
Name string // nombre del campo (snake_case)
|
||||
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
|
||||
Validations map[string]string // reglas: min_length, max_length, pattern, min, max, enum
|
||||
}
|
||||
|
||||
// CRUDListParams agrupa los parametros de paginacion, orden y filtro del endpoint list.
|
||||
// Page es 1-based (default 1). PerPage tiene default 20 y max 100.
|
||||
// SortBy es el nombre del campo por el que ordenar. SortDir es "asc" o "desc".
|
||||
// Filters contiene pares campo -> valor para filtros exactos en WHERE.
|
||||
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
|
||||
SortDir string // "asc" o "desc"
|
||||
Filters map[string]string // campo -> valor para WHERE exacto
|
||||
}
|
||||
|
||||
// CRUDListResult resultado paginado de una lista CRUD, serializable a JSON.
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func openCRUDTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("cannot open test DB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func sampleProjectFields() []CRUDField {
|
||||
return []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true,
|
||||
Validations: map[string]string{"min_length": "1", "max_length": "50"}},
|
||||
{Name: "description", Type: "TEXT"},
|
||||
{Name: "status", Type: "TEXT", Default: "'active'",
|
||||
Validations: map[string]string{"enum": "active,archived"}},
|
||||
{Name: "priority", Type: "INTEGER", Default: "0",
|
||||
Validations: map[string]string{"min": "0", "max": "10"}},
|
||||
}
|
||||
}
|
||||
|
||||
func buildProjectResource(t *testing.T, softDelete bool) (CRUDResource, *sql.DB) {
|
||||
t.Helper()
|
||||
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), softDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("define resource: %v", err)
|
||||
}
|
||||
db := openCRUDTestDB(t)
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
return res, db
|
||||
}
|
||||
|
||||
func doJSONRequest(t *testing.T, mux http.Handler, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(b)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, reqBody)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
var got map[string]any
|
||||
if rec.Body.Len() > 0 && strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
}
|
||||
return rec, got
|
||||
}
|
||||
|
||||
// --- CRUDDefineResource ---
|
||||
|
||||
func TestCRUDDefineResource(t *testing.T) {
|
||||
t.Run("construye un recurso valido", func(t *testing.T) {
|
||||
res, err := CRUDDefineResource("project", "projects", sampleProjectFields(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Name != "project" || res.Table != "projects" || len(res.Fields) != 4 {
|
||||
t.Errorf("unexpected resource: %+v", res)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza nombre vacio", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("", "projects", sampleProjectFields(), false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tabla vacia", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "", sampleProjectFields(), false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty table")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza lista de campos vacia", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", nil, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty fields")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza tipos invalidos", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: "x", Type: "FOO"}}, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid type")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza nombres reservados", func(t *testing.T) {
|
||||
for _, reserved := range []string{"id", "created_at", "updated_at", "deleted_at"} {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{{Name: reserved, Type: "TEXT"}}, false)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for reserved field %q", reserved)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rechaza duplicados", func(t *testing.T) {
|
||||
_, err := CRUDDefineResource("project", "projects", []CRUDField{
|
||||
{Name: "name", Type: "TEXT"},
|
||||
{Name: "name", Type: "TEXT"},
|
||||
}, false)
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate field")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDGenerateTableSQL ---
|
||||
|
||||
func TestCRUDGenerateTableSQL(t *testing.T) {
|
||||
t.Run("genera tabla basica con timestamps", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT"}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
for _, want := range []string{
|
||||
"CREATE TABLE IF NOT EXISTS projects",
|
||||
"id TEXT PRIMARY KEY",
|
||||
"name TEXT",
|
||||
"created_at TEXT NOT NULL",
|
||||
"updated_at TEXT NOT NULL",
|
||||
} {
|
||||
if !strings.Contains(ddl, want) {
|
||||
t.Errorf("DDL missing %q:\n%s", want, ddl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(ddl, "deleted_at") {
|
||||
t.Errorf("DDL should not contain deleted_at:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("aplica NOT NULL y UNIQUE", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT", Required: true, Unique: true}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "name TEXT NOT NULL UNIQUE") {
|
||||
t.Errorf("expected NOT NULL UNIQUE:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("aplica DEFAULT", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "priority", Type: "INTEGER", Default: "0"}}, false)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "priority INTEGER DEFAULT 0") {
|
||||
t.Errorf("expected DEFAULT clause:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anade deleted_at si soft_delete", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects",
|
||||
[]CRUDField{{Name: "name", Type: "TEXT"}}, true)
|
||||
ddl := CRUDGenerateTableSQL(res)
|
||||
if !strings.Contains(ddl, "deleted_at TEXT") {
|
||||
t.Errorf("expected deleted_at for soft_delete:\n%s", ddl)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("el DDL generado es valido en sqlite", func(t *testing.T) {
|
||||
res, _ := CRUDDefineResource("project", "projects", sampleProjectFields(), true)
|
||||
db := openCRUDTestDB(t)
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(res)); err != nil {
|
||||
t.Fatalf("DDL not valid: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDCreateHandler + CRUDGetHandler ---
|
||||
|
||||
func TestCRUDCreateAndGet(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
t.Run("crea un registro valido y retorna 201", func(t *testing.T) {
|
||||
rec, body := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "demo", "description": "hola", "status": "active", "priority": 5,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("got status %d, want 201: body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body["name"] != "demo" {
|
||||
t.Errorf("got name=%v, want demo", body["name"])
|
||||
}
|
||||
if body["id"] == nil || body["id"] == "" {
|
||||
t.Errorf("expected generated id, got %v", body["id"])
|
||||
}
|
||||
if body["created_at"] == nil {
|
||||
t.Errorf("expected created_at, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 400 si faltan required", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida min_length", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": ""})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for empty name", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida max_length", func(t *testing.T) {
|
||||
longName := strings.Repeat("a", 60)
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": longName})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for too-long name", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida enum", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "x", "status": "pirate",
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for invalid enum", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida min y max numericos", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "numeric-min", "priority": -1,
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for priority<0", rec.Code)
|
||||
}
|
||||
rec, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "numeric-max", "priority": 999,
|
||||
})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400 for priority>10", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 409 si se viola UNIQUE", func(t *testing.T) {
|
||||
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "unique-1"})
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Errorf("got %d, want 409", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET recupera el registro creado", func(t *testing.T) {
|
||||
rec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "get-me"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create failed: %d", rec.Code)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
rec, body := doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if body["name"] != "get-me" {
|
||||
t.Errorf("got name=%v, want get-me", body["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET no existente retorna 404", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "GET", "/api/projects/nonexistent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDListHandler ---
|
||||
|
||||
func TestCRUDListHandler(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
// Sembrar datos
|
||||
for i, name := range []string{"a", "b", "c", "d", "e"} {
|
||||
status := "active"
|
||||
if i%2 == 1 {
|
||||
status = "archived"
|
||||
}
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": name, "status": status, "priority": i,
|
||||
})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("seed %s failed: %d", name, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("lista todo con paginacion default", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
var got CRUDListResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("parse body: %v", err)
|
||||
}
|
||||
if got.Total != 5 {
|
||||
t.Errorf("got total=%d, want 5", got.Total)
|
||||
}
|
||||
if len(got.Items) != 5 {
|
||||
t.Errorf("got %d items, want 5", len(got.Items))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("respeta page y per_page", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?page=1&per_page=2", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got.Items) != 2 {
|
||||
t.Errorf("got %d items, want 2", len(got.Items))
|
||||
}
|
||||
if got.TotalPages != 3 {
|
||||
t.Errorf("got total_pages=%d, want 3", got.TotalPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filtra por campo con filter_<field>", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_status=archived", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 2 {
|
||||
t.Errorf("got total=%d, want 2 archived", got.Total)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordena con sort_by y sort_dir", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?sort_by=name&sort_dir=asc", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if len(got.Items) < 2 {
|
||||
t.Fatalf("not enough items: %d", len(got.Items))
|
||||
}
|
||||
if got.Items[0]["name"] != "a" || got.Items[1]["name"] != "b" {
|
||||
t.Errorf("sort asc failed: first=%v second=%v", got.Items[0]["name"], got.Items[1]["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignora filtros con campos desconocidos", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects?filter_unknown=xxx", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 5 {
|
||||
t.Errorf("got total=%d, want 5 (filter should be ignored)", got.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDUpdateHandler ---
|
||||
|
||||
func TestCRUDUpdateHandler(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
createRec, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{
|
||||
"name": "original", "description": "first", "priority": 2,
|
||||
})
|
||||
if createRec.Code != http.StatusCreated {
|
||||
t.Fatalf("create failed: %d", createRec.Code)
|
||||
}
|
||||
id := created["id"].(string)
|
||||
originalUpdatedAt := fmt.Sprintf("%v", created["updated_at"])
|
||||
|
||||
t.Run("actualiza solo los campos enviados", func(t *testing.T) {
|
||||
rec, body := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{
|
||||
"description": "updated",
|
||||
})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if body["description"] != "updated" {
|
||||
t.Errorf("description not updated: %v", body["description"])
|
||||
}
|
||||
if body["name"] != "original" {
|
||||
t.Errorf("name should not change: %v", body["name"])
|
||||
}
|
||||
if fmt.Sprintf("%v", body["updated_at"]) == originalUpdatedAt {
|
||||
t.Errorf("updated_at should change")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 404 si no existe", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/nonexistent", map[string]any{"description": "x"})
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valida los campos enviados", func(t *testing.T) {
|
||||
rec, _ := doJSONRequest(t, mux, "PUT", "/api/projects/"+id, map[string]any{"priority": 999})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDDeleteHandler ---
|
||||
|
||||
func TestCRUDDeleteHandler(t *testing.T) {
|
||||
t.Run("hard delete borra fisicamente", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "hard"})
|
||||
id := created["id"].(string)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("got %d, want 204", rec.Code)
|
||||
}
|
||||
if rec.Body.Len() != 0 {
|
||||
t.Errorf("expected empty body, got %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Verificar que ya no existe
|
||||
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("after delete, GET got %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Verificar que la fila ya no esta en la tabla
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM projects WHERE id = ?", id).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("hard delete should remove row, got count=%d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft delete actualiza deleted_at", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, true)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, created := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "soft"})
|
||||
id := created["id"].(string)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("got %d, want 204", rec.Code)
|
||||
}
|
||||
|
||||
// GET debe dar 404 (la fila esta oculta por soft delete)
|
||||
rec, _ = doJSONRequest(t, mux, "GET", "/api/projects/"+id, nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("after soft delete, GET got %d, want 404", rec.Code)
|
||||
}
|
||||
|
||||
// Pero la fila sigue fisica en la tabla con deleted_at no nulo
|
||||
var deletedAt sql.NullString
|
||||
if err := db.QueryRow("SELECT deleted_at FROM projects WHERE id = ?", id).Scan(&deletedAt); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if !deletedAt.Valid || deletedAt.String == "" {
|
||||
t.Errorf("expected deleted_at set, got %+v", deletedAt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna 404 si no existe", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
rec, _ := doJSONRequest(t, mux, "DELETE", "/api/projects/nonexistent", nil)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("soft delete no lista registros ocultos", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, true)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
|
||||
_, _ = doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "keep"})
|
||||
_, del := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "remove"})
|
||||
delID := del["id"].(string)
|
||||
_, _ = doJSONRequest(t, mux, "DELETE", "/api/projects/"+delID, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/projects", nil))
|
||||
var got CRUDListResult
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &got)
|
||||
if got.Total != 1 {
|
||||
t.Errorf("got total=%d, want 1 (soft deleted should be hidden)", got.Total)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- CRUDGenerateHandlers + CRUDRegisterRoutes integration ---
|
||||
|
||||
func TestCRUDGenerateHandlers(t *testing.T) {
|
||||
t.Run("retorna las 5 keys esperadas", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
handlers := CRUDGenerateHandlers(res, db)
|
||||
for _, key := range []string{"list", "get", "create", "update", "delete"} {
|
||||
if handlers[key] == nil {
|
||||
t.Errorf("handler %q is nil", key)
|
||||
}
|
||||
}
|
||||
if len(handlers) != 5 {
|
||||
t.Errorf("expected 5 handlers, got %d", len(handlers))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCRUDRegisterRoutesIntegration(t *testing.T) {
|
||||
t.Run("CRUD completo end-to-end via servidor HTTP", func(t *testing.T) {
|
||||
res, db := buildProjectResource(t, false)
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", res, db)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
// Create
|
||||
createBody, _ := json.Marshal(map[string]any{"name": "e2e", "description": "integration", "priority": 3})
|
||||
resp, err := http.Post(srv.URL+"/api/projects", "application/json", bytes.NewReader(createBody))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create: got %d, want 201", resp.StatusCode)
|
||||
}
|
||||
var created map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&created)
|
||||
id := created["id"].(string)
|
||||
|
||||
// Get
|
||||
resp, err = http.Get(srv.URL + "/api/projects/" + id)
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("get: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Update
|
||||
updateBody, _ := json.Marshal(map[string]any{"description": "modified"})
|
||||
req, _ := http.NewRequest("PUT", srv.URL+"/api/projects/"+id, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT: %v", err)
|
||||
}
|
||||
var updated map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&updated)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("update: got %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if updated["description"] != "modified" {
|
||||
t.Errorf("update did not persist: %v", updated["description"])
|
||||
}
|
||||
|
||||
// List
|
||||
resp, err = http.Get(srv.URL + "/api/projects")
|
||||
if err != nil {
|
||||
t.Fatalf("LIST: %v", err)
|
||||
}
|
||||
var list CRUDListResult
|
||||
_ = json.NewDecoder(resp.Body).Decode(&list)
|
||||
resp.Body.Close()
|
||||
if list.Total != 1 {
|
||||
t.Errorf("list total: got %d, want 1", list.Total)
|
||||
}
|
||||
|
||||
// Delete
|
||||
req, _ = http.NewRequest("DELETE", srv.URL+"/api/projects/"+id, nil)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("DELETE: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("delete: got %d, want 204", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Get after delete: 404
|
||||
resp, _ = http.Get(srv.URL + "/api/projects/" + id)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("after delete: got %d, want 404", resp.StatusCode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples recursos en el mismo mux", func(t *testing.T) {
|
||||
projectRes, db := buildProjectResource(t, false)
|
||||
// Segundo recurso sobre la misma DB
|
||||
userFields := []CRUDField{
|
||||
{Name: "email", Type: "TEXT", Required: true, Unique: true},
|
||||
}
|
||||
userRes, err := CRUDDefineResource("user", "users", userFields, false)
|
||||
if err != nil {
|
||||
t.Fatalf("define user: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(CRUDGenerateTableSQL(userRes)); err != nil {
|
||||
t.Fatalf("create users table: %v", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
CRUDRegisterRoutes(mux, "/api/projects", projectRes, db)
|
||||
CRUDRegisterRoutes(mux, "/api/users", userRes, db)
|
||||
|
||||
rec, _ := doJSONRequest(t, mux, "POST", "/api/projects", map[string]any{"name": "p1"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("projects: got %d", rec.Code)
|
||||
}
|
||||
rec, _ = doJSONRequest(t, mux, "POST", "/api/users", map[string]any{"email": "a@b.c"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("users: got %d", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: CRUDField
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type CRUDField struct {
|
||||
Name string
|
||||
Type string
|
||||
Required bool
|
||||
Unique bool
|
||||
Default string
|
||||
Validations map[string]string
|
||||
}
|
||||
description: "Define un campo de un recurso CRUD con su tipo SQLite, constraints y validaciones. Se agrega al slice Fields de CRUDResource."
|
||||
tags: [crud, field, sqlite, validation, rest, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/crud_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
field := CRUDField{
|
||||
Name: "name",
|
||||
Type: "TEXT",
|
||||
Required: true,
|
||||
Unique: true,
|
||||
Validations: map[string]string{
|
||||
"min_length": "1",
|
||||
"max_length": "100",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto. Type debe ser uno de: TEXT, INTEGER, REAL, BLOB. Default es SQL literal (por ejemplo "'active'" o "0"). Validations es un mapa generico con claves: min_length, max_length, pattern (TEXT), min, max (INTEGER/REAL), enum ("a,b,c" para TEXT). Las validaciones se evaluan en Go antes de cada INSERT/UPDATE.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: CRUDListParams
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type CRUDListParams struct {
|
||||
Page int
|
||||
PerPage int
|
||||
SortBy string
|
||||
SortDir string
|
||||
Filters map[string]string
|
||||
}
|
||||
description: "Parametros de paginacion, orden y filtrado de un endpoint list CRUD. Se extrae de los query params de la request HTTP."
|
||||
tags: [crud, list, pagination, filter, sort, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/crud_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
params := CRUDListParams{
|
||||
Page: 1,
|
||||
PerPage: 20,
|
||||
SortBy: "created_at",
|
||||
SortDir: "desc",
|
||||
Filters: map[string]string{"status": "active"},
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto. Page es 1-based con default 1. PerPage tiene default 20 y se satura a 100. SortDir solo acepta "asc" o "desc" (default "desc"). Filters usa igualdad exacta en WHERE — los campos se validan contra la definicion del recurso para evitar SQL injection.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: CRUDListResult
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
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"`
|
||||
}
|
||||
description: "Resultado paginado de un endpoint list CRUD. Incluye los items de la pagina y metadatos de paginacion para el cliente."
|
||||
tags: [crud, list, pagination, result, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/crud_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
result := CRUDListResult{
|
||||
Items: []map[string]any{{"id": "a", "name": "X"}},
|
||||
Total: 42,
|
||||
Page: 1,
|
||||
PerPage: 20,
|
||||
TotalPages: 3,
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto. Items es la lista de registros de la pagina actual serializados como map[string]any (schema dinamico). Total es el conteo global sin paginacion. TotalPages = ceil(Total / PerPage). Se serializa directamente como JSON con HTTPJSONResponse.
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: CRUDResource
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type CRUDResource struct {
|
||||
Name string
|
||||
Table string
|
||||
Fields []CRUDField
|
||||
SoftDelete bool
|
||||
}
|
||||
description: "Define un recurso CRUD completo (nombre, tabla, campos y modo de borrado) para generar handlers HTTP y SQL DDL. Se usa como input de crud_generate_table_sql y crud_generate_handlers."
|
||||
tags: [crud, resource, http, sqlite, rest, infra]
|
||||
uses_types: [CRUDField_go_infra]
|
||||
file_path: "functions/infra/crud_resource.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
res := CRUDResource{
|
||||
Name: "project",
|
||||
Table: "projects",
|
||||
Fields: []CRUDField{
|
||||
{Name: "name", Type: "TEXT", Required: true, Unique: true},
|
||||
{Name: "description", Type: "TEXT"},
|
||||
},
|
||||
SoftDelete: false,
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto. Name es el singular en snake_case ("project"), Table es el plural SQL ("projects"). Fields no incluye id ni timestamps: esos los gestiona el generador. Si SoftDelete es true, la tabla tendra una columna deleted_at TEXT y el handler delete hara UPDATE en vez de DELETE.
|
||||
Reference in New Issue
Block a user