chore(checkpoint): save current v0 build state
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "slhx-axum"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.7", default-features = false }
|
||||
slhx-core = { path = "../slhx-core" }
|
||||
slhx-js = { path = "../slhx-js" }
|
||||
@@ -0,0 +1,429 @@
|
||||
use axum::async_trait;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::extract::{FromRequest, FromRequestParts};
|
||||
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use slhx_core::{BuildFingerprint, EffectBatch, IntoEffect};
|
||||
use std::collections::BTreeMap;
|
||||
use std::convert::Infallible;
|
||||
|
||||
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
|
||||
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
|
||||
pub const SLHX_TITLE_HEADER: &str = "x-slhx-title";
|
||||
pub const SLHX_CONTENT_TYPE: &str = "application/slhx";
|
||||
pub const SLHX_HANDLE_FIELD: &str = "__h";
|
||||
pub const SLHX_RUNTIME_CONTENT_TYPE: &str = "application/javascript; charset=utf-8";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct RuntimeJs;
|
||||
|
||||
pub const fn runtime_js() -> RuntimeJs {
|
||||
RuntimeJs
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PageMode {
|
||||
Full,
|
||||
Partial,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PageRequest {
|
||||
pub mode: PageMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PageResponse {
|
||||
pub mode: PageMode,
|
||||
pub html: String,
|
||||
pub title: Option<String>,
|
||||
pub fingerprint: Option<BuildFingerprint>,
|
||||
}
|
||||
|
||||
impl PageRequest {
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
Self {
|
||||
mode: PageMode::from_headers(headers),
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn is_partial(self) -> bool {
|
||||
matches!(self.mode, PageMode::Partial)
|
||||
}
|
||||
|
||||
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse {
|
||||
let partial_html = partial_html.into();
|
||||
match self.mode {
|
||||
PageMode::Full => PageResponse::full(shell(partial_html)),
|
||||
PageMode::Partial => PageResponse::partial(partial_html),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for PageRequest
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Infallible;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(Self::from_headers(&parts.headers))
|
||||
}
|
||||
}
|
||||
|
||||
impl PageResponse {
|
||||
pub fn full(html: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mode: PageMode::Full,
|
||||
html: html.into(),
|
||||
title: None,
|
||||
fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn partial(html: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mode: PageMode::Partial,
|
||||
html: html.into(),
|
||||
title: None,
|
||||
fingerprint: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(mut self, title: impl Into<String>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn fingerprint(mut self, fingerprint: BuildFingerprint) -> Self {
|
||||
self.fingerprint = Some(fingerprint);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct EffectResponse {
|
||||
pub batch: EffectBatch,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct InteractionForm {
|
||||
pub handle_id: u32,
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct HandlerRegistry {
|
||||
fingerprint: BuildFingerprint,
|
||||
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InteractionFormRejection {
|
||||
InvalidBody,
|
||||
MissingHandle,
|
||||
InvalidHandle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DispatchRejection {
|
||||
UnknownHandle(u32),
|
||||
}
|
||||
|
||||
impl EffectResponse {
|
||||
pub fn new(effects: impl IntoEffect, fingerprint: BuildFingerprint) -> Self {
|
||||
Self {
|
||||
batch: effects.into_batch(fingerprint),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractionForm {
|
||||
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
|
||||
Self {
|
||||
handle_id,
|
||||
fields: fields.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
|
||||
let fields = parse_urlencoded_pairs(body)?;
|
||||
let Some(handle) = fields
|
||||
.iter()
|
||||
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
|
||||
else {
|
||||
return Err(InteractionFormRejection::MissingHandle);
|
||||
};
|
||||
let handle_id = handle
|
||||
.parse::<u32>()
|
||||
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
|
||||
Ok(Self { handle_id, fields })
|
||||
}
|
||||
|
||||
pub fn value(&self, name: &str) -> Option<&str> {
|
||||
self.fields
|
||||
.iter()
|
||||
.find_map(|(field, value)| (field == name).then_some(value.as_str()))
|
||||
}
|
||||
|
||||
pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> + 'a {
|
||||
self.fields
|
||||
.iter()
|
||||
.filter_map(move |(field, value)| (field == name).then_some(value.as_str()))
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> &[(String, String)] {
|
||||
&self.fields
|
||||
}
|
||||
}
|
||||
|
||||
impl HandlerRegistry {
|
||||
pub const fn new(fingerprint: BuildFingerprint) -> Self {
|
||||
Self {
|
||||
fingerprint,
|
||||
handlers: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register<E>(
|
||||
mut self,
|
||||
handle_id: u32,
|
||||
handler: impl Fn(InteractionForm) -> E + Send + Sync + 'static,
|
||||
) -> Self
|
||||
where
|
||||
E: IntoEffect,
|
||||
{
|
||||
let fingerprint = self.fingerprint;
|
||||
self.handlers.insert(
|
||||
handle_id,
|
||||
Box::new(move |form| handler(form).into_batch(fingerprint)),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dispatch(&self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
|
||||
let handle_id = form.handle_id;
|
||||
let Some(handler) = self.handlers.get(&handle_id) else {
|
||||
return Err(DispatchRejection::UnknownHandle(handle_id));
|
||||
};
|
||||
Ok(EffectResponse {
|
||||
batch: handler(form),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn contains(&self, handle_id: u32) -> bool {
|
||||
self.handlers.contains_key(&handle_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for InteractionFormRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let (status, message) = match self {
|
||||
Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid urlencoded slhx form body"),
|
||||
Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h slhx handle field"),
|
||||
Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h slhx handle field"),
|
||||
};
|
||||
(status, message).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequest<S> for InteractionForm
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = InteractionFormRejection;
|
||||
|
||||
async fn from_request(req: Request<Body>, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
let bytes = to_bytes(req.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.map_err(|_| InteractionFormRejection::InvalidBody)?;
|
||||
Self::parse_urlencoded(&bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl PageMode {
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
|
||||
Some("1" | "true") => Self::Partial,
|
||||
_ => Self::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for PageResponse {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let html = match self.fingerprint {
|
||||
Some(fingerprint) => html_with_root_fingerprint(self.html, fingerprint),
|
||||
None => self.html,
|
||||
};
|
||||
let mut response = Response::new(Body::from(html));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
if self.mode == PageMode::Partial {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SLHX_PARTIAL_HEADER, HeaderValue::from_static("true"));
|
||||
}
|
||||
if let Some(fingerprint) = self.fingerprint.and_then(fingerprint_header) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||
}
|
||||
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) {
|
||||
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
fn html_with_root_fingerprint(mut html: String, fingerprint: BuildFingerprint) -> String {
|
||||
if html.contains("data-slhx-fp=") {
|
||||
return html;
|
||||
}
|
||||
let Some(root_attr) = html.find("data-slhx-root") else {
|
||||
return html;
|
||||
};
|
||||
let Some(tag_start) = html[..root_attr].rfind('<') else {
|
||||
return html;
|
||||
};
|
||||
let Some(tag_end) = html[tag_start..].find('>') else {
|
||||
return html;
|
||||
};
|
||||
let insert_at = tag_start + tag_end;
|
||||
html.insert_str(insert_at, &format!(" data-slhx-fp=\"{}\"", fingerprint.0));
|
||||
html
|
||||
}
|
||||
|
||||
fn fingerprint_header(fingerprint: BuildFingerprint) -> Option<HeaderValue> {
|
||||
HeaderValue::from_str(&fingerprint.0.to_string()).ok()
|
||||
}
|
||||
|
||||
impl IntoResponse for EffectResponse {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let bytes = self.batch.to_wire();
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(SLHX_CONTENT_TYPE),
|
||||
);
|
||||
if let Some(fingerprint) = fingerprint_header(self.batch.fingerprint) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for DispatchRejection {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Self::UnknownHandle(handle_id) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
format!("unknown slhx handle id {handle_id}"),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for RuntimeJs {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let mut response = Response::new(Body::from(slhx_js::RUNTIME_JS));
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static(SLHX_RUNTIME_CONTENT_TYPE),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_js_source() -> &'static str {
|
||||
slhx_js::RUNTIME_JS
|
||||
}
|
||||
|
||||
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, InteractionFormRejection> {
|
||||
if body.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
body.split(|byte| *byte == b'&')
|
||||
.map(|pair| {
|
||||
let equals = pair.iter().position(|byte| *byte == b'=');
|
||||
let (name, value) = match equals {
|
||||
Some(index) => (&pair[..index], &pair[index + 1..]),
|
||||
None => (pair, &[][..]),
|
||||
};
|
||||
Ok((percent_decode(name)?, percent_decode(value)?))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn percent_decode(input: &[u8]) -> Result<String, InteractionFormRejection> {
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut i = 0;
|
||||
while i < input.len() {
|
||||
match input[i] {
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < input.len() => {
|
||||
let high = hex(input[i + 1]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
let low = hex(input[i + 2]).ok_or(InteractionFormRejection::InvalidBody)?;
|
||||
out.push((high << 4) | low);
|
||||
i += 3;
|
||||
}
|
||||
b'%' => return Err(InteractionFormRejection::InvalidBody),
|
||||
byte => {
|
||||
out.push(byte);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody)
|
||||
}
|
||||
|
||||
fn hex(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{html_with_root_fingerprint, BuildFingerprint};
|
||||
|
||||
#[test]
|
||||
fn root_fingerprint_is_added_to_initial_root() {
|
||||
let html = html_with_root_fingerprint(
|
||||
"<html><body><main data-slhx-root>Docs</main></body></html>".into(),
|
||||
BuildFingerprint(99),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
html,
|
||||
"<html><body><main data-slhx-root data-slhx-fp=\"99\">Docs</main></body></html>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_root_fingerprint_is_preserved() {
|
||||
let html = html_with_root_fingerprint(
|
||||
"<main data-slhx-root data-slhx-fp=\"1\">Docs</main>".into(),
|
||||
BuildFingerprint(99),
|
||||
);
|
||||
|
||||
assert_eq!(html, "<main data-slhx-root data-slhx-fp=\"1\">Docs</main>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use axum::http::{header, HeaderMap};
|
||||
use axum::response::IntoResponse;
|
||||
use slhx_axum::{
|
||||
runtime_js, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
|
||||
InteractionFormRejection, PageMode, PageRequest, PageResponse, SLHX_CONTENT_TYPE,
|
||||
SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE, SLHX_TITLE_HEADER,
|
||||
};
|
||||
use slhx_core::{push, BuildFingerprint, Slot};
|
||||
|
||||
#[test]
|
||||
fn page_mode_detects_partial_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Full);
|
||||
|
||||
headers.insert(SLHX_PARTIAL_HEADER, "true".parse().unwrap());
|
||||
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
|
||||
let full = PageRequest { mode: PageMode::Full }.page("<main>Docs</main>", |content| {
|
||||
format!("<html>{content}</html>")
|
||||
});
|
||||
assert_eq!(full.mode, PageMode::Full);
|
||||
assert_eq!(full.html, "<html><main>Docs</main></html>");
|
||||
|
||||
let partial = PageRequest { mode: PageMode::Partial }.page("<main>Docs</main>", |content| {
|
||||
format!("<html>{content}</html>")
|
||||
});
|
||||
assert_eq!(partial.mode, PageMode::Partial);
|
||||
assert_eq!(partial.html, "<main>Docs</main>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_page_response_sets_partial_and_title_headers() {
|
||||
let response = PageResponse::partial("<main>Docs</main>")
|
||||
.title("Docs")
|
||||
.into_response();
|
||||
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
assert_eq!(response.headers()[SLHX_PARTIAL_HEADER], "true");
|
||||
assert_eq!(response.headers()[SLHX_TITLE_HEADER], "Docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_response_is_wire_batch_with_fingerprint_header() {
|
||||
let response = EffectResponse::new(push("/docs"), BuildFingerprint(99)).into_response();
|
||||
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_CONTENT_TYPE);
|
||||
assert_eq!(response.headers()[SLHX_FINGERPRINT_HEADER], "99");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_form_parses_handle_and_fields() {
|
||||
let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(form.handle_id, 42);
|
||||
assert_eq!(form.value("title"), Some("Hello World"));
|
||||
assert_eq!(form.values("tag").collect::<Vec<_>>(), ["a", "b/c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interaction_form_requires_numeric_handle() {
|
||||
assert_eq!(
|
||||
InteractionForm::parse_urlencoded(b"title=Hello").unwrap_err(),
|
||||
InteractionFormRejection::MissingHandle
|
||||
);
|
||||
assert_eq!(
|
||||
InteractionForm::parse_urlencoded(b"__h=nope").unwrap_err(),
|
||||
InteractionFormRejection::InvalidHandle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_dispatches_by_numeric_handle_id() {
|
||||
let title = Slot::<String>::new(7);
|
||||
let registry = HandlerRegistry::new(BuildFingerprint(123)).register(42, move |form| {
|
||||
title.text(form.value("title").unwrap_or(""))
|
||||
});
|
||||
|
||||
let response = registry
|
||||
.dispatch(InteractionForm::new(42, [("title".into(), "Hello".into())]))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.batch.fingerprint, BuildFingerprint(123));
|
||||
assert_eq!(response.batch.ops, vec![title.text("Hello")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_registry_rejects_unknown_handle_ids() {
|
||||
let registry = HandlerRegistry::new(BuildFingerprint(123));
|
||||
|
||||
assert_eq!(
|
||||
registry.dispatch(InteractionForm::new(9, [])).unwrap_err(),
|
||||
DispatchRejection::UnknownHandle(9)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_js_response_serves_embedded_runtime() {
|
||||
let response = runtime_js().into_response();
|
||||
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], SLHX_RUNTIME_CONTENT_TYPE);
|
||||
assert!(response.headers().contains_key(header::CACHE_CONTROL));
|
||||
}
|
||||
Reference in New Issue
Block a user