diff --git a/functions/infra/http_error_response.go b/functions/infra/http_error_response.go new file mode 100644 index 00000000..2861a71a --- /dev/null +++ b/functions/infra/http_error_response.go @@ -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) +} diff --git a/functions/infra/http_error_response.md b/functions/infra/http_error_response.md new file mode 100644 index 00000000..552b6e9d --- /dev/null +++ b/functions/infra/http_error_response.md @@ -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":"..."}`. diff --git a/functions/infra/http_json_response.go b/functions/infra/http_json_response.go new file mode 100644 index 00000000..5fc683b7 --- /dev/null +++ b/functions/infra/http_json_response.go @@ -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) +} diff --git a/functions/infra/http_json_response.md b/functions/infra/http_json_response.md new file mode 100644 index 00000000..c4a991de --- /dev/null +++ b/functions/infra/http_json_response.md @@ -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. diff --git a/functions/infra/http_logger_middleware.go b/functions/infra/http_logger_middleware.go new file mode 100644 index 00000000..8ab8e243 --- /dev/null +++ b/functions/infra/http_logger_middleware.go @@ -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()) + }) + } +} diff --git a/functions/infra/http_logger_middleware.md b/functions/infra/http_logger_middleware.md new file mode 100644 index 00000000..bbc6d253 --- /dev/null +++ b/functions/infra/http_logger_middleware.md @@ -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. diff --git a/functions/infra/http_parse_body.go b/functions/infra/http_parse_body.go new file mode 100644 index 00000000..5a9ab6e0 --- /dev/null +++ b/functions/infra/http_parse_body.go @@ -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 +} diff --git a/functions/infra/http_parse_body.md b/functions/infra/http_parse_body.md new file mode 100644 index 00000000..727f24b6 --- /dev/null +++ b/functions/infra/http_parse_body.md @@ -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. diff --git a/functions/infra/http_router.go b/functions/infra/http_router.go new file mode 100644 index 00000000..52d4adac --- /dev/null +++ b/functions/infra/http_router.go @@ -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 +} diff --git a/functions/infra/http_router.md b/functions/infra/http_router.md new file mode 100644 index 00000000..9d3ef3f0 --- /dev/null +++ b/functions/infra/http_router.md @@ -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. diff --git a/functions/infra/http_serve.go b/functions/infra/http_serve.go new file mode 100644 index 00000000..1a9e19fd --- /dev/null +++ b/functions/infra/http_serve.go @@ -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 + } +} diff --git a/functions/infra/http_serve.md b/functions/infra/http_serve.md new file mode 100644 index 00000000..191ee6e7 --- /dev/null +++ b/functions/infra/http_serve.md @@ -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. diff --git a/functions/infra/http_server_test.go b/functions/infra/http_server_test.go new file mode 100644 index 00000000..09297bc2 --- /dev/null +++ b/functions/infra/http_server_test.go @@ -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") + } + }) +}