feat(core): add HandleRaw and BasePath for trailing-slash routes

This commit is contained in:
ryan
2026-08-30 10:44:14 +08:00
parent 3b24d248a7
commit be79eb4eb7
3 changed files with 97 additions and 3 deletions
+56 -3
View File
@@ -22,6 +22,11 @@ type RouterExtension interface {
Use(middlewares ...any) Use(middlewares ...any)
Group(prefix string, middlewares ...any) RouterExtension Group(prefix string, middlewares ...any) RouterExtension
Handle(method, path string, handlers ...any) RouteDefinition Handle(method, path string, handlers ...any) RouteDefinition
// HandleRaw joins path with the group prefix but preserves a trailing slash,
// so `/resource` and `/resource/` can coexist as distinct routes.
HandleRaw(method, path string, handlers ...any) RouteDefinition
// BasePath reports this group's absolute prefix ("" for the root registry).
BasePath() string
GET(path string, handlers ...any) RouteDefinition GET(path string, handlers ...any) RouteDefinition
POST(path string, handlers ...any) RouteDefinition POST(path string, handlers ...any) RouteDefinition
PUT(path string, handlers ...any) RouteDefinition PUT(path string, handlers ...any) RouteDefinition
@@ -80,6 +85,11 @@ func (r *RouterRegistry) Group(prefix string, middlewares ...any) RouterExtensio
// Handle registers a route with a custom HTTP method and handlers. // Handle registers a route with a custom HTTP method and handlers.
func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition { func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefinition {
return r.addRoute(method, cleanPath(path), handlers...)
}
// addRoute appends a route whose path is already normalised.
func (r *RouterRegistry) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
@@ -87,7 +97,7 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin
rd := RouteDefinition{ rd := RouteDefinition{
ID: r.nextID, ID: r.nextID,
Method: strings.ToUpper(method), Method: strings.ToUpper(method),
Path: cleanPath(path), Path: fullPath,
Handlers: handlers, Handlers: handlers,
Middlewares: append([]any(nil), r.middlewares...), Middlewares: append([]any(nil), r.middlewares...),
} }
@@ -223,11 +233,14 @@ func (g *RouterGroup) Group(prefix string, middlewares ...any) RouterExtension {
// Handle registers a route under this group. // Handle registers a route under this group.
func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition { func (g *RouterGroup) Handle(method, path string, handlers ...any) RouteDefinition {
return g.addRoute(method, joinPaths(g.prefix, path), handlers...)
}
// addRoute appends a route under this group whose path is already joined.
func (g *RouterGroup) addRoute(method, fullPath string, handlers ...any) RouteDefinition {
g.registry.mu.Lock() g.registry.mu.Lock()
defer g.registry.mu.Unlock() defer g.registry.mu.Unlock()
fullPath := joinPaths(g.prefix, path)
allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares))
allMiddlewares = append(allMiddlewares, g.registry.middlewares...) allMiddlewares = append(allMiddlewares, g.registry.middlewares...)
allMiddlewares = append(allMiddlewares, g.middlewares...) allMiddlewares = append(allMiddlewares, g.middlewares...)
@@ -503,3 +516,43 @@ func (w *PathWhitelist) Patterns() []string {
} }
return res return res
} }
// ─── Raw path registration ────────────────────────────────────────────────────
// ensureLeadingSlash normalises a path to start with exactly one "/" while
// preserving any trailing slash (unlike cleanPath).
func ensureLeadingSlash(p string) string {
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}
// joinPathPreservingTrailing joins a group prefix and a relative path without
// stripping a trailing slash, so a group "/x" can serve both "/x" and "/x/".
func joinPathPreservingTrailing(base, relative string) string {
rel := ensureLeadingSlash(relative)
if base == "" || base == "/" {
return rel
}
return strings.TrimSuffix(cleanPath(base), "/") + rel
}
// HandleRaw registers a route on the root registry, preserving a trailing slash.
func (r *RouterRegistry) HandleRaw(method, path string, handlers ...any) RouteDefinition {
return r.addRoute(method, ensureLeadingSlash(path), handlers...)
}
// BasePath returns "" because the root registry has no prefix.
func (r *RouterRegistry) BasePath() string { return "" }
// HandleRaw registers a route under this group, preserving a trailing slash.
func (g *RouterGroup) HandleRaw(method, path string, handlers ...any) RouteDefinition {
return g.addRoute(method, joinPathPreservingTrailing(g.prefix, path), handlers...)
}
// BasePath returns this group's absolute prefix.
func (g *RouterGroup) BasePath() string { return g.prefix }
+27
View File
@@ -0,0 +1,27 @@
package extpoints
import "testing"
func TestHandleRawPreservesTrailingSlash(t *testing.T) {
r := &RouterRegistry{}
g := r.Group("/api/v1/nodes")
if got := g.BasePath(); got != "/api/v1/nodes" {
t.Fatalf("BasePath() = %q, want %q", got, "/api/v1/nodes")
}
slashless := g.Handle("GET", "")
slashed := g.HandleRaw("GET", "/")
if slashless.Path != "/api/v1/nodes" {
t.Errorf("Handle(\"\") path = %q, want %q", slashless.Path, "/api/v1/nodes")
}
if slashed.Path != "/api/v1/nodes/" {
t.Errorf("HandleRaw(\"/\") path = %q, want %q", slashed.Path, "/api/v1/nodes/")
}
if slashed.ID == slashless.ID {
t.Error("HandleRaw must allocate its own route ID")
}
if got := len(r.Routes()); got != 2 {
t.Errorf("registry routes = %d, want 2", got)
}
}
+14
View File
@@ -40,6 +40,20 @@ func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) ext
return rd return rd
} }
// HandleRaw registers a trailing-slash-preserving route and tears it down with the scope.
func (s *scopedRouterExtension) HandleRaw(method, path string, handlers ...any) extpoints.RouteDefinition {
rd := s.underlying.HandleRaw(method, path, handlers...)
routeID := rd.ID
s.ctx.OnDispose(func() error {
s.underlying.UnregisterByID(routeID)
return nil
})
return rd
}
// BasePath delegates to the wrapped group prefix.
func (s *scopedRouterExtension) BasePath() string { return s.underlying.BasePath() }
func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition { func (s *scopedRouterExtension) GET(path string, handlers ...any) extpoints.RouteDefinition {
return s.Handle("GET", path, handlers...) return s.Handle("GET", path, handlers...)
} }