extractors.rs
181 lignes · 6130 octets
//! Custom extractors for form data with duplicate fields //! //! This module provides extractors that handle HTML form data with duplicate field names, //! which is common when multiple checkboxes share the same name (e.g., roles checkboxes). use axum::{ extract::{FromRequest, Request}, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; use std::collections::HashMap; /// Extract form data that handles duplicate field names /// /// This extractor parses URL-encoded form data and handles duplicate field names /// by collecting all values into a vector. This is needed for HTML forms where /// multiple checkboxes can have the same name (e.g., `roles=user&roles=admin`). /// /// # Usage /// /// ```rust,ignore /// pub async fn create_user( /// FormWithDuplicates(form_data): FormWithDuplicates<CreateUserRequest>, /// ) -> Result<impl IntoResponse, AppError> { /// // form_data.roles will be a Vec<String> with all selected roles /// } /// ``` #[derive(Debug)] pub struct FormWithDuplicates<T>(pub T); impl<T, S> FromRequest<S> for FormWithDuplicates<T> where T: for<'de> Deserialize<'de>, S: Send + Sync, { type Rejection = FormRejection; async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> { // Check content type let content_type = req .headers() .get("content-type") .and_then(|h| h.to_str().ok()) .unwrap_or(""); if !content_type.starts_with("application/x-www-form-urlencoded") { return Err(FormRejection::InvalidContentType); } // Extract body let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) .await .map_err(|_| FormRejection::BodyReadError)?; let body_str = std::str::from_utf8(&body_bytes).map_err(|_| FormRejection::InvalidEncoding)?; // Parse URL-encoded data and handle duplicates let parsed_map = parse_urlencoded_with_duplicates(body_str)?; // Convert to JSON value for deserialization let json_value = serde_json::to_value(&parsed_map) .map_err(|e| FormRejection::DeserializeError(e.to_string()))?; // Deserialize into target type let deserialized = T::deserialize(&json_value) .map_err(|e| FormRejection::DeserializeError(e.to_string()))?; Ok(FormWithDuplicates(deserialized)) } } /// Parse URL-encoded string and handle duplicate keys by collecting them into vectors fn parse_urlencoded_with_duplicates( input: &str, ) -> Result<HashMap<String, serde_json::Value>, FormRejection> { let mut map: HashMap<String, Vec<String>> = HashMap::new(); for pair in input.split('&') { if pair.is_empty() { continue; } let (key, value) = if let Some(eq_pos) = pair.find('=') { // Simple URL decoding - replace %XX with actual characters let key = percent_decode(&pair[..eq_pos]).map_err(|_| FormRejection::InvalidEncoding)?; let value = percent_decode(&pair[eq_pos + 1..]).map_err(|_| FormRejection::InvalidEncoding)?; (key, value) } else { let key = percent_decode(pair).map_err(|_| FormRejection::InvalidEncoding)?; (key, String::new()) }; map.entry(key).or_default().push(value); } // Convert to HashMap<String, Value> where single values are strings and multiple values are arrays let mut result = HashMap::new(); for (key, values) in map { if values.len() == 1 { result.insert(key, serde_json::Value::String(values[0].clone())); } else { let array: Vec<serde_json::Value> = values.into_iter().map(serde_json::Value::String).collect(); result.insert(key, serde_json::Value::Array(array)); } } Ok(result) } /// Simple percent decoding for URL-encoded strings /// Handles UTF-8 characters correctly by collecting bytes first fn percent_decode(input: &str) -> Result<String, ()> { let mut result = Vec::new(); let bytes = input.as_bytes(); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { if i + 2 >= bytes.len() { return Err(()); } let hex = &input[i + 1..i + 3]; if let Ok(byte) = u8::from_str_radix(hex, 16) { result.push(byte); i += 3; } else { return Err(()); } } else if bytes[i] == b'+' { result.push(b' '); i += 1; } else { result.push(bytes[i]); i += 1; } } String::from_utf8(result).map_err(|_| ()) } /// Rejection type for form extraction errors #[derive(Debug)] pub enum FormRejection { /// Content-Type header is not `application/x-www-form-urlencoded`. InvalidContentType, /// Failed to read the request body bytes. BodyReadError, /// Body bytes are not valid UTF-8 or percent-encoding is malformed. InvalidEncoding, /// Deserialization of the form data into the target type failed. DeserializeError(String), } impl IntoResponse for FormRejection { fn into_response(self) -> Response { let (status, message) = match self { FormRejection::InvalidContentType => ( StatusCode::BAD_REQUEST, "Invalid content type. Expected application/x-www-form-urlencoded".to_string(), ), FormRejection::BodyReadError => ( StatusCode::BAD_REQUEST, "Failed to read request body".to_string(), ), FormRejection::InvalidEncoding => { (StatusCode::BAD_REQUEST, "Invalid URL encoding".to_string()) } FormRejection::DeserializeError(msg) => ( StatusCode::BAD_REQUEST, format!("Failed to deserialize form: {}", msg), ), }; (status, message).into_response() } }
GitRust