- Log bearer token lookup and user lookup failures in AuthenticatedUser extractor - Log session lookup and user lookup failures in session-based authentication - Log fire-and-forget token update_last_used errors - Log JSON and form payload parse failures in FlexiblePayload
395 lines
12 KiB
Rust
395 lines
12 KiB
Rust
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<T> {
|
|
inner: T,
|
|
source: PayloadSource,
|
|
}
|
|
|
|
impl<T> FlexiblePayload<T> {
|
|
pub fn into_parts(self) -> (T, PayloadSource) {
|
|
(self.inner, self.source)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, Deserialize)]
|
|
pub(crate) struct ListQuery {
|
|
page: Option<u32>,
|
|
#[serde(default)]
|
|
page_size: Option<PageSizeParam>,
|
|
#[serde(default, rename = "sort")]
|
|
sort_key: Option<String>,
|
|
#[serde(default, rename = "dir")]
|
|
sort_dir: Option<String>,
|
|
#[serde(default)]
|
|
q: Option<String>,
|
|
}
|
|
|
|
#[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<K: SortKey>(self) -> (ListRequest<K>, Option<String>) {
|
|
self.into_request_and_search_with_default::<K>(DEFAULT_PAGE_SIZE)
|
|
}
|
|
|
|
pub fn into_request_and_search_with_default<K: SortKey>(
|
|
self,
|
|
default_page_size: u32,
|
|
) -> (ListRequest<K>, Option<String>) {
|
|
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<K, T>(request: ListRequest<K>, page: &Page<T>) -> ListRequest<K>
|
|
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<K, T, V>(
|
|
page: Page<T>,
|
|
request: ListRequest<K>,
|
|
view_mapper: impl FnMut(T) -> V,
|
|
base_path: impl Into<String>,
|
|
fragment_path: impl Into<String>,
|
|
search: Option<String>,
|
|
) -> (Paginated<V>, ListNavigator<K>)
|
|
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<T: Template>(
|
|
template: T,
|
|
selector: &'static str,
|
|
) -> Result<Response, AppError> {
|
|
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::<u32>() {
|
|
PageSize::limited(parsed)
|
|
} else {
|
|
PageSize::limited(DEFAULT_PAGE_SIZE)
|
|
}
|
|
}
|
|
|
|
fn parse_direction(value: &str) -> Option<SortDirection> {
|
|
match value.to_ascii_lowercase().as_str() {
|
|
"asc" => Some(SortDirection::Asc),
|
|
"desc" => Some(SortDirection::Desc),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl<S, T> FromRequest<S> for FlexiblePayload<T>
|
|
where
|
|
S: Send + Sync,
|
|
T: Send + 'static,
|
|
JsonPayload<T>: FromRequest<S>,
|
|
Form<T>: FromRequest<S>,
|
|
{
|
|
type Rejection = ApiError;
|
|
|
|
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
|
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::<T>::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::<T>::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<Vec<RoasterOptionView>, 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<Vec<RoastOptionView>, 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<Vec<CafeOptionView>, 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<dyn crate::domain::repositories::AiUsageRepository>,
|
|
user_id: crate::domain::ids::UserId,
|
|
model: &str,
|
|
endpoint: &str,
|
|
usage: Option<crate::infrastructure::ai::Usage>,
|
|
) {
|
|
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 `<div>` 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<Response, AppError> {
|
|
use std::fmt::Write;
|
|
|
|
let id = selector.strip_prefix('#').unwrap_or(selector);
|
|
|
|
let mut html = format!(r#"<div id="{id}""#);
|
|
for (name, value) in signals {
|
|
let js_expr = value.to_string();
|
|
let escaped = escape_html_attr(&js_expr);
|
|
// write! to String is infallible
|
|
let _ = write!(&mut html, r#" data-signals:{name}="{escaped}""#);
|
|
}
|
|
html.push_str("></div>");
|
|
|
|
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<Response, AppError> {
|
|
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"))
|
|
);
|
|
}
|
|
}
|