diff --git a/backend/core/extpoints/router.go b/backend/core/extpoints/router.go index ac3266c3..f7c281c1 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 @@ -503,3 +508,67 @@ 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 { + r.mu.Lock() + defer r.mu.Unlock() + + r.nextID++ + rd := RouteDefinition{ + ID: r.nextID, + Method: strings.ToUpper(method), + Path: ensureLeadingSlash(path), + Handlers: handlers, + Middlewares: append([]any(nil), r.middlewares...), + } + r.routes = append(r.routes, rd) + return rd +} + +// 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 { + g.registry.mu.Lock() + defer g.registry.mu.Unlock() + + g.registry.nextID++ + rd := RouteDefinition{ + ID: g.registry.nextID, + Method: strings.ToUpper(method), + Path: joinPathPreservingTrailing(g.prefix, path), + } + rd.Handlers = handlers + rd.Middlewares = append(g.registry.middlewares, g.middlewares...) + g.registry.routes = append(g.registry.routes, rd) + return rd +} + +// 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..31b0f38f --- /dev/null +++ b/backend/core/extpoints/router_raw_test.go @@ -0,0 +1,52 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "testing" + +// TestHandleRawPreservesTrailingSlash 验证 HandleRaw 能表达 /x 与 /x/ 两条不同路由, +// 而 Handle 会归一化掉尾部斜杠(server 插件的 list 端点历史行为依赖这一点)。 +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 so scoped teardown can unregister both") + } + if got := len(r.Routes()); got != 2 { + t.Errorf("registry routes = %d, want 2", got) + } + if !r.UnregisterByID(slashed.ID) { + t.Error("UnregisterByID(HandleRaw route) = false, want true") + } + if got := len(r.Routes()); got != 1 { + t.Errorf("routes after unregister = %d, want 1", got) + } +} + +// TestRegistryHandleRawKeepsAbsolutePath 根注册表上 HandleRaw 只做绝对化处理。 +func TestRegistryHandleRawKeepsAbsolutePath(t *testing.T) { + r := &RouterRegistry{} + if got := r.HandleRaw("GET", "/health/").Path; got != "/health/" { + t.Errorf("path = %q, want %q", got, "/health/") + } + if got := r.HandleRaw("POST", "submit").Path; got != "/submit" { + t.Errorf("path = %q, want %q", got, "/submit") + } + if got := r.BasePath(); got != "" { + t.Errorf("registry BasePath() = %q, want empty", 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...) }