From c43322ecc0a4bb8f56ccb426748b4b2c960af1f3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 13 Apr 2026 01:57:39 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20funciones=20puras=20HTTP=20=E2=80=94=20?= =?UTF-8?q?HTTPMiddlewareChain=20y=20HTTPCORSMiddleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTTPMiddlewareChain: compone N middlewares preservando el orden (el primero es el mas externo) - HTTPCORSMiddleware: genera Middleware con headers CORS configurables, maneja OPTIONS preflight con 204 Ambas son puras (sin I/O) y testeadas con httptest. Co-Authored-By: Claude Sonnet 4.6 --- functions/infra/http_cors_middleware.go | 30 +++++++ functions/infra/http_cors_middleware.md | 42 ++++++++++ functions/infra/http_cors_middleware_test.go | 73 +++++++++++++++++ functions/infra/http_middleware_chain.go | 17 ++++ functions/infra/http_middleware_chain.md | 40 +++++++++ functions/infra/http_middleware_chain_test.go | 81 +++++++++++++++++++ 6 files changed, 283 insertions(+) create mode 100644 functions/infra/http_cors_middleware.go create mode 100644 functions/infra/http_cors_middleware.md create mode 100644 functions/infra/http_cors_middleware_test.go create mode 100644 functions/infra/http_middleware_chain.go create mode 100644 functions/infra/http_middleware_chain.md create mode 100644 functions/infra/http_middleware_chain_test.go diff --git a/functions/infra/http_cors_middleware.go b/functions/infra/http_cors_middleware.go new file mode 100644 index 00000000..887d4bc0 --- /dev/null +++ b/functions/infra/http_cors_middleware.go @@ -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) + }) + } +} diff --git a/functions/infra/http_cors_middleware.md b/functions/infra/http_cors_middleware.md new file mode 100644 index 00000000..e5ca132f --- /dev/null +++ b/functions/infra/http_cors_middleware.md @@ -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. diff --git a/functions/infra/http_cors_middleware_test.go b/functions/infra/http_cors_middleware_test.go new file mode 100644 index 00000000..395fe81c --- /dev/null +++ b/functions/infra/http_cors_middleware_test.go @@ -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") + } + }) +} diff --git a/functions/infra/http_middleware_chain.go b/functions/infra/http_middleware_chain.go new file mode 100644 index 00000000..119ab8c8 --- /dev/null +++ b/functions/infra/http_middleware_chain.go @@ -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 + } +} diff --git a/functions/infra/http_middleware_chain.md b/functions/infra/http_middleware_chain.md new file mode 100644 index 00000000..6dfa0dd6 --- /dev/null +++ b/functions/infra/http_middleware_chain.md @@ -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. diff --git a/functions/infra/http_middleware_chain_test.go b/functions/infra/http_middleware_chain_test.go new file mode 100644 index 00000000..d29c1c97 --- /dev/null +++ b/functions/infra/http_middleware_chain_test.go @@ -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) + } + }) +}