topcoat 0.10.0

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
Topcoat reads and writes cookies through a jar shared by the request. Register `.cookies()` on the router, then call `cookies(cx)` to read cookies or queue changes. When the handler returns, Topcoat adds the changes to the response as `Set-Cookie` headers. This also works when the handler returns an error or redirect.

Cookies are part of the default feature set, and everything below is re-exported from `topcoat::cookie`. Topcoat builds on the `cookie` crate: a cookie is a [`Cookie`], and signing and encryption use its [`Key`].

```rust,standalone_crate
use topcoat::router::module_router;
use topcoat::{
    cookie::RouterBuilderCookieExt,
    router::Router,
};

let router = module_router!()
    .cookies()
    .build();
```

# Reading and writing

`cookies(cx)` returns the request's jar. Repeated calls share its contents, including pending changes. Import the [`Cookies`] trait to use its methods.

A cookie is a [`Cookie`] from the `cookie` crate. Build a bare one with `Cookie::new`, or use `Cookie::build` for attributes:

```rust
// src/app/api/theme.rs
use topcoat::{
    Result,
    context::Cx,
    cookie::{Cookie, Cookies, cookies},
    router::route,
};

#[route(POST)]
async fn toggle_theme(cx: &Cx) -> Result<String> {
    let jar = cookies(cx);

    let next = match jar.get("theme") {
        Some(theme) if theme.value() == "dark" => "light",
        _ => "dark",
    };

    jar.add(Cookie::build(("theme", next)).path("/").build());

    Ok(next.to_owned())
}
```

- `get(name)` returns the current cookie, including pending changes, or `None` if it is absent.
- `add(cookie)` queues a `Set-Cookie`.
- `remove(cookie)` queues an expiring removal cookie. Pass the same `Path`/`Domain` the cookie was set with so the browser matches and clears it:

```rust
# use topcoat::cookie::{Cookie, Cookies, cookies};
# fn _example(cx: &topcoat::context::Cx) {
# let jar = cookies(cx);
jar.remove(Cookie::build(("session", "")).path("/").build());
# }
```

`add` and `remove` accept anything that implements `Into<Cookie>`, so a plain `(name, value)` tuple works too when you don't need attributes:

```rust
# use topcoat::cookie::{Cookies, cookies};
# fn _example(cx: &topcoat::context::Cx) {
# let jar = cookies(cx);
jar.add(("theme", "dark"));
# }
```

# Writes must happen before the response

Set cookies before the handler returns. Later work, such as a streaming response, cannot change the response headers. Adding or removing a cookie after the jar has been sealed panics. Reading cookies still works.

```rust
// src/app/report.rs
use topcoat::{
    Result,
    context::Cx,
    cookie::{Cookies, cookies},
    router::page,
    view::{View, suspense, view},
};
# use topcoat::view::component;
# #[component]
# async fn figures() -> Result<impl View> { Ok(view! { <p>"42"</p> }) }

#[page]
async fn report(cx: &Cx) -> Result<impl View> {
    // Runs while the handler is still in charge of the response.
    cookies(cx).add(("last_report", "sales"));

    Ok(view! {
        suspense(
            fallback: view! { <p>"Loading..."</p> },
            // Streams in later, so it must not touch the jar.
            figures()
        )
    })
}
```

# Building cookies with `cookie!`

For cookies with several attributes, the [`cookie!`] macro is more compact than the builder. It mirrors the [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie) header: the `name = value` pair first, then any number of `;`-separated attributes.

```rust
use topcoat::cookie::{Cookie, SameSite, cookie, time::Duration};

let plain: Cookie = cookie!("theme" = "dark");

let session: Cookie = cookie! {
    "session" = "abc123";
    Path = "/";
    Secure;
    HttpOnly;
    SameSite = Lax;
    MaxAge = Duration::hours(1)
};
```

# Default and override attributes

Use the [`Cookies`] methods to apply shared attributes to cookies written through a jar:

- `default_*` fills the attribute only when the cookie does not already set it.
- `override_*` forces the attribute, replacing any value the cookie had.

```rust
# fn _example(cx: &topcoat::context::Cx) {
use topcoat::cookie::{Cookies, SameSite, cookie, cookies};

let jar = cookies(cx)
    .default_secure(true)
    .default_http_only(true)
    .default_same_site(SameSite::Lax)
    .default_path("/");

// Picks up Secure, HttpOnly, SameSite=Lax, Path=/ from the defaults above.
jar.add(cookie!("session" = "abc123"));
# }
```

Build the configured jar once and reuse it for several writes. See [`Cookies`] for the available attributes.

Put the configuration in an application helper so callers use consistent defaults:

```rust
use topcoat::{
    context::Cx,
    cookie::{Cookies, SameSite},
};

/// The application cookie jar, with our security defaults applied.
fn cookies(cx: &Cx) -> impl Cookies {
    topcoat::cookie::cookies(cx)
        .default_secure(true)
        .default_http_only(true)
        .default_same_site(SameSite::Lax)
        .default_path("/")
}
```

Callers use this helper whenever they need the configured jar. A helper can also return a signed or private jar.

Use [`map`](Cookies::map) to apply a custom change to each cookie:

```rust
# use topcoat::cookie::{Cookies, cookies};
# fn _example(cx: &topcoat::context::Cx) {
let jar = cookies(cx).map(|cookie| cookie.set_partitioned(true));
# }
```

# Name prefixes

[RFC 6265bis] cookie name prefixes ask the browser to enforce extra constraints based on the cookie's name. Topcoat applies the prefix *and* its required attributes for you, and strips the prefix back off on read so your code keeps using the bare name.

- `prefix_host` (`__Host-`): the cookie must be `Secure`, have `Path=/`, and carry no `Domain`. The tightest scoping: bound to the exact host, unavailable to subdomains.
- `prefix_secure` (`__Secure-`): the cookie must be `Secure`.

```rust
# fn _example(cx: &topcoat::context::Cx) {
use topcoat::cookie::{Cookies, cookie, cookies};

let jar = cookies(cx).override_prefix_host();

// Stored as `__Host-session`, forced Secure + Path=/, no Domain.
jar.add(cookie!("session" = "abc123"));

// Looked up under the prefixed name; returns it with the prefix stripped.
let session = jar.get("session");
# }
```

As with attributes, each prefix has a `default_*` form that fills the required attributes only when unset, and an `override_*` form that forces them for guaranteed RFC compliance. Use `override_*` unless you have a reason to let a caller's value stand.

[RFC 6265bis]: https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes

# Signed cookies

A signed cookie lets the server detect changes to its value. The client can still read it. Wrap the jar with a [`Key`] to sign writes and verify reads. A missing cookie or invalid signature returns `None`.

```rust
# fn _example(cx: &topcoat::context::Cx) {
use topcoat::cookie::{Cookies, Key, cookie, cookies};

let key = Key::generate();
let jar = cookies(cx).signed(&key);

jar.add(cookie!("user_id" = "42"));

// Returns the cookie only if the signature checks out.
let user_id = jar.get("user_id");
# }
```

# Private cookies

A private cookie encrypts and authenticates its value. The client cannot read the plaintext or change it without detection. Read and write it with the same name, key, and jar configuration.

```rust
# fn _example(cx: &topcoat::context::Cx) {
use topcoat::cookie::{Cookies, Key, cookie, cookies};

let key = Key::generate();
let jar = cookies(cx).private(&key);

jar.add(cookie!("session" = "secret-token"));

let session = jar.get("session"); // None if missing or it fails to decrypt
# }
```

Signed and private jars can be combined with prefixes and attribute defaults.

# Keys from app context

Share a [`Key`] across requests by registering it as [app context](crate::context::app_context):

```rust,standalone_crate
use topcoat::{
    cookie::{Key, RouterBuilderCookieExt},
    router::{Router, RouterBuilderDiscoverExt, module_router},
};

pub fn router() -> Router {
    module_router!()
        .discover()
        .cookies()
        .app_context(Key::generate())
        .build()
}
```

Then `signed_cookies(cx)` and `private_cookies(cx)` use the registered key:

```rust
// src/app/api/login.rs
use topcoat::{
    Result,
    context::Cx,
    cookie::{Cookies, cookie, private_cookies},
    router::route,
};

#[route(POST)]
async fn login(cx: &Cx) -> Result<&'static str> {
    private_cookies(cx).add(cookie!("session" = "secret-token"; Path = "/"));
    Ok("logged in")
}
```

Both functions panic if no [`Key`] was registered. The example generates a new key at startup. To keep existing cookies valid across restarts and server instances, load the same persisted key instead.

# Typed cookie stores

A [`CookieStore<T>`](CookieStore) stores a typed value as JSON in one cookie. The type must implement `Serialize` and `DeserializeOwned`.

Create a store with [`cookie_store`] and the jar it should use. The store inherits that jar's configuration. For example, a store built on [`private_cookies`] encrypts its value.

```rust
// src/app/api/cart.rs
use serde::{Deserialize, Serialize};
use topcoat::{
    Result,
    context::Cx,
    cookie::{cookie_store, private_cookies},
    router::route,
};

#[derive(Default, Serialize, Deserialize)]
struct Cart {
    items: Vec<String>,
}

#[route(POST)]
async fn add_item(cx: &Cx) -> Result<String> {
    let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
        .parse_or_default()
        .update(|cart| cart.items.push("widget".to_owned()))
        .commit()?;

    Ok(format!("{} items in cart", cart.items.len()))
}
```

## Reading the incoming value

[`cookie_store`] returns an [`UnparsedCookieStore`]. Choose how to handle a missing or malformed cookie when you parse it:

- [`parse`]UnparsedCookieStore::parse returns `Ok(None)` when the cookie is absent and `Err` when it is present but won't deserialize, so you can distinguish the two.
- [`parse_or(value)`]UnparsedCookieStore::parse_or falls back to `value` when the cookie is absent or malformed.
- [`parse_or_else(f)`]UnparsedCookieStore::parse_or_else falls back to `f()`.
- [`parse_or_default()`]UnparsedCookieStore::parse_or_default falls back to `T::default()`.

The fallback methods treat malformed and missing cookies alike. This can reset cookies that no longer match your data format. Use [`parse`](UnparsedCookieStore::parse) when you need to distinguish these cases.

Once parsed, you hold a [`CookieStore<T>`](CookieStore) whose value is known, so reads and mutations no longer return [`Result`]:

- [`read`]CookieStore::read borrows the value; [`get`]CookieStore::get clones it (when `T: Clone`).
- [`set(value)`]CookieStore::set replaces the value and [`update(f)`]CookieStore::update mutates it in place. Both return the store so calls can be chained.

## Nothing is written until `commit`

Reads and mutations touch only the in-memory value. **No `Set-Cookie` is queued until you call [`commit`](CookieStore::commit)**, which serializes the value, writes it through the jar, and hands the value back:

```rust
# use topcoat::cookie::{cookie_store, private_cookies};
# #[derive(Default, serde::Serialize, serde::Deserialize)] struct Cart { items: Vec<String> }
# fn _example(cx: &topcoat::context::Cx) -> topcoat::Result<()> {
let cart = cookie_store::<Cart, _>(private_cookies(cx), "cart")
    .parse_or_default()
    .update(|cart| cart.items.push("widget".to_owned()))
    .commit()?;
# let _ = cart;
# Ok(())
# }
```

Dropping the store without committing (or calling [`rollback`](CookieStore::rollback) to say so explicitly) discards the pending changes. This makes it easy to update a cookie only once some other work has succeeded: do the work first, and `commit` last.

To overwrite a cookie without reading its current contents, [`set`](UnparsedCookieStore::set) on the unparsed store skips the parse step entirely:

```rust
# use topcoat::cookie::{cookie_store, private_cookies};
# #[derive(Default, serde::Serialize, serde::Deserialize)] struct Cart { items: Vec<String> }
# fn _example(cx: &topcoat::context::Cx) -> topcoat::Result<()> {
cookie_store::<Cart, _>(private_cookies(cx), "cart")
    .set(Cart::default())
    .commit()?;
# Ok(())
# }
```

To delete a cookie, `remove` queues an expiring removal. It's available both on a parsed store ([`CookieStore::remove`]) and directly on the unparsed one ([`UnparsedCookieStore::remove`]) when you just want to clear the cookie without reading it, for example on logout:

```rust
# use topcoat::cookie::{cookie_store, private_cookies};
# #[derive(Default, serde::Serialize, serde::Deserialize)] struct Cart { items: Vec<String> }
# fn _example(cx: &topcoat::context::Cx) {
cookie_store::<Cart, _>(private_cookies(cx), "cart").remove();
# }
```

Use the same jar configuration for removal as for writing. The browser needs matching `Path`, `Domain`, and name attributes to clear the cookie.

## A helper per store

As with the jar combinators, the idiomatic pattern is to wrap each store in a small helper so its name and backing jar stay consistent everywhere it's used:

```rust
# #[derive(Default, serde::Serialize, serde::Deserialize)] struct Cart { items: Vec<String> }
use topcoat::{
    context::Cx,
    cookie::{CookieStore, Cookies, cookie_store, signed_cookies},
};

fn cart(cx: &Cx) -> CookieStore<Cart, impl Cookies> {
    cookie_store(signed_cookies(cx), "cart").parse_or_default()
}
```

Every handler then calls `cart(cx)`, mutates, and commits, without repeating the name or the jar configuration.