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, } }