fix(fn-run): propagar stdout/stderr de bash functions library-style #1
@@ -0,0 +1,184 @@
|
||||
# 0009 — HTTP Server Foundation
|
||||
|
||||
## Metadata
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **ID** | 0009 |
|
||||
| **Estado** | pendiente |
|
||||
| **Prioridad** | alta |
|
||||
| **Tipo** | feature |
|
||||
|
||||
## Dependencias
|
||||
|
||||
Ninguna.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Crear funciones reutilizables de HTTP server en Go (dominio infra) que permitan montar una API REST completa componiendo primitivas del registry, en vez de construir el serving desde cero en cada app.
|
||||
|
||||
## Contexto
|
||||
|
||||
- `deploy_server` y `sqlite_api` construyen su HTTP serving ad-hoc cada vez: router manual con `http.ServeMux`, middleware inline, helpers de respuesta repetidos.
|
||||
- Existen funciones HTTP **client** (`http_get_json_go_infra`, `http_post_json_go_infra`) pero **CERO** funciones de HTTP **server**.
|
||||
- Go stdlib `net/http` es suficiente como base — no se necesita framework externo, solo primitivas componibles encima de stdlib.
|
||||
- Con estas funciones, una app nueva que necesite API solo hace: registrar rutas + componer middlewares + `http_serve`.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
functions/infra/
|
||||
├── http_router.go — NEW: registro de rutas con path params y métodos
|
||||
├── http_router.md — NEW
|
||||
├── http_middleware_chain.go — NEW: composición de middlewares
|
||||
├── http_middleware_chain.md — NEW
|
||||
├── http_cors_middleware.go — NEW: middleware CORS configurable
|
||||
├── http_cors_middleware.md — NEW
|
||||
├── http_logger_middleware.go — NEW: middleware de logging request/response
|
||||
├── http_logger_middleware.md — NEW
|
||||
├── http_json_response.go — NEW: helper para escribir JSON responses
|
||||
├── http_json_response.md — NEW
|
||||
├── http_error_response.go — NEW: helper para escribir error responses estandar
|
||||
├── http_error_response.md — NEW
|
||||
├── http_parse_body.go — NEW: decode JSON body con validación de tamaño
|
||||
├── http_parse_body.md — NEW
|
||||
├── http_serve.go — NEW: ListenAndServe con graceful shutdown
|
||||
├── http_serve.md — NEW
|
||||
|
||||
types/infra/
|
||||
├── http_route.md — NEW: metadata del tipo Route
|
||||
├── http_middleware.md — NEW: metadata del tipo Middleware
|
||||
├── http_error.md — NEW: metadata del tipo HTTPError
|
||||
```
|
||||
|
||||
### Patrón pure core / impure shell
|
||||
|
||||
- **Pure:** `http_middleware_chain` (composición de funciones), `http_cors_middleware` (retorna función sin I/O)
|
||||
- **Impure:** `http_router`, `http_json_response`, `http_error_response`, `http_parse_body`, `http_logger_middleware`, `http_serve` — todos interactúan con `http.ResponseWriter` / `http.Request` / red
|
||||
|
||||
## Diseño
|
||||
|
||||
### Tipos
|
||||
|
||||
```go
|
||||
// Middleware es un wrapper de http.Handler
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Route define una ruta con método y handler
|
||||
type Route struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler http.HandlerFunc
|
||||
}
|
||||
|
||||
// HTTPError es un error estructurado para respuestas API
|
||||
type HTTPError struct {
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
```
|
||||
|
||||
### Funciones
|
||||
|
||||
| Función | Purity | Firma (simplificada) |
|
||||
|---------|--------|---------------------|
|
||||
| `http_router` | impure | `(routes []Route) *http.ServeMux` |
|
||||
| `http_middleware_chain` | pure | `(middlewares ...Middleware) Middleware` |
|
||||
| `http_cors_middleware` | pure | `(origins []string, methods []string) Middleware` |
|
||||
| `http_logger_middleware` | impure | `(logger io.Writer) Middleware` |
|
||||
| `http_json_response` | impure | `(w http.ResponseWriter, status int, data any)` |
|
||||
| `http_error_response` | impure | `(w http.ResponseWriter, err HTTPError)` |
|
||||
| `http_parse_body` | impure | `(r *http.Request, dst any, maxBytes int64) error` |
|
||||
| `http_serve` | impure | `(addr string, handler http.Handler, ctx context.Context) error` |
|
||||
|
||||
## Tareas
|
||||
|
||||
### Fase 1: Tipos
|
||||
|
||||
- [ ] **1.1** Crear tipos `Route`, `Middleware`, `HTTPError` en `functions/infra/` con `.md` en `types/infra/`
|
||||
|
||||
### Fase 2: Funciones puras
|
||||
|
||||
- [ ] **2.1** `http_middleware_chain` — compone N middlewares en uno solo (reduce de derecha a izquierda)
|
||||
- [ ] **2.2** `http_cors_middleware` — retorna Middleware que setea headers CORS según config
|
||||
|
||||
### Fase 3: Funciones impuras
|
||||
|
||||
- [ ] **3.1** `http_json_response` — serializa `data` a JSON, setea Content-Type, escribe status
|
||||
- [ ] **3.2** `http_error_response` — escribe HTTPError como JSON response
|
||||
- [ ] **3.3** `http_parse_body` — lee body con limit de tamaño, decode JSON, cierra body
|
||||
- [ ] **3.4** `http_logger_middleware` — loguea método, path, status, duración de cada request
|
||||
- [ ] **3.5** `http_router` — crea `http.ServeMux` y registra rutas con sus handlers
|
||||
- [ ] **3.6** `http_serve` — `http.Server` con graceful shutdown vía context cancelable + señales OS
|
||||
|
||||
### Fase 4: Tests y cleanup
|
||||
|
||||
- [ ] **4.1** Tests para cada función con `httptest.NewRecorder`
|
||||
- [ ] **4.2** `fn index` y verificar que todas las funciones aparecen en registry.db
|
||||
- [ ] **4.3** Verificar `go vet -tags fts5`
|
||||
|
||||
---
|
||||
|
||||
## Ejemplo de uso
|
||||
|
||||
```go
|
||||
// En cualquier app nueva:
|
||||
routes := []infra.Route{
|
||||
{Method: "GET", Path: "/health", Handler: healthHandler},
|
||||
{Method: "GET", Path: "/api/items", Handler: listItems},
|
||||
{Method: "POST", Path: "/api/items", Handler: createItem},
|
||||
}
|
||||
|
||||
mux := infra.HttpRouter(routes)
|
||||
|
||||
middleware := infra.HttpMiddlewareChain(
|
||||
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}),
|
||||
infra.HttpLoggerMiddleware(os.Stdout),
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||
defer cancel()
|
||||
|
||||
infra.HttpServe(":8080", middleware(mux), ctx)
|
||||
```
|
||||
|
||||
```go
|
||||
// Dentro de un handler:
|
||||
func listItems(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := db.GetItems()
|
||||
if err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "db_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
infra.HttpJsonResponse(w, 200, items)
|
||||
}
|
||||
|
||||
func createItem(w http.ResponseWriter, r *http.Request) {
|
||||
var input CreateItemInput
|
||||
if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
|
||||
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Decisiones de diseño
|
||||
|
||||
- **Solo stdlib `net/http`:** sin chi, gin, echo. Las funciones wrappean stdlib para mantener zero-dependency.
|
||||
- **`http.ServeMux` nativo:** Go 1.22+ soporta path params y métodos en `ServeMux`, suficiente sin router externo.
|
||||
- **Middleware como `func(http.Handler) http.Handler`:** patrón estándar de Go, compatible con cualquier middleware de terceros.
|
||||
- **Graceful shutdown con context:** permite que la app controle cuándo parar (señales OS, context padre, etc.).
|
||||
- **HTTPError como struct simple:** no implementa `error` interface — es un DTO de respuesta, no un error Go.
|
||||
|
||||
## Prerequisitos
|
||||
|
||||
Ninguno. Solo Go stdlib.
|
||||
|
||||
## Riesgos
|
||||
|
||||
- **Scope creep hacia un framework:** Mitigado manteniendo cada función atómica y sin estado compartido. No es un framework, son funciones sueltas.
|
||||
- **Colisión con patterns existentes en apps:** Las apps existentes pueden migrar gradualmente, no hay breaking change.
|
||||
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HTTPCORSMiddleware retorna un Middleware que setea headers CORS en cada respuesta.
|
||||
// origins es la lista de origenes permitidos (usa "*" para cualquiera).
|
||||
// methods es la lista de metodos HTTP permitidos.
|
||||
func HTTPCORSMiddleware(origins []string, methods []string) Middleware {
|
||||
originsHeader := strings.Join(origins, ", ")
|
||||
methodsHeader := strings.Join(methods, ", ")
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", originsHeader)
|
||||
w.Header().Set("Access-Control-Allow-Methods", methodsHeader)
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
// Responder preflight OPTIONS sin pasar al siguiente handler
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: http_cors_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func HTTPCORSMiddleware(origins []string, methods []string) Middleware"
|
||||
description: "Retorna un Middleware que setea headers CORS en cada respuesta HTTP. Maneja automaticamente las peticiones preflight OPTIONS con status 204."
|
||||
tags: [http, cors, middleware, headers, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Middleware_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [net/http, strings]
|
||||
params:
|
||||
- name: origins
|
||||
desc: "lista de origenes permitidos (ej: [\"https://example.com\"] o [\"*\"] para cualquiera)"
|
||||
- name: methods
|
||||
desc: "lista de metodos HTTP permitidos (ej: [\"GET\", \"POST\", \"PUT\", \"DELETE\"])"
|
||||
output: "Middleware que inyecta Access-Control-Allow-Origin, Access-Control-Allow-Methods y Access-Control-Allow-Headers en cada respuesta"
|
||||
tested: true
|
||||
tests: ["setea headers CORS en respuesta normal", "responde 204 a peticion OPTIONS preflight", "headers incluyen los origenes configurados", "headers incluyen los metodos configurados"]
|
||||
test_file_path: "functions/infra/http_cors_middleware_test.go"
|
||||
file_path: "functions/infra/http_cors_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
cors := HTTPCORSMiddleware(
|
||||
[]string{"https://app.example.com"},
|
||||
[]string{"GET", "POST", "PUT", "DELETE"},
|
||||
)
|
||||
chain := HTTPMiddlewareChain(cors)
|
||||
http.ListenAndServe(":8080", chain(mux))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — construye el middleware a partir de los parametros sin I/O. El header Access-Control-Allow-Headers siempre incluye "Content-Type, Authorization". Las peticiones OPTIONS reciben 204 No Content sin llegar al handler de negocio.
|
||||
@@ -0,0 +1,73 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPCORSMiddleware(t *testing.T) {
|
||||
origins := []string{"https://example.com"}
|
||||
methods := []string{"GET", "POST"}
|
||||
|
||||
base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
t.Run("setea headers CORS en respuesta normal", func(t *testing.T) {
|
||||
mw := HTTPCORSMiddleware(origins, methods)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||
t.Error("Access-Control-Allow-Origin header not set")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde 204 a peticion OPTIONS preflight", func(t *testing.T) {
|
||||
mw := HTTPCORSMiddleware(origins, methods)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("OPTIONS", "/api/test", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("got status %d, want 204", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headers incluyen los origenes configurados", func(t *testing.T) {
|
||||
mw := HTTPCORSMiddleware([]string{"https://app.example.com"}, methods)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
||||
if got != "https://app.example.com" {
|
||||
t.Errorf("got Access-Control-Allow-Origin=%q, want %q", got, "https://app.example.com")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headers incluyen los metodos configurados", func(t *testing.T) {
|
||||
mw := HTTPCORSMiddleware(origins, []string{"GET", "DELETE"})
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
got := rec.Header().Get("Access-Control-Allow-Methods")
|
||||
if got != "GET, DELETE" {
|
||||
t.Errorf("got Access-Control-Allow-Methods=%q, want %q", got, "GET, DELETE")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package infra
|
||||
|
||||
// HTTPError representa un error HTTP estructurado para respuestas JSON.
|
||||
type HTTPError struct {
|
||||
Status int // codigo HTTP (ej: 400, 404, 500)
|
||||
Code string // codigo de error legible por maquina (ej: "invalid_input")
|
||||
Message string // mensaje de error legible por humano
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPErrorResponse escribe un HTTPError como JSON en w.
|
||||
// Setea Content-Type a application/json y usa err.Status como status code.
|
||||
func HTTPErrorResponse(w http.ResponseWriter, err HTTPError) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(err.Status)
|
||||
json.NewEncoder(w).Encode(err)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: http_error_response
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPErrorResponse(w http.ResponseWriter, err HTTPError)"
|
||||
description: "Escribe un HTTPError como JSON en el ResponseWriter. Usa err.Status como status code HTTP y serializa los campos Code y Message en el body."
|
||||
tags: [http, error, response, json, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: [HTTPError_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, net/http]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe la respuesta de error"
|
||||
- name: err
|
||||
desc: "HTTPError con Status (codigo HTTP), Code (identificador maquina) y Message (texto legible)"
|
||||
output: "escribe la respuesta de error JSON directamente en w, sin valor de retorno"
|
||||
tested: true
|
||||
tests: ["escribe el status code del HTTPError", "setea Content-Type application/json", "serializa Code y Message en el body JSON"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_error_response.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var user User
|
||||
if err := HTTPParseBody(r, &user, 1<<20); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{
|
||||
Status: http.StatusBadRequest,
|
||||
Code: "invalid_body",
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Conveniente para APIs JSON que siempre retornan errores en formato estructurado. Se puede componer con HTTPParseBody: si el parse falla, llamar HTTPErrorResponse con 400. El body JSON tiene la forma `{"Status":400,"Code":"...","Message":"..."}`.
|
||||
@@ -0,0 +1,14 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPJSONResponse escribe data como JSON en w con el status code dado.
|
||||
// Setea Content-Type a application/json antes de escribir el body.
|
||||
func HTTPJSONResponse(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: http_json_response
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPJSONResponse(w http.ResponseWriter, status int, data any)"
|
||||
description: "Escribe data como JSON en el ResponseWriter con el status code dado. Setea Content-Type: application/json automaticamente."
|
||||
tags: [http, json, response, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, net/http]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe la respuesta HTTP"
|
||||
- name: status
|
||||
desc: "codigo de status HTTP (ej: 200, 201, 400, 500)"
|
||||
- name: data
|
||||
desc: "cualquier valor serializable a JSON que se escribe como body de la respuesta"
|
||||
output: "escribe la respuesta JSON directamente en w, sin valor de retorno"
|
||||
tested: true
|
||||
tests: ["escribe status code correcto", "setea Content-Type application/json", "serializa datos correctamente a JSON"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_json_response.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func getUser(w http.ResponseWriter, r *http.Request) {
|
||||
user := map[string]string{"id": "1", "name": "Lucas"}
|
||||
HTTPJSONResponse(w, http.StatusOK, user)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa json.NewEncoder para streaming directo al ResponseWriter sin buffer intermedio. El header Content-Type debe setearse antes de WriteHeader. Errores de serializacion se propagan parcialmente al body — asegurarse de pasar tipos serializable a JSON.
|
||||
@@ -0,0 +1,34 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// responseWriter captura el status code escrito al ResponseWriter.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(status int) {
|
||||
rw.status = status
|
||||
rw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// HTTPLoggerMiddleware retorna un Middleware que loguea metodo, path, status y duracion de cada request.
|
||||
// El formato de cada linea es: METHOD /path STATUS DURACIONms
|
||||
func HTTPLoggerMiddleware(logger io.Writer) Middleware {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
next.ServeHTTP(rw, r)
|
||||
duration := time.Since(start)
|
||||
fmt.Fprintf(logger, "%s %s %d %dms\n",
|
||||
r.Method, r.URL.Path, rw.status, duration.Milliseconds())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: http_logger_middleware
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPLoggerMiddleware(logger io.Writer) Middleware"
|
||||
description: "Retorna un Middleware que loguea metodo, path, status code y duracion de cada request HTTP. El formato es: METHOD /path STATUS DURACIONms"
|
||||
tags: [http, logger, middleware, logging, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Middleware_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, io, net/http, time]
|
||||
params:
|
||||
- name: logger
|
||||
desc: "io.Writer donde se escriben las lineas de log (ej: os.Stderr, un archivo, bytes.Buffer)"
|
||||
output: "Middleware que intercepta cada request, llama al siguiente handler, y loguea los detalles al terminar"
|
||||
tested: true
|
||||
tests: ["loguea metodo y path del request", "loguea el status code de la respuesta", "loguea la duracion en milisegundos"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_logger_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
logger := HTTPLoggerMiddleware(os.Stderr)
|
||||
chain := HTTPMiddlewareChain(logger)
|
||||
// Cada request imprime: GET /api/users 200 5ms
|
||||
http.ListenAndServe(":8080", chain(mux))
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Captura el status code con un responseWriter envolvente que intercepta WriteHeader. Si el handler no llama WriteHeader explicitamente, el status por defecto es 200. El logger recibe cualquier io.Writer — util para tests usando bytes.Buffer o para escribir a archivos de log.
|
||||
@@ -0,0 +1,6 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Middleware es una funcion que envuelve un http.Handler para añadir comportamiento.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// HTTPMiddlewareChain compone N middlewares en uno solo aplicandolos de izquierda a derecha.
|
||||
// El primer middleware de la lista es el mas externo (se ejecuta primero).
|
||||
// Si no se pasan middlewares retorna un Middleware que no modifica el handler.
|
||||
func HTTPMiddlewareChain(middlewares ...Middleware) Middleware {
|
||||
return func(final http.Handler) http.Handler {
|
||||
// Aplicar en orden inverso para que el primero quede mas externo
|
||||
h := final
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
h = middlewares[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: http_middleware_chain
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func HTTPMiddlewareChain(middlewares ...Middleware) Middleware"
|
||||
description: "Compone N middlewares en uno solo. El primer middleware de la lista es el mas externo (se ejecuta primero en el request y ultimo en el response)."
|
||||
tags: [http, middleware, chain, compose, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Middleware_go_infra]
|
||||
returns: [Middleware_go_infra]
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: [net/http]
|
||||
params:
|
||||
- name: middlewares
|
||||
desc: "lista variadic de middlewares a componer, aplicados de izquierda a derecha (el primero es el mas externo)"
|
||||
output: "un Middleware unico que aplica todos los middlewares en orden"
|
||||
tested: true
|
||||
tests: ["sin middlewares pasa al handler final", "un middleware se aplica correctamente", "dos middlewares se aplican en orden izquierda-derecha"]
|
||||
test_file_path: "functions/infra/http_middleware_chain_test.go"
|
||||
file_path: "functions/infra/http_middleware_chain.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
chain := HTTPMiddlewareChain(
|
||||
HTTPLoggerMiddleware(os.Stderr),
|
||||
HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST"}),
|
||||
)
|
||||
handler := chain(myHandler)
|
||||
http.ListenAndServe(":8080", handler)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Funcion pura — solo combina funciones, sin I/O ni estado. La composicion es de derecha a izquierda internamente para que el primer middleware quede en la capa mas externa. Con cero middlewares retorna un Middleware identidad que no modifica el handler.
|
||||
@@ -0,0 +1,81 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHTTPMiddlewareChain(t *testing.T) {
|
||||
t.Run("sin middlewares pasa al handler final", func(t *testing.T) {
|
||||
chain := HTTPMiddlewareChain()
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("final"))
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if rec.Body.String() != "final" {
|
||||
t.Errorf("got body %q, want %q", rec.Body.String(), "final")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("un middleware se aplica correctamente", func(t *testing.T) {
|
||||
addHeader := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "applied")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
chain := HTTPMiddlewareChain(Middleware(addHeader))
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-Test") != "applied" {
|
||||
t.Errorf("got X-Test=%q, want %q", rec.Header().Get("X-Test"), "applied")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dos middlewares se aplican en orden izquierda-derecha", func(t *testing.T) {
|
||||
order := []string{}
|
||||
|
||||
first := Middleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
order = append(order, "first")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
second := Middleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
order = append(order, "second")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
|
||||
chain := HTTPMiddlewareChain(first, second)
|
||||
handler := chain(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
order = append(order, "handler")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if len(order) != 3 || order[0] != "first" || order[1] != "second" || order[2] != "handler" {
|
||||
t.Errorf("got order %v, want [first second handler]", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPParseBody decodifica el body JSON de r en dst, limitando la lectura a maxBytes.
|
||||
// Retorna error si el body excede maxBytes, si el JSON es invalido, o si hay error de lectura.
|
||||
func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error {
|
||||
r.Body = http.MaxBytesReader(nil, r.Body, maxBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
return fmt.Errorf("parse body: %w", err)
|
||||
}
|
||||
// Verificar que no queda mas data despues del primer objeto JSON
|
||||
if dec.More() {
|
||||
return fmt.Errorf("parse body: request body must contain a single JSON object")
|
||||
}
|
||||
// Consumir el resto para liberar el body
|
||||
io.Copy(io.Discard, r.Body)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: http_parse_body
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPParseBody(r *http.Request, dst any, maxBytes int64) error"
|
||||
description: "Decodifica el body JSON de un http.Request en dst, limitando la lectura a maxBytes. Rechaza campos desconocidos y multiples objetos JSON."
|
||||
tags: [http, json, parse, body, request, server, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [encoding/json, fmt, io, net/http]
|
||||
params:
|
||||
- name: r
|
||||
desc: "http.Request cuyo body se va a decodificar"
|
||||
- name: dst
|
||||
desc: "puntero al valor donde se decodifica el JSON (ej: &myStruct)"
|
||||
- name: maxBytes
|
||||
desc: "limite maximo de bytes del body (ej: 1<<20 para 1 MB)"
|
||||
output: "nil si el parse fue exitoso, error si el body excede maxBytes, si el JSON es invalido, si hay campos desconocidos, o si hay multiples objetos"
|
||||
tested: true
|
||||
tests: ["decodifica JSON valido correctamente", "retorna error si el body supera maxBytes", "retorna error si el JSON es invalido", "retorna error si hay campos desconocidos"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_parse_body.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := HTTPParseBody(r, &input, 1<<20); err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 400, Code: "invalid_body", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
// usar input.Name, input.Email
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa http.MaxBytesReader para limitar el body antes de decodificar. DisallowUnknownFields rechaza campos JSON no mapeados en dst — util para validacion estricta de contratos API. Consume el body restante con io.Discard despues del decode.
|
||||
@@ -0,0 +1,10 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// Route define una ruta HTTP con metodo, path y handler.
|
||||
type Route struct {
|
||||
Method string // metodo HTTP (GET, POST, PUT, DELETE, PATCH, etc.)
|
||||
Path string // path de la ruta (ej: /api/users/{id})
|
||||
Handler http.Handler // handler que procesa la peticion
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPRouter crea un http.ServeMux y registra las rutas dadas.
|
||||
// Usa la sintaxis de Go 1.22+: "METHOD /path" como patron del mux.
|
||||
func HTTPRouter(routes []Route) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
for _, route := range routes {
|
||||
pattern := fmt.Sprintf("%s %s", route.Method, route.Path)
|
||||
mux.Handle(pattern, route.Handler)
|
||||
}
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: http_router
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPRouter(routes []Route) *http.ServeMux"
|
||||
description: "Crea un http.ServeMux y registra las rutas dadas usando la sintaxis de Go 1.22+ (METHOD /path). Retorna el mux listo para usar con http.ListenAndServe."
|
||||
tags: [http, router, mux, server, routes, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Route_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [fmt, net/http]
|
||||
params:
|
||||
- name: routes
|
||||
desc: "slice de Route con Method, Path y Handler a registrar en el mux"
|
||||
output: "http.ServeMux configurado con todas las rutas"
|
||||
tested: true
|
||||
tests: ["registra una ruta y responde correctamente", "registra multiples rutas en el mismo mux", "ruta no registrada retorna 404"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_router.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
mux := HTTPRouter([]Route{
|
||||
{Method: "GET", Path: "/api/health", Handler: http.HandlerFunc(healthHandler)},
|
||||
{Method: "POST", Path: "/api/users", Handler: http.HandlerFunc(createUserHandler)},
|
||||
{Method: "GET", Path: "/api/users/{id}", Handler: http.HandlerFunc(getUserHandler)},
|
||||
})
|
||||
http.ListenAndServe(":8080", mux)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa la sintaxis "METHOD /path" de Go 1.22+ para registrar rutas con metodo especifico. Soporta parametros de path como {id} accesibles via r.PathValue("id"). Sin rutas registradas retorna un mux vacio que responde 404 a todo.
|
||||
@@ -0,0 +1,37 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HTTPServe arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela.
|
||||
// Espera hasta 30 segundos para que las conexiones activas cierren antes de forzar el shutdown.
|
||||
func HTTPServe(addr string, handler http.Handler, ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("http serve %s: %w", addr, err)
|
||||
}
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("http shutdown %s: %w", addr, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: http_serve
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func HTTPServe(addr string, handler http.Handler, ctx context.Context) error"
|
||||
description: "Arranca un servidor HTTP en addr y realiza graceful shutdown cuando ctx se cancela. Espera hasta 30 segundos para que las conexiones activas cierren."
|
||||
tags: [http, server, serve, graceful, shutdown, context, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: [context, fmt, net/http, time]
|
||||
params:
|
||||
- name: addr
|
||||
desc: "direccion donde escuchar (ej: \":8080\", \"0.0.0.0:9090\")"
|
||||
- name: handler
|
||||
desc: "http.Handler que procesa las peticiones (normalmente un *http.ServeMux con middlewares)"
|
||||
- name: ctx
|
||||
desc: "contexto que al cancelarse dispara el graceful shutdown del servidor"
|
||||
output: "nil si el servidor se detuvo correctamente por cancelacion de contexto, error si fallo al arrancar o al hacer shutdown"
|
||||
tested: true
|
||||
tests: ["servidor arranca y responde peticiones", "graceful shutdown al cancelar contexto"]
|
||||
test_file_path: "functions/infra/http_server_test.go"
|
||||
file_path: "functions/infra/http_serve.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
mux := HTTPRouter(routes)
|
||||
chain := HTTPMiddlewareChain(HTTPLoggerMiddleware(os.Stderr))
|
||||
if err := HTTPServe(":8080", chain(mux), ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Arranca ListenAndServe en una goroutine y espera en un select: si el servidor falla devuelve el error inmediatamente, si ctx se cancela hace Shutdown con timeout de 30s. El patron estandar es usar signal.NotifyContext con SIGINT/SIGTERM para el graceful shutdown.
|
||||
@@ -0,0 +1,324 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- HTTPJSONResponse ---
|
||||
|
||||
func TestHTTPJSONResponse(t *testing.T) {
|
||||
t.Run("escribe status code correcto", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
HTTPJSONResponse(rec, http.StatusCreated, map[string]string{"ok": "true"})
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("got status %d, want 201", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("setea Content-Type application/json", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
HTTPJSONResponse(rec, http.StatusOK, "test")
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("got Content-Type=%q, want application/json", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serializa datos correctamente a JSON", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
data := map[string]int{"count": 42}
|
||||
HTTPJSONResponse(rec, http.StatusOK, data)
|
||||
|
||||
var got map[string]int
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v", err)
|
||||
}
|
||||
if got["count"] != 42 {
|
||||
t.Errorf("got count=%d, want 42", got["count"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- HTTPErrorResponse ---
|
||||
|
||||
func TestHTTPErrorResponse(t *testing.T) {
|
||||
t.Run("escribe el status code del HTTPError", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
HTTPErrorResponse(rec, HTTPError{Status: http.StatusBadRequest, Code: "bad", Message: "err"})
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("got status %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("setea Content-Type application/json", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
HTTPErrorResponse(rec, HTTPError{Status: 500, Code: "err", Message: "fail"})
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if ct != "application/json" {
|
||||
t.Errorf("got Content-Type=%q, want application/json", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("serializa Code y Message en el body JSON", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
HTTPErrorResponse(rec, HTTPError{Status: 422, Code: "validation_error", Message: "campo requerido"})
|
||||
|
||||
var got HTTPError
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse response JSON: %v", err)
|
||||
}
|
||||
if got.Code != "validation_error" {
|
||||
t.Errorf("got Code=%q, want validation_error", got.Code)
|
||||
}
|
||||
if got.Message != "campo requerido" {
|
||||
t.Errorf("got Message=%q, want 'campo requerido'", got.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- HTTPParseBody ---
|
||||
|
||||
func TestHTTPParseBody(t *testing.T) {
|
||||
t.Run("decodifica JSON valido correctamente", func(t *testing.T) {
|
||||
body := `{"name":"Lucas","age":30}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var dst struct {
|
||||
Name string `json:"name"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
err := HTTPParseBody(req, &dst, 1<<20)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if dst.Name != "Lucas" || dst.Age != 30 {
|
||||
t.Errorf("got {%q, %d}, want {Lucas, 30}", dst.Name, dst.Age)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna error si el body supera maxBytes", func(t *testing.T) {
|
||||
body := strings.Repeat("x", 100)
|
||||
payload := `{"data":"` + body + `"}`
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(payload))
|
||||
|
||||
var dst struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
err := HTTPParseBody(req, &dst, 10) // limite de 10 bytes
|
||||
if err == nil {
|
||||
t.Error("expected error for oversized body, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna error si el JSON es invalido", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(`{invalid json`))
|
||||
var dst map[string]any
|
||||
err := HTTPParseBody(req, &dst, 1<<20)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retorna error si hay campos desconocidos", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"name":"test","unknown":"field"}`))
|
||||
var dst struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
err := HTTPParseBody(req, &dst, 1<<20)
|
||||
if err == nil {
|
||||
t.Error("expected error for unknown fields, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- HTTPLoggerMiddleware ---
|
||||
|
||||
func TestHTTPLoggerMiddleware(t *testing.T) {
|
||||
t.Run("loguea metodo y path del request", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
mw := HTTPLoggerMiddleware(&buf)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
log := buf.String()
|
||||
if !strings.Contains(log, "GET") {
|
||||
t.Errorf("log does not contain method GET: %q", log)
|
||||
}
|
||||
if !strings.Contains(log, "/api/test") {
|
||||
t.Errorf("log does not contain path /api/test: %q", log)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loguea el status code de la respuesta", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
mw := HTTPLoggerMiddleware(&buf)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/missing", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
log := buf.String()
|
||||
if !strings.Contains(log, "404") {
|
||||
t.Errorf("log does not contain status 404: %q", log)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("loguea la duracion en milisegundos", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
mw := HTTPLoggerMiddleware(&buf)
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
log := buf.String()
|
||||
if !strings.Contains(log, "ms") {
|
||||
t.Errorf("log does not contain duration in ms: %q", log)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- HTTPRouter ---
|
||||
|
||||
func TestHTTPRouter(t *testing.T) {
|
||||
t.Run("registra una ruta y responde correctamente", func(t *testing.T) {
|
||||
mux := HTTPRouter([]Route{
|
||||
{
|
||||
Method: "GET",
|
||||
Path: "/api/ping",
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("pong"))
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/api/ping", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("got status %d, want 200", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != "pong" {
|
||||
t.Errorf("got body %q, want pong", rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("registra multiples rutas en el mismo mux", func(t *testing.T) {
|
||||
mux := HTTPRouter([]Route{
|
||||
{Method: "GET", Path: "/a", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("a"))
|
||||
})},
|
||||
{Method: "GET", Path: "/b", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("b"))
|
||||
})},
|
||||
})
|
||||
|
||||
recA := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recA, httptest.NewRequest("GET", "/a", nil))
|
||||
if recA.Body.String() != "a" {
|
||||
t.Errorf("route /a: got %q, want a", recA.Body.String())
|
||||
}
|
||||
|
||||
recB := httptest.NewRecorder()
|
||||
mux.ServeHTTP(recB, httptest.NewRequest("GET", "/b", nil))
|
||||
if recB.Body.String() != "b" {
|
||||
t.Errorf("route /b: got %q, want b", recB.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ruta no registrada retorna 404", func(t *testing.T) {
|
||||
mux := HTTPRouter([]Route{
|
||||
{Method: "GET", Path: "/known", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/unknown", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("got status %d, want 404", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- HTTPServe ---
|
||||
|
||||
func TestHTTPServe(t *testing.T) {
|
||||
t.Run("servidor arranca y responde peticiones", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
mux := HTTPRouter([]Route{
|
||||
{Method: "GET", Path: "/ping", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("pong"))
|
||||
})},
|
||||
})
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- HTTPServe("127.0.0.1:0", mux, ctx)
|
||||
}()
|
||||
|
||||
// Cancelar el contexto rapidamente — el servidor deberia cerrarse limpiamente
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error from HTTPServe: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("HTTPServe did not stop after context cancel")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("graceful shutdown al cancelar contexto", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- HTTPServe("127.0.0.1:0", handler, ctx)
|
||||
}()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("shutdown error: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not shutdown within 5 seconds")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: HTTPError
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type HTTPError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
description: "Error HTTP estructurado para respuestas JSON. Combina status code HTTP, codigo de error legible por maquina y mensaje para el usuario."
|
||||
tags: [http, error, server, json, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/http_error.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
err := HTTPError{
|
||||
Status: 400,
|
||||
Code: "invalid_input",
|
||||
Message: "el campo email es requerido",
|
||||
}
|
||||
HTTPErrorResponse(w, err)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto — los tres campos son complementarios. Status es el codigo HTTP numerico que se usa en la respuesta. Code es un identificador maquina (snake_case) para el cliente. Message es texto libre legible por humanos. Se serializa a JSON con HTTPErrorResponse.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Middleware
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: "type Middleware func(http.Handler) http.Handler"
|
||||
description: "Funcion que envuelve un http.Handler para añadir comportamiento (logging, auth, CORS, etc.)."
|
||||
tags: [http, middleware, server, handler, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/http_middleware.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Middleware que añade un header a cada respuesta
|
||||
func myMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Custom", "value")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo funcion — aplica el patron decorator sobre http.Handler. Se compone con HTTPMiddlewareChain para apilar comportamiento en orden. Cada Middleware recibe el siguiente handler y retorna un nuevo handler que puede modificar request y response.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Route
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type Route struct {
|
||||
Method string
|
||||
Path string
|
||||
Handler http.Handler
|
||||
}
|
||||
description: "Define una ruta HTTP con metodo, path y handler. Se usa con HTTPRouter para registrar rutas en un ServeMux."
|
||||
tags: [http, route, server, router, infra]
|
||||
uses_types: []
|
||||
file_path: "functions/infra/http_route.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
routes := []Route{
|
||||
{Method: "GET", Path: "/api/health", Handler: http.HandlerFunc(healthHandler)},
|
||||
{Method: "POST", Path: "/api/users", Handler: http.HandlerFunc(createUserHandler)},
|
||||
}
|
||||
mux := HTTPRouter(routes)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Tipo producto — los tres campos son obligatorios. Method debe ser un verbo HTTP valido en mayusculas. Path sigue la sintaxis del ServeMux de Go 1.22+ (soporta parametros como {id}). Handler puede ser cualquier http.Handler o http.HandlerFunc.
|
||||
Reference in New Issue
Block a user