From 92db29278d783bad011e987ddd7eb4c785b3f761 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 11 Apr 2026 15:17:25 +0000 Subject: [PATCH] feat: register routes with name --- internal/router/router.go | 66 +++++++++++++++++++++------------- internal/router/router_test.go | 24 +++++++++++++ internal/routes/routes.go | 35 +++++++++--------- internal/routes/routes_test.go | 13 +++++++ internal/routeurl/routeurl.go | 54 ++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 40 deletions(-) create mode 100644 internal/router/router_test.go create mode 100644 internal/routeurl/routeurl.go diff --git a/internal/router/router.go b/internal/router/router.go index ccc92a9..b3a3ba6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,6 +5,7 @@ import ( "time" "git.juancwu.dev/juancwu/budgit/internal/middleware" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" ) type Group struct { @@ -15,6 +16,20 @@ type Group struct { mux *http.ServeMux } +// Route is returned by route-registration calls so callers can attach +// metadata to a freshly registered route (e.g. a name for URL lookup). +type Route struct { + path string +} + +// Name registers this route under the given name so templates can resolve +// it via routeurl.URL. The stored path keeps Go 1.22 named wildcards intact +// (e.g. "/join/{token}") so URL() can substitute them at render time. +func (r *Route) Name(name string) *Route { + routeurl.Register(name, r.path) + return r +} + func (g *Group) Use(mw ...middleware.Middleware) { g.middleware = append(g.middleware, mw...) } @@ -36,7 +51,7 @@ const ( MethodPatch Method = "PATCH" ) -func (g *Group) Handle(method Method, path string, handler http.HandlerFunc, mw ...middleware.Middleware) { +func (g *Group) Handle(method Method, path string, handler http.HandlerFunc, mw ...middleware.Middleware) *Route { // Build chain: [rate limiters root→self] → [middleware root→self] → [route mw] → handler rateLimiters := g.collectRateLimiters() middlewares := g.collectMiddleware() @@ -44,25 +59,28 @@ func (g *Group) Handle(method Method, path string, handler http.HandlerFunc, mw chain := append(rateLimiters, middlewares...) - pattern := string(method) + " " + g.prefix + path + fullPath := g.prefix + path + pattern := string(method) + " " + fullPath wrapped := middleware.Chain(handler, chain...) g.mux.Handle(pattern, wrapped) + + return &Route{path: fullPath} } -func (g *Group) Get(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - g.Handle(MethodGet, path, h, mw...) +func (g *Group) Get(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return g.Handle(MethodGet, path, h, mw...) } -func (g *Group) Post(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - g.Handle(MethodPost, path, h, mw...) +func (g *Group) Post(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return g.Handle(MethodPost, path, h, mw...) } -func (g *Group) Put(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - g.Handle(MethodPut, path, h, mw...) +func (g *Group) Put(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return g.Handle(MethodPut, path, h, mw...) } -func (g *Group) Patch(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - g.Handle(MethodPatch, path, h, mw...) +func (g *Group) Patch(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return g.Handle(MethodPatch, path, h, mw...) } -func (g *Group) Delete(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - g.Handle(MethodDelete, path, h, mw...) +func (g *Group) Delete(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return g.Handle(MethodDelete, path, h, mw...) } // SubGroup creates a nested group. It inherits rate limits and middleware @@ -134,22 +152,22 @@ func (r *Router) Handler() http.Handler { return r.mux } -func (r *Router) Handle(method Method, path string, handler http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Handle(method, path, handler, mw...) +func (r *Router) Handle(method Method, path string, handler http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Handle(method, path, handler, mw...) } -func (r *Router) Get(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Get(path, h, mw...) +func (r *Router) Get(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Get(path, h, mw...) } -func (r *Router) Post(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Post(path, h, mw...) +func (r *Router) Post(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Post(path, h, mw...) } -func (r *Router) Put(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Put(path, h, mw...) +func (r *Router) Put(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Put(path, h, mw...) } -func (r *Router) Patch(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Patch(path, h, mw...) +func (r *Router) Patch(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Patch(path, h, mw...) } -func (r *Router) Delete(path string, h http.HandlerFunc, mw ...middleware.Middleware) { - r.root.Delete(path, h, mw...) +func (r *Router) Delete(path string, h http.HandlerFunc, mw ...middleware.Middleware) *Route { + return r.root.Delete(path, h, mw...) } diff --git a/internal/router/router_test.go b/internal/router/router_test.go new file mode 100644 index 0000000..ff3b7cf --- /dev/null +++ b/internal/router/router_test.go @@ -0,0 +1,24 @@ +package router_test + +import ( + "net/http" + "testing" + + "git.juancwu.dev/juancwu/budgit/internal/router" + "github.com/stretchr/testify/assert" +) + +// TestRouter_DuplicateWildcardPanics pins a property we rely on: net/http.ServeMux +// refuses to register a pattern that uses the same wildcard name twice. URL() in +// the routes package leans on this guarantee to replace each key exactly once, so +// if this ever regresses we want loud test failure, not silently wrong URLs. +func TestRouter_DuplicateWildcardPanics(t *testing.T) { + r := router.New() + assert.PanicsWithError( + t, + `parsing "GET /here/{token}/there/{token}": at offset 24: duplicate wildcard name "token"`, + func() { + r.Get("/here/{token}/there/{token}", func(http.ResponseWriter, *http.Request) {}) + }, + ) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index f197256..8a56b89 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -9,10 +9,13 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/app" "git.juancwu.dev/juancwu/budgit/internal/handler" "git.juancwu.dev/juancwu/budgit/internal/middleware" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" "git.juancwu.dev/juancwu/budgit/internal/router" ) func SetupRoutes(a *app.App) http.Handler { + routeurl.Reset() + authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) @@ -45,11 +48,11 @@ func SetupRoutes(a *app.App) http.Handler { ) // Public pages - r.Get("/{$}", homeH.HomePage) - r.Get("/forbidden", homeH.ForbiddenPage) - r.Get("/privacy", homeH.PrivacyPage) - r.Get("/terms", homeH.TermsPage) - r.Get("/join/{token}", authH.JoinSpace) + r.Get("/{$}", homeH.HomePage).Name("page.public.home") + r.Get("/forbidden", homeH.ForbiddenPage).Name("page.public.forbidden") + r.Get("/privacy", homeH.PrivacyPage).Name("page.public.privacy") + r.Get("/terms", homeH.TermsPage).Name("page.public.terms") + r.Get("/join/{token}", authH.JoinSpace).Name("page.public.join-space") // Permanent redirects r.Get("/app/dashboard", redirectH.Spaces) @@ -57,39 +60,39 @@ func SetupRoutes(a *app.App) http.Handler { // Auth - guest routes r.Group("/auth", func(g *router.Group) { g.Use(middleware.RequireGuest) - g.Get("", authH.AuthPage) - g.Get("/password", authH.PasswordPage) - g.Get("/magic-link/{token}", authH.VerifyMagicLink) + g.Get("", authH.AuthPage).Name("page.auth.index") + g.Get("/password", authH.PasswordPage).Name("page.auth.password") + g.Get("/magic-link/{token}", authH.VerifyMagicLink).Name("page.auth.magic-link.verify") g.SubGroup("", func(g *router.Group) { g.RateLimit(5, 15*time.Minute) - g.Post("/magic-link", authH.SendMagicLink) - g.Post("/password", authH.LoginWithPassword) + g.Post("/magic-link", authH.SendMagicLink).Name("action.auth.magic-link.send") + g.Post("/password", authH.LoginWithPassword).Name("action.auth.password.login") }) }) // Auth - authenticated routes r.Group("/auth", func(g *router.Group) { g.Use(middleware.RequireAuth) - g.Get("/onboarding", authH.OnboardingPage) - g.Post("/onboarding", authH.CompleteOnboarding) + g.Get("/onboarding", authH.OnboardingPage).Name("page.auth.onboarding") + g.Post("/onboarding", authH.CompleteOnboarding).Name("action.auth.onboarding.complete") }) - r.Post("/auth/logout", authH.Logout) + r.Post("/auth/logout", authH.Logout).Name("action.auth.logout") // App routes r.Group("/app", func(g *router.Group) { g.Use(middleware.RequireAuth) g.SubGroup("/spaces", func(g *router.Group) { - g.Get("", spaceH.SpacesPage) + g.Get("", spaceH.SpacesPage).Name("page.app.spaces") }) g.SubGroup("/settings", func(g *router.Group) { - g.Get("", settingsH.SettingsPage) + g.Get("", settingsH.SettingsPage).Name("page.app.settings") g.SubGroup("", func(g *router.Group) { g.RateLimit(5, 15*time.Minute) - g.Post("/password", settingsH.SetPassword) + g.Post("/password", settingsH.SetPassword).Name("action.app.settings.password.set") }) }) }) diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index e64e0e9..ec9242c 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -8,6 +8,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/app" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/testutil" "github.com/stretchr/testify/assert" @@ -217,6 +218,18 @@ func TestSetupRoutes_NotFound(t *testing.T) { }) } +func TestURL_ResolvesNamedRoute(t *testing.T) { + testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { + a := newTestApp(dbi) + SetupRoutes(a) + + assert.Equal(t, "/privacy", routeurl.URL("page.public.privacy")) + assert.Equal(t, "/app/spaces", routeurl.URL("page.app.spaces")) + assert.Equal(t, "/join/abc123", routeurl.URL("page.public.join-space", "token", "abc123")) + assert.Equal(t, "#", routeurl.URL("does.not.exist")) + }) +} + func TestSetupRoutes_StaticAssets(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { a := newTestApp(dbi) diff --git a/internal/routeurl/routeurl.go b/internal/routeurl/routeurl.go new file mode 100644 index 0000000..9dda83a --- /dev/null +++ b/internal/routeurl/routeurl.go @@ -0,0 +1,54 @@ +// Package routeurl resolves named routes to concrete URL paths. +// It is intentionally a dependency-free leaf package so templates in +// internal/ui/... can import it without creating a cycle through the +// routes/router/middleware/handler graph. +package routeurl + +import ( + "strings" + "sync" +) + +var ( + mu sync.RWMutex + registry = map[string]string{} +) + +// Register associates a name with a pattern path (e.g. "/join/{token}"). +// Called by router.Route.Name at route-registration time. +func Register(name, path string) { + mu.Lock() + registry[name] = path + mu.Unlock() +} + +// Reset clears the registry. Intended for tests that want isolation +// between SetupRoutes calls. +func Reset() { + mu.Lock() + registry = map[string]string{} + mu.Unlock() +} + +// URL resolves a named route, substituting named wildcard segments from +// key/value pairs. Unknown names return "#" so templates degrade gracefully +// instead of panicking. +// +// Each key is replaced exactly once — net/http.ServeMux forbids duplicate +// wildcard names in a single pattern, so this is safe by construction. +// +// routeurl.URL("page.public.join-space", "token", tok) // "/join/" +func URL(name string, kv ...string) string { + mu.RLock() + path, ok := registry[name] + mu.RUnlock() + if !ok { + return "#" + } + for i := 0; i+1 < len(kv); i += 2 { + key, val := kv[i], kv[i+1] + path = strings.Replace(path, "{"+key+"...}", val, 1) + path = strings.Replace(path, "{"+key+"}", val, 1) + } + return path +}