use axum::async_trait; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; use serde::Deserialize; use crate::domain::listing::{DEFAULT_PAGE_SIZE, ListRequest, PageSize, SortDirection, SortKey}; use crate::server::errors::{ApiError, AppError}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PayloadSource { Json, Form, } pub struct FlexiblePayload { inner: T, source: PayloadSource, } impl FlexiblePayload { pub fn into_parts(self) -> (T, PayloadSource) { (self.inner, self.source) } } #[derive(Debug, Default, Deserialize)] pub(crate) struct ListQuery { page: Option, #[serde(default)] page_size: Option, #[serde(default, rename = "sort")] sort_key: Option, #[serde(default, rename = "dir")] sort_dir: Option, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum PageSizeParam { Number(u32), Text(String), } impl ListQuery { pub fn into_request(self) -> ListRequest { let page = self.page.unwrap_or(1); let page_size = match self.page_size { Some(PageSizeParam::Number(value)) => PageSize::limited(value), Some(PageSizeParam::Text(text)) => page_size_from_text(&text), None => PageSize::limited(DEFAULT_PAGE_SIZE), }; let sort_key = self .sort_key .as_deref() .and_then(K::from_query) .unwrap_or_else(K::default); let sort_direction = self .sort_dir .as_deref() .and_then(parse_direction) .unwrap_or_else(|| sort_key.default_direction()); ListRequest::new(page, page_size, sort_key, sort_direction) } } fn page_size_from_text(value: &str) -> PageSize { if value.eq_ignore_ascii_case("all") { PageSize::All } else if let Ok(parsed) = value.parse::() { PageSize::limited(parsed) } else { PageSize::limited(DEFAULT_PAGE_SIZE) } } fn parse_direction(value: &str) -> Option { match value.to_ascii_lowercase().as_str() { "asc" => Some(SortDirection::Asc), "desc" => Some(SortDirection::Desc), _ => None, } } #[async_trait] impl FromRequest for FlexiblePayload where S: Send + Sync, T: Send + 'static, JsonPayload: FromRequest, Form: FromRequest, { type Rejection = ApiError; async fn from_request(req: Request, state: &S) -> Result { let content_type = req .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_ascii_lowercase(); if content_type.starts_with("application/json") { let JsonPayload(payload) = JsonPayload::::from_request(req, state) .await .map_err(|_| ApiError::from(AppError::validation("invalid JSON payload")))?; return Ok(Self { inner: payload, source: PayloadSource::Json, }); } if content_type.is_empty() || content_type.starts_with("application/x-www-form-urlencoded") { let Form(payload) = Form::::from_request(req, state) .await .map_err(|_| ApiError::from(AppError::validation("invalid form payload")))?; return Ok(Self { inner: payload, source: PayloadSource::Form, }); } Err(AppError::validation("unsupported content type").into()) } } pub fn is_datastar_request(headers: &HeaderMap) -> bool { headers .get("datastar-request") .and_then(|value| value.to_str().ok()) .map(|value| value.eq_ignore_ascii_case("true")) .unwrap_or(false) } pub fn set_datastar_patch_headers(headers: &mut HeaderMap, selector: &'static str) { let _ = headers.insert("datastar-selector", HeaderValue::from_static(selector)); let _ = headers.insert("datastar-mode", HeaderValue::from_static("replace")); }