Skip to main content

sova_core/
accept.rs

1//! Accept-aware error responses (HTML / problem+json / text).
2
3use crate::error::Error;
4use crate::problem::error_to_problem;
5use crate::request_id::current_request_id;
6use crate::response::Response;
7use std::cell::RefCell;
8
9tokio::task_local! {
10    static ACCEPT: RefCell<String>;
11    static PATH: RefCell<String>;
12}
13
14/// Run `fut` with the request `Accept` header visible to [`current_accept`].
15pub async fn with_accept<F, T>(accept: impl Into<String>, fut: F) -> T
16where
17    F: std::future::Future<Output = T>,
18{
19    ACCEPT.scope(RefCell::new(accept.into()), fut).await
20}
21
22/// Like [`with_accept`], also recording the request path for API-aware error negotiation.
23pub async fn with_request_meta<F, T>(
24    accept: impl Into<String>,
25    path: impl Into<String>,
26    fut: F,
27) -> T
28where
29    F: std::future::Future<Output = T>,
30{
31    ACCEPT
32        .scope(RefCell::new(accept.into()), async move {
33            PATH.scope(RefCell::new(path.into()), fut).await
34        })
35        .await
36}
37
38/// Accept header captured for the current request (if any).
39pub fn current_accept() -> Option<String> {
40    ACCEPT.try_with(|c| c.borrow().clone()).ok()
41}
42
43/// Request path captured for the current request (if any).
44pub fn current_path() -> Option<String> {
45    PATH.try_with(|c| c.borrow().clone()).ok()
46}
47
48/// Preferred error body format from an `Accept` header value.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ErrorFormat {
51    Html,
52    ProblemJson,
53    Text,
54}
55
56/// Pick a format: `text/html` wins over json/problem when both appear without clear exclusivity;
57/// `application/problem+json` / `application/json` → problem; else text.
58pub fn negotiate_error_format(accept: Option<&str>) -> ErrorFormat {
59    let accept = accept.unwrap_or("*/*").to_ascii_lowercase();
60    let wants_html = accept.contains("text/html");
61    let wants_problem = accept.contains("application/problem+json");
62    let wants_json = accept.contains("application/json");
63
64    if wants_html && !accept.contains("application/json") && !wants_problem {
65        return ErrorFormat::Html;
66    }
67    // Prefer HTML when client lists html early (browsers).
68    if wants_html {
69        if let Some(html_i) = accept.find("text/html") {
70            let json_i = accept
71                .find("application/json")
72                .or_else(|| accept.find("application/problem+json"))
73                .unwrap_or(usize::MAX);
74            if html_i <= json_i {
75                return ErrorFormat::Html;
76            }
77        }
78    }
79    if wants_problem || wants_json {
80        return ErrorFormat::ProblemJson;
81    }
82    if accept.trim() == "*/*" || accept.is_empty() {
83        return ErrorFormat::Text;
84    }
85    ErrorFormat::Text
86}
87
88fn error_title_detail(err: &Error) -> (u16, &'static str, String) {
89    match err {
90        Error::NotFound => (404, "Not Found", "Not Found".into()),
91        Error::Unauthorized => (401, "Unauthorized", "Unauthorized".into()),
92        Error::Forbidden => (403, "Forbidden", "Forbidden".into()),
93        Error::BadRequest(msg) => (400, "Bad Request", msg.clone()),
94        Error::PayloadTooLarge => (413, "Payload Too Large", "Payload Too Large".into()),
95        Error::MethodNotAllowed => (405, "Method Not Allowed", "Method Not Allowed".into()),
96        Error::Internal(msg) => (500, "Internal Server Error", msg.clone()),
97        Error::Json(e) => (400, "Bad Request", format!("JSON error: {e}")),
98        Error::Io(e) => (500, "Internal Server Error", format!("IO error: {e}")),
99        Error::Response(_) => (500, "Error", "Error".into()),
100    }
101}
102
103/// Minimal HTML error document (no templates).
104pub fn html_error_page(status: u16, title: &str, detail: &str) -> Response {
105    let rid = current_request_id().unwrap_or_default();
106    let rid_row = if rid.is_empty() {
107        String::new()
108    } else {
109        format!("<p class=\"rid\">request_id: {rid}</p>")
110    };
111    let body = format!(
112        "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">\
113         <title>{status} {title}</title>\
114         <style>body{{font-family:system-ui,sans-serif;margin:2rem;color:#111}}\
115         h1{{font-size:1.5rem}} .rid{{color:#666;font-size:.875rem}}</style></head>\
116         <body><h1>{status} {title}</h1><p>{detail}</p>{rid_row}</body></html>",
117        detail = html_escape(detail),
118        title = html_escape(title),
119    );
120    Response::html(body).status(status)
121}
122
123fn html_escape(s: &str) -> String {
124    s.replace('&', "&amp;")
125        .replace('<', "&lt;")
126        .replace('>', "&gt;")
127        .replace('"', "&quot;")
128}
129
130/// Map [`Error`] using `Accept` (or [`current_accept`] when `accept` is `None`).
131///
132/// Paths under `/api/` always get problem+json so browser address-bar hits on JSON
133/// APIs (common in SPA proxies) do not negotiate HTML error pages.
134pub fn error_response_for_accept(accept: Option<&str>, err: Error) -> Response {
135    if let Error::Response(res) = err {
136        return *res;
137    }
138    let path = current_path();
139    if path
140        .as_deref()
141        .is_some_and(|p| p == "/api" || p.starts_with("/api/"))
142    {
143        return error_to_problem(err);
144    }
145    let accept = accept.map(|s| s.to_string()).or_else(current_accept);
146    match negotiate_error_format(accept.as_deref()) {
147        ErrorFormat::Html => {
148            let (status, title, detail) = error_title_detail(&err);
149            html_error_page(status, title, &detail)
150        }
151        ErrorFormat::ProblemJson => error_to_problem(err),
152        ErrorFormat::Text => err.into_response(),
153    }
154}
155
156/// Status-line response (router 404/405) negotiated from request Accept.
157pub fn status_response_for_accept(accept: Option<&str>, status: u16, detail: &str) -> Response {
158    let title = match status {
159        404 => "Not Found",
160        405 => "Method Not Allowed",
161        _ => "Error",
162    };
163    match negotiate_error_format(accept) {
164        ErrorFormat::Html => html_error_page(status, title, detail),
165        ErrorFormat::ProblemJson => crate::problem::problem_response(status, title, detail, []),
166        ErrorFormat::Text => Response::text(detail.to_string()).status(status),
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn browser_accept_prefers_html() {
176        assert_eq!(
177            negotiate_error_format(Some(
178                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
179            )),
180            ErrorFormat::Html
181        );
182    }
183
184    #[test]
185    fn api_client_prefers_json() {
186        assert_eq!(
187            negotiate_error_format(Some("application/json")),
188            ErrorFormat::ProblemJson
189        );
190        assert_eq!(
191            negotiate_error_format(Some("application/problem+json")),
192            ErrorFormat::ProblemJson
193        );
194    }
195}