From 53480dba5a7f0ebfd1287f9197980320a53b1eec Mon Sep 17 00:00:00 2001 From: slhx agent Date: Fri, 5 Jun 2026 08:36:39 +0200 Subject: [PATCH] feat(hemx): clarify generated authoring path Close the fallible handler and example coherence slice: v0 uses typed todo newtypes, Result handler failure mapping, generated form/partial helpers, and page composition; kanban is explicitly marked as an advanced north-star milestone; raw render is isolated behind hemx::advanced::render. req: canonical_authoring/003 req: failure/004 req: public_api/005 req: examples/004 --- REQUIREMENTS.md | 4 +- examples/kanban/README.md | 4 +- examples/kanban/src/main.rs | 4 +- examples/v0/src/main.rs | 145 ++++++++++++++----- hemx-axum/src/lib.rs | 281 +++++++++++++++++++++++++++++++++--- hemx-axum/tests/response.rs | 90 +++++++++++- hemx-derive/src/lib.rs | 85 ++++++----- hemx/src/lib.rs | 33 +++-- 8 files changed, 536 insertions(+), 110 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 993896c..5605017 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -287,7 +287,7 @@ a `data-*` handle param is statically known or runtime-extracted. 004 If a common UI operation requires raw `EffectWriter`, the public API is considered incomplete. ### req: public_api/005 -005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs. +005 Beginner-facing page/template composition uses generated render or page helpers. Direct `SafeHtml` construction, raw `html(...)`, raw `target(...)`, raw route fragments, `hemx::advanced::render(...)`, and explicit `ui::render(...)` calls are advanced escape hatches and must not appear in beginner examples or docs. Server-rendered page boundaries may use `hemx::page(...)`; handlers and ordinary partial updates must use generated target/form/page commands. --- @@ -872,7 +872,7 @@ async fn add(app: State, form: Form) -> impl IntoEffect async fn rename(app: State, todo_id: TodoId, title: Title) -> impl IntoEffect async fn delete(app: State, todo_id: TodoId) -> impl IntoEffect ``` -`State` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary for integrations to map to form errors, toasts, events, or HTTP responses. +`State` is illustrative integration context; equivalent framework extractors or app references are adapter concerns. Form fields and `data-*` params parse through normal Rust `FromForm`/`FormValue`/`FromStr`-style traits, so domain newtypes remain user-authored. Handlers may return `impl IntoEffect` or `Result` for fallible database/domain work; `IntoEffect` values compose through tuples while `Result` paths preserve a typed error boundary (`IntoHandlerFailure` in the Axum adapter) for integrations to map failures to generated UI effects, toasts, events, or HTTP responses. Result-specific registry adapters are generated/integration internals; canonical app code uses the same `#[hemx::handler]` and `#[hemx::app]` authoring shape for plain and fallible handlers. --- diff --git a/examples/kanban/README.md b/examples/kanban/README.md index 9628131..4745c31 100644 --- a/examples/kanban/README.md +++ b/examples/kanban/README.md @@ -1,4 +1,6 @@ -# hemx Kanban browser example +# hemx Kanban advanced milestone example + +This is an explicitly advanced/low-level north-star boundary sketch, not beginner-facing guidance. It exercises the product boundary described in `../kanban.md`; use `examples/v0` for the canonical beginner path. Run: diff --git a/examples/kanban/src/main.rs b/examples/kanban/src/main.rs index b4dc33e..96d4332 100644 --- a/examples/kanban/src/main.rs +++ b/examples/kanban/src/main.rs @@ -288,7 +288,7 @@ fn page_html(board: &BoardState) -> Html { fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 - hemx::render(&AppShell { body }) + hemx::page(&AppShell { body }) } fn render_options() -> Html { @@ -332,12 +332,12 @@ fn render_card(card: &Card) -> BoardCard { #[cfg(test)] mod tests { use super::*; - use scraper::{Html, Selector}; use hemx_test::{ class_child_selector, disabled_button_selector, element_class_selector, escaped_markup_selector, form_selector, keyed_selector, root_element_selector, select_options_selector, small_text_selector, strong_text_selector, }; + use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") diff --git a/examples/v0/src/main.rs b/examples/v0/src/main.rs index b7b3033..bd2e3e2 100644 --- a/examples/v0/src/main.rs +++ b/examples/v0/src/main.rs @@ -6,7 +6,8 @@ use futures_util::{stream, StreamExt}; use hemplate::Hemplate; use hemx::{Html, IntoEffect}; use hemx_axum::{ - interactions, runtime_js, sse, EffectResponse, Form, InteractionRequest, PageRequest, Registry, + interactions, runtime_js, sse, EffectResponse, Form, HandlerErrorContext, HandlerFailure, + InteractionRequest, IntoHandlerFailure, PageRequest, Registry, }; use hemx_v0_examples::ui; use hemx_v0_examples::ui::{auth, counter, notifications, page_swap, todo_row, todos, wizard}; @@ -31,20 +32,64 @@ struct TodoRecord { title: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct TodoId(u64); + +impl std::fmt::Display for TodoId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::str::FromStr for TodoId { + type Err = std::num::ParseIntError; + + fn from_str(value: &str) -> Result { + value.parse().map(Self) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TodoTitle(String); + +impl TodoTitle { + fn as_str(&self) -> &str { + &self.0 + } + + fn into_string(self) -> String { + self.0 + } +} + +impl std::fmt::Display for TodoTitle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl std::str::FromStr for TodoTitle { + type Err = Infallible; + + fn from_str(value: &str) -> Result { + Ok(Self(value.trim().to_owned())) + } +} + #[hemx::form("new_todo")] struct NewTodo { - title: String, + title: TodoTitle, } #[hemx::form("rename_todo")] struct RenameTodo { - id: u64, - title: String, + id: TodoId, + title: TodoTitle, } #[hemx::form("delete_todo")] struct DeleteTodo { - id: u64, + id: TodoId, } #[hemx::form("wizard_input")] @@ -59,15 +104,24 @@ struct Credentials { } #[derive(Debug)] -struct TodoMutationError(String); +struct AppError(String); -impl std::fmt::Display for TodoMutationError { +impl std::fmt::Display for AppError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.0) } } -impl std::error::Error for TodoMutationError {} +impl std::error::Error for AppError {} + +impl IntoHandlerFailure for AppError { + fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { + HandlerFailure::effects( + notifications::notifications.set(format!("Could not save todo: {}", self.0)), + context, + ) + } +} #[derive(Hemplate)] #[hemplate = "partials"] @@ -203,7 +257,7 @@ fn all_examples() -> Html { // req: html_safety/001 req: html_safety/002 req: component/003 Html::join([ counter::render(&Counter), - ui::render(&todos_view(&[])), + hemx::page(&todos_view(&[])), wizard::render(&Wizard), auth::render(&Auth), render_page_swap("Welcome", "Welcome"), @@ -213,7 +267,7 @@ fn all_examples() -> Html { fn shell(body: Html) -> Html { // req: html_safety/001 req: html_safety/002 req: axum_integration/001 req: component/003 - ui::render(&AppShell { body }) + hemx::page(&AppShell { body }) } #[hemx::component("counter")] @@ -237,25 +291,29 @@ mod todo_handlers { async fn add_todo( State(state): State>, Form(form): Form, - ) -> Result { + ) -> Result { // req: examples/001 req: canonical_authoring/003 + if form.title.as_str().is_empty() { + return Ok(( + todos::new_todo.error("title", "Title required"), + todos::new_todo.focus("title"), + todos::summary.set(todo_summary(&state.todos.lock().unwrap())), + )); + } + if form.title.as_str() == "fail-db" { + return Err(AppError("todo store unavailable".to_owned())); + } + let mut todos = state.todos.lock().unwrap(); let id = todos.last().map_or(1, |todo| todo.id + 1); - let created = if form.title.is_empty() { - None - } else { - todos.push(TodoRecord { - id, - title: form.title.clone(), - }); - Some(todos::todo_row.append(TodoRow { - id, - title: form.title, - })) - }; + let title = form.title.into_string(); + todos.push(TodoRecord { + id, + title: title.clone(), + }); let summary = todo_summary(&todos); Ok(( - created, + todos::todo_row.append(TodoRow { id, title }), todos::summary.set(summary), todos::new_todo.clear(), )) @@ -340,12 +398,12 @@ fn rename_todo_effect(state: Arc, form: RenameTodo) -> impl IntoEf let mut todos = state.todos.lock().unwrap(); todos .iter_mut() - .find(|todo| todo.id == form.id) + .find(|todo| todo.id == form.id.0) .map(|todo| { - todo.title = form.title.clone(); + todo.title = form.title.into_string(); todos::todo_row.replace(TodoRow { - id: form.id, - title: form.title, + id: form.id.0, + title: todo.title.clone(), }) }) } @@ -354,7 +412,7 @@ fn delete_todo_effect(state: Arc, form: DeleteTodo) -> impl IntoEf // req: examples/001 req: canonical_authoring/003 let mut todos = state.todos.lock().unwrap(); let before = todos.len(); - todos.retain(|todo| todo.id != form.id); + todos.retain(|todo| todo.id != form.id.0); let deleted = todos.len() != before; let summary = todo_summary(&todos); ( @@ -394,18 +452,18 @@ fn render_page_swap(title: &'static str, message: &'static str) -> Html { fn render_docs_content(message: &'static str) -> Html { // req: html_safety/002 req: view/001 req: component/003 - ui::render(&DocsContent { message }) + hemx::page(&DocsContent { message }) } #[cfg(test)] mod tests { use super::*; - use scraper::{Html, Selector}; use hemx_test::{ article_selector, document_title_selector, escaped_markup_selector, heading_selector, inspect_batch, keyed_items_selector, keyed_selector, list_item_selector, page_nav_link_selector, prose_selector, root_element_selector, runtime_script_selector, }; + use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") @@ -509,7 +567,7 @@ mod tests { title: "Ship v0".to_owned(), }]; - let html = ui::render(&todos_view(&todos)); + let html = hemx::page(&todos_view(&todos)); let document = Html::parse_fragment(html.as_str()); let rows = document .select(&selector(&keyed_items_selector("li"))) @@ -529,7 +587,7 @@ mod tests { // req: html_safety/002 req: view/001 req: test/005 #[test] fn empty_todos_payload_is_rendered_by_a_hemplate_view() { - let html = ui::render(&todos_view(&[])); + let html = hemx::page(&todos_view(&[])); let document = Html::parse_fragment(html.as_str()); let rows = document .select(&selector(&list_item_selector(""))) @@ -553,6 +611,27 @@ mod tests { assert!(counter.updates_text(counter::counter_value)); assert!(counter.payload_contains("1")); + let validation = inspect_batch( + InteractionRequest::from(form(todos::add_todo, &[("title", " ")])) + .dispatch_async(registry(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(state.todos.lock().unwrap().is_empty()); + assert!(validation.payload_contains("Title required")); + + let store_failure = inspect_batch( + InteractionRequest::from(form(todos::add_todo, &[("title", "fail-db")])) + .dispatch_async(registry(state.clone())) + .await + .unwrap() + .batch, + ); + assert!(state.todos.lock().unwrap().is_empty()); + assert!(store_failure.updates_text(notifications::notifications)); + assert!(store_failure.payload_contains("todo store unavailable")); + let add = inspect_batch( InteractionRequest::from(form(todos::add_todo, &[("title", "Ship v0")])) .dispatch_async(registry(state.clone())) diff --git a/hemx-axum/src/lib.rs b/hemx-axum/src/lib.rs index cb8607a..6399378 100644 --- a/hemx-axum/src/lib.rs +++ b/hemx-axum/src/lib.rs @@ -9,7 +9,6 @@ use futures_util::{Stream, StreamExt}; use hemx_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; @@ -229,11 +228,61 @@ pub enum InteractionFormRejection { InvalidHandle, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HandlerErrorContext { + pub handle_id: u32, + pub fingerprint: BuildFingerprint, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HandlerFailure { + Response { status: StatusCode, message: String }, + Effects(EffectBatch), +} + +pub trait IntoHandlerFailure { + fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure; +} + +fn map_handler_failure( + error: E, + handle_id: u32, + fingerprint: BuildFingerprint, +) -> Result +where + E: IntoHandlerFailure, +{ + match error.into_handler_failure(HandlerErrorContext { + handle_id, + fingerprint, + }) { + HandlerFailure::Effects(batch) => Ok(batch), + failure => Err(DispatchRejection::HandlerError(failure)), + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum DispatchRejection { UnknownHandle(u32), InvalidForm { handle_id: u32, message: String }, - HandlerError { handle_id: u32, message: String }, + HandlerError(HandlerFailure), +} + +impl HandlerFailure { + pub fn internal(message: impl Into) -> Self { + Self::response(StatusCode::INTERNAL_SERVER_ERROR, message) + } + + pub fn response(status: StatusCode, message: impl Into) -> Self { + Self::Response { + status, + message: message.into(), + } + } + + pub fn effects(effects: impl IntoEffect, context: HandlerErrorContext) -> Self { + Self::Effects(effects.into_batch(context.fingerprint)) + } } impl EffectResponse { @@ -528,6 +577,67 @@ impl HandlerRegistry { self } + #[doc(hidden)] + pub fn register_state_result( + mut self, + handle_id: u32, + state: S, + handler: impl Fn(C) -> Result + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + O: IntoEffect, + E: IntoHandlerFailure, + { + let fingerprint = self.fingerprint; + self.handlers.insert( + handle_id, + Box::new(move |form| { + let handle_id = form.handle_id; + match handler(C::from_handler_state(state.clone())) { + Ok(effects) => Ok(effects.into_batch(fingerprint)), + Err(error) => map_handler_failure(error, handle_id, fingerprint), + } + }), + ); + self + } + + #[doc(hidden)] + pub fn register_state_typed_result( + mut self, + handle_id: u32, + state: S, + handler: impl Fn(C, T) -> Result + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + T: FromInteractionForm, + O: IntoEffect, + E: IntoHandlerFailure, + { + 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, + } + })?; + match handler(C::from_handler_state(state.clone()), input) { + Ok(effects) => Ok(effects.into_batch(fingerprint)), + Err(error) => map_handler_failure(error, handle_id, fingerprint), + } + }), + ); + self + } + pub fn register_async( mut self, handle_id: u32, @@ -604,6 +714,37 @@ impl HandlerRegistry { self } + #[doc(hidden)] + pub fn register_state_async_result( + mut self, + handle_id: u32, + state: S, + handler: impl Fn(C) -> F + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + F: Future> + Send + 'static, + O: IntoEffect, + E: IntoHandlerFailure, + { + let fingerprint = self.fingerprint; + self.async_handlers.insert( + handle_id, + Box::new(move |form| { + let handle_id = form.handle_id; + let future = handler(C::from_handler_state(state.clone())); + Box::pin(async move { + match future.await { + Ok(effects) => Ok(effects.into_batch(fingerprint)), + Err(error) => map_handler_failure(error, handle_id, fingerprint), + } + }) + }), + ); + self + } + pub fn register_state_typed_async( mut self, handle_id: u32, @@ -640,6 +781,7 @@ impl HandlerRegistry { self } + #[doc(hidden)] pub fn register_state_typed_async_result( mut self, handle_id: u32, @@ -652,7 +794,7 @@ impl HandlerRegistry { T: FromInteractionForm, F: Future> + Send + 'static, O: IntoEffect, - E: fmt::Display, + E: IntoHandlerFailure, { let fingerprint = self.fingerprint; self.async_handlers.insert( @@ -672,13 +814,10 @@ impl HandlerRegistry { }; 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(), - }) + match future.await { + Ok(effects) => Ok(effects.into_batch(fingerprint)), + Err(error) => map_handler_failure(error, handle_id, fingerprint), + } }) }), ); @@ -758,6 +897,39 @@ impl HandlerRegistry { self.register_state_typed(handle.id().id, state, handler) } + #[doc(hidden)] + pub fn on_state_result( + self, + handle: Handle, + state: S, + handler: impl Fn(C) -> Result + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.register_state_result(handle.id().id, state, handler) + } + + #[doc(hidden)] + pub fn on_state_form_result( + self, + handle: Handle, + state: S, + handler: impl Fn(C, T) -> Result + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + T: FromInteractionForm, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.register_state_typed_result(handle.id().id, state, handler) + } + pub fn on_async( self, handle: Handle, @@ -798,6 +970,23 @@ impl HandlerRegistry { self.register_state_async(handle.id().id, state, handler) } + #[doc(hidden)] + pub fn on_state_async_result( + self, + handle: Handle, + state: S, + handler: impl Fn(C) -> F + Send + Sync + 'static, + ) -> Self + where + S: Clone + Send + Sync + 'static, + C: FromHandlerState, + F: Future> + Send + 'static, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.register_state_async_result(handle.id().id, state, handler) + } + pub fn on_state_form_async( self, handle: Handle, @@ -814,6 +1003,7 @@ impl HandlerRegistry { self.register_state_typed_async(handle.id().id, state, handler) } + #[doc(hidden)] pub fn on_state_form_async_result( self, handle: Handle, @@ -826,7 +1016,7 @@ impl HandlerRegistry { T: FromInteractionForm, F: Future> + Send + 'static, O: IntoEffect, - E: fmt::Display, + E: IntoHandlerFailure, { self.register_state_typed_async_result(handle.id().id, state, handler) } @@ -892,6 +1082,41 @@ where self } + #[doc(hidden)] + pub fn on_state_result( + mut self, + handle: Handle, + handler: impl Fn(C) -> Result + Send + Sync + 'static, + ) -> Self + where + C: FromHandlerState, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.registry = self + .registry + .on_state_result(handle, self.state.clone(), handler); + self + } + + #[doc(hidden)] + pub fn on_result( + mut self, + handle: Handle, + handler: impl Fn(C, T) -> Result + Send + Sync + 'static, + ) -> Self + where + C: FromHandlerState, + T: FromInteractionForm, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.registry = self + .registry + .on_state_form_result(handle, self.state.clone(), handler); + self + } + pub fn on_state_async( mut self, handle: Handle, @@ -908,6 +1133,24 @@ where self } + #[doc(hidden)] + pub fn on_state_async_result( + mut self, + handle: Handle, + handler: impl Fn(C) -> F + Send + Sync + 'static, + ) -> Self + where + C: FromHandlerState, + F: Future> + Send + 'static, + O: IntoEffect, + E: IntoHandlerFailure, + { + self.registry = self + .registry + .on_state_async_result(handle, self.state.clone(), handler); + self + } + pub fn on_async( mut self, handle: Handle, @@ -925,6 +1168,7 @@ where self } + #[doc(hidden)] pub fn on_async_result( mut self, handle: Handle, @@ -935,7 +1179,7 @@ where T: FromInteractionForm, F: Future> + Send + 'static, O: IntoEffect, - E: fmt::Display, + E: IntoHandlerFailure, { self.registry = self.registry @@ -1117,11 +1361,12 @@ impl IntoResponse for DispatchRejection { format!("invalid hemx form for handle id {handle_id}: {message}"), ) .into_response(), - Self::HandlerError { handle_id, message } => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("hemx handler for handle id {handle_id} failed: {message}"), - ) - .into_response(), + Self::HandlerError(HandlerFailure::Response { status, message }) => { + (status, message).into_response() + } + Self::HandlerError(HandlerFailure::Effects(batch)) => { + EffectResponse { batch }.into_response() + } } } } @@ -1257,8 +1502,8 @@ mod tests { response::IntoResponse, }; use futures_util::stream; - use scraper::{Html, Selector}; use hemx_core::{EffectBatch, EFFECT_BATCH_ABI_VERSION}; + use scraper::{Html, Selector}; use std::convert::Infallible; fn selector(value: &str) -> Selector { diff --git a/hemx-axum/tests/response.rs b/hemx-axum/tests/response.rs index 93d76c5..e0384d2 100644 --- a/hemx-axum/tests/response.rs +++ b/hemx-axum/tests/response.rs @@ -1,14 +1,14 @@ use axum::extract::State; use axum::http::{header, HeaderMap}; use axum::response::IntoResponse; -use scraper::{Html, Selector}; use hemx_axum::{ - interactions, runtime_js, DispatchRejection, EffectResponse, Form, InteractionForm, - InteractionFormRejection, InteractionRequest, PageMode, PageRequest, PageResponse, - HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, - HEMX_TITLE_HEADER, + interactions, runtime_js, DispatchRejection, EffectResponse, Form, HandlerErrorContext, + HandlerFailure, InteractionForm, InteractionFormRejection, InteractionRequest, + IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE, + HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, }; use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot}; +use scraper::{Html, Selector}; fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") @@ -306,6 +306,21 @@ impl std::fmt::Display for HandlerBoom { impl std::error::Error for HandlerBoom {} +impl IntoHandlerFailure for HandlerBoom { + fn into_handler_failure(self, _context: HandlerErrorContext) -> HandlerFailure { + HandlerFailure::internal(self.to_string()) + } +} + +#[derive(Debug)] +struct UiFailure; + +impl IntoHandlerFailure for UiFailure { + fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { + HandlerFailure::effects(Slot::::new(3).text("try again"), context) + } +} + #[tokio::test] async fn interaction_request_dispatches_async_typed_state_extractors() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 @@ -359,10 +374,69 @@ async fn interaction_request_reports_async_result_handler_errors() { assert_eq!( error, - DispatchRejection::HandlerError { - handle_id: 11, + DispatchRejection::HandlerError(HandlerFailure::Response { + status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, message: "database unavailable".to_owned(), - } + }) + ); +} + +#[tokio::test] +async fn interaction_request_maps_async_result_errors_to_effects() { + // req: axum_integration/003 req: failure/004 + async fn open_project( + State(_multiplier): State, + Form(_input): Form, + ) -> Result { + Err::<(), _>(UiFailure) + } + + let request = InteractionRequest::from(InteractionForm::for_handle( + Handle::<()>::new(12), + vec![("project_id".to_owned(), "21".to_owned())], + )); + let response = request + .dispatch_async( + interactions(BuildFingerprint(4)) + .with_state(2_u32) + .on_async_result(Handle::<()>::new(12), open_project) + .into_registry(), + ) + .await + .unwrap(); + + assert_eq!( + response.batch.ops, + vec![Slot::::new(3).text("try again")] + ); +} + +#[test] +fn interaction_request_maps_sync_result_errors_to_effects() { + // req: axum_integration/003 req: failure/004 + fn open_project( + State(_multiplier): State, + Form(_input): Form, + ) -> Result { + Err::<(), _>(UiFailure) + } + + let request = InteractionRequest::from(InteractionForm::for_handle( + Handle::<()>::new(13), + vec![("project_id".to_owned(), "21".to_owned())], + )); + let response = request + .dispatch( + interactions(BuildFingerprint(4)) + .with_state(2_u32) + .on_result(Handle::<()>::new(13), open_project) + .into_registry(), + ) + .unwrap(); + + assert_eq!( + response.batch.ops, + vec![Slot::::new(3).text("try again")] ); } diff --git a/hemx-derive/src/lib.rs b/hemx-derive/src/lib.rs index 517d35f..f82fe2c 100644 --- a/hemx-derive/src/lib.rs +++ b/hemx-derive/src/lib.rs @@ -1,4 +1,5 @@ use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; use quote::{format_ident, quote}; use std::path::PathBuf; use syn::parse::Parser; @@ -651,20 +652,9 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo if handlers.is_empty() { return module; } - let calls = handlers.iter().map(|handler| { - let ident = &handler.ident; - if handler.typed_arg_count == 1 && handler.is_async { - quote!(.on_state_async(super::#component_ident::#ident, #ident)) - } else if handler.typed_arg_count == 1 { - quote!(.on_state(super::#component_ident::#ident, #ident)) - } else if handler.is_async && handler.returns_result { - quote!(.on_async_result(super::#component_ident::#ident, #ident)) - } else if handler.is_async { - quote!(.on_async(super::#component_ident::#ident, #ident)) - } else { - quote!(.on(super::#component_ident::#ident, #ident)) - } - }); + let calls = handlers + .iter() + .map(|handler| component_registration_call(handler, &component_ident)); let register: Item = syn::parse2(quote! { pub fn register( registry: ::hemx_axum::StateHandlerRegistry<#state_ty>, @@ -676,20 +666,9 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo } }) .expect("generated component register helper parses"); - let calls = handlers.iter().map(|handler| { - let ident = &handler.ident; - if handler.typed_arg_count == 1 && handler.is_async { - quote!(.on_state_async(super::#component_ident::#ident, #ident)) - } else if handler.typed_arg_count == 1 { - quote!(.on_state(super::#component_ident::#ident, #ident)) - } else if handler.is_async && handler.returns_result { - quote!(.on_async_result(super::#component_ident::#ident, #ident)) - } else if handler.is_async { - quote!(.on_async(super::#component_ident::#ident, #ident)) - } else { - quote!(.on(super::#component_ident::#ident, #ident)) - } - }); + let calls = handlers + .iter() + .map(|handler| component_registration_call(handler, &component_ident)); let register_with_state: Item = syn::parse2(quote! { pub fn register_with_state( registry: ::hemx_axum::HandlerRegistry, @@ -710,6 +689,30 @@ fn add_component_register_helper(mut module: ItemMod, component: &str) -> ItemMo module } +fn component_registration_call( + handler: &ComponentHandler, + component_ident: &syn::Ident, +) -> TokenStream2 { + let ident = &handler.ident; + if handler.typed_arg_count == 1 && handler.is_async && handler.returns_result { + quote!(.on_state_async_result(super::#component_ident::#ident, #ident)) + } else if handler.typed_arg_count == 1 && handler.returns_result { + quote!(.on_state_result(super::#component_ident::#ident, #ident)) + } else if handler.typed_arg_count == 1 && handler.is_async { + quote!(.on_state_async(super::#component_ident::#ident, #ident)) + } else if handler.typed_arg_count == 1 { + quote!(.on_state(super::#component_ident::#ident, #ident)) + } else if handler.is_async && handler.returns_result { + quote!(.on_async_result(super::#component_ident::#ident, #ident)) + } else if handler.returns_result { + quote!(.on_result(super::#component_ident::#ident, #ident)) + } else if handler.is_async { + quote!(.on_async(super::#component_ident::#ident, #ident)) + } else { + quote!(.on(super::#component_ident::#ident, #ident)) + } +} + fn component_contract_errors( path: &PathBuf, component: Option<&str>, @@ -1048,18 +1051,28 @@ mod tests { let module = parse_quote! { mod handlers { #[hemx::handler] - fn create(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect { - hemx::EventName::new("created").emit("") + fn sync_form(app: super::App, form: super::NewTodo) -> impl hemx::IntoEffect { + hemx::EventName::new("sync-form").emit("") } #[hemx::handler] - async fn increment(app: super::App) -> impl hemx::IntoEffect { - hemx::EventName::new("incremented").emit("") + async fn async_state(app: super::App) -> impl hemx::IntoEffect { + hemx::EventName::new("async-state").emit("") } #[hemx::handler] - async fn save(app: super::App, form: super::NewTodo) -> Result { - Ok(hemx::EventName::new("saved").emit("")) + fn sync_result(app: super::App) -> Result { + Ok(hemx::EventName::new("sync-result").emit("")) + } + + #[hemx::handler] + async fn async_result(app: super::App) -> Result { + Ok(hemx::EventName::new("async-result").emit("")) + } + + #[hemx::handler] + async fn async_form_result(app: super::App, form: super::NewTodo) -> Result { + Ok(hemx::EventName::new("async-form-result").emit("")) } } }; @@ -1070,11 +1083,13 @@ mod tests { assert!(generated.contains("register_with_state"), "{generated}"); assert!(generated.contains("StateHandlerRegistry"), "{generated}"); assert!( - generated.contains("super :: todos :: create"), + generated.contains("super :: todos :: sync_form"), "{generated}" ); assert!(generated.contains(". on"), "{generated}"); assert!(generated.contains(". on_state_async"), "{generated}"); + assert!(generated.contains(". on_state_result"), "{generated}"); + assert!(generated.contains(". on_state_async_result"), "{generated}"); assert!(generated.contains(". on_async_result"), "{generated}"); } } diff --git a/hemx/src/lib.rs b/hemx/src/lib.rs index 361e021..a9a1b01 100644 --- a/hemx/src/lib.rs +++ b/hemx/src/lib.rs @@ -18,6 +18,10 @@ pub use hemx_derive::{app, component, form, handler, surface}; /// handles/forms/classes, `Html`, `IntoEffect`, and tuple composition. req: dx/001 req: public_api/005 pub mod advanced { pub use hemx_core::*; + + pub fn render(view: &impl hemplate::Hemplate) -> crate::Html { + crate::render_template(view) + } } /// Rendered, checked HTML produced by hemplate/hemx rendering helpers. @@ -68,13 +72,17 @@ pub mod __private { } } -pub fn render(view: &impl hemplate::Hemplate) -> Html { +fn render_template(view: &impl hemplate::Hemplate) -> Html { let mut html = String::new(); view.render_into(&mut html) .expect("hemplate view renders into hemx effect payload"); __private::html_trusted(html) } +pub fn page(view: &impl hemplate::Hemplate) -> Html { + render_template(view) +} + /// A hemplate partial that carries the stable key for a generated keyed target. /// /// Generated keyed target helpers use this to keep ordinary handler code at the @@ -86,7 +94,7 @@ pub trait KeyedPartial { #[doc(hidden)] pub fn render_html(view: &impl hemplate::Hemplate) -> Html { - render(view) + render_template(view) } /// Compatibility shim for raw slot rendering. @@ -107,7 +115,7 @@ pub trait RenderSlotExt { impl RenderSlotExt for Slot { fn render(self, view: &impl hemplate::Hemplate) -> Effect { - self.html(render(view)) + self.html(render_template(view)) } } @@ -148,21 +156,20 @@ where K: ToString, { fn append(self, key: K, view: &impl hemplate::Hemplate) -> Effect { - self.append_html(key, render(view)) + self.append_html(key, render_template(view)) } fn prepend(self, key: K, view: &impl hemplate::Hemplate) -> Effect { - self.prepend_html(key, render(view)) + self.prepend_html(key, render_template(view)) } fn replace(self, key: K, view: &impl hemplate::Hemplate) -> Effect { - self.replace_html(key, render(view)) + self.replace_html(key, render_template(view)) } } pub mod prelude { - pub use crate::render; - pub use crate::Html; + pub use crate::{page, Html}; pub use hemx_core::{ navigate, push, redirect, replace, Atom, ComponentRef, CssClass, CssClasses, EventName, Form, FormModel, FormValue, Handle, IntoEffect, ParamName, @@ -182,13 +189,17 @@ mod tests { } #[test] - fn render_is_the_short_safe_html_helper() { + fn page_is_the_short_safe_html_helper() { // req: dx/006 req: html_safety/002 - assert_eq!(crate::render(&InlineView).as_str(), "ok"); + assert_eq!(crate::page(&InlineView).as_str(), "ok"); assert_eq!( crate::render_html(&InlineView).as_str(), "ok" ); + assert_eq!( + crate::advanced::render(&InlineView).as_str(), + "ok" + ); } #[test] @@ -196,7 +207,7 @@ mod tests { // req: dx/006 req: public_api/005 req: html_safety/002 use crate::prelude::*; - let html = Html::join([render(&InlineView)]); + let html = Html::join([page(&InlineView)]); assert_eq!(html.as_str(), "ok"); }