feat(core): RouterExtension 增加 HandleRaw 与 BasePath

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 干净。
This commit is contained in:
ryan
2026-08-30 00:22:56 +08:00
parent 3b24d248a7
commit cb339ab0dc
3 changed files with 135 additions and 0 deletions
+69
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
@@ -503,3 +508,67 @@ 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 {
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 }
+52
View File
@@ -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)
}
}
+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...)
} }