github tokio-rs/topcoat v0.12.0

one hour ago

This release reworks some foundations of Topcoat. Most apps will need many small edits. We are sorry for the churn, but we believe these changes make Topcoat simpler and more extensible. The upgrade guide at the end lists the required changes.

  • Context
    • One context
    • Route context
    • Router settings
  • Page metadata
  • Macros
    • Context comes first
    • One argument style
    • Completions while typing
    • Formatting
    • Removed cookie! and mail!
  • Path parameter accessors
  • Mail
  • Fixes
  • Upgrade guide
    • Checklist
    • Renamed and removed APIs
    • Behavior changes

Context

One context

Topcoat used to keep app context and request context apart, each with its own functions. They are now one context, read with context, try_context and the new iter_context. Adding a value with cx.with(value) shadows older values of the same type instead of replacing them. The router can now provide a default that a layer, layout or page changes for its own part of the app.

use topcoat::context::{Cx, context};

struct Greeting(&'static str);

let router = module_router!().with(Greeting("Hi")).build();

#[layer("/formal")]
async fn formal(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
    next.run(&cx.with(Greeting("Good day")), body).await
}

#[page]
async fn home(cx: &Cx) -> Result<impl View> {
    let Greeting(greeting) = context::<Greeting>(cx);
    Ok(view! { <p>(greeting)</p> })
}

Shadowed values stay available. iter_context::<T>(cx) visits every value of a type, closest first, which is useful for values that several scopes add to, such as breadcrumbs. #[memoize] now also tracks values set on the router, so a function that reads a shadowed value runs again for the child context only.

Migrating is mostly a rename:

-module_router!().app_context(PageViews::default())
+module_router!().with(PageViews::default())

-let views = app_context::<PageViews>(cx);
-let user = try_request_context::<User>(cx);
+let views = context::<PageViews>(cx);
+let user = try_context::<User>(cx);

See context.

Route context

Routes, pages, layouts and layers can now attach values with with(...). When a request matches, the router adds the values of every layer, layout and the route itself to the context before any of them run. For example, a page could use this to tell its outer layout which <title> to show:

struct Title(&'static str);

#[layout("/", with(Title("My shop")))]
async fn shell(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
    let Title(title) = context::<Title>(cx);
    Ok(view! {
        <!DOCTYPE html>
        <html>
            <head><title>(title)</title></head>
            <body>(slot)</body>
        </html>
    })
}

#[page("/products", with(Title("Products")))]
async fn products(cx: &Cx) -> Result<impl View> {
    Ok(view! { <h1>"Products"</h1> })
}

Any type can be route context, so apps and libraries can define their own per-route settings without new router APIs. Here, a layer enforces a role that a page declares:

struct RequireRole(Role);

#[layer]
async fn roles(cx: &Cx, body: Body, next: Next<'_>) -> Result<Response> {
    if let Some(RequireRole(role)) = try_context::<RequireRole>(cx) {
        if !current_user(cx).await?.has_role(*role) {
            return Err(forbidden().into());
        }
    }
    next.run(cx, body).await
}

#[page(with(RequireRole(Role::Admin)))]
async fn settings(cx: &Cx) -> Result<impl View> { ... }

Values follow the module tree. A layout's values apply to every page below it, and a layer's values apply to every route and page under its path. Inner values shadow outer ones.

The values are built once with the router, so they cannot read the request. For values that depend on it, store a BoxCxFn, an async function of cx that you call when you need the value. The admin demo uses this for breadcrumbs, where each page adds a Crumb and the outer layout collects them with iter_context.

See route context.

Router settings

Some router settings that had their own builder methods are now context values. You set them for the whole app with .with(...) on the router builder, and for part of the app with with(...) on a route, page, layout or layer. Features that wrap requests, such as cookies and the browser runtime, are added as layers.

 module_router!()
-    .compression(Compression::new().brotli(false))
-    .origin_policy(OriginPolicy::new().trust_origins(["https://accounts.example.com"]))
-    .base_url("https://example.com")
-    .prefetch(PrefetchMode::Viewport)
-    .suspense(SuspenseMode::Wait)
-    .cookies()
-    .sessions(SessionConfig::default())
-    .runtime()
+    .with(Compression::default().brotli(false))
+    .with(OriginPolicy::trust_origins(["https://accounts.example.com"]))
+    .with(BaseUrl::new("https://example.com")?)
+    .with(PrefetchMode::Viewport)
+    .with(SuspenseMode::Wait)
+    .layer(CookieLayer::new())
+    .layer(RuntimeLayer::new())
     .build()

Per-route settings replace BodyLimit::at and OriginPolicy::exempt_paths:

#[route(POST "/upload", with(BodyLimit::max(32 * 1024 * 1024)))]
async fn upload(cx: &Cx, bytes: Bytes) -> Result<()> { ... }

#[route(POST "/webhook", with(OriginPolicy::dangerous_disable()))]
async fn webhook(cx: &Cx) -> Result<()> { ... }

#[route(GET "/export.zip", with(Compression::disable()))]
async fn export(cx: &Cx) -> Result<Response> { ... }

Sessions only need the CookieLayer now. Change their defaults with .with(SessionLifetime(duration)) and .with(SessionTokenStore::new(store)). RuntimeLayer::new().max_runs_per_connection(n) replaces the builder method of the same name.

Page metadata

The new metadata module renders the page title, description, canonical URL, robots, theme color, Open Graph and X tags. It is built on route context. Layouts set defaults, pages override single fields, and topcoat::metadata::tags() renders the result at the top of <head>.

use topcoat::metadata::{Metadata, OpenGraph};

#[layout("/", with(
    Metadata::new()
        .title("Acme")
        .description("Everything you need for tea.")
        .open_graph(OpenGraph::new().site_name("Acme").image(asset!("./preview.png")))
))]
async fn shell(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {
    Ok(view! {
        <!DOCTYPE html>
        <html>
            <head>topcoat::metadata::tags()</head>
            <body>(slot)</body>
        </html>
    })
}

#[page("/products", with(Metadata::new().title("Products")))]
async fn products(cx: &Cx) -> Result<impl View> { ... }

t! and asset! values can be passed directly. For values that need a database lookup, use Metadata::from_fn:

#[page("/product", with(Metadata::from_fn(async |cx| {
    let product = find_product(cx).await?;
    Ok(Metadata::new().title(product.name.clone()))
})))]

Canonical and Open Graph URLs must be absolute, so add .with(BaseUrl::new(...)?) to the router when you use relative paths. The metadata feature is enabled by default. See page metadata and examples/metadata.

Macros

Topcoat's macros were added one at a time and drifted apart. Some took context and some did not, and options used = in some places and : in others. A macro is only worth having when it does something plain Rust cannot, like checking HTML at compile time or bundling an asset. So we gave the remaining macros the same rules and removed macros that added little value over a plain builder. Almost all of the resulting changes are caught by the compiler, and we think a smaller set of consistent macros is worth it.

Context comes first

Functions with #[component], #[page], #[route], #[layout], #[layer], #[shard], #[procedure] and #[memoize] must now take cx: &Cx as their first parameter, even when they do not use it.

 #[page]
-async fn home() -> Result<impl View> {
+async fn home(cx: &Cx) -> Result<impl View> {

 #[layout]
-async fn shell(slot: Slot<'_>) -> Result<impl View> {
+async fn shell(cx: &Cx, slot: Slot<'_>) -> Result<impl View> {

 #[route(POST)]
-async fn save(Form(input): Form<Input>, cx: &Cx) -> Result<Response> {
+async fn save(cx: &Cx, Form(input): Form<Input>) -> Result<Response> {

view!, live!, emit! and attributes! no longer support the cx => prefix. Instead, they use the cx variable in scope, so to change the context of a view, shadow cx first.

 let cx = cx.with(PrefetchMode::Never);
-view! { cx =>
+view! {
     link(href: href!(reports), "Reports")
 }

view! moves cx into the view like any other captured variable and does not clone it. If a view must outlive the current function, for example in a stream, add let cx = cx.clone(); before it.

One argument style

Attribute macros take options as key = value, like #[serde(rename = "...")]. Function-like macros take key: value, like component props in view!.

-t!(greeting, name = user.name)
+t!(greeting, name: user.name)

-segment!(kind = CatchAll, rename = "path");
+segment!(kind: CatchAll, rename: "path");

path_param! and module_param! already use : for the parameter type, so their error handler moves to an else clause.

-module_param!(pub post_id: u64, error = bad_request);
+module_param!(pub post_id: u64 else bad_request);

href! took path parameters as wrapper types in path order. It now takes them as name: value pairs, in any order.

-href!(post, PostId(42))
+href!(post, post_id: 42)

The name refers to the parameter like a normal Rust path. When post_id is in scope, write post_id: 42. Otherwise, import it or write its path, such as posts::post_id: 42. See links.

Component calls used to accept a missing comma between the last prop and the first child, as in card(title: name "child"). This was a bug, and the comma is now required.

Completions while typing

Topcoat macros now handle incomplete code much better, which makes rust-analyzer work more reliably inside them. For example, a half-typed user. in a component body or a prop no longer breaks the rest of the macro, so rust-analyzer can still complete it. Option names in function-like macros also complete, and a misspelled option is reported on the option itself.

Formatting

topcoat fmt now formats every Topcoat macro, including attributes such as #[page(...)] and macros such as href!, asset! and path_param!. Run it once after upgrading. See topcoat fmt.

To support this, asset! is now a procedural macro. Its path must be a string literal, and a missing local file is a compile error. Two new prefixes are available. {mod}/ points at the module's own directory, so src/about.rs can use asset!("{mod}/hero.png") for src/about/hero.png. {out_dir}/ points at the build script output.

Removed cookie! and mail!

cookie! and mail! turned into the same builder calls you can write yourself. Plain builders give you completions, normal type errors and rustfmt, and they work with if and loops.

-cookies(cx).add(cookie!("theme" = theme));
-cookies(cx).add(cookie! { "session" = token; Path = "/"; Secure; HttpOnly; SameSite = Lax });
+cookies(cx).add(("theme", theme));
+cookies(cx).add(
+    Cookie::build(("session", token))
+        .path("/")
+        .secure(true)
+        .http_only(true)
+        .same_site(SameSite::Lax),
+);
-let mail = mail! {
-    from: "welcome@example.com",
-    to: "ada@example.com",
-    subject: "Welcome!",
-    html: { <h1>"Welcome!"</h1> },
-}?;
+let mail = Mail::builder()
+    .from(Mailbox::new("welcome@example.com")?)
+    .to([Mailbox::new("ada@example.com")?])
+    .subject("Welcome!")
+    .html(cx, view! { <h1>"Welcome!"</h1> })
+    .await?
+    .build()?;
 send(cx, mail).await?;

Path parameter accessors

path_param! and module_param! now generate an accessor function named after the parameter. It returns the parsed value, which is still parsed only once per request. The accessor has the visibility you give the macro, so other modules can import it like any other function. The generic path_param::<T>(cx) function and the generated wrapper types such as PostId are removed.

 module_param!(pub post_id: u64 else bad_request);

 #[page]
 async fn post(cx: &Cx) -> Result<impl View> {
-    let id = path_param::<PostId>(cx)?;
+    let id = post_id(cx)?;

See path parameters.

Mail

Mail is configured with a Mailer in the context, which also makes it easy to swap the transport in tests.

-module_router!().mail(MailConfig::builder().transport(transport).build())
+module_router!().with(Mailer::new(transport))

MailBuilder::build() now returns a Result and checks the sender, recipients, body, attachments and headers. A built Mail always has these, so its accessors no longer return Option. The Message-ID and date are set when the mail is built and the Message-ID no longer contains the machine's hostname. Mailboxes are created with Mailbox::new, Mailbox::named or by parsing a string. See mail.

Fixes

  • Props and local variables can have the same name as a component.
  • topcoat l10n check, topcoat asset list and the other commands that build now respect default-run in packages with several binaries.
  • The Tailwind CLI download is verified against checksums shipped with Topcoat.
  • accept_language no longer requires the l10n-fluent feature.
  • FileTransport no longer writes the Bcc header, matching delivered mail.

Upgrade guide

The compiler reports most of these changes. Each item links to the section with a full example.

Checklist

  • Add cx: &Cx as the first parameter of every #[component], #[page], #[route], #[layout], #[layer], #[shard], #[procedure] and #[memoize] function. It must be named exactly cx. (details)
  • Remove cx => from view!, live!, emit! and attributes!. Where you passed a different context, shadow cx before the macro. (details)
  • Replace app_context, request_context and their try_ versions with context and try_context, and .app_context(value) with .with(value). (details)
  • Set compression, origin policy, base URL, prefetch mode and suspense mode with .with(...) on the router. (details)
  • Replace .cookies() with .layer(CookieLayer::new()) and .runtime() with .layer(RuntimeLayer::new()), and remove .sessions(...). (details)
  • Replace BodyLimit::at and OriginPolicy::exempt_paths with with(...) on the routes they covered. (details)
  • Change the options of t! and segment! from = to :. (details)
  • Change error = ... in path_param! and module_param! to else .... (details)
  • Change href! parameters such as PostId(42) to post_id: 42. (details)
  • Add a comma between the last prop and the first child in component calls. (details)
  • Use a string literal path in asset!. (details)
  • Replace cookie! with Cookie::build and mail! with Mail::builder(). (details)
  • Replace path_param::<PostId>(cx) with post_id(cx). (details)
  • Replace .mail(...) with .with(Mailer::new(transport)), and handle the Result from MailBuilder::build(). (details)
  • Run topcoat fmt. (details)

Renamed and removed APIs

Before After
CxTestBuilder::new().app_context(a).request_context(b).build() Cx::new().with_many((a, b))
Cx::new(app_context) Cx::new().with_shared(map)
RouterBuilder::get_app_context::<T>() RouterBuilder::try_context::<T>()
.layer(BodyLimit::max(n)) .with(BodyLimit::max(n))
Compression::new() Compression::default()
Compression::off() Compression::disable()
OriginPolicy::new() OriginPolicy::default()
SessionConfig::builder().token_store(s).lifetime(d) .with(SessionTokenStore::new(s)).with(SessionLifetime(d))
RuntimeLayer RuntimeLayer::new()
href!(POSTS) for a Path constant href!((POSTS))
asset!(path, rename) asset!(path, rename: rename)
topcoat_asset::asset!, asset_options! topcoat::asset::asset!
SendError::delivery(err) SendError::new(err)

Behavior changes

These still compile, so check them by hand:

  • Adding the same type to the router twice no longer panics. The later value wins.
  • A type that was in both app context and request context now has one lookup, and the request value wins.
  • MailBuilder::html renders the view right away and panics on live views, since mail cannot stream.

Don't miss a new topcoat release

NewReleases is sending notifications on new releases.