ページとリンク
ルートは、サーバーがあるパスで描画するページです。ルーターの配信もほかのページからのリンクも同じ値から作るので、パラメータの型や数の誤りはコンパイルエラーになります。
ルート
Route<P> は、パラメータが P のパスです。パラメータは axum と同じく {name} と書いたセグメント 1 つ分です。P は、パラメータのないパスでは ()、1 つなら Segment 1 つ、2 つから 6 つならパスの順に並べたタプルです。Segment になるのは整数、String、そしてフィールドのないヴァリアントだけを持ち #[derive(Segment)] を付けた列挙型です。列挙型のセグメントは serde がそのヴァリアントに書く文字列なので、#[serde(rename_all = "lowercase")] を付けると /ja/ になります。最後のセグメントを {*name} と書くと、"/files/{*path}" のように、/ を含むパスの残りを Rest に受け取ります。ルートは routes! の中で、それを配信するハンドラを書いた行に宣言します。そのため、ハンドラのないルートは宣言できません。マクロがセグメントを数えるので、数が P と合わないパスはコンパイルが通りません。routes! には axum フィーチャーが要ります。このフィーチャーはデフォルトで有効です。
use hypersig::Route;
hypersig::routes! {
fn pages(Router<()>);
// error[E0080]: evaluation panicked: the route's `{name}` segments differ
// in number from the values of its parameter type
const MEMBER: Route<i32> = "/members" => member;
}routes! の 1 行目には関数の名前とルーターの状態を書き、その関数が各ルートをハンドラと一緒に GET に登録します。ハンドラはルーターの状態と解析済みのパラメータを受け取り、Page を返します。セグメントをパラメータの型に解析できないパスには 404 を返し、None を返したハンドラにも 404 を返します。下の member は知らない id に対して None を返します。
use axum::Router;
use hypersig::html::{a, each, h1, li, p, ul};
use hypersig::{Page, Route, RouterExt, Signals};
use serde::{Deserialize, Serialize};
#[derive(Signals, Serialize, Deserialize, Clone)]
struct Member {
id: i32,
name: String,
}
#[derive(Signals, Serialize, Deserialize)]
struct Team {
members: Vec<Member>,
}
#[derive(Signals, Serialize, Deserialize)]
struct Profile {
name: String,
}
hypersig::routes! {
fn pages(Router<()>);
const TEAM: Route = "/" => team;
const MEMBER: Route<i32> = "/members/{id}" => member;
}
fn members() -> Vec<Member> {
vec![
Member {
id: 1,
name: "Ada".to_owned(),
},
Member {
id: 2,
name: "Grace".to_owned(),
},
]
}
async fn team((): (), (): ()) -> Page<Team> {
Page::new("Team", Team { members: members() })
.child(h1().text("Team"))
.child(ul().child(each(Team::members(), |member| {
li().child(
a().href(MEMBER.link_with(member.at(Member::id())))
.bind_text(member.at(Member::name())),
)
})))
}
async fn member((): (), id: i32) -> Option<Page<Profile>> {
let member = members().into_iter().find(|member| member.id == id)?;
Some(
Page::new(member.name.clone(), Profile { name: member.name })
.child(h1().bind_text(Profile::name()))
.child(p().child(a().href(TEAM.link()).text("Back to the team"))),
)
}
#[tokio::main]
async fn main() -> std::io::Result<()> {
let app = pages(Router::new()).hypersig_runtime();
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await
}リンク
El::href は Link を受け取ります。パラメータのないルートでは link() が、パラメータのあるルートでは link_with(params) がリンクを作ります。パラメータは、アクションを呼び出すときの引数と同じく、ルートの型の式です。リテラルを渡すと href は固定になります。上のチームのページのようにシグナルやリストの項目を読むパラメータは、ブラウザで評価され、値が変わるたびに更新されます。両側とも encodeURIComponent と同じ方法で値をパーセントエンコードするので、最初の HTML とランタイムは同じ URL を書きます。
use hypersig::Route;
use hypersig::html::a;
#[derive(Signals, Serialize, Deserialize)]
struct Search {
team: String,
}
hypersig::routes! {
fn pages(Router<()>);
const MEMBER: Route<(String, i32)> = "/teams/{team}/members/{id}" => member;
}
// Literal parameters give a fixed href, percent-encoded on the server.
let fixed = a::<Search>().href(MEMBER.link_with(("red team", 7)));
assert_eq!(fixed.render(), r#"<a href="/teams/red%20team/members/7"></a>"#);
// A parameter that reads a signal is kept up to date in the browser.
let bound = a().href(MEMBER.link_with((Search::team(), 7)));
assert!(bound.render().contains("data-hs-attr"));ページの下のページ
パスのあとに in PARENT と書くと、それより前の行のページの下にルートを宣言します。子のパスは親のパスにセグメントを続けたもので、パラメータは親のパラメータに自分のパラメータを続けたものです。そのため、子へのリンクには親の値も要ります。親のパスの続きになっていないパスや、親のパラメータで始まらないパラメータは、コンパイルが通りません。
use hypersig::Route;
use hypersig::html::a;
hypersig::routes! {
fn pages(Router<()>);
const ORG: Route<String> = "/orgs/{org}" => org;
// The child repeats the parent's path and parameters, then adds its own.
const MEMBER: Route<(String, i32)> = "/orgs/{org}/members/{id}" in ORG => member;
}
let link = a::<Org>().href(MEMBER.link_with(("acme", 9)));
assert_eq!(link.render(), r#"<a href="/orgs/acme/members/9"></a>"#);プロキシの裏の /app のようにパスの接頭辞の下で配信するアプリケーションは、接頭辞を Base の定数として 1 か所で宣言します。routes! の 1 行目に fn pages(Router<State>) under APP; と書き、アクション、ストリーム、アップロードにはそれぞれ Action::post("/save").under(APP) のように付けます。するとリンク、URL、リダイレクト、アクションの呼び出し、ページがランタイムを読み込むパスが /app で始まり、ルーターは各パスを接頭辞なしで登録します。同じルーターを Router::nest(APP.as_str(), ...) の中でも、/app を取り除くプロキシの裏でも動かせます。接頭辞を付けるルートとアクションが定数なので、接頭辞も定数で、プログラムをビルドするときに決まります。ほかのパスに nest したルーターのページは、すべてのリクエストに両方のパスを示す 500 を返します。ページが呼ぶアクションが同じ接頭辞で宣言されているかどうかは、コンパイラは検査しません。
クエリパラメータ
ルートの 2 つ目の型 Route<P, Q> は、クエリ文字列の型です。Q は #[derive(Signals, Search)] を付けた構造体で、リンクとハンドラは (P, Q) を受け取ります。パラメータのないパスでは Q だけを受け取ります。#[derive(Search)] は、各フィールドが SearchValue であることを求めます。SearchValue は文字列、数値、bool、#[derive(Segment)] を付けた列挙型、またはそれらの Option か Vec なので、フィールドに構造体やリストのリストを置くとコンパイルエラーになります。Vec のフィールドは ?color=red&color=blue のように要素ごとに名前を書き、サーバーは繰り返されたキーをリストに読み戻します。#[serde(try_from = "i32")] を付けた newtype のように Deserialize で値を検査する型のフィールドは、クエリ文字列を検証します。Option のフィールドにハンドラで unwrap_or を使えば、既定値を決められます。クエリ文字列はフィールドを名前の順に並べ、None のフィールドは書きません。これはサーバーでもブラウザでも同じです。Q にデシリアライズできないクエリ文字列には 400 を返します。
use hypersig::{Expr, Route, Search, Signals};
#[derive(Signals, Search, Serialize, Deserialize)]
struct Filter {
q: String,
page: i32,
tag: Option<String>,
}
hypersig::routes! {
fn pages(Router<()>);
// `team` receives `(String, Filter)`.
const TEAM: Route<String, Filter> = "/teams/{team}" => team;
}
let first = Filter { q: "rust".to_owned(), page: 1, tag: None };
let link = a::<Filter>().href(TEAM.link_with(("red", &first)));
assert_eq!(link.render(), r#"<a href="/teams/red?page=1&q=rust"></a>"#);
// A field can come from a signal, here the page the reader is on.
let next = Expr::lit(&first).with(Filter::page(), Filter::page() + 1);
let _ = a().href(TEAM.link_with(("red", next)));リダイレクトとページの移動
サーバーは url(params) でページの URL を書きます。url はルートのパラメータの値そのものを受け取り、リンクと同じ方法でエンコードします。ブラウザを別のページへ送るページのハンドラは、PageResponse を返します。PageResponse は PageResponse::redirect(url) で作るか、Page から変換します。アクションでは Patches::navigate(url) で同じことをし、ブラウザはそれより前の差分を適用してから移動します。
ブラウザでは、link.navigate() がリンク先のページを読み込む文です。ほかの文を実行してからページを離れるハンドラに使います。
use hypersig::html::button;
use hypersig::{Action, PageResponse, Patches, Route};
#[derive(Signals, Serialize, Deserialize)]
struct Draft {
saving: bool,
}
hypersig::routes! {
fn pages(Router<()>);
const POST: Route<i32> = "/posts/{id}" => post;
const OLD_POST: Route<i32> = "/p/{id}" => old_post;
}
const PUBLISH: Action<Draft> = Action::post("/publish");
// A page handler answers an old address with a redirect.
async fn old_post((): (), id: i32) -> PageResponse {
PageResponse::redirect(POST.url(id))
}
// An action sends the browser to the page it created.
async fn publish((): (), (): ()) -> Patches<Draft> {
let id = 7;
Patches::from(Draft::saving().patch(false)).navigate(POST.url(id))
}
// A button leaves the page after setting a signal.
let _back = button::<Draft>().on_click(Draft::saving().set(false).then(POST.link_with(&1).navigate()));ページを読み込まずに URL を変える
フィルター、並び順、ページ番号をクエリ文字列に持つリストは、それらが変わってもページを読み込み直さずに済みます。link.push_url() はリンクの URL を新しい履歴の項目としてアドレスバーに出す文です。link.replace_url() は今の URL と置き換えるので、検索欄に打つ 1 文字ごとのように、戻るボタンで飛ばしたい変更に使います。ページのシグナルとフォーカスはそのまま残るので、この文は行を読み込む呼び出しと並べて書きます。アクションでは Patches::push_url(url) と Patches::replace_url(url) で同じことをし、その後の差分も適用されます。訪問者が戻るボタンや進むボタンを押すと、ブラウザはその項目の URL のページをサーバーから読み込み、サーバーはクエリ文字列からページを描きます。
use hypersig::{Action, Expr, Page, Patches, Route, Search, Signals};
// `/fruit` without a query string leaves `q` as `None`.
#[derive(Signals, Search, Serialize, Deserialize, Default)]
struct Filter {
q: Option<String>,
}
#[derive(Signals, Serialize, Deserialize)]
struct Fruit {
query: String,
matches: Vec<String>,
}
hypersig::routes! {
fn pages(Router<()>);
const FRUIT: Route<(), Filter> = "/fruit" => fruit;
}
const SEARCH: Action<Fruit, String> = Action::post("/search");
fn find(q: &str) -> Vec<String> {
["apple", "fig", "kiwi"].into_iter().filter(|name| name.contains(q)).map(String::from).collect()
}
// The page renders what the query string names, so Back and Forward load it again.
async fn fruit((): (), filter: Filter) -> Page<Fruit> {
let q = filter.q.unwrap_or_default();
let matches = find(&q);
let query = Fruit::query();
let shown = Expr::lit(&Filter::default()).with(Filter::q(), query.some());
Page::new("Fruit", Fruit { query: q, matches }).child(
input().bind(query).on_debounced(
Event::Input,
Duration::from_millis(250),
// Each pause in typing replaces the URL rather than adding an entry.
SEARCH.call_with(query).then(FRUIT.link_with(shown).replace_url()),
),
)
}
const CLEAR: Action<Fruit> = Action::post("/clear");
// An action changes the URL too, here as a new entry after emptying the search.
async fn clear((): (), (): ()) -> Patches<Fruit> {
let empty = Fruit::query().patch(String::new()).and(Fruit::matches().patch(find("")));
Patches::from(empty).push_url(FRUIT.url(Filter::default()))
}見つからないパスのページ
routes! の 1 行目に fn pages(Router<State>) else missing; と書くと、どのページにも届かなかったページのリクエストを受け取るハンドラを指定できます。対象は、どのルートにも合わないパス、パースできないセグメント、None を返したハンドラ、デシリアライズできないクエリ文字列です。ハンドラは Unserved を受け取り、extractor も受け取れます。返したページは Unserved::status が示す 404 か 400 で送られます。Unserved::within(HOME) はパスの先頭から HOME のパラメータを読むので、/{lang} の下にあるサイトはパスの言語でページを出せます。ほかの状態を付けたページは PageResponse::with_status(status, page) で返します。
検索結果とリンクのプレビュー
Page::description は検索結果に出る要約を、Page::icon はタブのアイコンを書きます。canonical の URL とリンクのプレビューの画像は絶対 URL でなければならないので、Page::canonical と OpenGraph::image はパスを Origin につなぎます。Origin の定数は、スキーム、ホスト、ポート以外を含むとコンパイルエラーになります。プロキシが /blog のようなパスの下に配信するアプリは、Origin::under でそのパスを足します。under も、形の正しくない接頭辞を同じようにコンパイルエラーにします。OpenGraph はカードのタイトル、本文、URL をページから取るので、どれも 1 回書くだけで済みます。
use hypersig::{OpenGraph, Origin, Page, Route};
#[derive(Signals, Serialize, Deserialize)]
struct Post {
title: String,
}
hypersig::routes! {
fn pages(Router<()>);
const POST: Route<u32> = "/posts/{id}" => post;
}
// The scheme and host, checked when the constant is compiled.
const SITE: Origin = Origin::new("https://blog.example.com");
let page = Page::new("Hello", Post { title: "Hello".to_owned() })
.description("The first post.")
.canonical(SITE, &POST.url(1))
.icon("/icon.svg")
.open_graph(OpenGraph::new().site_name("Blog").image(SITE, "/og/1.png", "A wave"));
let html = page.render();
assert!(html.contains(r#"<meta property="og:url" content="https://blog.example.com/posts/1">"#));Page::bind_title(expr) は、未読の件数のように変わる String の式にタブのタイトルを合わせます。最初の値はサーバーが <title> に書き、その後の値はランタイムが document.title に書きます。OpenGraph のカードには Page::new に渡したタイトルが残ります。
ルートが検査しないこと
routes!が書く関数が呼び出されているかどうかは検査されません。アプリケーションのページはすべて 1 つのroutes!ブロックに宣言し、その関数 1 つを呼び出してください。関数が一度も呼び出されないブロックは、どのページにも 404 を返します。このとき rustc はハンドラの関数が使われていないと報告しますが、関数そのものとクロージャで書いたハンドラは報告しません。inのあとに書く親はそれ自体がページなので、自分のページを持たないパスの接頭辞は親にできません。- リンクは普通の
<a href>なので、たどると次のページをサーバーから読み込み、離れたページのシグナルは失われます。 push_urlやreplace_urlが出す URL が、画面のページのものかどうかは検査されません。別のページの URL を出すと、このページの内容のままその URL が表示され、再読み込みすると別のページが読み込まれます。- ほかのサイトや
#fragmentへのリンクのために、attr("href", ...)で任意の URL を書くこともできます。