use askama::Template; use axum::async_trait; use axum::extract::{Form, FromRequest, Json as JsonPayload, Request}; use axum::http::{HeaderMap, HeaderValue, header::CONTENT_TYPE}; use axum::response::{Html, IntoResponse, Response}; use serde::Deserialize; use tracing::warn; use crate::application::errors::{ApiError, AppError}; use crate::application::server::AppState; use crate::domain::listing::{ DEFAULT_PAGE_SIZE, ListRequest, Page, PageSize, SortDirection, SortKey, }; use crate::presentation::web::views::{ CafeOptionView, ListNavigator, Paginated, RoastOptionView, RoasterOptionView, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PayloadSource { Json, Form, } #[derive(Debug)] 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, #[serde(default)] q: Option, } #[derive(Debug, Deserialize)] #[serde(untagged)] enum PageSizeParam { Number(u32), Text(String), } impl ListQuery { pub fn search_value(&self) -> String { self.q.clone().unwrap_or_default() } pub fn into_request_and_search(self) -> (ListRequest, Option) { self.into_request_and_search_with_default::(DEFAULT_PAGE_SIZE) } pub fn into_request_and_search_with_default( self, default_page_size: u32, ) -> (ListRequest, Option) { let ListQuery { page, page_size, sort_key, sort_dir, q, } = self; let search = q.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); let page = page.unwrap_or(1); let page_size = match page_size { Some(PageSizeParam::Number(value)) => PageSize::limited(value), Some(PageSizeParam::Text(text)) => page_size_from_text(&text), None => PageSize::limited(default_page_size.max(1)), }; let sk = sort_key .as_deref() .and_then(K::from_query) .unwrap_or_else(K::default); let sd = sort_dir .as_deref() .and_then(parse_direction) .unwrap_or_else(|| sk.default_direction()); (ListRequest::new(page, page_size, sk, sd), search) } } pub fn normalize_request(request: ListRequest, page: &Page) -> ListRequest where K: SortKey, { let page_size = if page.showing_all { PageSize::All } else { PageSize::limited(page.page_size) }; ListRequest::new( page.page, page_size, request.sort_key(), request.sort_direction(), ) } pub fn build_page_view( page: Page, request: ListRequest, view_mapper: impl FnMut(T) -> V, base_path: impl Into, fragment_path: impl Into, search: Option, ) -> (Paginated, ListNavigator) where K: SortKey, { let normalized_request = normalize_request(request, &page); let view_page = Paginated::from_page(page, view_mapper); let navigator = ListNavigator::new(base_path, fragment_path, normalized_request, search); (view_page, navigator) } pub fn render_fragment( template: T, selector: &'static str, ) -> Result { let html = crate::presentation::web::templates::render_template(template) .map_err(|err| AppError::unexpected(format!("failed to render fragment: {err}")))?; let mut response = Html(html).into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); set_datastar_patch_headers(response.headers_mut(), selector); Ok(response) } 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(|_| { warn!("failed to parse JSON payload"); 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(|_| { warn!("failed to parse form payload"); ApiError::from(AppError::validation("invalid form payload")) })?; return Ok(Self { inner: payload, source: PayloadSource::Form, }); } Err(AppError::validation("unsupported content type").into()) } } pub(super) async fn load_roaster_options( state: &AppState, ) -> Result, AppError> { use crate::domain::roasters::RoasterSortKey; let roasters = state .roaster_repo .list_all_sorted(RoasterSortKey::Name, SortDirection::Asc) .await .map_err(AppError::from)?; Ok(roasters.into_iter().map(RoasterOptionView::from).collect()) } pub(super) async fn load_roast_options(state: &AppState) -> Result, AppError> { let roasts = state.roast_repo.list_all().await.map_err(AppError::from)?; Ok(roasts.into_iter().map(RoastOptionView::from).collect()) } pub(super) async fn load_cafe_options(state: &AppState) -> Result, AppError> { use crate::domain::cafes::CafeSortKey; let cafes = state .cafe_repo .list_all_sorted(CafeSortKey::Name, SortDirection::Asc) .await .map_err(AppError::from)?; Ok(cafes.into_iter().map(CafeOptionView::from).collect()) } /// Record AI usage in the background. Failures are logged but do not affect the response. pub fn record_ai_usage( repo: std::sync::Arc, user_id: crate::domain::ids::UserId, model: &str, endpoint: &str, usage: Option, ) { let Some(usage) = usage else { return }; let new_usage = crate::domain::ai_usage::NewAiUsage { user_id, model: model.to_string(), endpoint: endpoint.to_string(), prompt_tokens: usage.prompt_tokens, completion_tokens: usage.completion_tokens, total_tokens: usage.total_tokens, cost: usage.cost, }; tokio::spawn(async move { if let Err(err) = repo.insert(new_usage).await { tracing::warn!(error = %err, "failed to record AI usage"); } }); } pub fn is_datastar_request(headers: &HeaderMap) -> bool { headers .get("datastar-request") .and_then(|value| value.to_str().ok()) .is_some_and(|value| value.eq_ignore_ascii_case("true")) } 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")); } /// Render a `
` fragment with `data-signals` attributes for Datastar signal merging. /// /// The selector must be a `#id` selector. Signal values are JSON-encoded and HTML-escaped /// so they are safe to embed in HTML attributes and evaluate as JavaScript expressions. pub fn render_signals_fragment( selector: &'static str, signals: &[(&str, serde_json::Value)], ) -> Result { use std::fmt::Write; let id = selector.strip_prefix('#').unwrap_or(selector); let mut html = format!(r#"
"); let mut response = Html(html).into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("text/html")); set_datastar_patch_headers(response.headers_mut(), selector); Ok(response) } /// Return a JSON response that Datastar interprets as a signal patch. /// /// Signal names may use kebab-case (`_roaster-name`); they are automatically /// converted to camelCase (`_roasterName`) to match Datastar's internal store. pub fn render_signals_json(signals: &[(&str, serde_json::Value)]) -> Result { let mut map = serde_json::Map::new(); for (name, value) in signals { map.insert(kebab_to_camel(name), value.clone()); } let body = serde_json::Value::Object(map).to_string(); let mut response = body.into_response(); response .headers_mut() .insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); Ok(response) } fn kebab_to_camel(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut cap_next = false; for c in s.chars() { if c == '-' { cap_next = true; } else if cap_next { result.push(c.to_ascii_uppercase()); cap_next = false; } else { result.push(c); } } result } fn escape_html_attr(s: &str) -> String { s.replace('&', "&") .replace('"', """) .replace('<', "<") .replace('>', ">") } #[cfg(test)] mod tests { use super::*; #[test] fn is_datastar_request_detects_correctly() { let mut headers = HeaderMap::new(); headers.insert("datastar-request", HeaderValue::from_static("true")); assert!(is_datastar_request(&headers)); } #[test] fn is_datastar_request_detects_true_flag_case_insensitively() { let mut headers = HeaderMap::new(); headers.insert("datastar-request", HeaderValue::from_static("TrUe")); assert!(is_datastar_request(&headers)); } #[test] fn is_datastar_request_defaults_to_false() { let mut headers = HeaderMap::new(); headers.insert("datastar-request", HeaderValue::from_static("nope")); assert!(!is_datastar_request(&headers)); assert!(!is_datastar_request(&HeaderMap::new())); } #[test] fn set_datastar_patch_headers_sets_expected_values() { let mut headers = HeaderMap::new(); set_datastar_patch_headers(&mut headers, "body > div"); assert_eq!( headers.get("datastar-selector"), Some(&HeaderValue::from_static("body > div")) ); assert_eq!( headers.get("datastar-mode"), Some(&HeaderValue::from_static("replace")) ); } }