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
This commit is contained in:
slhx agent
2026-06-05 08:36:39 +02:00
parent c33500440e
commit 53480dba5a
8 changed files with 536 additions and 110 deletions
+263 -18
View File
@@ -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<E>(
error: E,
handle_id: u32,
fingerprint: BuildFingerprint,
) -> Result<EffectBatch, DispatchRejection>
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<String>) -> Self {
Self::response(StatusCode::INTERNAL_SERVER_ERROR, message)
}
pub fn response(status: StatusCode, message: impl Into<String>) -> 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<S, C, E, O>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
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<S, C, T, E, O>(
mut self,
handle_id: u32,
state: S,
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
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<E, F>(
mut self,
handle_id: u32,
@@ -604,6 +714,37 @@ impl HandlerRegistry {
self
}
#[doc(hidden)]
pub fn register_state_async_result<S, C, E, O, 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 = Result<O, E>> + 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<S, C, T, E, F>(
mut self,
handle_id: u32,
@@ -640,6 +781,7 @@ impl HandlerRegistry {
self
}
#[doc(hidden)]
pub fn register_state_typed_async_result<S, C, T, E, O, F>(
mut self,
handle_id: u32,
@@ -652,7 +794,7 @@ impl HandlerRegistry {
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + 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<I, S, C, E, O>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
O: IntoEffect,
E: IntoHandlerFailure,
{
self.register_state_result(handle.id().id, state, handler)
}
#[doc(hidden)]
pub fn on_state_form_result<I, S, C, T, E, O>(
self,
handle: Handle<I>,
state: S,
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
S: Clone + Send + Sync + 'static,
C: FromHandlerState<S>,
T: FromInteractionForm,
O: IntoEffect,
E: IntoHandlerFailure,
{
self.register_state_typed_result(handle.id().id, state, handler)
}
pub fn on_async<I, E, F>(
self,
handle: Handle<I>,
@@ -798,6 +970,23 @@ impl HandlerRegistry {
self.register_state_async(handle.id().id, state, handler)
}
#[doc(hidden)]
pub fn on_state_async_result<I, S, C, E, O, 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 = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: IntoHandlerFailure,
{
self.register_state_async_result(handle.id().id, state, handler)
}
pub fn on_state_form_async<I, S, C, T, E, F>(
self,
handle: Handle<I>,
@@ -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<I, S, C, T, E, O, F>(
self,
handle: Handle<I>,
@@ -826,7 +1016,7 @@ impl HandlerRegistry {
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + 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<I, C, E, O>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
O: IntoEffect,
E: IntoHandlerFailure,
{
self.registry = self
.registry
.on_state_result(handle, self.state.clone(), handler);
self
}
#[doc(hidden)]
pub fn on_result<I, C, T, E, O>(
mut self,
handle: Handle<I>,
handler: impl Fn(C, T) -> Result<O, E> + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
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<I, C, E, F>(
mut self,
handle: Handle<I>,
@@ -908,6 +1133,24 @@ where
self
}
#[doc(hidden)]
pub fn on_state_async_result<I, C, E, O, F>(
mut self,
handle: Handle<I>,
handler: impl Fn(C) -> F + Send + Sync + 'static,
) -> Self
where
C: FromHandlerState<S>,
F: Future<Output = Result<O, E>> + Send + 'static,
O: IntoEffect,
E: IntoHandlerFailure,
{
self.registry = self
.registry
.on_state_async_result(handle, self.state.clone(), handler);
self
}
pub fn on_async<I, C, T, E, F>(
mut self,
handle: Handle<I>,
@@ -925,6 +1168,7 @@ where
self
}
#[doc(hidden)]
pub fn on_async_result<I, C, T, E, O, F>(
mut self,
handle: Handle<I>,
@@ -935,7 +1179,7 @@ where
T: FromInteractionForm,
F: Future<Output = Result<O, E>> + 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 {
+82 -8
View File
@@ -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::<String>::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<u32>,
Form(_input): Form<OpenProject>,
) -> Result<impl IntoEffect, UiFailure> {
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::<String>::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<u32>,
Form(_input): Form<OpenProject>,
) -> Result<impl IntoEffect, UiFailure> {
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::<String>::new(3).text("try again")]
);
}