Skip to content

URL Generation

Every URL an Almasix application emits goes through one place, because two rules have to hold everywhere. An app mounted under a subpath (APP_BASE_PATH=/apps/progress) must never emit a root-absolute link, and a URI written once in routes/web.py should not be repeated in every template that links to it.

Env / config Role
APP_URL Origin (https://example.com)
APP_BASE_PATH Public path prefix (/apps/progress)

The HTTP kernel mounts the ASGI app at the same prefix, so smith serve answers on exactly the paths url() emits.

Everything on this page is available two ways: as a module-level helper for the common case, and as a method on UrlGenerator when you need to configure the generator itself. UrlGenerator.from_config() builds the one this application’s configuration describes.

url() builds a URL for a path, prefixed with the base path and rooted at APP_URL. The outputs below are what an app configured with APP_URL=https://shop.test and APP_BASE_PATH=/eu emits:

app/http/controllers/checkout_controller.py
from almasix.routing import url
url("/checkout") # 'https://shop.test/eu/checkout'
url("/checkout", absolute=False) # '/eu/checkout'

Extra path segments may be passed as the second argument, and a query string as query:

examples/urls.py
url("users", [1, "posts", 2]) # 'https://shop.test/eu/users/1/posts/2'
url("/posts", query={"page": 2}) # 'https://shop.test/eu/posts?page=2'
url("/posts", query={"tag": ["a", "b"]}) # '…/posts?tag=a&tag=b'

A list value repeats the key, and a None value is left out rather than serialized as the word None. A path that is already absolute (https://… or //…) is returned unchanged, though it will still take a query string.

asset() is the same code path for files under public/, so a subpath app emits correct asset links too:

app/http/controllers/welcome_controller.py
from almasix.routing import asset
asset("build/app.css") # 'https://shop.test/eu/build/app.css'
asset("build/app.css", absolute=False) # '/eu/build/app.css'

Default apps ship Vite and Tailwind emitting into public/build/; keep calling asset(...) for anything under public/. See Asset Bundling.

secure_url and secure_asset emit https whatever APP_URL says, for the handful of links — a checkout, a login form — that must not be downgraded even in a development environment served over HTTP:

routes/web.py
from almasix.routing import secure_asset, secure_url
# With APP_URL=http://shop.test:
secure_url("/checkout") # 'https://shop.test/checkout'
secure_asset("build/app.css") # 'https://shop.test/build/app.css'
url("/checkout") # 'http://shop.test/checkout'

A script or a unit test has no APP_URL to honour and no base path to prefix, so the generator emits a relative URL rather than raising:

examples/urls.py
url("/a") # '/a'
asset("build/app.css") # '/build/app.css'

UrlGenerator reads the request in flight:

app/http/controllers/nav_controller.py
from almasix.routing import UrlGenerator
class NavController(Controller):
async def index(self):
urls = UrlGenerator.from_config()
urls.full() # 'https://shop.test/posts?page=2'
urls.current() # 'https://shop.test/posts' — the query string dropped
urls.previous() # the Referer, or the site root
urls.previous("/dashboard") # the Referer, or '/dashboard'
urls.previous_path() # '/back' — previous() without the origin
urls.query("/posts", {"page": 3}) # '…/posts?page=3'
return {}

query merges onto whatever the path already carries, so query("/posts?a=1", {"b": 2}) gives /posts?a=1&b=2. Outside a request full() and current() answer the site root instead of raising.

route() is the reason route names exist. Moving /posts/{post} to /blog/{post} becomes one edit in routes/web.py instead of a search through every template:

routes/web.py
Route.get("/posts/{post}", [PostController, "show"]).name("posts.show")
routes/web.py
from almasix.routing import route
route("posts.show", 7) # 'https://shop.test/posts/7'

Parameters may be passed in whichever shape reads best at the call site — all five of these mean the same thing:

examples/urls.py
route("posts.show", 7) # a scalar fills a single-parameter route
route("posts.show", [7]) # a list, positionally
route("posts.show", (7,)) # a tuple, positionally
route("posts.show", {"post": 7}) # a mapping, by name
route("posts.show", post=7) # keyword arguments

Keyword arguments are usually clearest once there is more than one:

/users/{user}/posts/{post}
route("users.posts.show", user=1, post=2) # 'https://shop.test/users/1/posts/2'

A model is read for its route key, so you can pass the object you already have rather than digging out its id:

examples/urls.py
route("posts.show", post) # calls post.get_route_key()

Almasix looks for get_route_key(), then getRouteKey(), then get_key().

A parameter the URI has no place for becomes the query string, which is the rule that makes pagination and filter links work:

examples/urls.py
route("posts.show", post=7, page=2)
# 'https://shop.test/posts/7?page=2'
route("posts.index", {"page": 2, "sort": "new"})
# 'https://shop.test/posts?page=2&sort=new'

Values are URL-encoded on the way in, so route("greet", "a b/c") gives /greet/a%20b%2Fc rather than an extra path segment.

An optional parameter may simply be left out, and absolute=False drops the origin while keeping the base path — which is what a Location header or an in-page link wants:

examples/urls.py
route("greet") # 'https://shop.test/greet'
route("greet", "ada") # 'https://shop.test/greet/ada'
route("posts.show", 7, absolute=False) # '/posts/7'

Both failures name what is wrong rather than emitting a broken link:

examples/urls.py
route("nope")
# RouteNotFound: No route is named 'nope'. Named routes: about, greet, home, …
route("posts.show")
# MissingRouteParameter: The URI '/posts/{post}' names the parameter 'post'
# and nothing supplied it.
route("posts.show", [1, 2, 3])
# MissingRouteParameter: Route 'posts.show' takes 1 parameter(s) (post),
# and 3 were passed.

to_route builds the redirect response, with a relative Location so it works behind a subpath or a proxy:

app/http/controllers/post_controller.py
from almasix.routing import to_route
class PostController(Controller):
async def store(self, request: Request):
return to_route("posts.show", 7) # 302 → /posts/7
# or to_route("posts.index", status=303)

defaults() on a route supplies a parameter the URL did not carry, for that route alone. It wins over the application-wide URL defaults below, being the more specific statement about that one URI:

routes/web.py
Route.get("/{locale}/about", [PageController, "about"]).name("about").defaults(
"locale", "en"
)
examples/urls.py
route("about") # 'https://shop.test/en/about'

defaults also takes a mapping, and the value it supplies reaches the handler as well when the URL omitted it.

A signed URL carries an HMAC of itself under APP_KEY, so a link may be handed to a user without also handing them the ability to edit it. An “unsubscribe me” mail can name the subscription in the open, because changing the id invalidates the signature.

app/mail/unsubscribe.py
from almasix.routing import signed_route, temporary_signed_route
signed_route("unsubscribe", user.id)
# 'https://shop.test/unsubscribe/7?signature=2f7a4786c8b1…'
temporary_signed_route("unsubscribe", 30, user.id)
# the same, plus ?expires=… — valid for 30 minutes

temporary_signed_route takes the lifetime in minutes as its second argument, before the parameters. Both accept every parameter shape route() does, and both take absolute=False.

The signed middleware is the usual way to check one — see Routing. It answers 403 rather than 404, because the resource is there and this link is simply not allowed to reach it:

routes/web.py
Route.get("/unsubscribe/{user}", [UnsubscribeController, "show"]).name(
"unsubscribe"
).middleware("signed")

To check one yourself:

routes/web.py
from almasix.routing import has_valid_relative_signature, has_valid_signature
target = signed_route("unsubscribe", 7)
has_valid_signature(target) # True
has_valid_signature(target.replace("/7?", "/8?")) # False — edited
has_valid_signature("https://shop.test/unsubscribe/7") # False — unsigned

The two shapes differ in what the signature covers:

  • Absolute — scheme, host, path, and query. This is what signed_route produces by default, and it cannot be replayed against another host.
  • Relative — path and query only, so the link survives a proxy that rewrites the origin, and http becoming https. Produce it with absolute=False, check it with has_valid_relative_signature, and validate it on the route with signed:relative.
examples/urls.py
target = signed_route("unsubscribe", 7, absolute=False)
# '/unsubscribe/7?signature=ea8ee073a083…'
has_valid_relative_signature(target) # True
has_valid_relative_signature(f"https://elsewhere.test{target}") # True
has_valid_signature(f"https://shop.test{target}") # False

An expired link fails has_valid_signature even though its signature is still correct. Pass ignore_expiry=True to separate the two cases, which is how you tell a user “this link has expired” instead of “this link is invalid”:

routes/web.py
import time
from almasix.routing.signing import sign
stale = sign("https://shop.test/unsubscribe/7", expires_at=int(time.time()) - 10)
has_valid_signature(stale) # False
has_valid_signature(stale, ignore_expiry=True) # True — only the deadline passed

Three details worth knowing:

  • Query order does not matter. The parameters are sorted before hashing, so a mail client that reorders them — or a router that rebuilds the query — does not invalidate the link.
  • Signing is idempotent. Signing an already-signed URL replaces the signature rather than appending a second one.
  • The key is app.key, with the base64: prefix that key:generate writes tolerated. With no application booted a fixed development key is used, so a script can sign a URL without bootstrapping.

The lower-level pieces are on almasix.routing.signing: sign(url, expires_at=…, absolute=…) signs any URL, expiry_from(minutes) (or expiry_from(seconds=…)) builds the timestamp, and signature_parameters(query) strips the two parameters signing owns — signature and expires.

action generates the URL of a controller action, for a route you would rather find by its handler than by a name. It takes the same shapes a route registration does:

resources/views/examples/urls.prism.html
from almasix.routing import action, to_action
action([PostController, "index"]) # 'https://shop.test/posts'
action("PostController@index") # the same route
action([PostController, "show"], 7) # 'https://shop.test/posts/7'
to_action([PostController, "index"]) # a 302 redirect to /posts

Parameters behave exactly as they do for route(). An action no route answers raises RouteNotFound.

A localized application prefixes every route with {locale} and would otherwise have to pass it at every single call site. defaults supplies the value once:

routes/web.py
from almasix.routing import UrlGenerator
urls = UrlGenerator.from_config()
urls.defaults({"locale": "en"})
route("about") # 'https://shop.test/en/about'
route("about", locale="fr") # 'https://shop.test/fr/about' — explicit wins
urls.defaults() # {'locale': 'en'} — reads them back
urls.forget_defaults() # drops the application-wide ones

A default only fills a parameter a URI actually names. It never becomes a query string, so setting a locale default does not append ?locale=en to every other link in the application:

examples/urls.py
route("posts.show", 7) # 'https://shop.test/posts/7'

The url.defaults middleware opens that overlay, copying the parameters it is named off the current request:

routes/web.py
Route.get("/{locale}/dashboard", [DashboardController, "index"]).middleware(
"url.defaults"
)
Route.get("/{locale}/help", [HelpController, "index"]).name("localized.help")

A request to /fr/dashboard now generates https://shop.test/fr/help from route("localized.help"), and a concurrent request to /en/dashboard generates https://shop.test/en/help. The middleware watches locale by default; name others as a comma-separated parameter, url.defaults:locale,tenant.

Calling defaults() inside a request the middleware opened writes to that request’s overlay; called anywhere else it writes application-wide, which is what a service provider wants. Defaults resolve most-specific-first: a route’s own defaults(), then the request overlay, then the application-wide values.

Behind a proxy that terminates TLS, the application sees http and would emit http links. force_scheme overrides the scheme of every absolute URL:

examples/urls.py
urls = UrlGenerator.from_config()
urls.force_scheme("https")
urls.to("/a") # 'https://shop.test/a'
urls.force_scheme(None) # back to whatever APP_URL says

force_root_url overrides APP_URL itself, for the generator you call it on — pointing asset URLs at a CDN, say:

examples/urls.py
urls.force_root_url("https://cdn.test")
urls.to("/a") # 'https://cdn.test/a'
urls.force_root_url(None)
urls.to("/a") # '/a' — no root left, so a relative URL

Templates receive the whole URL surface without importing anything:

Injected What it is
url, asset url() and asset()
secure_url, secure_asset The HTTPS-forcing pair
route, signed_route Named-route generation
action Controller-action generation
route_is, current_route_name The current route, for active nav links
resources/views/examples/urls.prism.html
<!-- resources/views/posts/index.prism.html -->
<a href="{{ route('posts.show', post) }}">{{ post.title }}</a>
<a href="{{ url('/checkout') }}">Checkout</a>
<img src="{{ asset('images/logo.svg') }}" alt="Logo">
<a href="{{ route('posts.index') }}" class="@if(route_is('posts.*'))active@endif">
Posts
</a>

Two directives are shorthand for the two most common calls, and take the same arguments as the functions:

resources/views/examples/urls.prism.html
<!-- resources/views/mail/unsubscribe.prism.html -->
<a href="@route('posts.show', 7)">Read the post</a>
<a href="@signedRoute('unsubscribe', 7)">Unsubscribe</a>

Both write an escaped URL, so they are safe inside an attribute.

  • Default parameters are request-scoped, not process-wide, because an ASGI process serves many requests at once. See Defaults are request-scoped.
  • Leftover positional parameters raise instead of being appended to the URL as path segments.
  • The query string is a query= keyword argument.
  • previous() reads the Referer header only, not a session-stored previous URL.
  • No fluent Uri object — build URLs with the helpers here and urllib.parse for the rest.
  • Every method also accepts a camelCase aliassecureAsset for secure_asset, hasValidSignature for has_valid_signature, forceScheme for force_scheme. The snake_case name is the documented one.
  • Without a booted application the generator emits relative URLs rather than raising, so a script or a unit test gets a usable answer.
  • Routing — named routes, the signed middleware, resource routes
  • Responsesredirect(), back(), and everything a controller can return
  • Asset Bundling — Vite, Tailwind, and public/build
  • Views — the Prism directives and the helpers templates receive