Implement closed typed effect path
This commit is contained in:
+10
-3
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# hemx-axum
|
||||
|
||||
Axum integration for Hemx pages, generated routes, assets, effects, and server push.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+13
-23
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user