日本語

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.

A GET action also answers a request without the 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.

The file name and the media type come from the visitor's browser, so check the bytes before trusting them. The 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.