From cb339ab0dc5d7fa689ccba98adcd4c66c9c54fed Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 30 Aug 2026 00:22:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20RouterExtension=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=20HandleRaw=20=E4=B8=8E=20BasePath?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle 经 cleanPath 归一化会剥掉尾部斜杠,插件无法同时声明 /resource 与 /resource/ 两条路由;部署关闭 gin 的 RedirectTrailingSlash 时,缺失的那条 直接 404。下游 OpenFlare 有 20 个历史列表接口依赖该行为。 - HandleRaw:与组前缀拼接但保留尾部斜杠,分配独立路由 ID; - BasePath:返回组的绝对前缀(根注册表为空串); - 作用域包装器为 HandleRaw 同样登记 OnDispose 反注册。 验证:go build 通过;go test ./... exit 0(48 包 ok); check_cordis_architecture.sh 0 violations;gofmt 干净。 --- backend/core/extpoints/router.go | 69 +++++++++++++++++++++++ backend/core/extpoints/router_raw_test.go | 52 +++++++++++++++++ backend/core/scoped_extpoints.go | 14 +++++ 3 files changed, 135 insertions(+) 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..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...) }