Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -775,12 +775,14 @@ func (e *Echo) Add(method, path string, handler HandlerFunc, middleware ...Middl

// Group creates a new router group with prefix and optional group-level middleware.
// The prefix is concatenated with each route path without inserting a slash.
// If prefix is not empty and does not start with a leading slash, a leading slash is added.
// For example:
//
// g := e.Group("/v1")
// g.GET("/posts", handler) // Registers /v1/posts.
// g.GET("posts", handler) // Registers /v1posts.
func (e *Echo) Group(prefix string, m ...MiddlewareFunc) (g *Group) {
prefix = normalizeGroupPrefix(prefix)
g = &Group{
prefix: prefix,
echo: e,
Expand Down
8 changes: 8 additions & 0 deletions group.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package echo
import (
"io/fs"
"net/http"
"strings"
)

// Group is a set of sub-routes for a specified route. It can be used for inner
Expand Down Expand Up @@ -221,3 +222,10 @@ func (g *Group) AddRoute(route Route) (RouteInfo, error) {
groupRoute := route.WithPrefix(g.prefix, append([]MiddlewareFunc{}, g.middleware...))
return g.echo.add(groupRoute)
}

func normalizeGroupPrefix(prefix string) string {
if prefix != "" && !strings.HasPrefix(prefix, "/") {
return "/" + prefix
}
return prefix
}
143 changes: 143 additions & 0 deletions group_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1010,3 +1010,146 @@ func TestGroup_StaticFS_dotAndEmptySegments(t *testing.T) {
e.ServeHTTP(rec, req)
assert.Equal(t, "x", rec.Body.String())
}

func TestGroup_normalizeGroupPrefix(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{input: "", expected: ""},
{input: "/", expected: "/"},
{input: "api", expected: "/api"},
{input: "/api", expected: "/api"},
{input: "api/", expected: "/api/"},
{input: "/api/", expected: "/api/"},
{input: "v1/users", expected: "/v1/users"},
{input: "/v1/users", expected: "/v1/users"},
}

for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
assert.Equal(t, tc.expected, normalizeGroupPrefix(tc.input))
})
}
}

func TestGroup_PrefixSlashVariations(t *testing.T) {
testCases := []struct {
name string
groupPrefix string
routePath string
requestPath string
expectedPath string
}{
{
name: "without leading slash and without trailing slash",
groupPrefix: "api",
routePath: "/users",
requestPath: "/api/users",
expectedPath: "/api/users",
},
{
name: "with leading slash and without trailing slash",
groupPrefix: "/api",
routePath: "/users",
requestPath: "/api/users",
expectedPath: "/api/users",
},
{
name: "without leading slash and with trailing slash",
groupPrefix: "api/",
routePath: "users",
requestPath: "/api/users",
expectedPath: "/api/users",
},
{
name: "with leading slash and with trailing slash",
groupPrefix: "/api/",
routePath: "users",
requestPath: "/api/users",
expectedPath: "/api/users",
},
{
name: "empty prefix",
groupPrefix: "",
routePath: "/users",
requestPath: "/users",
expectedPath: "/users",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
e := New()
g := e.Group(tc.groupPrefix)
g.GET(tc.routePath, func(c *Context) error {
return c.String(http.StatusOK, "matched")
})

req := httptest.NewRequest(http.MethodGet, tc.requestPath, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "matched", rec.Body.String())

routes := e.Router().Routes()
assert.Len(t, routes, 1)
assert.Equal(t, tc.expectedPath, routes[0].Path)
})
}
}

func TestGroup_NestedGroupPrefixNormalization(t *testing.T) {
e := New()
api := e.Group("api")
v1 := api.Group("/v1")
v1.GET("/items", func(c *Context) error {
return c.String(http.StatusOK, "items")
})

req := httptest.NewRequest(http.MethodGet, "/api/v1/items", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "items", rec.Body.String())

routes := e.Router().Routes()
assert.Len(t, routes, 1)
assert.Equal(t, "/api/v1/items", routes[0].Path)
}

func TestGroup_MiddlewareWithPrefixNormalization(t *testing.T) {
e := New()
middlewareRan := false
mw := func(next HandlerFunc) HandlerFunc {
return func(c *Context) error {
middlewareRan = true
return next(c)
}
}

g := e.Group("api", mw)
g.GET("/test", func(c *Context) error {
return c.String(http.StatusOK, "ok")
})

// Test registered route executes middleware
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "ok", rec.Body.String())
assert.True(t, middlewareRan)

// Test auto-registered 404 route inside group executes middleware
middlewareRan = false
reqNotFound := httptest.NewRequest(http.MethodGet, "/api/nonexistent", nil)
recNotFound := httptest.NewRecorder()
e.ServeHTTP(recNotFound, reqNotFound)

assert.Equal(t, http.StatusNotFound, recNotFound.Code)
assert.True(t, middlewareRan)
}