Routing
The box office knows about real concerts now. Next, every concert deserves its own page, with links from the line-up, and the staff need a back office.
Defining routes
A route is a singleton with #[responds_to_http] and a #[process] method:
use margaret::framework::http::response::Response;
use margaret::framework::macros::process;
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::singleton;
use margaret::framework::route_method::route_method::RouteMethod;
use crate::concerts::concert_with_venue::ConcertWithVenue;
#[singleton]
#[responds_to_http(
method = RouteMethod::Get,
name = "show_concert",
path = "/concerts/{concert}",
server = "public"
)]
pub struct ShowConcert;
impl ShowConcert {
#[process]
pub fn respond(
&self,
#[route_parameter(from = "concert")] ConcertWithVenue { concert, venue }: ConcertWithVenue,
) -> anyhow::Result<Response> {
Ok(Response::text(
200,
format!(
"{} at {}, {}. Tickets from {} EUR.",
concert.artist, venue.name, venue.city, concert.price
),
))
}
}
RouteMethod offers Get, Post, Put, Patch, Delete, and Query.
Route model binding
Look at the parameter above: the route asks for a whole ConcertWithVenue, and the URL only contains an id, like /concerts/01a13257-80cf-7996-ad23-ed86b5b49159. Every model and every #[eager_load] shape can be bound by its primary key, with no extra code. When the id is malformed, or there's no such concert, the visitor gets a 404 and your route never runs.
Your own parameter types
Fans love browsing from a date, like /concerts/from/2026-11-20. Give the date its own type with #[route_parameter_value]:
use chrono::NaiveDate;
use margaret::framework::macros::route_parameter_value;
#[route_parameter_value]
pub struct ConcertDate(pub NaiveDate);
impl TryFrom<String> for ConcertDate {
type Error = chrono::ParseError;
fn try_from(value: String) -> Result<Self, Self::Error> {
Ok(Self(value.parse()?))
}
}
The repository walks the concerts_by_start index from the start of that day:
pub async fn from_day(
&self,
day: NaiveDate,
) -> Result<Vec<ConcertWithVenue>, ActiveRecordError> {
Ok(Concert::query()
.starts_at
.at_least(day.and_time(NaiveTime::MIN).and_utc())
.ascending()
.limit::<50>()
.load::<ConcertWithVenue, _>(self.database.as_ref())
.await?
.records)
}
#[singleton]
#[responds_to_http(method = RouteMethod::Get, path = "/concerts/from/{date}", server = "public")]
pub struct ListConcertsFrom {
concerts: Arc<ConcertRepository>,
}
impl ListConcertsFrom {
#[constructor]
pub fn create(concerts: Arc<ConcertRepository>) -> anyhow::Result<Self> {
Ok(Self { concerts })
}
#[process]
pub async fn respond(
&self,
#[route_parameter(from = "date")] ConcertDate(date): ConcertDate,
) -> anyhow::Result<Response> {
A date that doesn't parse answers 404.
Binding parameters yourself
Promoters already link to the box office with their own slugs, like /shows/paper-lanterns-hamburg, in newsletters that are long gone. A route parameter binder turns such a slug into something your route can use. It's a singleton with #[provides_route_parameter] that implements HttpRouteParameterBinder:
#[singleton]
#[provides_route_parameter]
pub struct PromoterLinks {
database: Arc<Database>,
}
impl PromoterLinks {
#[constructor]
pub fn create(database: Arc<Database>) -> anyhow::Result<Self> {
Ok(Self { database })
}
}
#[async_trait]
impl HttpRouteParameterBinder for PromoterLinks {
type Model = PromoterLink;
async fn bind(
&self,
slug: String,
) -> anyhow::Result<RouteParameterBindingOutcome<PromoterLink>> {
Ok(
match Concert::query()
.slug
.eq(slug)
.find(self.database.as_ref())
.await?
{
Lookup::Found(concert) => {
RouteParameterBindingOutcome::Bound(PromoterLink { concert })
}
Lookup::Missing => RouteParameterBindingOutcome::NotFound,
},
)
}
}
PromoterLink is a plain struct holding the concert. Models and shapes are always bound by their primary key, so a binder's Model is your own type, never a model itself. Every route that asks for a PromoterLink gets it from this binder. Add async-trait to your dependencies.
Named routes and links
Give a route a name, and Margaret generates a typed builder for its URL. Because a value can't always fill a path segment, a builder with parameters returns a RouteAddressing:
Addressed(route) when the URL works,
Misrouted { url } when the URL would reach a different route, like /concerts/search if a value were literally search,
Unaddressable when the value can't fill its segment at all, like one containing a /.
Concert ids are always addressable, so the box office turns the other two outcomes into errors in one small helper:
use margaret::framework::http::route_addressing::RouteAddressing;
use margaret::framework::http::unaddressable_parameter::UnaddressableParameter;
pub fn addressed<Route>(addressing: RouteAddressing<Route>) -> anyhow::Result<Route> {
match addressing {
RouteAddressing::Addressed(route) => Ok(route),
RouteAddressing::Misrouted { url } => {
anyhow::bail!("the link '{url}' would reach another route")
}
RouteAddressing::Unaddressable(UnaddressableParameter { name, rejection }) => {
Err(anyhow::Error::from(rejection).context(format!(
"the route parameter '{name}' can't hold this value"
)))
}
}
}
The line-up links to every concert page:
#[singleton]
#[responds_to_http(
method = RouteMethod::Get,
name = "list_concerts",
path = "/concerts",
server = "public"
)]
pub struct ListConcerts {
concerts: Arc<ConcertRepository>,
}
impl ListConcerts {
#[constructor]
pub fn create(concerts: Arc<ConcertRepository>) -> anyhow::Result<Self> {
Ok(Self { concerts })
}
#[process]
pub async fn respond(&self, routes: &Routes) -> anyhow::Result<Response> {
let lines = self
.concerts
.upcoming()
.await?
.into_iter()
.map(|ConcertWithVenue { concert, venue }| {
let link = addressed(routes.public.show_concert(concert.id.to_string()))?.url();
Ok(format!("{} at {}: {link}", concert.artist, venue.name))
})
.collect::<anyhow::Result<Vec<String>>>()?;
Ok(Response::text(200, lines.join("\n")))
}
}
Routes without parameters, like list_concerts, are plain fields: routes.public.list_concerts.url(). Rename or remove show_concert, and every link to it stops compiling.
Redirects
Now the promoters' old links can point at the real concert pages. A permanent redirect tells browsers and search engines to remember the new address:
use margaret::framework::http::redirect::Redirect;
use margaret::framework::macros::process;
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::singleton;
use margaret::framework::route_method::route_method::RouteMethod;
use crate::concerts::promoter_link::PromoterLink;
use crate::links::addressed;
use crate::margaret::routes::Routes;
#[singleton]
#[responds_to_http(method = RouteMethod::Get, path = "/shows/{show}", server = "public")]
pub struct RedirectPromoterLink;
impl RedirectPromoterLink {
#[process]
pub fn respond(
&self,
routes: &Routes,
#[route_parameter(from = "show")] PromoterLink { concert }: PromoterLink,
) -> anyhow::Result<Redirect> {
Ok(addressed(routes.public.show_concert(concert.id.to_string()))?.permanent_redirect())
}
}
$ curl -i http://127.0.0.1:8080/shows/paper-lanterns-hamburg
HTTP/1.1 308 Permanent Redirect
location: http://127.0.0.1:8080/concerts/01a13257-80cf-7996-ad23-ed86b5b49159
see_other() and temporary_redirect() work the same way.
Forwarding
The home page shows the line-up. Instead of copying it, / forwards the request to the list_concerts route, without a round trip to the browser:
use margaret::framework::http::forward::Forward;
use margaret::framework::macros::process;
use margaret::framework::macros::responds_to_http;
use margaret::framework::macros::singleton;
use margaret::framework::route_method::route_method::RouteMethod;
use crate::margaret::forwarders::public::Forwarder;
#[singleton]
#[responds_to_http(method = RouteMethod::Get, path = "/", server = "public")]
pub struct ShowHome;
impl ShowHome {
#[process]
pub fn respond(&self, forward: Forwarder) -> anyhow::Result<Forward> {
Ok(forward.list_concerts())
}
}
A forwarder reaches named GET routes on the same server.
Several servers
The staff need a back office that fans should never see. Routes pick their server with server, and every name you use becomes its own HTTP server with its own address:
#[singleton]
#[responds_to_http(
method = RouteMethod::Get,
name = "show_dashboard",
path = "/",
server = "admin"
)]
pub struct ShowDashboard {
concerts: Arc<ConcertRepository>,
}
impl ShowDashboard {
#[constructor]
pub fn create(concerts: Arc<ConcertRepository>) -> anyhow::Result<Self> {
Ok(Self { concerts })
}
#[process]
pub async fn respond(&self, routes: &Routes) -> anyhow::Result<Response> {
let lines = self
.concerts
.upcoming()
.await?
.into_iter()
.map(|ConcertWithVenue { concert, .. }| {
let page = addressed(routes.public.show_concert(concert.id.to_string()))?.url();
Ok(format!(
"{} ({} seats): {page}",
concert.artist, concert.capacity
))
})
.collect::<anyhow::Result<Vec<String>>>()?;
Ok(Response::text(200, lines.join("\n")))
}
}
Links work across servers, so the dashboard links straight to the public concert pages. Each server gets its own flags:
$ box_office serve \
--public-addr 0.0.0.0:8080 --public-url https://tickets.example.com \
--admin-addr 10.0.0.5:9090 --admin-url https://admin.tickets.internal
The admin server listens on the office network only. A server URL is an origin, and anything more is refused:
$ box_office serve --public-url http://127.0.0.1:8080/tickets ...
error: invalid value 'http://127.0.0.1:8080/tickets' for '--public-url <public-url>': the server url http://127.0.0.1:8080/tickets carries more than its scheme, host and port
What the build checks
Every server decides its routes once, while the project builds. Paths must start with /, parameter names are snake case and unique within a path, {*path} catches the rest of a path, and two routes may never answer the same request:
Error: Http { source: DuplicateRoute { existing_responder: "crate::routes::public::redirect_promoter_link::RedirectPromoterLink", method: Get, path: "/concerts/{concert}", responder: "crate::routes::public::show_concert::ShowConcert", server: "public" } }
Two paths that differ only in a parameter's name, like /concerts/{concert} and /concerts/{show}, conflict too:
Error: Http { source: UnadmittedRoutePath { path: "/concerts/{concert}", responder: "crate::routes::public::show_concert::ShowConcert", server: "public", source: ConflictingRoutePath { conflicting_path: "/concerts/{show}" } } }