From 1abc64065d693bc94707e99ac6312bfd1c1dd074 Mon Sep 17 00:00:00 2001 From: Jaeseon Date: Thu, 17 Sep 2026 16:40:30 +0900 Subject: [PATCH] fix: preserve auto HEAD status for route middleware --- middleware/request_logger_test.go | 44 +++++++++++++++++++++++++++++++ response.go | 1 + response_test.go | 10 +++++-- router.go | 2 ++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/middleware/request_logger_test.go b/middleware/request_logger_test.go index 2232c6f6a..e5a90dc17 100644 --- a/middleware/request_logger_test.go +++ b/middleware/request_logger_test.go @@ -70,6 +70,50 @@ func TestRequestLoggerOK(t *testing.T) { assert.Equal(t, expect, logAttrs) } +func TestRequestLoggerAutoHandleHEADStatus(t *testing.T) { + for _, scope := range []string{"global", "group", "route"} { + for _, method := range []string{http.MethodGet, http.MethodHead} { + t.Run(scope+"/"+method, func(t *testing.T) { + e := echo.NewWithConfig(echo.Config{ + Router: echo.NewRouter(echo.RouterConfig{AutoHandleHEAD: true}), + }) + var loggedStatus int + logger := RequestLoggerWithConfig(RequestLoggerConfig{ + LogStatus: true, + LogValuesFunc: func(c *echo.Context, values RequestLoggerValues) error { + loggedStatus = values.Status + return nil + }, + }) + handler := func(c *echo.Context) error { + return c.String(http.StatusNotFound, "missing") + } + switch scope { + case "global": + e.Use(logger) + e.GET("/test", handler) + case "group": + e.Group("", logger).GET("/test", handler) + case "route": + e.GET("/test", handler, logger) + } + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(method, "/test", nil)) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Equal(t, http.StatusNotFound, loggedStatus) + if method == http.MethodHead { + assert.Empty(t, rec.Body.String()) + assert.Equal(t, "7", rec.Header().Get(echo.HeaderContentLength)) + } else { + assert.Equal(t, "missing", rec.Body.String()) + } + }) + } + } +} + func TestRequestLoggerError(t *testing.T) { old := slog.Default() t.Cleanup(func() { diff --git a/response.go b/response.go index e218ef4d6..c0d7056f3 100644 --- a/response.go +++ b/response.go @@ -200,6 +200,7 @@ func (w *headResponseWriter) WriteHeader(code int) { w.wroteStatus = true w.status = code if r, err := UnwrapResponse(w.rw); err == nil { + r.Status = code r.Committed = true } } diff --git a/response_test.go b/response_test.go index 4ad849c04..360b81ac0 100644 --- a/response_test.go +++ b/response_test.go @@ -384,15 +384,21 @@ func TestWrapHeadHandler_HandlerPanic_OriginalWriterRestored(t *testing.T) { assert.Equal(t, original, c.Response()) // defer must have run } -func TestHeadResponseWriter_WriteHeader_SetsCommittedOnUnderlying(t *testing.T) { +func TestHeadResponseWriter_WriteHeader_SetsStatusAndCommittedOnUnderlying(t *testing.T) { e := New() rec := httptest.NewRecorder() underlying := NewResponse(rec, e.Logger) w := &headResponseWriter{rw: underlying} - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusAccepted) assert.True(t, underlying.Committed) + assert.Equal(t, http.StatusAccepted, underlying.Status) + assert.False(t, rec.Flushed) + assert.Equal(t, http.StatusOK, rec.Code) + + w.WriteHeader(http.StatusBadRequest) + assert.Equal(t, http.StatusAccepted, underlying.Status) } func TestWrapHeadHandler_RouteLevelMiddlewareSeesTrueCommitted(t *testing.T) { diff --git a/router.go b/router.go index 99950aae7..bfe93b6ca 100644 --- a/router.go +++ b/router.go @@ -104,6 +104,8 @@ type RouterConfig struct { // the route, but the response body is suppressed in accordance with // HTTP semantics. Headers (e.g., Content-Length, Content-Type) are // preserved as if a GET request was made. + // The status written by the handler is available to route and group middleware even + // though sending the headers is delayed until the handler returns. // // Security considerations: the GET handler is fully executed for every // HEAD request, including all side effects: