merge: issue/0009-http-server — implementación paralela

This commit is contained in:
2026-04-13 02:04:50 +02:00
26 changed files with 1311 additions and 0 deletions
+184
View File
@@ -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.
+30
View File
@@ -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)
})
}
}
+42
View File
@@ -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")
}
})
}
+8
View File
@@ -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
}
+14
View File
@@ -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)
}
+48
View File
@@ -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":"..."}`.
+14
View File
@@ -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)
}
+42
View File
@@ -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.
+34
View File
@@ -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())
})
}
}
+38
View File
@@ -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.
+6
View File
@@ -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
+17
View File
@@ -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
}
}
+40
View File
@@ -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)
}
})
}
+26
View File
@@ -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
}
+49
View File
@@ -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.
+10
View File
@@ -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
}
+17
View File
@@ -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
}
+40
View File
@@ -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.
+37
View File
@@ -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
}
}
+46
View File
@@ -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.
+324
View File
@@ -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")
}
})
}
+32
View File
@@ -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.
+28
View File
@@ -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.
+31
View File
@@ -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.