diff --git a/internal/router/router.go b/internal/router/router.go index ccc92a9..289472f 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -13,6 +13,24 @@ type Group struct { limiter *middleware.RateLimiter parent *Group mux *http.ServeMux + router *Router +} + +// 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 { + router *Router + path string +} + +// Name registers this route under the given name in the router's name +// registry. 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 { + if r.router != nil { + r.router.names[name] = r.path + } + return r } func (g *Group) Use(mw ...middleware.Middleware) { @@ -36,7 +54,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 +62,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{router: g.router, 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 @@ -72,6 +93,7 @@ func (g *Group) SubGroup(prefix string, fn func(*Group)) { prefix: g.prefix + prefix, parent: g, mux: g.mux, + router: g.router, } fn(sub) } @@ -101,18 +123,29 @@ func (g *Group) collectMiddleware() []middleware.Middleware { } type Router struct { - root *Group - mux *http.ServeMux + root *Group + mux *http.ServeMux + names map[string]string } func New() *Router { mux := http.NewServeMux() - return &Router{ - mux: mux, - root: &Group{ - mux: mux, - }, + r := &Router{ + mux: mux, + names: make(map[string]string), } + r.root = &Group{ + mux: mux, + router: r, + } + return r +} + +// Lookup returns the registered path for a named route, if any. +// The returned path retains Go 1.22 wildcard syntax ("/join/{token}"). +func (r *Router) Lookup(name string) (string, bool) { + path, ok := r.names[name] + return path, ok } func (r *Router) Mux() *http.ServeMux { @@ -134,22 +167,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..768e68d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -3,6 +3,7 @@ package routes import ( "io/fs" "net/http" + "strings" "time" "git.juancwu.dev/juancwu/budgit/assets" @@ -12,6 +13,10 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/router" ) +// registry holds the most recently built router so templates can resolve +// named routes via URL(). SetupRoutes assigns this during construction. +var registry *router.Router + func SetupRoutes(a *app.App) http.Handler { authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() @@ -20,6 +25,7 @@ func SetupRoutes(a *app.App) http.Handler { redirectH := handler.NewRedirectHandler() r := router.New() + registry = r // Global middleware r.Use( @@ -45,11 +51,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 +63,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") }) }) }) @@ -99,3 +105,33 @@ func SetupRoutes(a *app.App) http.Handler { return r.Handler() } + +// URL resolves a named route to its concrete path, substituting named +// wildcard segments from the supplied key/value pairs. Unknown names +// return "#" so templates degrade gracefully instead of panicking. +// +// Each key is replaced exactly once. This is safe because net/http.ServeMux +// rejects patterns with duplicate wildcard names at registration time, so any +// path stored in the registry has at most one `{key}` (or `{key...}`) segment +// per name. If two segments need similar semantics (e.g. nested tokens), they +// must be given distinct wildcard names — `/spaces/{spaceToken}/invites/{inviteToken}`. +// +// Example: +// +// routes.URL("page.public.join-space", "token", tok) +// // → "/join/" +func URL(name string, kv ...string) string { + if registry == nil { + return "#" + } + path, ok := registry.Lookup(name) + 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 +} diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index e64e0e9..3f3f10c 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -217,6 +217,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", URL("page.public.privacy")) + assert.Equal(t, "/app/spaces", URL("page.app.spaces")) + assert.Equal(t, "/join/abc123", URL("page.public.join-space", "token", "abc123")) + assert.Equal(t, "#", URL("does.not.exist")) + }) +} + func TestSetupRoutes_StaticAssets(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { a := newTestApp(dbi)