github tokio-rs/topcoat v0.8.1

3 hours ago

Topcoat 0.8.1 makes a bunch of routing-related improvements, including support for trailing slahes and relative route paths on the module router.

Relative paths in the module router

The module router derives a handler's path from its enclosing module: src/app/settings.rs serves /settings. Until now, a handler either used that module path or opted out of it with an absolute path string, which also opted it out of the module router. Serving /settings/export from settings.rs meant creating a settings/export.rs module for it.

A path string starting with ./ is now joined onto the module path. The handler stays in the module router, so module_router!() still discovers it, segment! and path_param! declarations in the module tree still apply to it, and layouts and layers still wrap it by prefix.

// src/app/settings.rs: GET /settings
#[page]
async fn settings() -> Result<impl View> {
    Ok(view! { <h1>"Settings"</h1> })
}

// src/app/settings.rs: POST /settings/export
#[page(POST "./export")]
async fn export() -> Result<impl View> {
    Ok(view! { <p>"Export started"</p> })
}

The same form works for #[route], #[layout], and #[layer]. A layout declared with #[layout("./admin")] in settings.rs wraps the pages under /settings/admin, and a layer declared with #[layer("./v1")] in api.rs wraps every request under /api/v1. See the module router guide.

Absolute path strings behave as before: they disable module path derivation for that handler, and the handler is registered by name rather than discovered.

Trailing slashes

A path may now end in a /, and the slash is part of the path. A page at /users/ is served at /users/, and a page at /users is served at /users. In 0.8, a path ending in a slash was an empty segment and rejected outright.

By default, a request for the form a route did not declare is redirected to the declared one with a 308. The status code preserves the method and the body, so a form posted to /signup/ is resubmitted to a page at /signup. The query string is kept. RouterBuilder::trailing_slash selects one of three policies:

  • TrailingSlash::Redirect redirects to the declared form. This is the default.
  • TrailingSlash::Serve serves the route under both forms. The client keeps the URL it asked for, and the handler can read it through uri.
  • TrailingSlash::Strict serves the declared form only, and the other form responds 404.
use topcoat::router::{Router, TrailingSlash};

let router = Router::builder()
    .trailing_slash(TrailingSlash::Serve)
    .build();

The policy never touches the root /, a route ending in a catch-all parameter, or a pair of routes registered at both forms of one path. A route that claims a form for itself is left alone.

In the module router, a bare ./ serves the module path itself with a trailing slash, and a relative path ending in a slash keeps it:

// src/app/settings.rs: GET /settings/
#[page("./")]
async fn settings() -> Result<impl View> {
    Ok(view! { <h1>"Settings"</h1> })
}

// src/app/settings.rs: GET /settings/export/
#[page("./export/")]
async fn export() -> Result<impl View> {
    Ok(view! { <h1>"Export"</h1> })
}

see_other from a page

see_other(uri) builds the 303 response for the Post/Redirect/Get pattern: a completed POST sends the browser on to a new location with a GET, so a reload does not resubmit the form. In 0.8, SeeOther was only a response, returned through Ok. That works for a route, but a page's Ok value is a view, so a page could not answer a submission with a 303.

SeeOther is now an error as well. A route can keep returning it through Ok, and a page returns it through Err. Both produce the same 303 response:

#[page(POST "/signup")]
async fn signup(cx: &Cx, Form(input): Form<Signup>) -> Result<impl View> {
    if create_account(cx, &input.email).await? {
        return Err(see_other("/welcome").into());
    }
    Ok(view! { <p>"That email is already taken."</p> })
}

Like redirect, a see_other raised inside a streaming page after the first content was sent degrades to a client-side navigation script, since the status line can no longer change.

Cookies on error responses

A cookie added during a request is written to the response once the handler returns. In 0.8, that only happened when the handler returned a response. A handler that set a cookie and then returned an error, such as a redirect or unauthorized(), lost the cookie, because the error's response is only built after the cookie layer has already returned.

The cookie layer now queues its Set-Cookie headers for the router to apply after the error's response exists. A cookie added before a redirect is set by the redirect, and a session cookie cleared before an unauthorized() is cleared by the 401.

The mechanism behind the fix is public. response_headers(cx) returns a per-request slot of headers the router appends to whatever response the request ends with, success or error. A layer that wants a header on a 404 as much as on a handler's own response can queue it there instead of setting it on the response from Next::run:

impl Layer for RequestId {
    fn path(&self) -> Option<&Path> {
        None
    }

    fn handle<'a>(&'a self, cx: &'a Cx, body: Body, next: Next<'a>) -> LayerFuture<'a> {
        Box::pin(async move {
            response_headers(cx).append(
                header::HeaderName::from_static("x-request-id"),
                header::HeaderValue::from_static("42"),
            );
            next.run(cx, body).await
        })
    }
}

The slot is per dispatch, so a rewrite drops whatever the discarded dispatch queued.

Upgrading

  • A request for /users/ against a page at /users responded 404 in 0.8 and redirects with a 308 now. Set TrailingSlash::Strict on the router builder to keep the old behavior.
  • Code that walks a Path's segments can now see an empty static segment at the end. Check has_trailing_slash where that matters.
  • A page that answers a form submission by redirecting can return Err(see_other(uri).into()) in place of a route that existed only to send the 303.
  • Handlers under the module router that used an absolute path to serve a URL below their module can switch to a ./ path and be discovered again.
  • Layers that set a header on the response returned by Next::run keep working. Move the header to response_headers(cx) if it should also land on error responses.

Don't miss a new topcoat release

NewReleases is sending notifications on new releases.