1use 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
14pub 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
22pub 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
38pub fn current_accept() -> Option<String> {
40 ACCEPT.try_with(|c| c.borrow().clone()).ok()
41}
42
43pub fn current_path() -> Option<String> {
45 PATH.try_with(|c| c.borrow().clone()).ok()
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ErrorFormat {
51 Html,
52 ProblemJson,
53 Text,
54}
55
56pub 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 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
103pub 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('&', "&")
125 .replace('<', "<")
126 .replace('>', ">")
127 .replace('"', """)
128}
129
130pub 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
156pub 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}