diff --git a/docs/api/app.md b/docs/api/app.md index 67a3ae7da9c..a10283a8f35 100644 --- a/docs/api/app.md +++ b/docs/api/app.md @@ -535,6 +535,8 @@ func main() { This method retrieves a route by its name. +The returned `Route` can be inspected or used to generate a URL directly with `route.URL(params)`. + ```go title="Signature" func (app *App) GetRoute(name string) Route ``` @@ -554,12 +556,17 @@ func main() { app := fiber.New() app.Get("/", handler).Name("index") + app.Get("/user/:name/:id", handler).Name("user") route := app.GetRoute("index") data, _ := json.MarshalIndent(route, "", " ") fmt.Println(string(data)) + userRoute := app.GetRoute("user") + location, _ := userRoute.URL(fiber.Map{"name": "john", "id": 1}) + fmt.Println(location) // /user/john/1 + log.Fatal(app.Listen(":3000")) } ``` diff --git a/docs/whats_new.md b/docs/whats_new.md index 20c7781d092..97df46f5bd1 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -415,6 +415,8 @@ app.RouteChain("/api").RouteChain("/user/:id?") You can find more information about `app.RouteChain` and `app.Route` in the API documentation ([RouteChain](./api/app#routechain), [Route](./api/app#route)). +Named routes retrieved with `app.GetRoute(name)` also support `route.URL(params)` for generating relative URLs directly from the route definition, including parameter substitution for named, wildcard (`*`), and plus (`+`) segments. + ### Domain routing `Domain` creates a router scoped to a specific hostname pattern. Routes registered through the returned `Router` only match requests whose hostname (from `c.Hostname()`) matches the pattern. When `TrustProxy` is enabled and the proxy is trusted (as defined by [`TrustProxyConfig`](./api/app#trustproxyconfig)), the hostname may be derived from the `X-Forwarded-Host` header. Be sure to configure `TrustProxyConfig` to restrict which proxies are trusted and prevent header spoofing when enabling `TrustProxy`. diff --git a/path.go b/path.go index 4c37868558d..4aea2c3132a 100644 --- a/path.go +++ b/path.go @@ -124,8 +124,6 @@ const ( var ( // slash has a special role, unlike the other parameters it must not be interpreted as a parameter routeDelimiter = []byte{slashDelimiter, '-', '.'} - // list of greedy parameters - greedyParameters = []byte{wildcardParam, plusParam} // list of chars for the parameter recognizing parameterStartChars = [256]bool{ wildcardParam: true, diff --git a/res.go b/res.go index 98ccc2ac1e2..60540945dba 100644 --- a/res.go +++ b/res.go @@ -2,7 +2,6 @@ package fiber import ( "bufio" - "bytes" "fmt" "html/template" "io" @@ -646,38 +645,14 @@ func (r *DefaultRes) ViewBind(vars Map) error { return r.c.ViewBind(vars) } -// getLocationFromRoute get URL location from route using parameters +// getLocationFromRoute get URL location from route using parameters. +// Nil receivers and missing routes return ErrNotFound to match Route.URL semantics. func (r *DefaultRes) getLocationFromRoute(route *Route, params Map) (string, error) { - if route == nil || route.Path == "" { + if r == nil || route == nil || route.Path == "" { return "", ErrNotFound } - app := r.c.app - buf := bytebufferpool.Get() - for _, segment := range route.routeParser.segs { - if !segment.IsParam { - _, err := buf.WriteString(segment.Const) - if err != nil { - return "", fmt.Errorf("failed to write string: %w", err) - } - continue - } - - for key, val := range params { - isSame := key == segment.ParamName || (!app.config.CaseSensitive && utils.EqualFold(key, segment.ParamName)) - isGreedy := segment.IsGreedy && len(key) == 1 && bytes.IndexByte(greedyParameters, key[0]) >= 0 - if isSame || isGreedy { - _, err := buf.WriteString(utils.ToString(val)) - if err != nil { - return "", fmt.Errorf("failed to write string: %w", err) - } - } - } - } - location := buf.String() - // release buffer - bytebufferpool.Put(buf) - return location, nil + return buildRouteURL(route, params) } // GetRouteURL generates URLs to named routes, with parameters. URLs are relative, for example: "/user/1831" diff --git a/router.go b/router.go index dd4b7de4e17..44fc901295d 100644 --- a/router.go +++ b/router.go @@ -11,6 +11,7 @@ import ( "github.com/gofiber/utils/v2" utilsstrings "github.com/gofiber/utils/v2/strings" + "github.com/valyala/bytebufferpool" "github.com/valyala/fasthttp" ) @@ -58,11 +59,120 @@ type Route struct { routeParser routeParser // Parameter parser // Data for routing - use bool // USE matches path prefixes - mount bool // Indicated a mounted app on a specific route - star bool // Path equals '*' - root bool // Path equals '/' - autoHead bool // Automatically generated HEAD route + use bool // USE matches path prefixes + mount bool // Indicated a mounted app on a specific route + star bool // Path equals '*' + root bool // Path equals '/' + autoHead bool // Automatically generated HEAD route + caseSensitive bool // Whether parameter matching is case-sensitive +} + +var ( + defaultGreedyParameterKeys = []string{"*", "+"} + preferredWildcardGreedyParameters = []string{"*", "+"} + preferredPlusGreedyParameters = []string{"+", "*"} +) + +// URL generates a URL from the route path and parameters. +// This method fills in the route parameters with the provided values. +// Parameter matching respects the app's CaseSensitive configuration: +// case-insensitive by default, case-sensitive when CaseSensitive is true. +// +// Example: +// +// app.Get("/user/:name/:id", handler).Name("user") +// url, err := app.GetRoute("user").URL(Map{"name": "john", "id": "123"}) +// // Returns: "/user/john/123" +// +//nolint:gocritic // hugeParam: app.GetRoute returns a value, so URL must be callable on that value directly. +func (r Route) URL(params Map) (string, error) { + if r.Path == "" { + return "", ErrNotFound + } + + return buildRouteURL(&r, params) +} + +// buildRouteURL generates a URL from route segments and parameters. +// This shared helper is used by both Route.URL() and DefaultRes.getLocationFromRoute() +// to ensure consistent URL generation behavior across APIs. +// +// Parameter resolution uses a deterministic three-step lookup: +// 1. Exact key match on segment.ParamName +// 2. Case-insensitive fallback picking the lexicographically-smallest matching key (when !caseSensitive) +// 3. Greedy parameter fallback for wildcard (*) and plus (+) parameters +func buildRouteURL(route *Route, params Map) (string, error) { + if len(route.routeParser.segs) == 0 { + return route.Path, nil + } + + buf := bytebufferpool.Get() + defer bytebufferpool.Put(buf) + + for _, segment := range route.routeParser.segs { + if !segment.IsParam { + _, err := buf.WriteString(segment.Const) + if err != nil { + return "", fmt.Errorf("failed to write string: %w", err) + } + continue + } + + var ( + val any + found bool + ) + + // Prefer an exact parameter name match + if val, found = params[segment.ParamName]; !found && !route.caseSensitive { + // Fall back to a case-insensitive match using a deterministic winner + var matchedKey string + for key := range params { + if utils.EqualFold(key, segment.ParamName) && (!found || key < matchedKey) { + matchedKey = key + found = true + } + } + if found { + val = params[matchedKey] + } + } + + // For greedy parameters, fall back to generic greedy keys + if !found && segment.IsGreedy { + for _, greedyKey := range preferredGreedyParameters(segment.ParamName) { + if val, found = params[greedyKey]; found { + break + } + } + } + + if found { + _, err := buf.WriteString(utils.ToString(val)) + if err != nil { + return "", fmt.Errorf("failed to write string: %w", err) + } + } + } + + return buf.String(), nil +} + +// preferredGreedyParameters returns the generic greedy fallback lookup order +// for a route parameter name. +// Parameter names starting with '+' prefer '+' before '*', names starting with +// '*' prefer '*' before '+', and all other names fall back to the default order. +func preferredGreedyParameters(paramName string) []string { + if paramName != "" { + switch paramName[0] { + case plusParam: + return preferredPlusGreedyParameters + case wildcardParam: + return preferredWildcardGreedyParameters + } + } + + return defaultGreedyParameterKeys } func (r *Route) match(detectionPath, path string, params *[maxParams]string) bool { @@ -369,6 +479,7 @@ func (app *App) addPrefixToRoute(prefix string, route *Route) *Route { route.routeParser = parseRoute(prettyPath, app.customConstraints...) route.root = false route.star = false + route.caseSensitive = app.config.CaseSensitive return route } @@ -376,11 +487,12 @@ func (app *App) addPrefixToRoute(prefix string, route *Route) *Route { func (*App) copyRoute(route *Route) *Route { return &Route{ // Router booleans - use: route.use, - mount: route.mount, - star: route.star, - root: route.root, - autoHead: route.autoHead, + use: route.use, + mount: route.mount, + star: route.star, + root: route.root, + autoHead: route.autoHead, + caseSensitive: route.caseSensitive, // Path data path: route.path, @@ -554,10 +666,11 @@ func (app *App) register(methods []string, pathRaw string, group *Group, handler isRoot := pathClean == "/" route := Route{ - use: isUse, - mount: isMount, - star: isStar, - root: isRoot, + use: isUse, + mount: isMount, + star: isStar, + root: isRoot, + caseSensitive: app.config.CaseSensitive, path: pathClean, routeParser: parsedPretty, diff --git a/router_test.go b/router_test.go index 02aa85bc7e0..4e646d71968 100644 --- a/router_test.go +++ b/router_test.go @@ -2360,3 +2360,200 @@ func Benchmark_Router_GitHub_API_Parallel(b *testing.B) { require.True(b, match) } } + +// go test -run Test_Route_URL +func Test_Route_URL(t *testing.T) { + t.Parallel() + + t.Run("simple parameter", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name", emptyHandler).Name("User") + + route := app.GetRoute("User") + url, err := route.URL(Map{"name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/user/fiber", url) + }) + + t.Run("multiple parameters", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name/:id", emptyHandler).Name("UserID") + + route := app.GetRoute("UserID") + url, err := route.URL(Map{"name": "john", "id": "123"}) + require.NoError(t, err) + require.Equal(t, "/user/john/123", url) + }) + + t.Run("wildcard parameters", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/:phone/*/send/*", emptyHandler).Name("SendSms") + + route := app.GetRoute("SendSms") + url, err := route.URL(Map{ + "phone": "23456789", + "*1": "sms", + "*2": "test-msg", + }) + require.NoError(t, err) + require.Equal(t, "/23456789/sms/send/test-msg", url) + }) + + t.Run("single wildcard parameter", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/:phone/*/send", emptyHandler).Name("SendSms") + + route := app.GetRoute("SendSms") + url, err := route.URL(Map{ + "phone": "23456789", + "*": "sms", + }) + require.NoError(t, err) + require.Equal(t, "/23456789/sms/send", url) + }) + + t.Run("plus parameters prefer plus fallback", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/+", emptyHandler).Name("UserGreedy") + + route := app.GetRoute("UserGreedy") + url, err := route.URL(Map{ + "*": "wildcard", + "+": "plus", + }) + require.NoError(t, err) + require.Equal(t, "/user/plus", url) + }) + + t.Run("preferred greedy parameters default fallback", func(t *testing.T) { + t.Parallel() + require.Equal(t, preferredPlusGreedyParameters, preferredGreedyParameters("+1")) + require.Equal(t, preferredWildcardGreedyParameters, preferredGreedyParameters("*1")) + require.Equal(t, defaultGreedyParameterKeys, preferredGreedyParameters("")) + require.Equal(t, defaultGreedyParameterKeys, preferredGreedyParameters("name")) + }) + + t.Run("case insensitive default", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name", emptyHandler).Name("User") + + route := app.GetRoute("User") + // Case-insensitive by default (matching Fiber's default behavior) + url, err := route.URL(Map{"Name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/user/fiber", url) + + // When multiple keys case-fold to the same param name, prefer the exact match. + url, err = route.URL(Map{"name": "exact", "Name": "fallback"}) + require.NoError(t, err) + require.Equal(t, "/user/exact", url) + }) + + t.Run("case sensitive", func(t *testing.T) { + t.Parallel() + app := New(Config{CaseSensitive: true}) + app.Get("/user/:name", emptyHandler).Name("User") + + route := app.GetRoute("User") + // Exact case match succeeds + url, err := route.URL(Map{"name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/user/fiber", url) + // Different case does not match when CaseSensitive is true + url, err = route.URL(Map{"Name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/user/", url) + }) + + t.Run("empty route", func(t *testing.T) { + t.Parallel() + route := Route{} + url, err := route.URL(Map{"name": "fiber"}) + require.Error(t, err) + require.Equal(t, ErrNotFound, err) + require.Empty(t, url) + }) + + t.Run("route without parsed segments returns path", func(t *testing.T) { + t.Parallel() + // This covers fallback/manual Route values whose Path is set but whose + // routeParser was never populated, such as synthetic routes outside the + // normal registration pipeline. + route := Route{Path: "/error"} + url, err := route.URL(Map{"name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/error", url) + }) + + t.Run("GetRoute direct call", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name", emptyHandler).Name("User") + + url, err := app.GetRoute("User").URL(Map{"name": "fiber"}) + require.NoError(t, err) + require.Equal(t, "/user/fiber", url) + }) + + t.Run("no parameters", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/static/path", emptyHandler).Name("Static") + + route := app.GetRoute("Static") + url, err := route.URL(Map{}) + require.NoError(t, err) + require.Equal(t, "/static/path", url) + }) + + t.Run("missing parameter value", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name/:id", emptyHandler).Name("UserID") + + route := app.GetRoute("UserID") + url, err := route.URL(Map{"name": "john"}) + require.NoError(t, err) + // Missing id parameter results in empty placeholder + require.Equal(t, "/user/john/", url) + }) + + t.Run("extra parameters ignored", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/user/:name", emptyHandler).Name("User") + + route := app.GetRoute("User") + url, err := route.URL(Map{"name": "fiber", "extra": "ignored"}) + require.NoError(t, err) + require.Equal(t, "/user/fiber", url) + }) + + t.Run("numeric parameter values", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/item/:id", emptyHandler).Name("Item") + + route := app.GetRoute("Item") + url, err := route.URL(Map{"id": 42}) + require.NoError(t, err) + require.Equal(t, "/item/42", url) + }) + + t.Run("complex path with mixed content", func(t *testing.T) { + t.Parallel() + app := New() + app.Get("/api/v1/users/:userId/posts/:postId/comments", emptyHandler).Name("Comments") + + route := app.GetRoute("Comments") + url, err := route.URL(Map{"userId": "user123", "postId": "post456"}) + require.NoError(t, err) + require.Equal(t, "/api/v1/users/user123/posts/post456/comments", url) + }) +}