feat(api): streamline generated app authoring

Move the canonical examples toward generated component-root helpers, typed form decoding, async/state handler registration, and derive-driven app/component registry wiring. Tighten requirements and diagnostics for the server-first, selectorless authoring path.

Verified with cargo run -p slhx-xtask -- test, cargo check --workspace, redgate list, redgate refs, redgate health --strict, and git diff --check.

req: canonical/001

req: canonical/003

req: canonical/004

req: dx/002

req: derive_app/001

req: component/003

req: form/004

req: axum_integration/003
This commit is contained in:
slhx agent
2026-06-05 06:33:41 +02:00
parent eb6086616c
commit d4e865ef92
34 changed files with 4573 additions and 1156 deletions
+647 -23
View File
@@ -1,13 +1,17 @@
use axum::async_trait;
use axum::body::{to_bytes, Body};
pub use axum::extract::State;
use axum::extract::{FromRequest, FromRequestParts, Multipart};
use axum::http::{header, request::Parts, HeaderMap, HeaderValue, Request, Response, StatusCode};
use axum::response::sse::{Event, Sse};
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::response::IntoResponse;
use futures_util::{Stream, StreamExt};
use slhx_core::{BuildFingerprint, EffectBatch, Handle, IntoEffect, SafeHtml};
use slhx_core::{BuildFingerprint, EffectBatch, FromForm, Handle, IntoEffect, SafeHtml};
use std::collections::BTreeMap;
use std::convert::Infallible;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
pub const SLHX_PARTIAL_HEADER: &str = "x-slhx-partial";
pub const SLHX_FINGERPRINT_HEADER: &str = "x-slhx-fingerprint";
@@ -54,7 +58,11 @@ impl PageRequest {
matches!(self.mode, PageMode::Partial)
}
pub fn page(self, partial_html: impl Into<String>, shell: impl FnOnce(String) -> String) -> PageResponse {
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)),
@@ -62,14 +70,14 @@ impl PageRequest {
}
}
pub fn page_html(
self,
partial_html: SafeHtml,
shell: impl FnOnce(SafeHtml) -> SafeHtml,
) -> PageResponse {
pub fn page_html<P, S>(self, partial_html: P, shell: impl FnOnce(P) -> S) -> PageResponse
where
P: Into<SafeHtml>,
S: Into<SafeHtml>,
{
match self.mode {
PageMode::Full => PageResponse::full(shell(partial_html).into_string()),
PageMode::Partial => PageResponse::partial(partial_html.into_string()),
PageMode::Full => PageResponse::full(shell(partial_html).into().into_string()),
PageMode::Partial => PageResponse::partial(partial_html.into().into_string()),
}
}
}
@@ -145,9 +153,73 @@ pub trait DispatchRegistry {
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
}
pub trait FromInteractionForm: Sized {
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Form<T>(pub T);
impl<T> Form<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> std::ops::Deref for Form<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> FromInteractionForm for T
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
impl<T> FromInteractionForm for Form<T>
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map(Self)
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
pub trait FromHandlerState<S>: Sized {
fn from_handler_state(state: S) -> Self;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FormDecodeError {
message: String,
}
type SyncHandler =
Box<dyn Fn(InteractionForm) -> Result<EffectBatch, DispatchRejection> + Send + Sync>;
type HandlerFuture = Pin<Box<dyn Future<Output = Result<EffectBatch, DispatchRejection>> + Send>>;
type AsyncHandler = Box<dyn Fn(InteractionForm) -> HandlerFuture + Send + Sync>;
pub struct HandlerRegistry {
fingerprint: BuildFingerprint,
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
handlers: BTreeMap<u32, SyncHandler>,
async_handlers: BTreeMap<u32, AsyncHandler>,
}
pub type Registry = HandlerRegistry;
pub struct StateHandlerRegistry<S> {
registry: HandlerRegistry,
state: S,
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -160,6 +232,8 @@ pub enum InteractionFormRejection {
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DispatchRejection {
UnknownHandle(u32),
InvalidForm { handle_id: u32, message: String },
HandlerError { handle_id: u32, message: String },
}
impl EffectResponse {
@@ -170,6 +244,30 @@ impl EffectResponse {
}
}
impl<S> FromHandlerState<S> for S {
fn from_handler_state(state: S) -> Self {
state
}
}
impl<S> FromHandlerState<S> for State<S> {
fn from_handler_state(state: S) -> Self {
State(state)
}
}
impl FormDecodeError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl InteractionForm {
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
@@ -179,7 +277,10 @@ impl InteractionForm {
}
}
pub fn for_handle<I>(handle: Handle<I>, fields: impl IntoIterator<Item = (String, String)>) -> Self {
pub fn for_handle<I>(
handle: Handle<I>,
fields: impl IntoIterator<Item = (String, String)>,
) -> Self {
Self::new(handle.id().id, fields)
}
@@ -188,7 +289,9 @@ impl InteractionForm {
}
// req: multipart/001, req: multipart/002
pub async fn parse_multipart(mut multipart: Multipart) -> Result<Self, InteractionFormRejection> {
pub async fn parse_multipart(
mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> {
let mut fields = Vec::new();
let mut files = Vec::new();
@@ -224,7 +327,10 @@ impl InteractionForm {
Self::from_parts(fields, files)
}
fn from_parts(fields: Vec<(String, String)>, files: Vec<InteractionFile>) -> Result<Self, InteractionFormRejection> {
fn from_parts(
fields: Vec<(String, String)>,
files: Vec<InteractionFile>,
) -> Result<Self, InteractionFormRejection> {
let Some(handle) = fields
.iter()
.find_map(|(name, value)| (name == SLHX_HANDLE_FIELD).then_some(value))
@@ -234,7 +340,11 @@ impl InteractionForm {
let handle_id = handle
.parse::<u32>()
.map_err(|_| InteractionFormRejection::InvalidHandle)?;
Ok(Self { handle_id, fields, files })
Ok(Self {
handle_id,
fields,
files,
})
}
pub fn value(&self, name: &str) -> Option<&str> {
@@ -267,6 +377,20 @@ impl InteractionForm {
pub fn file(&self, name: &str) -> Option<&InteractionFile> {
self.files.iter().find(|file| file.name == name)
}
pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> {
self.value(name)
.ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`")))
}
pub fn parse_required<T>(&self, name: &str) -> Result<T, FormDecodeError>
where
T: std::str::FromStr,
{
self.required(name)?
.parse()
.map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`")))
}
}
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
@@ -285,6 +409,13 @@ impl InteractionRequest {
registry.dispatch_form(self.form)
}
pub async fn dispatch_async(
self,
registry: HandlerRegistry,
) -> Result<EffectResponse, DispatchRejection> {
registry.dispatch_async(self.form).await
}
pub fn form(&self) -> &InteractionForm {
&self.form
}
@@ -301,6 +432,7 @@ impl HandlerRegistry {
Self {
fingerprint,
handlers: BTreeMap::new(),
async_handlers: BTreeMap::new(),
}
}
@@ -315,7 +447,240 @@ impl HandlerRegistry {
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |form| handler(form).into_batch(fingerprint)),
Box::new(move |form| Ok(handler(form).into_batch(fingerprint))),
);
self
}
pub fn register_typed<T, E>(
mut self,
handle_id: u32,
handler: impl Fn(T) -> E + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = T::from_interaction_form(&form).map_err(|error| {
DispatchRejection::InvalidForm {
handle_id,
message: error.message,
}
})?;
Ok(handler(input).into_batch(fingerprint))
}),
);
self
}
pub fn register_state<S, C, E>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |_| {
Ok(handler(C::from_handler_state(state.clone())).into_batch(fingerprint))
}),
);
self
}
pub fn register_state_typed<S, C, T, E>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = T::from_interaction_form(&form).map_err(|error| {
DispatchRejection::InvalidForm {
handle_id,
message: error.message,
}
})?;
Ok(handler(C::from_handler_state(state.clone()), input).into_batch(fingerprint))
}),
);
self
}
pub fn register_async<E, F>(
mut self,
handle_id: u32,
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
) -> Self
where
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let future = handler(form);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_typed_async<T, E, F>(
mut self,
handle_id: u32,
handler: impl Fn(T) -> F + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(input);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_async<S, C, E, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |_| {
let future = handler(C::from_handler_state(state.clone()));
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_typed_async<S, C, T, E, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(C::from_handler_state(state.clone()), input);
Box::pin(async move { Ok(future.await.into_batch(fingerprint)) })
}),
);
self
}
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
let fingerprint = self.fingerprint;
self.async_handlers.insert(
handle_id,
Box::new(move |form| {
let handle_id = form.handle_id;
let input = match T::from_interaction_form(&form) {
Ok(input) => input,
Err(error) => {
return Box::pin(async move {
Err(DispatchRejection::InvalidForm {
handle_id,
message: error.message,
})
});
}
};
let future = handler(C::from_handler_state(state.clone()), input);
Box::pin(async move {
future
.await
.map(|effects| effects.into_batch(fingerprint))
.map_err(|error| DispatchRejection::HandlerError {
handle_id,
message: error.to_string(),
})
})
}),
);
self
}
@@ -331,6 +696,16 @@ impl HandlerRegistry {
self.register(handle.id().id, handler)
}
pub fn with_state<S>(self, state: S) -> StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
StateHandlerRegistry {
registry: self,
state,
}
}
pub fn on<I, E>(
self,
handle: Handle<I>,
@@ -342,18 +717,243 @@ impl HandlerRegistry {
self.register_handle(handle, handler)
}
pub fn on_form<I, T, E>(
self,
handle: Handle<I>,
handler: impl Fn(T) -> E + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
E: IntoEffect,
{
self.register_typed(handle.id().id, handler)
}
pub fn on_state<I, S, C, E>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
E: IntoEffect,
{
self.register_state(handle.id().id, state, handler)
}
pub fn on_state_form<I, S, C, T, E>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
self.register_state_typed(handle.id().id, state, handler)
}
pub fn on_async<I, E, F>(
self,
handle: Handle<I>,
handler: impl Fn(InteractionForm) -> F + Send + Sync + 'static,
) -> Self
where
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_async(handle.id().id, handler)
}
pub fn on_form_async<I, T, E, F>(
self,
handle: Handle<I>,
handler: impl Fn(T) -> F + Send + Sync + 'static,
) -> Self
where
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_typed_async(handle.id().id, handler)
}
pub fn on_state_async<I, S, C, E, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_state_async(handle.id().id, state, handler)
}
pub fn on_state_form_async<I, S, C, T, E, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.register_state_typed_async(handle.id().id, state, handler)
}
pub fn on_state_form_async_result<I, S, C, T, E, O, F>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
self.register_state_typed_async_result(handle.id().id, state, handler)
}
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),
batch: handler(form)?,
})
}
pub async fn dispatch_async(
&self,
form: InteractionForm,
) -> Result<EffectResponse, DispatchRejection> {
let handle_id = form.handle_id;
if let Some(handler) = self.async_handlers.get(&handle_id) {
return Ok(EffectResponse {
batch: handler(form).await?,
});
}
self.dispatch(form)
}
pub fn contains(&self, handle_id: u32) -> bool {
self.handlers.contains_key(&handle_id)
self.handlers.contains_key(&handle_id) || self.async_handlers.contains_key(&handle_id)
}
}
impl<S> StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
pub fn on_state<I, C, E>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> E + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
E: IntoEffect,
{
self.registry = self.registry.on_state(handle, self.state.clone(), handler);
self
}
pub fn on<I, C, T, E>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> E + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_form(handle, self.state.clone(), handler);
self
}
pub fn on_state_async<I, C, E, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_async(handle, self.state.clone(), handler);
self
}
pub fn on_async<I, C, T, E, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = E> + Send + 'static,
E: IntoEffect,
{
self.registry = self
.registry
.on_state_form_async(handle, self.state.clone(), handler);
self
}
pub fn on_async_result<I, C, T, E, O, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: fmt::Display,
{
self.registry =
self.registry
.on_state_form_async_result(handle, self.state.clone(), handler);
self
}
pub fn into_registry(self) -> HandlerRegistry {
self.registry
}
}
impl<S> DispatchRegistry for StateHandlerRegistry<S>
where
S: Clone + Send + Sync + 'static,
{
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
self.registry.dispatch_form(form)
}
}
@@ -424,7 +1024,10 @@ fn is_multipart(headers: &HeaderMap) -> bool {
impl PageMode {
pub fn from_headers(headers: &HeaderMap) -> Self {
match headers.get(SLHX_PARTIAL_HEADER).and_then(|value| value.to_str().ok()) {
match headers
.get(SLHX_PARTIAL_HEADER)
.and_then(|value| value.to_str().ok())
{
Some("1" | "true") => Self::Partial,
_ => Self::Full,
}
@@ -452,7 +1055,10 @@ impl IntoResponse for PageResponse {
.headers_mut()
.insert(SLHX_FINGERPRINT_HEADER, fingerprint);
}
if let Some(title) = self.title.and_then(|title| HeaderValue::from_str(&title).ok()) {
if let Some(title) = self
.title
.and_then(|title| HeaderValue::from_str(&title).ok())
{
response.headers_mut().insert(SLHX_TITLE_HEADER, title);
}
response
@@ -506,6 +1112,16 @@ impl IntoResponse for DispatchRejection {
format!("unknown slhx handle id {handle_id}"),
)
.into_response(),
Self::InvalidForm { handle_id, message } => (
StatusCode::BAD_REQUEST,
format!("invalid slhx form for handle id {handle_id}: {message}"),
)
.into_response(),
Self::HandlerError { handle_id, message } => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("slhx handler for handle id {handle_id} failed: {message}"),
)
.into_response(),
}
}
}
@@ -535,7 +1151,7 @@ where
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
E: Into<axum::BoxError>,
{
Sse::new(batches.map(|batch| batch.map(sse_event)))
Sse::new(batches.map(|batch| batch.map(sse_event))).keep_alive(KeepAlive::default())
}
pub fn sse_event(batch: EffectBatch) -> Event {
@@ -630,8 +1246,16 @@ fn hex(byte: u8) -> Option<u8> {
#[cfg(test)]
mod tests {
use super::{encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, SLHX_SSE_EVENT};
use axum::{body::{to_bytes, Body}, extract::FromRequest, http::{header, Request}, response::IntoResponse};
use super::{
encode_sse_batch, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
SLHX_SSE_EVENT,
};
use axum::{
body::{to_bytes, Body},
extract::FromRequest,
http::{header, Request},
response::IntoResponse,
};
use futures_util::stream;
use scraper::{Html, Selector};
use slhx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION};
+231 -28
View File
@@ -1,18 +1,50 @@
use axum::extract::State;
use axum::http::{header, HeaderMap};
use axum::response::IntoResponse;
use scraper::{Html, Selector};
use slhx_axum::{
interactions, runtime_js, DispatchRejection, EffectResponse, InteractionForm,
interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm,
InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse,
SLHX_CONTENT_TYPE, SLHX_FINGERPRINT_HEADER, SLHX_PARTIAL_HEADER, SLHX_RUNTIME_CONTENT_TYPE,
SLHX_TITLE_HEADER,
};
use slhx_core::{push, BuildFingerprint, Handle, SafeHtml, Slot};
use slhx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot};
fn selector(value: &str) -> Selector {
Selector::parse(value).expect("test selector parses")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ProjectId(u32);
impl std::str::FromStr for ProjectId {
type Err = std::num::ParseIntError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
value.parse().map(ProjectId)
}
}
struct OpenProject {
project_id: ProjectId,
}
impl slhx_core::FromForm for OpenProject {
fn from_form_fields(fields: &[(String, String)]) -> Result<Self, slhx_core::FormError> {
let Some(value) = fields
.iter()
.find_map(|(name, value)| (name == "project_id").then_some(value.as_str()))
else {
return Err(slhx_core::FormError::new("missing form field `project_id`"));
};
Ok(Self {
project_id: value
.parse()
.map_err(|_| slhx_core::FormError::new("invalid form field `project_id`"))?,
})
}
}
#[test]
fn page_mode_detects_partial_header() {
let mut headers = HeaderMap::new();
@@ -25,27 +57,43 @@ fn page_mode_detects_partial_header() {
#[test]
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
// req: test/005
let full = PageRequest { mode: PageMode::Full }.page(
"<main data-page=\"docs\">Docs</main>",
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
);
let full = PageRequest {
mode: PageMode::Full,
}
.page("<main data-page=\"docs\">Docs</main>", |content| {
format!("<html><body data-shell=\"docs\">{content}</body></html>")
});
assert_eq!(full.mode, PageMode::Full);
let full_document = Html::parse_document(&full.html);
assert_eq!(
full_document
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
.select(&selector(
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
))
.count(),
1
);
let partial = PageRequest { mode: PageMode::Partial }.page(
"<main data-page=\"docs\">Docs</main>",
|content| format!("<html><body data-shell=\"docs\">{content}</body></html>"),
);
let partial = PageRequest {
mode: PageMode::Partial,
}
.page("<main data-page=\"docs\">Docs</main>", |content| {
format!("<html><body data-shell=\"docs\">{content}</body></html>")
});
assert_eq!(partial.mode, PageMode::Partial);
let partial_fragment = Html::parse_fragment(&partial.html);
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
assert_eq!(
partial_fragment
.select(&selector("main[data-page=\"docs\"]"))
.count(),
1
);
assert_eq!(
partial_fragment
.select(&selector("body[data-shell=\"docs\"]"))
.count(),
0
);
}
#[test]
@@ -66,7 +114,9 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
let full_document = Html::parse_document(&full.html);
assert_eq!(
full_document
.select(&selector("body[data-shell=\"docs\"] main[data-page=\"docs\"]"))
.select(&selector(
"body[data-shell=\"docs\"] main[data-page=\"docs\"]"
))
.count(),
1
);
@@ -84,8 +134,18 @@ fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
);
assert_eq!(partial.mode, PageMode::Partial);
let partial_fragment = Html::parse_fragment(&partial.html);
assert_eq!(partial_fragment.select(&selector("main[data-page=\"docs\"]")).count(), 1);
assert_eq!(partial_fragment.select(&selector("body[data-shell=\"docs\"]")).count(), 0);
assert_eq!(
partial_fragment
.select(&selector("main[data-page=\"docs\"]"))
.count(),
1
);
assert_eq!(
partial_fragment
.select(&selector("body[data-shell=\"docs\"]"))
.count(),
0
);
}
#[test]
@@ -94,7 +154,10 @@ fn partial_page_response_sets_partial_and_title_headers() {
.title("Docs")
.into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8");
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");
}
@@ -109,8 +172,8 @@ fn effect_response_is_wire_batch_with_fingerprint_header() {
#[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();
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"));
@@ -120,8 +183,8 @@ fn interaction_form_parses_handle_and_fields() {
#[test]
fn interaction_form_parses_typed_values() {
// req: form/004 req: dx/003
let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope")
.expect("form should parse");
let form =
InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
assert_eq!(form.parse::<u32>("count"), Some(7));
assert_eq!(form.parse::<u32>("bad"), None);
@@ -143,8 +206,7 @@ fn interaction_form_requires_numeric_handle() {
#[test]
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
// req: auth/004
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello")
.unwrap();
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
assert_eq!(form.handle_id, 42);
assert_eq!(form.value("csrf_token"), Some("abc123"));
@@ -159,15 +221,151 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
Vec::new(),
));
let response = request
.dispatch(interactions(BuildFingerprint(4)).on(Handle::<()>::new(7), |_| {
Slot::<String>::new(3).text("ok")
}))
.dispatch(
interactions(BuildFingerprint(4))
.on(Handle::<()>::new(7), |_| Slot::<String>::new(3).text("ok")),
)
.unwrap();
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text("ok")]);
}
#[test]
fn interaction_request_dispatches_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "42".to_owned())],
));
let response = request
.dispatch(
interactions(BuildFingerprint(4))
.on_form(Handle::<()>::new(8), |input: OpenProject| {
Slot::<String>::new(3).text(input.project_id.0)
}),
)
.unwrap();
assert_eq!(response.batch.fingerprint, BuildFingerprint(4));
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[test]
fn interaction_request_rejects_invalid_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "nope".to_owned())],
));
let rejection = request
.dispatch(
interactions(BuildFingerprint(4))
.on_form(Handle::<()>::new(8), |input: OpenProject| {
Slot::<String>::new(3).text(input.project_id.0)
}),
)
.unwrap_err();
assert!(matches!(
rejection,
DispatchRejection::InvalidForm { handle_id: 8, .. }
));
}
#[test]
fn interaction_request_dispatches_typed_state_handlers() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(9),
vec![("project_id".to_owned(), "21".to_owned())],
));
let response = request
.dispatch(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on(Handle::<()>::new(9), open_project),
)
.unwrap();
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[derive(Debug)]
struct HandlerBoom;
impl std::fmt::Display for HandlerBoom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("database unavailable")
}
}
impl std::error::Error for HandlerBoom {}
#[tokio::test]
async fn interaction_request_dispatches_async_typed_state_extractors() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
async fn open_project(
State(multiplier): State<u32>,
Form(input): Form<OpenProject>,
) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(10),
vec![("project_id".to_owned(), "21".to_owned())],
));
let response = request
.dispatch_async(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on_async(Handle::<()>::new(10), open_project)
.into_registry(),
)
.await
.unwrap();
assert_eq!(response.batch.ops, vec![Slot::<String>::new(3).text(42)]);
}
#[tokio::test]
async fn interaction_request_reports_async_result_handler_errors() {
// req: axum_integration/003 req: form/004 req: failure/003
async fn open_project(
State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>,
) -> Result<impl IntoEffect, HandlerBoom> {
Err::<(), _>(HandlerBoom)
}
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(11),
vec![("project_id".to_owned(), "21".to_owned())],
));
let error = request
.dispatch_async(
interactions(BuildFingerprint(4))
.with_state(2_u32)
.on_async_result(Handle::<()>::new(11), open_project)
.into_registry(),
)
.await
.unwrap_err();
assert_eq!(
error,
DispatchRejection::HandlerError {
handle_id: 11,
message: "database unavailable".to_owned(),
}
);
}
#[test]
fn interactions_dispatch_by_checked_handle() {
// req: ceremony/004 req: public_api/001
@@ -193,7 +391,9 @@ fn interactions_reject_unknown_handle_ids() {
let request = InteractionRequest::from(InteractionForm::new(9, []));
assert_eq!(
request.dispatch(interactions(BuildFingerprint(123))).unwrap_err(),
request
.dispatch(interactions(BuildFingerprint(123)))
.unwrap_err(),
DispatchRejection::UnknownHandle(9)
);
}
@@ -202,6 +402,9 @@ fn interactions_reject_unknown_handle_ids() {
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_eq!(
response.headers()[header::CONTENT_TYPE],
SLHX_RUNTIME_CONTENT_TYPE
);
assert!(response.headers().contains_key(header::CACHE_CONTROL));
}