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.

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}" } } }