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
114        .map(|s| s.to_string())
115        .or_else(current_accept);
116    match negotiate_error_format(accept.as_deref()) {
117        ErrorFormat::Html => {
118            let (status, title, detail) = error_title_detail(&err);
119            html_error_page(status, title, &detail)
120        }
121        ErrorFormat::ProblemJson => error_to_problem(err),
122        ErrorFormat::Text => err.into_response(),
123    }
124}
125
126/// Status-line response (router 404/405) negotiated from request Accept.
127pub fn status_response_for_accept(accept: Option<&str>, status: u16, detail: &str) -> Response {
128    let title = match status {
129        404 => "Not Found",
130        405 => "Method Not Allowed",
131        _ => "Error",
132    };
133    match negotiate_error_format(accept) {
134        ErrorFormat::Html => html_error_page(status, title, detail),
135        ErrorFormat::ProblemJson => crate::problem::problem_response(status, title, detail, []),
136        ErrorFormat::Text => Response::text(detail.to_string()).status(status),
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn browser_accept_prefers_html() {
146        assert_eq!(
147            negotiate_error_format(Some(
148                "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
149            )),
150            ErrorFormat::Html
151        );
152    }
153
154    #[test]
155    fn api_client_prefers_json() {
156        assert_eq!(
157            negotiate_error_format(Some("application/json")),
158            ErrorFormat::ProblemJson
159        );
160        assert_eq!(
161            negotiate_error_format(Some("application/problem+json")),
162            ErrorFormat::ProblemJson
163        );
164    }
165}