From be79eb4eb705479589d6d3d8a4a825a650a31ea5 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 10:44:14 +0800 Subject: [PATCH] feat(core): add HandleRaw and BasePath for trailing-slash routes --- backend/core/extpoints/router.go | 59 +++++++++++++++++++++-- backend/core/extpoints/router_raw_test.go | 27 +++++++++++ backend/core/scoped_extpoints.go | 14 ++++++ 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 backend/core/extpoints/router_raw_test.go diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index ac3266c3..44258028 100644 --- a/backend/core/extpoints/router.go +++ b/backend/core/extpoints/router.go @@ -22,6 +22,11 @@ type RouterExtension interface { Use(middlewares ...any) Group(prefix string, middlewares ...any) RouterExtension 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 POST(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. 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() defer r.mu.Unlock() @@ -87,7 +97,7 @@ func (r *RouterRegistry) Handle(method, path string, handlers ...any) RouteDefin rd := RouteDefinition{ ID: r.nextID, Method: strings.ToUpper(method), - Path: cleanPath(path), + Path: fullPath, Handlers: handlers, 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. 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() defer g.registry.mu.Unlock() - fullPath := joinPaths(g.prefix, path) - allMiddlewares := make([]any, 0, len(g.registry.middlewares)+len(g.middlewares)) allMiddlewares = append(allMiddlewares, g.registry.middlewares...) allMiddlewares = append(allMiddlewares, g.middlewares...) @@ -503,3 +516,43 @@ func (w *PathWhitelist) Patterns() []string { } 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 } diff --git a/backend/core/extpoints/router_raw_test.go b/backend/core/extpoints/router_raw_test.go new file mode 100644 index 00000000..2d4f62b0 --- /dev/null +++ b/backend/core/extpoints/router_raw_test.go @@ -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) + } +} diff --git a/backend/core/scoped_extpoints.go b/backend/core/scoped_extpoints.go index 9bd4d923..f567544a 100644 --- a/backend/core/scoped_extpoints.go +++ b/backend/core/scoped_extpoints.go @@ -40,6 +40,20 @@ func (s *scopedRouterExtension) Handle(method, path string, handlers ...any) ext 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 { return s.Handle("GET", path, handlers...) }