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::Redirectredirects to the declared form. This is the default.TrailingSlash::Serveserves the route under both forms. The client keeps the URL it asked for, and the handler can read it throughuri.TrailingSlash::Strictserves 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/usersresponded 404 in 0.8 and redirects with a 308 now. SetTrailingSlash::Stricton 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. Checkhas_trailing_slashwhere 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::runkeep working. Move the header toresponse_headers(cx)if it should also land on error responses.