Localize Axum interaction forms
This commit is contained in:
@@ -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<T>` or custom multipart model -> registered handler/rejection.
|
||||
Build: move the complete form boundary with its tests; add compile coverage for documented `Form<T>` 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<T> spelling check; custom multipart typed extraction; Redgate; diff check; fresh review.
|
||||
State: Done
|
||||
Blocked by: none
|
||||
|
||||
## Closure
|
||||
|
||||
@@ -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
@@ -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<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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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<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)]
|
||||
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},
|
||||
|
||||
@@ -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<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]
|
||||
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]
|
||||
|
||||
+25
-1
@@ -231,7 +231,12 @@ fn form_model_type(ty: &Type) -> Option<Type> {
|
||||
let segments = path.path.segments.iter().collect::<Vec<_>>();
|
||||
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<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]
|
||||
fn generated_file_and_form_helpers_preserve_exact_contracts() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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<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-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
|
||||
|
||||
Reference in New Issue
Block a user