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