Implement closed typed effect path

This commit is contained in:
tmk241
2026-09-01 00:58:29 +02:00
parent 353174604e
commit 31a0f02211
58 changed files with 4247 additions and 5933 deletions
+10 -3
View File
@@ -2,6 +2,13 @@
name = "hemx-axum"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
readme = "README.md"
description = "Axum integration for Hemx applications"
keywords = ["axum", "hypermedia", "html", "web"]
categories = ["web-programming"]
[lib]
path = "src/lib.rs"
@@ -9,10 +16,10 @@ path = "src/lib.rs"
[dependencies]
axum = { version = "0.8", default-features = false, features = ["multipart", "tokio"] }
futures-util = { version = "0.3", default-features = false }
hemx-core = { path = "../hemx-core" }
hemx-js = { path = "../hemx-js" }
hemx-core = { version = "0.3.0", path = "../hemx-core" }
hemx-js = { version = "0.3.0", path = "../hemx-js" }
[dev-dependencies]
scraper = "0.25"
tokio = { version = "1", features = ["macros", "rt"] }
tokio = { version = "1", features = ["macros", "rt", "sync"] }
tower = { version = "0.5", features = ["util"] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Thomas Hain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+7
View File
@@ -0,0 +1,7 @@
# hemx-axum
Axum integration for Hemx pages, generated routes, assets, effects, and server push.
## License
MIT
+13 -23
View File
@@ -26,12 +26,10 @@ pub const fn runtime_js() -> RuntimeJs {
RuntimeJs
}
// req: axum_integration/005
pub const fn runtime_js_hash() -> &'static str {
hemx_js::RUNTIME_JS_HASH
}
// req: axum_integration/005
pub const fn runtime_js_path() -> &'static str {
hemx_js::RUNTIME_JS_PATH
}
@@ -354,7 +352,6 @@ impl InteractionForm {
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
}
// req: multipart/001, req: multipart/002
pub async fn parse_multipart(
mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> {
@@ -471,7 +468,6 @@ pub fn state_interactions<S>(fingerprint: BuildFingerprint, state: S) -> StateHa
where
S: Clone + Send + Sync + 'static,
{
// req: axum_integration/003 req: ceremony/001
interactions(fingerprint).with_state(state)
}
@@ -1254,8 +1250,8 @@ where
{
type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(req, state)
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(request, state)
.await
.map(|form| Self { form })
}
@@ -1267,16 +1263,16 @@ where
{
type Rejection = InteractionFormRejection;
async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
match interaction_media_type(req.headers())? {
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
match interaction_media_type(request.headers())? {
InteractionMediaType::Multipart => {
let multipart = Multipart::from_request(req, state)
let multipart = Multipart::from_request(request, state)
.await
.map_err(extractor_rejection)?;
Self::parse_multipart(multipart).await
}
InteractionMediaType::UrlEncoded => {
let bytes = Bytes::from_request(req, state)
let bytes = Bytes::from_request(request, state)
.await
.map_err(extractor_rejection)?;
Self::parse_urlencoded(&bytes)
@@ -1459,7 +1455,6 @@ pub fn runtime_js_route_path() -> &'static str {
runtime_js_path()
}
// req: push/001, req: push/003, req: push/004
pub fn sse<S, E>(batches: S) -> Sse<impl Stream<Item = Result<Event, E>> + Send>
where
S: Stream<Item = Result<EffectBatch, E>> + Send + 'static,
@@ -1481,14 +1476,14 @@ pub fn encode_sse_batch(batch: &EffectBatch) -> String {
fn base64_url_no_pad(input: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity((input.len() * 4).div_ceil(3));
let mut chunks = input.chunks_exact(3);
for chunk in &mut chunks {
out.push(ALPHABET[(chunk[0] >> 2) as usize] as char);
out.push(ALPHABET[(((chunk[0] & 0x03) << 4) + (chunk[1] >> 4)) as usize] as char);
out.push(ALPHABET[(((chunk[1] & 0x0f) << 2) + (chunk[2] >> 6)) as usize] as char);
out.push(ALPHABET[(chunk[2] & 0x3f) as usize] as char);
let (chunks, remainder) = input.as_chunks::<3>();
for [a, b, c] in chunks {
out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[(((a & 0x03) << 4) + (b >> 4)) as usize] as char);
out.push(ALPHABET[(((b & 0x0f) << 2) + (c >> 6)) as usize] as char);
out.push(ALPHABET[(c & 0x3f) as usize] as char);
}
match chunks.remainder() {
match remainder {
[a] => {
out.push(ALPHABET[(a >> 2) as usize] as char);
out.push(ALPHABET[((a & 0x03) << 4) as usize] as char);
@@ -1577,7 +1572,6 @@ mod tests {
#[test]
fn root_fingerprint_is_added_to_initial_root() {
// req: test/005
let html = html_with_root_fingerprint(
"<html><body><main data-hemx-root>Docs</main></body></html>".into(),
BuildFingerprint(99),
@@ -1594,7 +1588,6 @@ mod tests {
#[test]
fn existing_root_fingerprint_is_preserved() {
// req: test/005
let html = html_with_root_fingerprint(
"<main data-hemx-root data-hemx-fp=\"1\">Docs</main>".into(),
BuildFingerprint(99),
@@ -1618,7 +1611,6 @@ mod tests {
html_with_root_fingerprint(html.clone(), BuildFingerprint(99)),
html
);
// test req: abi/005
}
#[test]
@@ -1639,7 +1631,6 @@ mod tests {
] {
assert_eq!(base64_url_no_pad(input), expected);
}
// req: push/008 test
}
#[test]
@@ -1677,7 +1668,6 @@ mod tests {
InteractionForm::parse_urlencoded(b"__h=1&bad=%"),
Err(InteractionFormRejection::InvalidBody)
);
// test req: form/002 req: failure/003
}
#[tokio::test]
+5 -38
View File
@@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use tower::ServiceExt;
static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0);
static BOUNDARY_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
static BOUNDARY_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
MUTATION_CALLS.fetch_add(1, Ordering::SeqCst);
@@ -40,10 +40,7 @@ async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
#[tokio::test]
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
// test req: security/003
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let _guard = BOUNDARY_TEST_LOCK.lock().await;
MUTATION_CALLS.store(0, Ordering::SeqCst);
let app = Router::new()
.route("/mutate", post(bounded_mutation))
@@ -104,9 +101,7 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
#[tokio::test]
async fn interaction_boundary_extracts_multipart_fields_and_files() {
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let _guard = BOUNDARY_TEST_LOCK.lock().await;
let boundary = "hemx-boundary";
let body = concat!(
"--hemx-boundary\r\n",
@@ -136,14 +131,11 @@ async fn interaction_boundary_extracts_multipart_fields_and_files() {
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// test req: multipart/001 req: multipart/002 req: multipart/003
}
#[tokio::test]
async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() {
let _guard = BOUNDARY_TEST_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
let _guard = BOUNDARY_TEST_LOCK.lock().await;
let app = Router::new().route("/mutate", post(bounded_mutation));
let missing_boundary = app
.clone()
@@ -208,7 +200,6 @@ async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart(
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
// test req: multipart/001 req: multipart/003
}
fn selector(value: &str) -> Selector {
@@ -266,12 +257,10 @@ fn page_mode_detects_only_explicit_partial_header_values() {
headers.insert(HEMX_PARTIAL_HEADER, value.parse().unwrap());
assert_eq!(PageMode::from_headers(&headers), PageMode::Partial);
}
// test req: page_swap/001
}
#[test]
fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
// req: test/005
assert!(!PageRequest {
mode: PageMode::Full
}
@@ -321,7 +310,6 @@ fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() {
#[test]
fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() {
// req: axum_integration/001 req: html_safety/001 req: html_safety/002 req: test/005
let full = PageRequest {
mode: PageMode::Full,
}
@@ -389,7 +377,6 @@ fn page_response_constructors_preserve_mode_and_optional_fingerprint() {
partial.fingerprint(fingerprint).fingerprint,
Some(fingerprint)
);
// test req: page_swap/001 req: abi/005
}
#[tokio::test]
@@ -410,7 +397,6 @@ async fn full_page_response_preserves_html_and_fingerprint_without_partial_heade
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
b"<html>Full</html>"
);
// test req: page_swap/001 req: abi/005
}
#[test]
@@ -447,7 +433,6 @@ fn interaction_form_parses_handle_and_fields() {
#[test]
fn interaction_form_parses_typed_values() {
// req: form/004 req: dx/003
let form =
InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse");
@@ -470,7 +455,6 @@ fn interaction_form_requires_numeric_handle() {
#[test]
fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
// req: auth/004
let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap();
assert_eq!(form.handle_id, 42);
@@ -480,7 +464,6 @@ fn interaction_form_preserves_hidden_csrf_fields_for_extractors() {
#[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(),
@@ -498,7 +481,6 @@ fn interaction_request_dispatches_with_concise_handlers_helper() {
#[test]
fn state_interactions_starts_stateful_wiring_without_nested_closures() {
// req: axum_integration/003 req: ceremony/001 req: dx/003
fn ping(prefix: String) -> impl IntoEffect {
Slot::<String>::new(3).text(format!("{prefix}: ping"))
}
@@ -568,7 +550,6 @@ fn interaction_form_accessors_preserve_repeated_values_and_decode_diagnostics()
.fields()
.iter()
.any(|pair| pair == &("count".into(), "7".into())));
// test req: form/002 req: form/004 req: multipart/001
}
#[tokio::test]
@@ -607,12 +588,10 @@ async fn interaction_form_rejections_return_stable_status_and_diagnostic() {
message.as_bytes()
);
}
// test req: failure/003 req: multipart/003
}
#[test]
fn interaction_request_dispatches_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "42".to_owned())],
@@ -632,7 +611,6 @@ fn interaction_request_dispatches_typed_form_inputs() {
#[test]
fn interaction_request_rejects_invalid_typed_form_inputs() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/004
let request = InteractionRequest::from(InteractionForm::for_handle(
Handle::<()>::new(8),
vec![("project_id".to_owned(), "nope".to_owned())],
@@ -654,7 +632,6 @@ fn interaction_request_rejects_invalid_typed_form_inputs() {
#[test]
fn interaction_request_dispatches_typed_state_handlers() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect {
Slot::<String>::new(3).text(input.project_id.0 * multiplier)
}
@@ -702,7 +679,6 @@ impl IntoHandlerFailure for UiFailure {
#[tokio::test]
async fn interaction_request_dispatches_async_typed_state_extractors() {
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
async fn open_project(
State(multiplier): State<u32>,
Form(input): Form<OpenProject>,
@@ -729,7 +705,6 @@ async fn interaction_request_dispatches_async_typed_state_extractors() {
#[tokio::test]
async fn interaction_request_reports_async_result_handler_errors() {
// req: axum_integration/003 req: form/004 req: failure/003
async fn open_project(
State(_multiplier): State<u32>,
Form(_input): Form<OpenProject>,
@@ -762,7 +737,6 @@ async fn interaction_request_reports_async_result_handler_errors() {
#[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>,
@@ -792,7 +766,6 @@ async fn interaction_request_maps_async_result_errors_to_effects() {
#[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>,
@@ -821,7 +794,6 @@ fn interaction_request_maps_sync_result_errors_to_effects() {
#[test]
fn interactions_dispatch_by_checked_handle() {
// req: ceremony/004 req: public_api/001
let title = Slot::<String>::new(7);
let create = Handle::<()>::new(42);
let request = InteractionRequest::from(InteractionForm::for_handle(
@@ -862,7 +834,6 @@ async fn registry_contains_registered_sync_and_async_handles_and_async_falls_bac
sync_response.batch.ops,
vec![Slot::<String>::new(1).text("sync")]
);
// test req: axum_integration/003 req: public_api/001
}
#[tokio::test]
@@ -883,9 +854,8 @@ async fn state_result_registration_paths_preserve_state_form_and_failures() {
typed,
String::from("typed"),
|state: State<String>, Form(form): Form<OpenProject>| {
Err::<Effect, _>(HandlerBoom).map_err(|error| {
Err::<Effect, _>(HandlerBoom).inspect_err(|_| {
let _ = (state, form);
error
})
},
)
@@ -1011,7 +981,6 @@ async fn state_result_registration_paths_preserve_state_form_and_failures() {
response.batch.ops,
vec![Slot::<String>::new(1).text("typed-async:9")]
);
// test req: axum_integration/003 req: derive_handler/005
}
#[test]
@@ -1074,7 +1043,6 @@ async fn effect_and_dispatch_responses_preserve_status_wire_and_diagnostics() {
let response = DispatchRejection::HandlerError(HandlerFailure::Effects(batch)).into_response();
assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE);
// test req: axum_integration/003 req: failure/005 req: wire/007
}
#[tokio::test]
@@ -1106,7 +1074,6 @@ async fn runtime_js_response_serves_embedded_runtime() {
);
}
// req: axum_integration/005
#[test]
fn runtime_js_path_is_content_hashed() {
let path = runtime_js_path();