Skip to content

Commit a1867aa

Browse files
committed
AutoHandleHEAD enables automatic handling of HTTP HEAD requests by falling back to the corresponding GET route
1 parent 6bab72d commit a1867aa

4 files changed

Lines changed: 160 additions & 224 deletions

File tree

echo.go

Lines changed: 0 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ import (
5454
"os"
5555
"os/signal"
5656
"path/filepath"
57-
"strconv"
5857
"strings"
5958
"sync"
6059
"sync/atomic"
@@ -101,7 +100,6 @@ type Echo struct {
101100

102101
// formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm)
103102
formParseMaxMemory int64
104-
AutoHead bool
105103
}
106104

107105
// JSONSerializer is the interface that encodes and decodes JSON to and from interfaces.
@@ -290,11 +288,6 @@ type Config struct {
290288
// FormParseMaxMemory is default value for memory limit that is used
291289
// when parsing multipart forms (See (*http.Request).ParseMultipartForm)
292290
FormParseMaxMemory int64
293-
294-
// AutoHead enables automatic registration of HEAD routes for GET routes.
295-
// When enabled, a HEAD request to a GET-only path will be handled automatically
296-
// using the same handler as GET, with the response body suppressed.
297-
AutoHead bool
298291
}
299292

300293
// NewWithConfig creates an instance of Echo with given configuration.
@@ -333,9 +326,6 @@ func NewWithConfig(config Config) *Echo {
333326
if config.FormParseMaxMemory > 0 {
334327
e.formParseMaxMemory = config.FormParseMaxMemory
335328
}
336-
if config.AutoHead {
337-
e.AutoHead = config.AutoHead
338-
}
339329
return e
340330
}
341331

@@ -431,67 +421,6 @@ func DefaultHTTPErrorHandler(exposeError bool) HTTPErrorHandler {
431421
}
432422
}
433423

434-
// headResponseWriter wraps an http.ResponseWriter and suppresses the response body
435-
// while preserving headers and status code. Used for automatic HEAD route handling.
436-
// It counts the bytes that would have been written so we can set Content-Length accurately.
437-
type headResponseWriter struct {
438-
http.ResponseWriter
439-
bytesWritten int64
440-
statusCode int
441-
wroteHeader bool
442-
}
443-
444-
// Write intercepts writes to the response body and counts bytes without actually writing them.
445-
func (hw *headResponseWriter) Write(b []byte) (int, error) {
446-
if !hw.wroteHeader {
447-
hw.statusCode = http.StatusOK
448-
hw.wroteHeader = true
449-
}
450-
hw.bytesWritten += int64(len(b))
451-
// Return success without actually writing the body for HEAD requests
452-
return len(b), nil
453-
}
454-
455-
// WriteHeader intercepts the status code but still writes it to the underlying ResponseWriter.
456-
func (hw *headResponseWriter) WriteHeader(statusCode int) {
457-
if !hw.wroteHeader {
458-
hw.statusCode = statusCode
459-
hw.wroteHeader = true
460-
hw.ResponseWriter.WriteHeader(statusCode)
461-
}
462-
}
463-
464-
// Unwrap returns the underlying http.ResponseWriter for compatibility with echo.Response unwrapping.
465-
func (hw *headResponseWriter) Unwrap() http.ResponseWriter {
466-
return hw.ResponseWriter
467-
}
468-
469-
func wrapHeadHandler(handler HandlerFunc) HandlerFunc {
470-
return func(c *Context) error {
471-
if c.Request().Method != http.MethodHead {
472-
return handler(c)
473-
}
474-
originalWriter := c.Response()
475-
headWriter := &headResponseWriter{ResponseWriter: originalWriter}
476-
477-
c.SetResponse(headWriter)
478-
defer func() {
479-
c.SetResponse(originalWriter)
480-
}()
481-
err := handler(c)
482-
483-
if headWriter.bytesWritten > 0 {
484-
originalWriter.Header().Set("Content-Length", strconv.FormatInt(headWriter.bytesWritten, 10))
485-
}
486-
487-
if !headWriter.wroteHeader && headWriter.statusCode > 0 {
488-
originalWriter.WriteHeader(headWriter.statusCode)
489-
}
490-
491-
return err
492-
}
493-
}
494-
495424
// Pre adds middleware to the chain which is run before router tries to find matching route.
496425
// Meaning middleware is executed even for 404 (not found) cases.
497426
func (e *Echo) Pre(middleware ...MiddlewareFunc) {
@@ -705,20 +634,6 @@ func (e *Echo) add(route Route) (RouteInfo, error) {
705634
if paramsCount > e.contextPathParamAllocSize.Load() {
706635
e.contextPathParamAllocSize.Store(paramsCount)
707636
}
708-
709-
// Auto-register HEAD route for GET if AutoHead is enabled
710-
if e.AutoHead && route.Method == http.MethodGet {
711-
headRoute := Route{
712-
Method: http.MethodHead,
713-
Path: route.Path,
714-
Handler: wrapHeadHandler(route.Handler),
715-
Middlewares: route.Middlewares,
716-
Name: route.Name,
717-
}
718-
// Attempt to add HEAD route, but ignore errors if an explicit HEAD route already exists
719-
_, _ = e.router.Add(headRoute)
720-
}
721-
722637
return ri, nil
723638
}
724639

echo_test.go

Lines changed: 45 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -1233,157 +1233,87 @@ func TestDefaultHTTPErrorHandler_CommitedResponse(t *testing.T) {
12331233
assert.Equal(t, http.StatusOK, resp.Code)
12341234
}
12351235

1236-
func TestAutoHeadRoute(t *testing.T) {
1236+
func TestRouterAutoHandleHEADFullHTTPHandlerFlow(t *testing.T) {
12371237
tests := []struct {
1238-
name string
1239-
autoHead bool
1240-
method string
1241-
wantBody bool
1242-
wantCode int
1243-
wantCLen bool // expect Content-Length header
1238+
name string
1239+
givenAutoHandleHEAD bool
1240+
whenMethod string
1241+
expectBody string
1242+
expectCode int
1243+
expectContentLength string
12441244
}{
12451245
{
1246-
name: "AutoHead disabled - HEAD returns 405",
1247-
autoHead: false,
1248-
method: http.MethodHead,
1249-
wantCode: http.StatusMethodNotAllowed,
1250-
wantBody: false,
1246+
name: "AutoHandleHEAD disabled - HEAD returns 405",
1247+
givenAutoHandleHEAD: false,
1248+
whenMethod: http.MethodHead,
1249+
expectCode: http.StatusMethodNotAllowed,
1250+
expectBody: "",
12511251
},
12521252
{
1253-
name: "AutoHead enabled - HEAD returns 200 with Content-Length",
1254-
autoHead: true,
1255-
method: http.MethodHead,
1256-
wantCode: http.StatusOK,
1257-
wantBody: false,
1258-
wantCLen: true,
1253+
name: "AutoHandleHEAD enabled - HEAD returns 200 with Content-Length",
1254+
givenAutoHandleHEAD: true,
1255+
whenMethod: http.MethodHead,
1256+
expectCode: http.StatusOK,
1257+
expectBody: "",
1258+
expectContentLength: "4",
12591259
},
12601260
{
1261-
name: "GET request works normally with AutoHead enabled",
1262-
autoHead: true,
1263-
method: http.MethodGet,
1264-
wantCode: http.StatusOK,
1265-
wantBody: true,
1261+
name: "GET request works normally with AutoHandleHEAD enabled",
1262+
givenAutoHandleHEAD: true,
1263+
whenMethod: http.MethodGet,
1264+
expectCode: http.StatusOK,
1265+
expectBody: "test",
12661266
},
12671267
}
12681268

1269-
for _, tt := range tests {
1270-
t.Run(tt.name, func(t *testing.T) {
1271-
// Create Echo instance with AutoHead configuration
1272-
e := New()
1273-
e.AutoHead = tt.autoHead
1269+
for _, tc := range tests {
1270+
t.Run(tc.name, func(t *testing.T) {
1271+
e := NewWithConfig(Config{
1272+
Router: NewRouter(RouterConfig{
1273+
AutoHandleHEAD: tc.givenAutoHandleHEAD,
1274+
}),
1275+
})
12741276

1275-
// Register a simple GET route
1276-
testBody := "Hello, World!"
12771277
e.GET("/hello", func(c *Context) error {
1278-
return c.String(http.StatusOK, testBody)
1278+
return c.String(http.StatusOK, "test")
12791279
})
12801280

1281-
// Create request and response
1282-
req := httptest.NewRequest(tt.method, "/hello", nil)
1281+
req := httptest.NewRequest(tc.whenMethod, "/hello", nil)
12831282
rec := httptest.NewRecorder()
12841283

1285-
// Serve the request
12861284
e.ServeHTTP(rec, req)
12871285

1288-
// Verify status code
1289-
if rec.Code != tt.wantCode {
1290-
t.Errorf("expected status %d, got %d", tt.wantCode, rec.Code)
1291-
}
1292-
1293-
// Verify response body
1294-
if tt.wantBody {
1295-
if rec.Body.String() != testBody {
1296-
t.Errorf("expected body %q, got %q", testBody, rec.Body.String())
1297-
}
1298-
} else {
1299-
if rec.Body.String() != "" {
1300-
t.Errorf("expected empty body for HEAD, got %q", rec.Body.String())
1301-
}
1302-
}
1303-
1304-
// Verify Content-Length header for HEAD
1305-
if tt.wantCLen && tt.method == http.MethodHead {
1306-
clen := rec.Header().Get("Content-Length")
1307-
if clen == "" {
1308-
t.Error("expected Content-Length header for HEAD request")
1309-
}
1310-
}
1286+
assert.Equal(t, tc.expectCode, rec.Code)
1287+
assert.Equal(t, tc.expectContentLength, rec.Header().Get(HeaderContentLength))
1288+
assert.Equal(t, tc.expectBody, rec.Body.String())
13111289
})
13121290
}
13131291
}
13141292

13151293
func TestAutoHeadExplicitHeadTakesPrecedence(t *testing.T) {
1316-
e := New()
1317-
e.AutoHead = true
1294+
e := NewWithConfig(Config{
1295+
Router: NewRouter(RouterConfig{
1296+
AutoHandleHEAD: true,
1297+
}),
1298+
})
13181299

13191300
// Register explicit HEAD route FIRST with custom behavior
13201301
e.HEAD("/api/users", func(c *Context) error {
13211302
c.Response().Header().Set("X-Custom-Header", "explicit-head")
1322-
return c.NoContent(http.StatusOK)
1303+
return c.NoContent(http.StatusTeapot)
13231304
})
13241305

1325-
// Then register GET route - AutoHead will try to add a HEAD route but fail silently
1326-
// since one already exists
13271306
e.GET("/api/users", func(c *Context) error {
1328-
return c.JSON(http.StatusOK, map[string]string{"name": "John"})
1307+
return c.JSON(http.StatusNotFound, map[string]string{"name": "John"})
13291308
})
13301309

1331-
// Test that the explicit HEAD route behavior is preserved
13321310
req := httptest.NewRequest(http.MethodHead, "/api/users", nil)
13331311
rec := httptest.NewRecorder()
13341312
e.ServeHTTP(rec, req)
13351313

1336-
if rec.Code != http.StatusOK {
1337-
t.Errorf("expected status 200, got %d", rec.Code)
1338-
}
1339-
1340-
if rec.Header().Get("X-Custom-Header") != "explicit-head" {
1341-
t.Error("expected explicit HEAD route to be used")
1342-
}
1343-
1344-
// Verify body is empty
1345-
if rec.Body.String() != "" {
1346-
t.Errorf("expected empty body for HEAD, got %q", rec.Body.String())
1347-
}
1348-
}
1349-
1350-
func TestAutoHeadWithMiddleware(t *testing.T) {
1351-
e := New()
1352-
e.AutoHead = true
1353-
1354-
// Add request logger middleware
1355-
middlewareExecuted := false
1356-
e.Use(func(next HandlerFunc) HandlerFunc {
1357-
return func(c *Context) error {
1358-
middlewareExecuted = true
1359-
c.Response().Header().Set("X-Middleware", "executed")
1360-
return next(c)
1361-
}
1362-
})
1363-
1364-
// Register GET route
1365-
e.GET("/test", func(c *Context) error {
1366-
return c.String(http.StatusOK, "test response")
1367-
})
1368-
1369-
// Test HEAD request goes through middleware
1370-
req := httptest.NewRequest(http.MethodHead, "/test", nil)
1371-
rec := httptest.NewRecorder()
1372-
1373-
middlewareExecuted = false
1374-
e.ServeHTTP(rec, req)
1375-
1376-
if !middlewareExecuted {
1377-
t.Error("middleware should execute for automatic HEAD route")
1378-
}
1379-
1380-
if rec.Header().Get("X-Middleware") != "executed" {
1381-
t.Error("middleware header not set")
1382-
}
1383-
1384-
if rec.Body.String() != "" {
1385-
t.Errorf("expected empty body for HEAD, got %q", rec.Body.String())
1386-
}
1314+
assert.Equal(t, http.StatusTeapot, rec.Code)
1315+
assert.Equal(t, "explicit-head", rec.Header().Get("X-Custom-Header"))
1316+
assert.Equal(t, "", rec.Body.String())
13871317
}
13881318

13891319
func benchmarkEchoRoutes(b *testing.B, routes []testRoute) {
@@ -1431,23 +1361,3 @@ func BenchmarkEchoGitHubAPIMisses(b *testing.B) {
14311361
func BenchmarkEchoParseAPI(b *testing.B) {
14321362
benchmarkEchoRoutes(b, parseAPI)
14331363
}
1434-
1435-
func BenchmarkAutoHeadRoute(b *testing.B) {
1436-
e := New()
1437-
e.AutoHead = true
1438-
1439-
e.GET("/bench", func(c *Context) error {
1440-
return c.String(http.StatusOK, "benchmark response body")
1441-
})
1442-
1443-
req := httptest.NewRequest(http.MethodHead, "/bench", nil)
1444-
rec := httptest.NewRecorder()
1445-
1446-
b.ReportAllocs()
1447-
b.ResetTimer()
1448-
1449-
for i := 0; i < b.N; i++ {
1450-
rec.Body.Reset()
1451-
e.ServeHTTP(rec, req)
1452-
}
1453-
}

0 commit comments

Comments
 (0)