diff --git a/PLAN.md b/PLAN.md index e645566..3356b40 100644 --- a/PLAN.md +++ b/PLAN.md @@ -40,16 +40,10 @@ Blocked by: HMX-M02 ## HMX-M04 — Localize Axum interaction forms - -Outcome: media-type enforcement, body limits, URL-encoded/multipart extraction, typed decoding, and rejections have one private Axum owner. +Outcome: the interaction-form boundary is privately owned without public extraction, decoding, limit, or rejection drift. Delta: architecture/004 assurance/002 -Path: HTTP request -> `InteractionRequest` extraction -> typed `Form` or custom multipart model -> registered handler/rejection. -Build: move the complete form boundary with its tests; add compile coverage for documented `Form` spellings and custom `FromInteractionForm` extraction. -Risk: extraction order, limits, rejection status/body, or public adapter signatures change. -Checks: `cargo test -p hemx-axum`; `cargo test -p hemx-derive`; focused URL-encoded, multipart, limit, rejection, and compile-pass/fail assertions; `redgate check`. -Non-goals: framework-owned CSRF/auth policy, a general extractor abstraction, or changing native form semantics. -Residual risk: none beyond the existing application-owned security policy boundary after focused route proof. -State: Ready +Checks: Axum interaction boundary suite; derive Form spelling check; custom multipart typed extraction; Redgate; diff check; fresh review. +State: Done Blocked by: none ## Closure diff --git a/hemx-axum/src/forms.rs b/hemx-axum/src/forms.rs new file mode 100644 index 0000000..d6ded9b --- /dev/null +++ b/hemx-axum/src/forms.rs @@ -0,0 +1,343 @@ +use super::*; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractionFile { + pub name: String, + pub file_name: Option, + pub content_type: Option, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractionForm { + pub handle_id: u32, + fields: Vec<(String, String)>, + files: Vec, +} + +/// A validated hemx mutation request. +/// +/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are +/// accepted. Body size is intentionally host policy: apply Axum's +/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit) +/// to the mutation route; limit rejections become HTTP 413 before dispatch. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InteractionRequest { + pub(super) form: InteractionForm, +} + +pub trait FromInteractionForm: Sized { + fn from_interaction_form(form: &InteractionForm) -> Result; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Form(pub T); + +impl Form { + pub fn into_inner(self) -> T { + self.0 + } +} + +impl std::ops::Deref for Form { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromInteractionForm for T +where + T: FromForm, +{ + fn from_interaction_form(form: &InteractionForm) -> Result { + T::from_form_fields(form.fields()) + .map_err(|error| FormDecodeError::new(error.message().to_owned())) + } +} + +impl FromInteractionForm for Form +where + T: FromForm, +{ + fn from_interaction_form(form: &InteractionForm) -> Result { + T::from_form_fields(form.fields()) + .map(Self) + .map_err(|error| FormDecodeError::new(error.message().to_owned())) + } +} + +impl FormDecodeError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +impl InteractionForm { + pub fn new(handle_id: u32, fields: impl IntoIterator) -> Self { + Self { + handle_id, + fields: fields.into_iter().collect(), + files: Vec::new(), + } + } + + pub fn for_handle( + handle: Handle, + fields: impl IntoIterator, + ) -> Self { + Self::new(handle.id().id, fields) + } + + pub fn parse_urlencoded(body: &[u8]) -> Result { + Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new()) + } + + pub async fn parse_multipart( + mut multipart: Multipart, + ) -> Result { + let mut fields = Vec::new(); + let mut files = Vec::new(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| InteractionFormRejection::InvalidBody)? + { + let Some(name) = field.name().map(str::to_owned) else { + continue; + }; + let file_name = field.file_name().map(str::to_owned); + let content_type = field.content_type().map(str::to_owned); + let bytes = field + .bytes() + .await + .map_err(|_| InteractionFormRejection::InvalidBody)?; + + if file_name.is_some() { + files.push(InteractionFile { + name, + file_name, + content_type, + bytes: bytes.to_vec(), + }); + } else { + let value = String::from_utf8(bytes.to_vec()) + .map_err(|_| InteractionFormRejection::InvalidBody)?; + fields.push((name, value)); + } + } + + Self::from_parts(fields, files) + } + + fn from_parts( + fields: Vec<(String, String)>, + files: Vec, + ) -> Result { + let Some(handle) = fields + .iter() + .find_map(|(name, value)| (name == HEMX_HANDLE_FIELD).then_some(value)) + else { + return Err(InteractionFormRejection::MissingHandle); + }; + let handle_id = handle + .parse::() + .map_err(|_| InteractionFormRejection::InvalidHandle)?; + Ok(Self { + handle_id, + fields, + files, + }) + } + + pub fn value(&self, name: &str) -> Option<&str> { + self.fields + .iter() + .find_map(|(field, value)| (field == name).then_some(value.as_str())) + } + + pub fn parse(&self, name: &str) -> Option + where + T: std::str::FromStr, + { + self.value(name).and_then(|value| value.parse().ok()) + } + + pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { + self.fields + .iter() + .filter_map(move |(field, value)| (field == name).then_some(value.as_str())) + } + + pub fn fields(&self) -> &[(String, String)] { + &self.fields + } + + pub fn files(&self) -> &[InteractionFile] { + &self.files + } + + pub fn file(&self, name: &str) -> Option<&InteractionFile> { + self.files.iter().find(|file| file.name == name) + } + + pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> { + self.value(name) + .ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`"))) + } + + pub fn parse_required(&self, name: &str) -> Result + where + T: std::str::FromStr, + { + self.required(name)? + .parse() + .map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`"))) + } +} + +impl IntoResponse for InteractionFormRejection { + fn into_response(self) -> axum::response::Response { + let (status, message) = match self { + Self::UnsupportedMediaType => ( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "hemx interactions require application/x-www-form-urlencoded or multipart/form-data", + ), + Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"), + Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"), + Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"), + Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"), + }; + (status, message).into_response() + } +} + +impl FromRequest for InteractionRequest +where + S: Send + Sync, +{ + type Rejection = InteractionFormRejection; + + async fn from_request(request: Request, state: &S) -> Result { + InteractionForm::from_request(request, state) + .await + .map(|form| Self { form }) + } +} + +impl FromRequest for InteractionForm +where + S: Send + Sync, +{ + type Rejection = InteractionFormRejection; + + async fn from_request(request: Request, state: &S) -> Result { + match interaction_media_type(request.headers())? { + InteractionMediaType::Multipart => { + let multipart = Multipart::from_request(request, state) + .await + .map_err(extractor_rejection)?; + Self::parse_multipart(multipart).await + } + InteractionMediaType::UrlEncoded => { + let bytes = Bytes::from_request(request, state) + .await + .map_err(extractor_rejection)?; + Self::parse_urlencoded(&bytes) + } + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum InteractionMediaType { + Multipart, + UrlEncoded, +} + +fn interaction_media_type( + headers: &HeaderMap, +) -> Result { + let content_type = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .ok_or(InteractionFormRejection::UnsupportedMediaType)?; + match content_type + .split(';') + .next() + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("multipart/form-data") => Ok(InteractionMediaType::Multipart), + Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded), + _ => Err(InteractionFormRejection::UnsupportedMediaType), + } +} + +fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection { + if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE { + InteractionFormRejection::BodyTooLarge + } else { + InteractionFormRejection::InvalidBody + } +} + +pub(super) fn parse_urlencoded_pairs( + body: &[u8], +) -> Result, InteractionFormRejection> { + if body.is_empty() { + return Ok(Vec::new()); + } + + body.split(|byte| *byte == b'&') + .map(|pair| { + let equals = pair.iter().position(|byte| *byte == b'='); + let (name, value) = match equals { + Some(index) => (&pair[..index], &pair[index + 1..]), + None => (pair, &[][..]), + }; + Ok((percent_decode(name)?, percent_decode(value)?)) + }) + .collect() +} + +pub(super) fn percent_decode(input: &[u8]) -> Result { + let mut out = Vec::with_capacity(input.len()); + let mut bytes = input.iter().copied(); + while let Some(byte) = bytes.next() { + match byte { + b'+' => out.push(b' '), + b'%' => { + let high = bytes + .next() + .and_then(hex) + .ok_or(InteractionFormRejection::InvalidBody)?; + let low = bytes + .next() + .and_then(hex) + .ok_or(InteractionFormRejection::InvalidBody)?; + out.push(high * 16 + low); + } + byte => out.push(byte), + } + } + String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody) +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} diff --git a/hemx-axum/src/lib.rs b/hemx-axum/src/lib.rs index ff0f39f..afc676d 100644 --- a/hemx-axum/src/lib.rs +++ b/hemx-axum/src/lib.rs @@ -11,6 +11,10 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; +mod forms; + +pub use forms::{Form, FromInteractionForm, InteractionFile, InteractionForm, InteractionRequest}; + pub const HEMX_PARTIAL_HEADER: &str = "x-hemx-partial"; pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint"; pub const HEMX_TITLE_HEADER: &str = "x-hemx-title"; @@ -134,78 +138,10 @@ pub struct EffectResponse { pub batch: EffectBatch, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InteractionFile { - pub name: String, - pub file_name: Option, - pub content_type: Option, - pub bytes: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InteractionForm { - pub handle_id: u32, - fields: Vec<(String, String)>, - files: Vec, -} - -/// A validated hemx mutation request. -/// -/// Only `application/x-www-form-urlencoded` and `multipart/form-data` are -/// accepted. Body size is intentionally host policy: apply Axum's -/// [`axum::extract::DefaultBodyLimit`] (or a compatible request-body limit) -/// to the mutation route; limit rejections become HTTP 413 before dispatch. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InteractionRequest { - form: InteractionForm, -} - pub trait DispatchRegistry { fn dispatch_form(self, form: InteractionForm) -> Result; } -pub trait FromInteractionForm: Sized { - fn from_interaction_form(form: &InteractionForm) -> Result; -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Form(pub T); - -impl Form { - pub fn into_inner(self) -> T { - self.0 - } -} - -impl std::ops::Deref for Form { - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl FromInteractionForm for T -where - T: FromForm, -{ - fn from_interaction_form(form: &InteractionForm) -> Result { - T::from_form_fields(form.fields()) - .map_err(|error| FormDecodeError::new(error.message().to_owned())) - } -} - -impl FromInteractionForm for Form -where - T: FromForm, -{ - fn from_interaction_form(form: &InteractionForm) -> Result { - T::from_form_fields(form.fields()) - .map(Self) - .map_err(|error| FormDecodeError::new(error.message().to_owned())) - } -} - pub trait FromHandlerState: Sized { fn from_handler_state(state: S) -> Self; } @@ -320,142 +256,6 @@ impl FromHandlerState for State { } } -impl FormDecodeError { - pub fn new(message: impl Into) -> Self { - Self { - message: message.into(), - } - } - - pub fn message(&self) -> &str { - &self.message - } -} - -impl InteractionForm { - pub fn new(handle_id: u32, fields: impl IntoIterator) -> Self { - Self { - handle_id, - fields: fields.into_iter().collect(), - files: Vec::new(), - } - } - - pub fn for_handle( - handle: Handle, - fields: impl IntoIterator, - ) -> Self { - Self::new(handle.id().id, fields) - } - - pub fn parse_urlencoded(body: &[u8]) -> Result { - Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new()) - } - - pub async fn parse_multipart( - mut multipart: Multipart, - ) -> Result { - let mut fields = Vec::new(); - let mut files = Vec::new(); - - while let Some(field) = multipart - .next_field() - .await - .map_err(|_| InteractionFormRejection::InvalidBody)? - { - let Some(name) = field.name().map(str::to_owned) else { - continue; - }; - let file_name = field.file_name().map(str::to_owned); - let content_type = field.content_type().map(str::to_owned); - let bytes = field - .bytes() - .await - .map_err(|_| InteractionFormRejection::InvalidBody)?; - - if file_name.is_some() { - files.push(InteractionFile { - name, - file_name, - content_type, - bytes: bytes.to_vec(), - }); - } else { - let value = String::from_utf8(bytes.to_vec()) - .map_err(|_| InteractionFormRejection::InvalidBody)?; - fields.push((name, value)); - } - } - - Self::from_parts(fields, files) - } - - fn from_parts( - fields: Vec<(String, String)>, - files: Vec, - ) -> Result { - let Some(handle) = fields - .iter() - .find_map(|(name, value)| (name == HEMX_HANDLE_FIELD).then_some(value)) - else { - return Err(InteractionFormRejection::MissingHandle); - }; - let handle_id = handle - .parse::() - .map_err(|_| InteractionFormRejection::InvalidHandle)?; - Ok(Self { - handle_id, - fields, - files, - }) - } - - pub fn value(&self, name: &str) -> Option<&str> { - self.fields - .iter() - .find_map(|(field, value)| (field == name).then_some(value.as_str())) - } - - pub fn parse(&self, name: &str) -> Option - where - T: std::str::FromStr, - { - self.value(name).and_then(|value| value.parse().ok()) - } - - pub fn values<'a>(&'a self, name: &'a str) -> impl Iterator + 'a { - self.fields - .iter() - .filter_map(move |(field, value)| (field == name).then_some(value.as_str())) - } - - pub fn fields(&self) -> &[(String, String)] { - &self.fields - } - - pub fn files(&self) -> &[InteractionFile] { - &self.files - } - - pub fn file(&self, name: &str) -> Option<&InteractionFile> { - self.files.iter().find(|file| file.name == name) - } - - pub fn required(&self, name: &str) -> Result<&str, FormDecodeError> { - self.value(name) - .ok_or_else(|| FormDecodeError::new(format!("missing form field `{name}`"))) - } - - pub fn parse_required(&self, name: &str) -> Result - where - T: std::str::FromStr, - { - self.required(name)? - .parse() - .map_err(|_| FormDecodeError::new(format!("invalid form field `{name}`"))) - } -} - pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry { HandlerRegistry::new(fingerprint) } @@ -1228,93 +1028,6 @@ impl DispatchRegistry for HandlerRegistry { } } -impl IntoResponse for InteractionFormRejection { - fn into_response(self) -> axum::response::Response { - let (status, message) = match self { - Self::UnsupportedMediaType => ( - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "hemx interactions require application/x-www-form-urlencoded or multipart/form-data", - ), - Self::BodyTooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "hemx interaction body exceeds the host limit"), - Self::InvalidBody => (StatusCode::BAD_REQUEST, "invalid hemx form body"), - Self::MissingHandle => (StatusCode::BAD_REQUEST, "missing __h hemx handle field"), - Self::InvalidHandle => (StatusCode::BAD_REQUEST, "invalid __h hemx handle field"), - }; - (status, message).into_response() - } -} - -impl FromRequest for InteractionRequest -where - S: Send + Sync, -{ - type Rejection = InteractionFormRejection; - - async fn from_request(request: Request, state: &S) -> Result { - InteractionForm::from_request(request, state) - .await - .map(|form| Self { form }) - } -} - -impl FromRequest for InteractionForm -where - S: Send + Sync, -{ - type Rejection = InteractionFormRejection; - - async fn from_request(request: Request, state: &S) -> Result { - match interaction_media_type(request.headers())? { - InteractionMediaType::Multipart => { - let multipart = Multipart::from_request(request, state) - .await - .map_err(extractor_rejection)?; - Self::parse_multipart(multipart).await - } - InteractionMediaType::UrlEncoded => { - let bytes = Bytes::from_request(request, state) - .await - .map_err(extractor_rejection)?; - Self::parse_urlencoded(&bytes) - } - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum InteractionMediaType { - Multipart, - UrlEncoded, -} - -fn interaction_media_type( - headers: &HeaderMap, -) -> Result { - let content_type = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .ok_or(InteractionFormRejection::UnsupportedMediaType)?; - match content_type - .split(';') - .next() - .map(str::trim) - .map(str::to_ascii_lowercase) - .as_deref() - { - Some("multipart/form-data") => Ok(InteractionMediaType::Multipart), - Some("application/x-www-form-urlencoded") => Ok(InteractionMediaType::UrlEncoded), - _ => Err(InteractionFormRejection::UnsupportedMediaType), - } -} - -fn extractor_rejection(rejection: impl IntoResponse) -> InteractionFormRejection { - if rejection.into_response().status() == StatusCode::PAYLOAD_TOO_LARGE { - InteractionFormRejection::BodyTooLarge - } else { - InteractionFormRejection::InvalidBody - } -} - impl PageMode { pub fn from_headers(headers: &HeaderMap) -> Self { match headers @@ -1502,61 +1215,13 @@ fn base64_url_no_pad(input: &[u8]) -> String { out } -fn parse_urlencoded_pairs(body: &[u8]) -> Result, InteractionFormRejection> { - if body.is_empty() { - return Ok(Vec::new()); - } - - body.split(|byte| *byte == b'&') - .map(|pair| { - let equals = pair.iter().position(|byte| *byte == b'='); - let (name, value) = match equals { - Some(index) => (&pair[..index], &pair[index + 1..]), - None => (pair, &[][..]), - }; - Ok((percent_decode(name)?, percent_decode(value)?)) - }) - .collect() -} - -fn percent_decode(input: &[u8]) -> Result { - let mut out = Vec::with_capacity(input.len()); - let mut bytes = input.iter().copied(); - while let Some(byte) = bytes.next() { - match byte { - b'+' => out.push(b' '), - b'%' => { - let high = bytes - .next() - .and_then(hex) - .ok_or(InteractionFormRejection::InvalidBody)?; - let low = bytes - .next() - .and_then(hex) - .ok_or(InteractionFormRejection::InvalidBody)?; - out.push(high * 16 + low); - } - byte => out.push(byte), - } - } - String::from_utf8(out).map_err(|_| InteractionFormRejection::InvalidBody) -} - -fn hex(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - #[cfg(test)] mod tests { use super::{ - base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs, - percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection, - HEMX_SSE_EVENT, + base64_url_no_pad, encode_sse_batch, + forms::{parse_urlencoded_pairs, percent_decode}, + html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm, + InteractionFormRejection, HEMX_SSE_EVENT, }; use axum::{ body::{to_bytes, Body}, diff --git a/hemx-axum/tests/response.rs b/hemx-axum/tests/response.rs index 58e1034..e7093d6 100644 --- a/hemx-axum/tests/response.rs +++ b/hemx-axum/tests/response.rs @@ -9,9 +9,10 @@ use axum::{ use hemx_axum::{ interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, runtime_js_script_src, runtime_js_source, 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, + FormDecodeError, FromInteractionForm, 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, Effect, Handle, IntoEffect, SafeHtml, Slot}; use scraper::{Html, Selector}; @@ -38,6 +39,39 @@ async fn multipart_mutation(request: InteractionRequest) -> StatusCode { StatusCode::NO_CONTENT } +struct TypedUpload { + title: String, + bytes: usize, +} + +impl FromInteractionForm for TypedUpload { + fn from_interaction_form(form: &InteractionForm) -> Result { + let title = form + .value("title") + .ok_or_else(|| FormDecodeError::new("missing title"))?; + let upload = form + .file("upload") + .ok_or_else(|| FormDecodeError::new("missing upload"))?; + Ok(Self { + title: title.to_owned(), + bytes: upload.bytes.len(), + }) + } +} + +async fn typed_multipart_mutation( + request: InteractionRequest, +) -> Result { + request.dispatch(interactions(BuildFingerprint(4)).on_form( + Handle::<()>::new(7), + |upload: TypedUpload| { + assert_eq!(upload.title, "report"); + assert_eq!(upload.bytes, 5); + Slot::::new(3).text("accepted") + }, + )) +} + #[tokio::test] async fn interaction_boundary_honors_media_type_and_host_body_limit() { let _guard = BOUNDARY_TEST_LOCK.lock().await; @@ -131,6 +165,25 @@ async fn interaction_boundary_extracts_multipart_fields_and_files() { .unwrap(); assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let typed_response = Router::new() + .route("/typed-upload", post(typed_multipart_mutation)) + .oneshot( + Request::post("/typed-upload") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(typed_response.status(), StatusCode::OK); + assert_eq!( + typed_response.headers()[header::CONTENT_TYPE], + HEMX_CONTENT_TYPE + ); } #[tokio::test] diff --git a/hemx-derive/src/lib.rs b/hemx-derive/src/lib.rs index f6d2543..4bf37a5 100644 --- a/hemx-derive/src/lib.rs +++ b/hemx-derive/src/lib.rs @@ -231,7 +231,12 @@ fn form_model_type(ty: &Type) -> Option { let segments = path.path.segments.iter().collect::>(); let form = match segments.as_slice() { [form] if form.ident == "Form" => form, - [hemx, .., form] if hemx.ident == "hemx" && form.ident == "Form" => form, + [namespace, .., form] + if (namespace.ident == "hemx" || namespace.ident == "hemx_axum") + && form.ident == "Form" => + { + form + } _ => return None, }; let PathArguments::AngleBracketed(args) = &form.arguments else { @@ -928,6 +933,25 @@ mod tests { use quote::{quote, ToTokens}; use syn::{parse_quote, ItemFn, Type}; + #[test] + fn handler_form_model_accepts_documented_qualified_spellings() { + let local: ItemFn = parse_quote!( + fn save(form: Form) {} + ); + let adapter: ItemFn = parse_quote!( + fn save(form: hemx_axum::Form) {} + ); + let facade: ItemFn = parse_quote!( + fn save(form: hemx::Form) {} + ); + + for item in [&local, &adapter, &facade] { + let model = handler_form_model_type(item).expect("typed form model"); + assert_eq!(model.to_token_stream().to_string(), "Project"); + assert!(has_form_param(item)); + } + } + #[test] fn generated_file_and_form_helpers_preserve_exact_contracts() { assert_eq!( diff --git a/tests/redgate_test.sh b/tests/redgate_test.sh index 080605b..8ca5417 100755 --- a/tests/redgate_test.sh +++ b/tests/redgate_test.sh @@ -19,13 +19,15 @@ cargo test -p hemx-build no_op_build_preserves_generated_artifact_timestamps --l cargo test -p hemx-build changed_template_refreshes_generated_artifacts --lib # spec: resource/005 check cargo test -p hemx-derive --test surface surface_macro_preserves_inline_module_with_generated_file -- --exact # spec: derive/001 check cargo test -p hemx-derive --test compile_fail handler_macro_reports_unknown_handle_and_bad_shape -- --exact # spec: derive/002 check +cargo test -p hemx-derive handler_form_model_accepts_documented_qualified_spellings --lib # spec: assurance/002 check cargo test -p hemx-derive --test compile_fail component_macro_reports_missing_handler_implementation -- --exact # spec: derive/003 check cargo test -p hemx-derive --test compile_fail generated_resource_references_fail_when_name_is_absent -- --exact # spec: derive/004 check # spec: resource/006 check cargo test -p hemx-axum --test response effect_response_is_wire_batch_with_fingerprint_header -- --exact # spec: axum/001 check cargo test -p hemx-axum --test response runtime_js_response_serves_embedded_runtime -- --exact # spec: axum/002 check # spec: boundary/001 check cargo test -p hemx-axum --test response partial_page_response_sets_partial_and_title_headers -- --exact # spec: axum/003 check cargo test -p hemx-js --test runtime runtime_popstate_failed_partials_reload_instead_of_stale_ui -- --exact # spec: axum/003 check -cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check +cargo test -p hemx-axum --test response interaction_boundary_honors_media_type_and_host_body_limit -- --exact # spec: axum/004 check # spec: assurance/002 check +test -f hemx-axum/src/forms.rs && grep -q '^mod forms;$' hemx-axum/src/lib.rs && ! grep -Eq '^impl IntoResponse for InteractionFormRejection|^impl FromRequest for InteractionRequest|^fn parse_urlencoded_pairs\(' hemx-axum/src/lib.rs # spec: architecture/004 check cargo test -p hemx-axum --test response effect_and_dispatch_responses_preserve_status_wire_and_diagnostics -- --exact # spec: axum/005 check cargo test -p hemx-js --test runtime runtime_exposes_debug_api_before_startup_side_effects -- --exact # spec: runtime/002 check cargo test -p hemx-build generated_lowering_preserves_native_interaction_contract --lib # spec: html/001 check # spec: html/002 check # spec: html/003 check # spec: html/004 check