English

リクエスト、Cookie、エラー

ハンドラは axum の extractor でリクエストを読み、答えと組にしたヘッダーで Cookie を設定し、? で失敗を返します。

extractor とヘッダー

ページとアクションのハンドラは状態と引数を受け取り、そのあいだに FromRequestParts の extractor を 1 つ受け取れます。HeaderMap、extractor のタプル、セッションの Cookie から読んだサインイン中のメンバーのようなアプリケーションの型が使えます。extractor が失敗すると、その rejection が応答になり、ハンドラは動きません。([(SET_COOKIE, value)], patches) のようにヘッダーとページや差分を組にすると、ヘッダーが答えと一緒に送られます。

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);

アクションを守る extractor は、サインインしていないリクエストに 401 などの状態で答えます。ブラウザの fetch はリダイレクトをたどるので、リダイレクトで答えたアクションはサインインのページの HTML を受け取り、ページには何も表示されません。2 つは Accept ヘッダーで見分けられます。ランタイムは text/event-stream を求めます。

エラー

ハンドラは、E が IntoResponse を実装する Result<_, E> を返せるので、? が使えます。応答の状態が 2xx 以外のアクションは、ページでは失敗になります。ランタイムは、Call::error に渡したシグナルに応答の本文を書くので、エラーの応答の文言がそのまま訪問者に見えます。本文が空のときは、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);

アクションが断るリクエスト

アクションは、ハンドラが動く前に、Sec-Fetch-Site が same-origin でないリクエストに 403 を、本文が application/json でない POST に 415 を返します。別のサイトのフォームは JSON を送れず、そこでスクリプトが送ろうとするとプリフライトのリクエストが要り、このサーバーはそれに答えません。どちらも訪問者の Cookie を付けてアクションを呼び出せません。

GET のアクションは、ランタイムが送る Accept: text/event-stream のないリクエストにも 403 を返すので、別のサイトのリンクや画像からは呼び出せません。ただし、別のサイトのスクリプトはこのヘッダーをプリフライトなしで送れます。Sec-Fetch-Site を送らないブラウザからは GET のアクションに届くので、GET のアクションでは何も変更しないでください。リポジトリの auth の例は、Cookie のセッションでのサインインを最初から最後まで示します。

ファイルのアップロード

Upload<S, A> は、FileInput<S> で選んだファイルも受け取るアクションです。ページに FileInput::input を置き、同じ定数を call か call_with に渡します。RouterExt::upload で配信し、そのハンドラは引数と、ファイルごとの名前、メディアタイプ、中身を持つ Uploaded<A> を受け取ります。応答はアクションのハンドラと同じなので、Result を返したり 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);

multipart/form-data の本文は、別のサイトのフォームからプリフライトなしで送れます。そこでランタイムはフォームでは付けられない hypersig-upload ヘッダーを付け、ルーターはそれがないアップロードを 403 で断ります。普通のアクションは、これまでどおりマルチパートの本文を 415 で断ります。Upload::DEFAULT_MAX_BYTES(8 MiB)か、max_bytes で決めた上限を超える本文には 413 を返します。Content-Length が上限を超えていれば、本文を読む前に返します。

ファイル名とメディアタイプは訪問者のブラウザが決めるので、信用する前に中身を検査してください。auth の例は、先頭のバイトが PNG か JPEG のものである画像だけを受け付けます。

大きなファイル

RouterExt::upload は、ハンドラが動く前にすべてのファイルをメモリに読み込みます。そのため、上限は受け付ける最大のファイルに近い値にしてください。それより大きなファイルには、同じ Upload を RouterExt::upload_stream で配信します。ハンドラは UploadStream<A> を受け取ります。引数はランタイムが先頭で送るので読み終えてあり、next_file がファイルを 1 つずつ返します。ハンドラはファイルの中身を、届いた分ずつ chunk で読みます。本文が上限を超えると、next_file か chunk が状態 413 の RequestError を返すので、ハンドラは ? でそのまま返せます。どちらの配信も、先頭の部分が引数でない本文には 400 を返し、引数とファイル以外の部分は読み飛ばします。ハンドラの extractor は本文を読む前に動くので、ログインしていない訪問者のアップロードは、ファイルが届く前に断られます。