From fcb1ea28a1a520ef5ee471c25168c7617a877d50 Mon Sep 17 00:00:00 2001 From: jean0t Date: Sun, 5 Apr 2026 00:29:22 -0300 Subject: [PATCH 1/4] feat: add automatic HEAD request generation from GET request, the choice is opt-out --- echo.go | 19 +++++++++++++++++++ echo_test.go | 28 ++++++++++++++++++++++++++++ group.go | 16 +++++++++++++--- group_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/echo.go b/echo.go index 4855e8429..fa10ec80a 100644 --- a/echo.go +++ b/echo.go @@ -92,6 +92,9 @@ type Echo struct { // formParseMaxMemory is passed to Context for multipart form parsing (See http.Request.ParseMultipartForm) formParseMaxMemory int64 + + // automatically registers a HEAD request within GET + autoHeadInGet bool } // JSONSerializer is the interface that encodes and decodes JSON to and from interfaces. @@ -330,6 +333,7 @@ func New() *Echo { Binder: &DefaultBinder{}, JSONSerializer: &DefaultJSONSerializer{}, formParseMaxMemory: defaultMemory, + autoHeadInGet: true, } e.serveHTTPFunc = e.serveHTTP @@ -341,6 +345,14 @@ func New() *Echo { return e } +// AutoHeadCancel turns the flag autoHeadInGet to false. +// +// This flag is used to register HEAD request automatically +// everytime a GET request is registered. +func (e *Echo) AutoHeadCancel() { + e.autoHeadInGet = false +} + // NewContext returns a new Context instance. // // Note: both request and response can be left to nil as Echo.ServeHTTP will call c.Reset(req,resp) anyway @@ -437,7 +449,14 @@ func (e *Echo) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo // GET registers a new GET route for a path with matching handler in the router // with optional route-level middleware. Panics on error. +// +// Note: if autoHeadInGet flag is true, it will also register a HEAD request +// to the same path. func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { + if e.autoHeadInGet { + e.Add(http.MethodHead, path, h, m...) + } + return e.Add(http.MethodGet, path, h, m...) } diff --git a/echo_test.go b/echo_test.go index f26eed8e2..c2a9a2165 100644 --- a/echo_test.go +++ b/echo_test.go @@ -529,6 +529,16 @@ func TestEchoWrapMiddleware(t *testing.T) { assert.Equal(t, "/:id", actualPattern) } +func TestAutoHeadCancel(t *testing.T) { + e := New() + + assert.Equal(t, true, e.autoHeadInGet) + + e.AutoHeadCancel() + + assert.Equal(t, false, e.autoHeadInGet) +} + func TestEchoConnect(t *testing.T) { e := New() @@ -580,6 +590,24 @@ func TestEchoGet(t *testing.T) { assert.Equal(t, "OK", body) } +func TestEchoAutoHead(t *testing.T) { + e := New() + + assert.Equal(t, true, e.autoHeadInGet) // guarantees the flag is true + ri := e.GET("/", func(c *Context) error { + return c.String(http.StatusTeapot, "OK") + }) + + assert.Equal(t, http.MethodHead, ri.Method) + assert.Equal(t, "/", ri.Path) + assert.Equal(t, http.MethodHead+":/", ri.Name) + assert.Nil(t, ri.Parameters) + + status, body := request(http.MethodHead, "/", e) + assert.Equal(t, http.StatusTeapot, status) + assert.Equal(t, "OK", body) +} + func TestEchoHead(t *testing.T) { e := New() diff --git a/group.go b/group.go index d81cd9163..9a3a0170e 100644 --- a/group.go +++ b/group.go @@ -12,9 +12,15 @@ import ( // routes that share a common middleware or functionality that should be separate // from the parent echo instance while still inheriting from it. type Group struct { - echo *Echo - prefix string - middleware []MiddlewareFunc + echo *Echo + prefix string + middleware []MiddlewareFunc + autoHeadInGet bool +} + +// AutoHeadCancel implements `Echo#AutoHeadCancel()` for the Group struct. +func (g *Group) AutoHeadCancel() { + g.autoHeadInGet = false } // Use implements `Echo#Use()` for sub-routes within the Group. @@ -35,6 +41,10 @@ func (g *Group) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInf // GET implements `Echo#GET()` for sub-routes within the Group. Panics on error. func (g *Group) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { + if g.autoHeadInGet { + g.Add(http.MethodHead, path, h, m...) + } + return g.Add(http.MethodGet, path, h, m...) } diff --git a/group_test.go b/group_test.go index 7078b6497..df0a67d0e 100644 --- a/group_test.go +++ b/group_test.go @@ -162,6 +162,17 @@ func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) { } +func TestAutoHeadCancel(t *testing.T) { + e := New() + g := e.Group("/group") + + assert.Equal(t, true, g.autoHeadInGet) + + g.AutoHeadCancel() + + assert.Equal(t, false, g.autoHeadInGet) +} + func TestGroup_CONNECT(t *testing.T) { e := New() @@ -198,6 +209,25 @@ func TestGroup_DELETE(t *testing.T) { assert.Equal(t, `OK`, body) } +func TestGroup_AutoHEAD_in_GET(t *testing.T) { + e := New() + + users := e.Group("/users") + ri := users.GET("/activate", func(c *Context) error { + return c.String(http.StatusTeapot, "OK") + }) + + assert.Equal(t, true, users.autoHeadInGet) + assert.Equal(t, http.MethodHead, ri.Method) + assert.Equal(t, "/users/activate", ri.Path) + assert.Equal(t, http.MethodHead+":/users/activate", ri.Name) + assert.Nil(t, ri.Parameters) + + status, body := request(http.MethodHead, "/users/activate", e) + assert.Equal(t, http.StatusTeapot, status) + assert.Equal(t, `OK`, body) +} + func TestGroup_HEAD(t *testing.T) { e := New() From c461cd26ebfcd22f04e1bbf1051ec5041979f769 Mon Sep 17 00:00:00 2001 From: jean0t Date: Sun, 5 Apr 2026 15:40:31 -0300 Subject: [PATCH 2/4] fix: explicit throw HEAD info in GET definition --- echo.go | 2 +- group.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/echo.go b/echo.go index fa10ec80a..6286555a9 100644 --- a/echo.go +++ b/echo.go @@ -454,7 +454,7 @@ func (e *Echo) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo // to the same path. func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { if e.autoHeadInGet { - e.Add(http.MethodHead, path, h, m...) + _ = e.Add(http.MethodHead, path, h, m...) } return e.Add(http.MethodGet, path, h, m...) diff --git a/group.go b/group.go index 9a3a0170e..ee029246d 100644 --- a/group.go +++ b/group.go @@ -42,7 +42,7 @@ func (g *Group) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInf // GET implements `Echo#GET()` for sub-routes within the Group. Panics on error. func (g *Group) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { if g.autoHeadInGet { - g.Add(http.MethodHead, path, h, m...) + _ = g.Add(http.MethodHead, path, h, m...) } return g.Add(http.MethodGet, path, h, m...) From f21a770c80a5a6f7c7cdf122f9ad62ddc65d78f1 Mon Sep 17 00:00:00 2001 From: jean0t Date: Sun, 5 Apr 2026 16:36:37 -0300 Subject: [PATCH 3/4] fix: renamed test TestAutoHeadCancel in group_test to avoid duplicated functions --- group_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/group_test.go b/group_test.go index df0a67d0e..52d7c114e 100644 --- a/group_test.go +++ b/group_test.go @@ -162,7 +162,7 @@ func TestGroupRouteMiddlewareWithMatchAny(t *testing.T) { } -func TestAutoHeadCancel(t *testing.T) { +func TestAutoHeadCancelInGroup(t *testing.T) { e := New() g := e.Group("/group") From 2be8b7c0c4dea4efe80420fae396798f4a11feed Mon Sep 17 00:00:00 2001 From: jean0t Date: Sun, 5 Apr 2026 23:03:56 -0300 Subject: [PATCH 4/4] fix: tests regarding GET and the autoHeadInGet flag --- echo.go | 4 ++-- echo_test.go | 7 ++++--- group.go | 3 ++- group_test.go | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/echo.go b/echo.go index 6286555a9..f07a6adcc 100644 --- a/echo.go +++ b/echo.go @@ -454,7 +454,7 @@ func (e *Echo) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo // to the same path. func (e *Echo) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { if e.autoHeadInGet { - _ = e.Add(http.MethodHead, path, h, m...) + _ = e.HEAD(path, h, m...) } return e.Add(http.MethodGet, path, h, m...) @@ -658,7 +658,7 @@ func (e *Echo) Add(method, path string, handler HandlerFunc, middleware ...Middl // Group creates a new router group with prefix and optional group-level middleware. func (e *Echo) Group(prefix string, m ...MiddlewareFunc) (g *Group) { - g = &Group{prefix: prefix, echo: e} + g = &Group{prefix: prefix, echo: e, autoHeadInGet: true} g.Use(m...) return } diff --git a/echo_test.go b/echo_test.go index c2a9a2165..48af01dba 100644 --- a/echo_test.go +++ b/echo_test.go @@ -598,9 +598,9 @@ func TestEchoAutoHead(t *testing.T) { return c.String(http.StatusTeapot, "OK") }) - assert.Equal(t, http.MethodHead, ri.Method) + assert.Equal(t, http.MethodGet, ri.Method) assert.Equal(t, "/", ri.Path) - assert.Equal(t, http.MethodHead+":/", ri.Name) + assert.Equal(t, http.MethodGet+":/", ri.Name) assert.Nil(t, ri.Parameters) status, body := request(http.MethodHead, "/", e) @@ -937,7 +937,7 @@ func TestEchoMethodNotAllowed(t *testing.T) { e.ServeHTTP(rec, req) assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) - assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow)) + assert.Equal(t, "OPTIONS, GET, HEAD", rec.Header().Get(HeaderAllow)) } func TestEcho_OnAddRoute(t *testing.T) { @@ -978,6 +978,7 @@ func TestEcho_OnAddRoute(t *testing.T) { t.Run(tc.name, func(t *testing.T) { e := New() + e.AutoHeadCancel() added := make([]string, 0) cnt := 0 diff --git a/group.go b/group.go index ee029246d..7a39ba5cb 100644 --- a/group.go +++ b/group.go @@ -42,7 +42,7 @@ func (g *Group) DELETE(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInf // GET implements `Echo#GET()` for sub-routes within the Group. Panics on error. func (g *Group) GET(path string, h HandlerFunc, m ...MiddlewareFunc) RouteInfo { if g.autoHeadInGet { - _ = g.Add(http.MethodHead, path, h, m...) + _ = g.HEAD(path, h, m...) } return g.Add(http.MethodGet, path, h, m...) @@ -115,6 +115,7 @@ func (g *Group) Group(prefix string, middleware ...MiddlewareFunc) (sg *Group) { m = append(m, g.middleware...) m = append(m, middleware...) sg = g.echo.Group(g.prefix+prefix, m...) + sg.autoHeadInGet = true return } diff --git a/group_test.go b/group_test.go index 52d7c114e..bde75b8ae 100644 --- a/group_test.go +++ b/group_test.go @@ -218,14 +218,14 @@ func TestGroup_AutoHEAD_in_GET(t *testing.T) { }) assert.Equal(t, true, users.autoHeadInGet) - assert.Equal(t, http.MethodHead, ri.Method) + assert.Equal(t, http.MethodGet, ri.Method) assert.Equal(t, "/users/activate", ri.Path) - assert.Equal(t, http.MethodHead+":/users/activate", ri.Name) + assert.Equal(t, http.MethodGet+":/users/activate", ri.Name) assert.Nil(t, ri.Parameters) status, body := request(http.MethodHead, "/users/activate", e) assert.Equal(t, http.StatusTeapot, status) - assert.Equal(t, `OK`, body) + assert.Equal(t, "OK", body) } func TestGroup_HEAD(t *testing.T) {