URL Generation

URL Generation

On this page

UrlGenerator turns a named route back into a URL: name the route once, derive every URL from it.

let urls = app.resolve::<UrlGenerator>()?;
// or
Url::instance()
urls.route("posts.show", &[("post", "hello-world")])?;   // "/posts/hello-world"
urls.absolute("posts.show", &[("post", "hello")])?;      // "https://example.com/posts/hello"
urls.to("/assets/app.css");                              // "https://example.com/assets/app.css"

The generator is built at boot from the compiled router's named routes, so every name that resolves is a route that actually exists.

Parameters

Named parameters fill the path:

// route: /posts/{post}/comments/{comment}
urls.route("comments.show", &[("post", "hello"), ("comment", "7")])?;
// "/posts/hello/comments/7"

Anything left over becomes a query string:

urls.route("posts.index", &[("page", "2"), ("sort", "new")])?;
// "/posts?page=2&sort=new"

That rule means you rarely have to build a query string by hand. Extra parameters are sorted, so the same call always produces the same URL — which matters for caching and for asserting on a URL in a test.

A missing required parameter is an error rather than a URL with {post} still in it:

Error: the `posts.show` route needs a `post` parameter

An unknown name says so too, which is the failure you get when a route was renamed and a template was not.

Optional parameters ({post?}) may be omitted; the segment is dropped. A wildcard ({path*}) keeps its slashes — that is the point of it — while each piece is still encoded.

Absolute URLs

UrlGenerator::from_routes(compiled.named_routes()).with_base("https://example.com")

The base comes from app.url in config, which comes from APP_URL in .env. route() gives you a path; absolute() prefixes the base. Use absolute in anything that leaves the application — mail, webhooks, redirects to a different host.

Escaping

Both path segments and query values are percent-encoded, and this is not cosmetic.

An unescaped & in a query value would let the value forge additional parameters:

urls.route("search", &[("q", "a&admin=1")])?;
// "/search?q=a%26admin%3D1"    — one parameter, as intended

The query encoding set escapes &, =, + and ? in addition to the usual characters, precisely so a value cannot break out of its own slot. Path segments are escaped for path context.

Signed URLs

A link that proves this application produced it, so following it needs no session and no database row.

let signed = app.resolve::<SignedUrls>()?;

// /unsubscribe/42?signature=…
let link = signed.route("unsubscribe", &[("user", "42")])?;

// …and one that stops working. `expires` is in the query, so it is signed.
let link = signed.temporary_route("verify", expires_at, &[("user", "42")])?;

// What goes in an email.
let link = signed.absolute_route("verify", &[("user", "42")])?;

Checking it is a middleware:

router.get("/unsubscribe/{user}", unsubscribe)
    .name("unsubscribe")
    .middleware(ValidateSignature::resolved());

It answers 403 for a missing, forged or expired signature — and says which, because "this link has expired" and "this link is not valid" send the reader to different places.

What this removes

An unsubscribe link, an email-verification link and a one-time download all have the same shape without it: a token table, a lookup on every visit, and a scheduled job to sweep the rows nobody used. The URL carries its own proof instead, so there is nothing to store and nothing to sweep.

What is signed

The path and the query, with parameters sorted and signature removed. Sorting is what makes the signature independent of the order a client, a proxy or a mail client happens to send them in; removing signature is obvious in hindsight and is the first thing everybody gets wrong.

The host is not. The key is the boundary, not the hostname: two deployments holding the same APP_KEY are the same application by definition, and signing the host would break every link the moment a request arrives through a proxy that rewrote it. It also means an absolute link generated for an email verifies at a server that only ever sees a path — which is the whole point of having absolute_route.

If a link must work on exactly one hostname, put the hostname in the query. Then it is signed like everything else and your handler can check it.

Two things a signature is not

Not single-use. Anyone holding the link can follow it as many times as they like until it expires, and a link in an email lives in that mailbox forever. For anything that must happen once — accepting an invitation, changing an address — the signature proves this application issued it and something stateful still has to prove it has not been used. See challenges.

Not secret. The query is in the address bar, in the referrer header, and in whatever logs the URL. Sign an id; do not sign anything you would mind reading over somebody's shoulder.

The signer uses the same key ring as everything else, so rotating a key out invalidates every link it signed. That is correct, and worth knowing before rotating one: every outstanding verification email stops working. A key kept on the ring as a previous key still verifies, which is how a rotation happens without that.

Checking a name exists

urls.has("posts.show");

Useful in a template helper or a navigation builder where a route may be conditionally registered.

Building one by hand

let mut urls = UrlGenerator::new();
urls.insert("posts.show", "/posts/{post}");

Mostly for tests. In an application the booted one is already in the container, and it knows every route.

This page is the framework’s docs/urls.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.