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