Requests, cookies, and errors
A handler reads the request through an axum extractor, sets cookies by returning headers beside its answer, and fails with ?.
Extractors and headers
A page or action handler takes the state and its arguments, and may take one FromRequestParts extractor between them: HeaderMap, a tuple of extractors, or the application's own type, such as the signed-in member read from a session cookie. When the extractor fails, its rejection is the response and the handler does not run. A pair of headers and a page or patches, such as ([(SET_COOKIE, value)], patches), sends the headers with the answer.
use axum::Router;
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::{HeaderName, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use hypersig::{Action, Patches, RouterExt, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Home {
name: String,
}
#[derive(Clone)]
struct AppState;
impl AppState {
fn member_of(&self, token: &str) -> Option<String> {
(token == "abc").then(|| "Ada".to_owned())
}
}
/// The member whose session cookie came with the request.
struct Member(String);
impl FromRequestParts<AppState> for Member {
type Rejection = SignedOut;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, SignedOut> {
let cookies = parts.headers.get(header::COOKIE).and_then(|value| value.to_str().ok());
let token = cookies.and_then(|cookies| {
cookies.split(';').find_map(|cookie| cookie.trim().strip_prefix("session="))
});
let name = token.and_then(|token| state.member_of(token));
// An action's `fetch` asks for `text/event-stream` and would follow a redirect.
let accept = parts.headers.get(header::ACCEPT);
let from_action = accept.is_some_and(|accept| accept == "text/event-stream");
name.map(Member).ok_or(SignedOut { from_action })
}
}
struct SignedOut {
from_action: bool,
}
impl IntoResponse for SignedOut {
fn into_response(self) -> Response {
if self.from_action {
(StatusCode::UNAUTHORIZED, "Sign in again.").into_response()
} else {
Redirect::to("/login").into_response()
}
}
}
const REFRESH: Action<Home> = Action::post("/refresh");
const SIGN_OUT: Action<Home> = Action::post("/sign-out");
// The extractor sits between the state and the arguments.
async fn refresh(_: AppState, Member(name): Member, (): ()) -> Patches<Home> {
Home::name().patch(name).into()
}
// Headers go beside the patches.
type Cookie = [(HeaderName, &'static str); 1];
async fn sign_out(_: AppState, _: Member, (): ()) -> (Cookie, Patches<Home>) {
let forget = [(header::SET_COOKIE, "session=; Max-Age=0; HttpOnly; Path=/")];
(forget, Patches::new())
}
let _app: Router = Router::new()
.action(REFRESH, refresh)
.action(SIGN_OUT, sign_out)
.with_state(AppState);An extractor that guards actions answers a signed-out request with a status such as 401. The browser's fetch follows a redirect, so an action answered with one receives the sign-in page's HTML and the page shows nothing. The Accept header tells the two apart: the runtime asks for text/event-stream.
Errors
A handler may return Result<_, E> where E implements IntoResponse, so ? works. An action whose response has a status outside 2xx fails on the page: the runtime writes the body to the signal given to Call::error, so the error's response text is what the visitor reads. An empty body writes the status and its reason, such as 502 Bad Gateway.
use axum::Router;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use hypersig::html::{button, p};
use hypersig::{Action, Patches, RouterExt, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Notes {
count: i32,
problem: String,
}
struct NotesFull;
impl IntoResponse for NotesFull {
fn into_response(self) -> Response {
(StatusCode::CONFLICT, "You can keep 3 notes.").into_response()
}
}
fn add_one(count: i32) -> Result<i32, NotesFull> {
(count < 3).then_some(count + 1).ok_or(NotesFull)
}
const ADD: Action<Notes, i32> = Action::post("/notes");
async fn add((): (), count: i32) -> Result<Patches<Notes>, NotesFull> {
let count = add_one(count)?;
Ok(Notes::count().patch(count).into())
}
// The page shows the status and the body of a failed call.
let call = ADD.call_with(Notes::count()).error(Notes::problem());
let _add = button().on_click(call).text("Add");
let _problem = p().bind_text(Notes::problem());
let _app: Router = Router::new().action(ADD, add);Requests an action refuses
Before its handler runs, an action answers a request whose Sec-Fetch-Site is not same-origin with a 403, and a POST whose body is not application/json with a 415. A form on another site cannot send JSON, and a script there that tries needs a preflight request this server does not answer, so neither can call an action with the visitor's cookie.
Accept: text/event-stream the runtime sends with a 403, which a link or an image on another site cannot send. A script on another site can send that header without a preflight, so from a browser that sends no Sec-Fetch-Site it still reaches a GET action, and a GET action should change nothing. The auth example in the repository signs in with a cookie session end to end.Uploads
An Upload<S, A> is an action that also receives the files picked in a FileInput<S>. Put FileInput::input on the page and pass the same constant to call or call_with. RouterExt::upload serves it, and its handler receives an Uploaded<A> with the arguments and each file's name, media type, and bytes. It answers as an action's handler does, so it can return a Result and take an extractor.
use axum::Router;
use axum::http::StatusCode;
use hypersig::html::{button, div};
use hypersig::{FileInput, Page, Patches, RouterExt, Signals, Upload, Uploaded};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize)]
struct Profile {
picture: String,
uploading: bool,
problem: String,
}
// Both constants carry the page's type, so a file input from another page does not compile.
const PICTURE: FileInput<Profile> = FileInput::new("picture");
const AVATAR: Upload<Profile> = Upload::post("/avatar").max_bytes(256 * 1024);
let signals = Profile { picture: String::new(), uploading: false, problem: String::new() };
let page = Page::new("Profile", signals).child(
div()
.child(PICTURE.input().attr("accept", "image/png"))
.child(
button()
.on_click(
AVATAR
.call(PICTURE)
.indicator(Profile::uploading())
.error(Profile::problem())
.then(PICTURE.clear()),
)
.text("Upload"),
),
);
// The handler gets the arguments and the files, and answers as an action's does.
async fn avatar((): (), uploaded: Uploaded<()>) -> Result<Patches<Profile>, StatusCode> {
let [file] = uploaded.files() else {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
};
if !file.bytes().starts_with(b"\x89PNG") {
return Err(StatusCode::UNPROCESSABLE_ENTITY);
}
let name = file.file_name().unwrap_or("picture").to_owned();
Ok(Profile::picture().patch(name).into())
}
let _app: Router = Router::new().upload(AVATAR, avatar);A form on another site can send a multipart/form-data body without a preflight, so the runtime adds the hypersig-upload header, which no form can set, and the router refuses an upload without it with a 403. An ordinary action still refuses a multipart body with a 415. A body longer than Upload::DEFAULT_MAX_BYTES (8 MiB), or the limit max_bytes sets, gets a 413, before it is read when its Content-Length says so.
auth example accepts a picture only when its first bytes are those of a PNG or a JPEG.Large files
RouterExt::upload reads every file into memory before its handler runs, so keep its limit near the largest file you accept. For larger files, serve the same Upload with RouterExt::upload_stream. Its handler receives an UploadStream<A>: the arguments, already read because the runtime sends them first, and next_file, which yields each file for the handler to read with chunk as its bytes arrive. Once the body passes the limit, next_file or chunk returns a RequestError with status 413, which the handler can return with ?. Both routes refuse a body whose first part is not the arguments with a 400, and skip parts other than the arguments and the files. A handler's extractor runs before the body is read, so an upload from a visitor who is not signed in is refused before its files arrive.