From 42ede63361e3951b0168678f755c60a6cf5996d9 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Tue, 2 Jun 2026 00:19:38 +0200 Subject: [PATCH] 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 --- examples/kanban/src/main.rs | 13 ++++--- examples/techdemo/src/main.rs | 12 +++---- examples/v0/src/main.rs | 12 ++++--- slhx-axum/src/lib.rs | 52 ++++++++++++++++++++++++++++ slhx-axum/tests/response.rs | 24 +++++++++++-- slhx-test/tests/examples_contract.rs | 2 ++ 6 files changed, 96 insertions(+), 19 deletions(-) diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index bfba402..7fd2260 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -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>, - form: InteractionForm, + request: InteractionRequest, ) -> Result { - 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>) -> impl IntoResp sse(batches) } -fn registry(state: Arc) -> HandlerRegistry { - HandlerRegistry::new(ui::BUILD_FINGERPRINT) +fn registry(state: Arc) -> impl DispatchRegistry { + handlers(ui::BUILD_FINGERPRINT) .register_handle(handles::create_card, { let state = state.clone(); move |form| { diff --git a/examples/techdemo/src/main.rs b/examples/techdemo/src/main.rs index 0e0b8d4..7d30479 100644 --- a/examples/techdemo/src/main.rs +++ b/examples/techdemo/src/main.rs @@ -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>, - form: InteractionForm, + request: InteractionRequest, ) -> Result { - 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>) -> impl IntoResp sse(batches) } -fn registry(shared: Arc) -> HandlerRegistry { - HandlerRegistry::new(ui::BUILD_FINGERPRINT) +fn registry(shared: Arc) -> impl DispatchRegistry { + handlers(ui::BUILD_FINGERPRINT) .register_handle(handles::launch_work, { let shared = shared.clone(); move |form| { diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index d45ff2b..2377cee 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -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>, - form: InteractionForm, + request: InteractionRequest, ) -> Result { - registry(state).dispatch(form) + request.dispatch(registry(state)) } async fn runtime() -> impl IntoResponse { @@ -121,8 +123,8 @@ async fn events(Query(params): Query>) -> impl IntoResp sse(batches) } -fn registry(state: Arc) -> HandlerRegistry { - HandlerRegistry::new(ui::BUILD_FINGERPRINT) +fn registry(state: Arc) -> impl DispatchRegistry { + handlers(ui::BUILD_FINGERPRINT) .register_handle(counter_handles::increment, { let state = state.clone(); move |_| { diff --git a/slhx-axum/src/lib.rs b/slhx-axum/src/lib.rs index 7e0ac19..d94372b 100644 --- a/slhx-axum/src/lib.rs +++ b/slhx-axum/src/lib.rs @@ -136,6 +136,15 @@ pub struct InteractionForm { files: Vec, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractionRequest { + form: InteractionForm, +} + +pub trait DispatchRegistry { + fn dispatch_form(self, form: InteractionForm) -> Result; +} + pub struct HandlerRegistry { fingerprint: BuildFingerprint, handlers: BTreeMap 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 { + registry.dispatch_form(self.form) + } + + pub fn form(&self) -> &InteractionForm { + &self.form + } +} + +impl From 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 { + 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 FromRequest for InteractionRequest +where + S: Send + Sync, +{ + type Rejection = InteractionFormRejection; + + async fn from_request(req: Request, state: &S) -> Result { + InteractionForm::from_request(req, state) + .await + .map(|form| Self { form }) + } +} + #[async_trait] impl FromRequest for InteractionForm where diff --git a/slhx-axum/tests/response.rs b/slhx-axum/tests/response.rs index c5000cd..6ea3423 100644 --- a/slhx-axum/tests/response.rs +++ b/slhx-axum/tests/response.rs @@ -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::::new(3).text("ok") + })) + .unwrap(); + + assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); + assert_eq!(response.batch.ops, vec![Slot::::new(3).text("ok")]); +} + #[test] fn handler_registry_dispatches_by_checked_handle() { // req: ceremony/004 req: public_api/001 diff --git a/slhx-test/tests/examples_contract.rs b/slhx-test/tests/examples_contract.rs index c5d8623..d8fe33a 100644 --- a/slhx-test/tests/examples_contract.rs +++ b/slhx-test/tests/examples_contract.rs @@ -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'", ];