feat(axum): add concise interaction request dispatch

Wrap extracted interaction forms in an InteractionRequest with a concise dispatch path, add a handlers() helper, and move canonical examples off direct HandlerRegistry/InteractionForm plumbing.

req: axum_integration/003

req: ceremony/001

req: dx/003

req: examples/003
This commit is contained in:
slhx agent
2026-06-02 00:19:38 +02:00
parent 7b7cb7da15
commit 42ede63361
6 changed files with 96 additions and 19 deletions
+8 -5
View File
@@ -5,7 +5,10 @@ use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_axum::{
handlers, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_kanban_example::ui::{self, board as board_ui};
use slhx_kanban_example::ui::board::{forms, handles, slots};
use slhx_kanban_example::ui::board_card::handles as card_handles;
@@ -127,9 +130,9 @@ async fn runtime() -> impl IntoResponse {
async fn interact(
State(state): State<Arc<AppState>>,
form: InteractionForm,
request: InteractionRequest,
) -> Result<EffectResponse, DispatchRejection> {
registry(state).dispatch(form)
request.dispatch(registry(state))
}
// req: push/001 req: push/003 req: examples/001
@@ -148,8 +151,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
fn registry(state: Arc<AppState>) -> HandlerRegistry {
HandlerRegistry::new(ui::BUILD_FINGERPRINT)
fn registry(state: Arc<AppState>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT)
.register_handle(handles::create_card, {
let state = state.clone();
move |form| {
+6 -6
View File
@@ -6,8 +6,8 @@ use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{CssClass, CssClasses, IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{
runtime_js, sse, DispatchRejection, EffectResponse, HandlerRegistry, InteractionForm,
PageRequest,
handlers, runtime_js, sse, DispatchRegistry, DispatchRejection, EffectResponse,
InteractionRequest, PageRequest,
};
use slhx_techdemo::ui;
use slhx_techdemo::ui::control_center::{classes, forms, handles, slots};
@@ -261,9 +261,9 @@ async fn island_js() -> impl IntoResponse {
async fn interact(
State(state): State<Arc<Shared>>,
form: InteractionForm,
request: InteractionRequest,
) -> Result<EffectResponse, DispatchRejection> {
registry(state).dispatch(form)
request.dispatch(registry(state))
}
// req: push/001 req: push/003 req: examples/001
@@ -282,8 +282,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
fn registry(shared: Arc<Shared>) -> HandlerRegistry {
HandlerRegistry::new(ui::BUILD_FINGERPRINT)
fn registry(shared: Arc<Shared>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT)
.register_handle(handles::launch_work, {
let shared = shared.clone();
move |form| {
+7 -5
View File
@@ -5,7 +5,9 @@ use axum::Router;
use futures_util::{stream, StreamExt};
use hemplate::Hemplate;
use slhx::{push, IntoEffect, RenderSlotExt, SafeHtml};
use slhx_axum::{runtime_js, sse, EffectResponse, HandlerRegistry, InteractionForm, PageRequest};
use slhx_axum::{
handlers, runtime_js, sse, DispatchRegistry, EffectResponse, InteractionRequest, PageRequest,
};
use slhx_v0_examples::ui;
use slhx_v0_examples::ui::{auth, counter, notifications, page_swap, todos, wizard};
use slhx_v0_examples::ui::auth::{handles as auth_handles, slots as auth_slots};
@@ -96,9 +98,9 @@ async fn docs(request: PageRequest) -> impl IntoResponse {
async fn interact(
State(state): State<Arc<ExampleState>>,
form: InteractionForm,
request: InteractionRequest,
) -> Result<EffectResponse, impl IntoResponse> {
registry(state).dispatch(form)
request.dispatch(registry(state))
}
async fn runtime() -> impl IntoResponse {
@@ -121,8 +123,8 @@ async fn events(Query(params): Query<BTreeMap<String, String>>) -> impl IntoResp
sse(batches)
}
fn registry(state: Arc<ExampleState>) -> HandlerRegistry {
HandlerRegistry::new(ui::BUILD_FINGERPRINT)
fn registry(state: Arc<ExampleState>) -> impl DispatchRegistry {
handlers(ui::BUILD_FINGERPRINT)
.register_handle(counter_handles::increment, {
let state = state.clone();
move |_| {
+52
View File
@@ -136,6 +136,15 @@ pub struct InteractionForm {
files: Vec<InteractionFile>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionRequest {
form: InteractionForm,
}
pub trait DispatchRegistry {
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
}
pub struct HandlerRegistry {
fingerprint: BuildFingerprint,
handlers: BTreeMap<u32, Box<dyn Fn(InteractionForm) -> EffectBatch + Send + Sync>>,
@@ -253,6 +262,29 @@ impl InteractionForm {
}
}
pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
HandlerRegistry::new(fingerprint)
}
impl InteractionRequest {
pub fn dispatch(
self,
registry: impl DispatchRegistry,
) -> Result<EffectResponse, DispatchRejection> {
registry.dispatch_form(self.form)
}
pub fn form(&self) -> &InteractionForm {
&self.form
}
}
impl From<InteractionForm> for InteractionRequest {
fn from(form: InteractionForm) -> Self {
Self { form }
}
}
impl HandlerRegistry {
pub const fn new(fingerprint: BuildFingerprint) -> Self {
Self {
@@ -303,6 +335,12 @@ impl HandlerRegistry {
}
}
impl DispatchRegistry for HandlerRegistry {
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection> {
self.dispatch(form)
}
}
impl IntoResponse for InteractionFormRejection {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
@@ -314,6 +352,20 @@ impl IntoResponse for InteractionFormRejection {
}
}
#[async_trait]
impl<S> FromRequest<S> for InteractionRequest
where
S: Send + Sync,
{
type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(req, state)
.await
.map(|form| Self { form })
}
}
#[async_trait]
impl<S> FromRequest<S> for InteractionForm
where
+21 -3
View File
@@ -2,9 +2,10 @@ use axum::http::{header, HeaderMap};
use axum::response::IntoResponse;
use scraper::{Html, Selector};
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,
handlers, runtime_js, DispatchRejection, EffectResponse, HandlerRegistry, 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};
@@ -139,6 +140,23 @@ fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
assert!(form.fields().iter().any(|(name, _)| name == "csrf_token"));
}
#[test]
fn interaction_request_dispatches_with_concise_handlers_helper() {
// req: axum_integration/003 req: ceremony/001 req: dx/003
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(7),
Vec::new(),
));
let response = request
.dispatch(handlers(BuildFingerprint(4)).register_handle(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 handler_registry_dispatches_by_checked_handle() {
// req: ceremony/004 req: public_api/001
+2
View File
@@ -63,6 +63,8 @@ fn canonical_examples_do_not_author_low_level_resource_plumbing() {
"::lower(include_str!",
"lower_html(",
"render_html(",
"HandlerRegistry",
"InteractionForm",
"name=\"__h\"",
"name='__h'",
];