feat: funciones impuras HTTP — response, parse, logger, router, serve
Seis funciones de servidor HTTP con tests usando httptest: - HTTPJSONResponse: escribe JSON con Content-Type y status code - HTTPErrorResponse: escribe HTTPError como JSON estructurado - HTTPParseBody: decode JSON con limite de bytes y campos estrictos - HTTPLoggerMiddleware: loguea method/path/status/duration a io.Writer - HTTPRouter: crea ServeMux con rutas Go 1.22+ (METHOD /path) - HTTPServe: ListenAndServe con graceful shutdown por contexto 23 tests pasando, solo stdlib net/http. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,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,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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user