Localize Axum interaction forms

This commit is contained in:
tmk241
2026-09-01 23:50:26 +02:00
parent 42710354a6
commit 4559434068
6 changed files with 438 additions and 357 deletions
+3 -9
View File
@@ -40,16 +40,10 @@ Blocked by: HMX-M02
## HMX-M04 — Localize Axum interaction forms ## HMX-M04 — Localize Axum interaction forms
Outcome: the interaction-form boundary is privately owned without public extraction, decoding, limit, or rejection drift.
Outcome: media-type enforcement, body limits, URL-encoded/multipart extraction, typed decoding, and rejections have one private Axum owner.
Delta: architecture/004 assurance/002 Delta: architecture/004 assurance/002
Path: HTTP request -> `InteractionRequest` extraction -> typed `Form<T>` or custom multipart model -> registered handler/rejection. Checks: Axum interaction boundary suite; derive Form<T> spelling check; custom multipart typed extraction; Redgate; diff check; fresh review.
Build: move the complete form boundary with its tests; add compile coverage for documented `Form<T>` spellings and custom `FromInteractionForm` extraction. State: Done
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
Blocked by: none Blocked by: none
## Closure ## Closure
+343
View File
@@ -0,0 +1,343 @@
use super::*;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionFile {
pub name: String,
pub file_name: Option<String>,
pub content_type: Option<String>,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionForm {
pub handle_id: u32,
fields: Vec<(String, String)>,
files: Vec<InteractionFile>,
}
/// 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<Self, FormDecodeError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Form<T>(pub T);
impl<T> Form<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> std::ops::Deref for Form<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> FromInteractionForm for T
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
impl<T> FromInteractionForm for Form<T>
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
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<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl InteractionForm {
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
handle_id,
fields: fields.into_iter().collect(),
files: Vec::new(),
}
}
pub fn for_handle<I>(
handle: Handle<I>,
fields: impl IntoIterator<Item = (String, String)>,
) -> Self {
Self::new(handle.id().id, fields)
}
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
}
pub async fn parse_multipart(
mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> {
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<InteractionFile>,
) -> Result<Self, InteractionFormRejection> {
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::<u32>()
.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<T>(&self, name: &str) -> Option<T>
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<Item = &'a str> + '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<T>(&self, name: &str) -> Result<T, FormDecodeError>
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<S> FromRequest<S> for InteractionRequest
where
S: Send + Sync,
{
type Rejection = InteractionFormRejection;
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(request, state)
.await
.map(|form| Self { form })
}
}
impl<S> FromRequest<S> for InteractionForm
where
S: Send + Sync,
{
type Rejection = InteractionFormRejection;
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(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<InteractionMediaType, InteractionFormRejection> {
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<Vec<(String, String)>, 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<String, InteractionFormRejection> {
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<u8> {
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,
}
}
+8 -343
View File
@@ -11,6 +11,10 @@ use std::convert::Infallible;
use std::future::Future; use std::future::Future;
use std::pin::Pin; 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_PARTIAL_HEADER: &str = "x-hemx-partial";
pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint"; pub const HEMX_FINGERPRINT_HEADER: &str = "x-hemx-fingerprint";
pub const HEMX_TITLE_HEADER: &str = "x-hemx-title"; pub const HEMX_TITLE_HEADER: &str = "x-hemx-title";
@@ -134,78 +138,10 @@ pub struct EffectResponse {
pub batch: EffectBatch, pub batch: EffectBatch,
} }
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionFile {
pub name: String,
pub file_name: Option<String>,
pub content_type: Option<String>,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InteractionForm {
pub handle_id: u32,
fields: Vec<(String, String)>,
files: Vec<InteractionFile>,
}
/// 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 { pub trait DispatchRegistry {
fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>; fn dispatch_form(self, form: InteractionForm) -> Result<EffectResponse, DispatchRejection>;
} }
pub trait FromInteractionForm: Sized {
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError>;
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Form<T>(pub T);
impl<T> Form<T> {
pub fn into_inner(self) -> T {
self.0
}
}
impl<T> std::ops::Deref for Form<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T> FromInteractionForm for T
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
impl<T> FromInteractionForm for Form<T>
where
T: FromForm,
{
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
T::from_form_fields(form.fields())
.map(Self)
.map_err(|error| FormDecodeError::new(error.message().to_owned()))
}
}
pub trait FromHandlerState<S>: Sized { pub trait FromHandlerState<S>: Sized {
fn from_handler_state(state: S) -> Self; fn from_handler_state(state: S) -> Self;
} }
@@ -320,142 +256,6 @@ impl<S> FromHandlerState<S> for State<S> {
} }
} }
impl FormDecodeError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
pub fn message(&self) -> &str {
&self.message
}
}
impl InteractionForm {
pub fn new(handle_id: u32, fields: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
handle_id,
fields: fields.into_iter().collect(),
files: Vec::new(),
}
}
pub fn for_handle<I>(
handle: Handle<I>,
fields: impl IntoIterator<Item = (String, String)>,
) -> Self {
Self::new(handle.id().id, fields)
}
pub fn parse_urlencoded(body: &[u8]) -> Result<Self, InteractionFormRejection> {
Self::from_parts(parse_urlencoded_pairs(body)?, Vec::new())
}
pub async fn parse_multipart(
mut multipart: Multipart,
) -> Result<Self, InteractionFormRejection> {
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<InteractionFile>,
) -> Result<Self, InteractionFormRejection> {
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::<u32>()
.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<T>(&self, name: &str) -> Option<T>
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<Item = &'a str> + '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<T>(&self, name: &str) -> Result<T, FormDecodeError>
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 { pub const fn handlers(fingerprint: BuildFingerprint) -> HandlerRegistry {
HandlerRegistry::new(fingerprint) 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<S> FromRequest<S> for InteractionRequest
where
S: Send + Sync,
{
type Rejection = InteractionFormRejection;
async fn from_request(request: Request<Body>, state: &S) -> Result<Self, Self::Rejection> {
InteractionForm::from_request(request, state)
.await
.map(|form| Self { form })
}
}
impl<S> FromRequest<S> for InteractionForm
where
S: Send + Sync,
{
type Rejection = InteractionFormRejection;
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(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<InteractionMediaType, InteractionFormRejection> {
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 { impl PageMode {
pub fn from_headers(headers: &HeaderMap) -> Self { pub fn from_headers(headers: &HeaderMap) -> Self {
match headers match headers
@@ -1502,61 +1215,13 @@ fn base64_url_no_pad(input: &[u8]) -> String {
out out
} }
fn parse_urlencoded_pairs(body: &[u8]) -> Result<Vec<(String, String)>, 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<String, InteractionFormRejection> {
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<u8> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
base64_url_no_pad, encode_sse_batch, html_with_root_fingerprint, parse_urlencoded_pairs, base64_url_no_pad, encode_sse_batch,
percent_decode, sse, BuildFingerprint, InteractionForm, InteractionFormRejection, forms::{parse_urlencoded_pairs, percent_decode},
HEMX_SSE_EVENT, html_with_root_fingerprint, sse, BuildFingerprint, InteractionForm,
InteractionFormRejection, HEMX_SSE_EVENT,
}; };
use axum::{ use axum::{
body::{to_bytes, Body}, body::{to_bytes, Body},
+56 -3
View File
@@ -9,9 +9,10 @@ use axum::{
use hemx_axum::{ use hemx_axum::{
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
HandlerErrorContext, HandlerFailure, InteractionForm, InteractionFormRejection, FormDecodeError, FromInteractionForm, HandlerErrorContext, HandlerFailure, InteractionForm,
InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE, InteractionFormRejection, InteractionRequest, IntoHandlerFailure, PageMode, PageRequest,
HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, 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 hemx_core::{push, BuildFingerprint, Effect, Handle, IntoEffect, SafeHtml, Slot};
use scraper::{Html, Selector}; use scraper::{Html, Selector};
@@ -38,6 +39,39 @@ async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
struct TypedUpload {
title: String,
bytes: usize,
}
impl FromInteractionForm for TypedUpload {
fn from_interaction_form(form: &InteractionForm) -> Result<Self, FormDecodeError> {
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<EffectResponse, DispatchRejection> {
request.dispatch(interactions(BuildFingerprint(4)).on_form(
Handle::<()>::new(7),
|upload: TypedUpload| {
assert_eq!(upload.title, "report");
assert_eq!(upload.bytes, 5);
Slot::<String>::new(3).text("accepted")
},
))
}
#[tokio::test] #[tokio::test]
async fn interaction_boundary_honors_media_type_and_host_body_limit() { async fn interaction_boundary_honors_media_type_and_host_body_limit() {
let _guard = BOUNDARY_TEST_LOCK.lock().await; let _guard = BOUNDARY_TEST_LOCK.lock().await;
@@ -131,6 +165,25 @@ async fn interaction_boundary_extracts_multipart_fields_and_files() {
.unwrap(); .unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT); 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] #[tokio::test]
+25 -1
View File
@@ -231,7 +231,12 @@ fn form_model_type(ty: &Type) -> Option<Type> {
let segments = path.path.segments.iter().collect::<Vec<_>>(); let segments = path.path.segments.iter().collect::<Vec<_>>();
let form = match segments.as_slice() { let form = match segments.as_slice() {
[form] if form.ident == "Form" => form, [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, _ => return None,
}; };
let PathArguments::AngleBracketed(args) = &form.arguments else { let PathArguments::AngleBracketed(args) = &form.arguments else {
@@ -928,6 +933,25 @@ mod tests {
use quote::{quote, ToTokens}; use quote::{quote, ToTokens};
use syn::{parse_quote, ItemFn, Type}; use syn::{parse_quote, ItemFn, Type};
#[test]
fn handler_form_model_accepts_documented_qualified_spellings() {
let local: ItemFn = parse_quote!(
fn save(form: Form<Project>) {}
);
let adapter: ItemFn = parse_quote!(
fn save(form: hemx_axum::Form<Project>) {}
);
let facade: ItemFn = parse_quote!(
fn save(form: hemx::Form<Project>) {}
);
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] #[test]
fn generated_file_and_form_helpers_preserve_exact_contracts() { fn generated_file_and_form_helpers_preserve_exact_contracts() {
assert_eq!( assert_eq!(
+3 -1
View File
@@ -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-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 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 --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 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-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 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 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-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-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<S> FromRequest<S> 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-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-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 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