1use axum::body::{to_bytes, Body};
10use axum::http::{header, HeaderValue, StatusCode};
11use axum::response::{IntoResponse, Response};
12use axum::Json;
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy)]
18pub enum AppErrorKind {
19 Unauthorized,
20 BadRequest,
21 NotFound,
22 Conflict,
23 Internal,
24 PayloadTooLarge,
29 UnsupportedMediaType,
30 MethodNotAllowed,
31 ServiceUnavailable,
36 FormationCycle,
40 FormationMultipleCoordinators,
41 FormationNoCoordinator,
42 FormationAuthorityNotNarrowing,
43}
44
45impl AppErrorKind {
46 pub fn status(self) -> StatusCode {
47 match self {
48 AppErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
49 AppErrorKind::BadRequest
50 | AppErrorKind::FormationCycle
51 | AppErrorKind::FormationMultipleCoordinators
52 | AppErrorKind::FormationNoCoordinator
53 | AppErrorKind::FormationAuthorityNotNarrowing => StatusCode::BAD_REQUEST,
54 AppErrorKind::NotFound => StatusCode::NOT_FOUND,
55 AppErrorKind::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
56 AppErrorKind::Conflict => StatusCode::CONFLICT,
57 AppErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
58 AppErrorKind::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
59 AppErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
60 AppErrorKind::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
61 }
62 }
63
64 pub fn type_uri(self) -> &'static str {
68 match self {
69 AppErrorKind::Unauthorized => "/problems/unauthorized",
70 AppErrorKind::BadRequest => "/problems/bad-request",
71 AppErrorKind::NotFound => "/problems/not-found",
72 AppErrorKind::Conflict => "/problems/conflict",
73 AppErrorKind::Internal => "/problems/internal",
74 AppErrorKind::PayloadTooLarge => "/problems/payload-too-large",
75 AppErrorKind::UnsupportedMediaType => "/problems/unsupported-media-type",
76 AppErrorKind::MethodNotAllowed => "/problems/method-not-allowed",
77 AppErrorKind::ServiceUnavailable => "/problems/service-unavailable",
78 AppErrorKind::FormationCycle => "/problems/formation/cycle",
79 AppErrorKind::FormationMultipleCoordinators => {
80 "/problems/formation/multiple-coordinators"
81 }
82 AppErrorKind::FormationNoCoordinator => "/problems/formation/no-coordinator",
83 AppErrorKind::FormationAuthorityNotNarrowing => {
84 "/problems/formation/authority-not-narrowing"
85 }
86 }
87 }
88
89 pub fn title(self) -> &'static str {
90 match self {
91 AppErrorKind::Unauthorized => "Unauthorized",
92 AppErrorKind::BadRequest => "Bad Request",
93 AppErrorKind::NotFound => "Not Found",
94 AppErrorKind::Conflict => "Conflict",
95 AppErrorKind::Internal => "Internal Server Error",
96 AppErrorKind::PayloadTooLarge => "Payload Too Large",
97 AppErrorKind::UnsupportedMediaType => "Unsupported Media Type",
98 AppErrorKind::MethodNotAllowed => "Method Not Allowed",
99 AppErrorKind::ServiceUnavailable => "Event store unavailable",
100 AppErrorKind::FormationCycle => "Formation rejected: authority cycle",
101 AppErrorKind::FormationMultipleCoordinators => {
102 "Formation rejected: multiple coordinators"
103 }
104 AppErrorKind::FormationNoCoordinator => "Formation rejected: no coordinator",
105 AppErrorKind::FormationAuthorityNotNarrowing => {
106 "Formation rejected: authority does not narrow"
107 }
108 }
109 }
110}
111
112#[derive(Debug, Clone)]
113pub struct AppError {
114 pub kind: AppErrorKind,
115 pub detail: String,
116}
117
118impl AppError {
119 pub fn new(kind: AppErrorKind, detail: impl Into<String>) -> Self {
120 Self {
121 kind,
122 detail: detail.into(),
123 }
124 }
125
126 pub fn bad_request(detail: impl Into<String>) -> Self {
127 Self::new(AppErrorKind::BadRequest, detail)
128 }
129
130 pub fn unauthorized(detail: impl Into<String>) -> Self {
131 Self::new(AppErrorKind::Unauthorized, detail)
132 }
133
134 pub fn not_found(detail: impl Into<String>) -> Self {
135 Self::new(AppErrorKind::NotFound, detail)
136 }
137
138 pub fn internal(detail: impl Into<String>) -> Self {
139 Self::new(AppErrorKind::Internal, detail)
140 }
141
142 pub fn payload_too_large(detail: impl Into<String>) -> Self {
143 Self::new(AppErrorKind::PayloadTooLarge, detail)
144 }
145
146 pub fn unsupported_media_type(detail: impl Into<String>) -> Self {
147 Self::new(AppErrorKind::UnsupportedMediaType, detail)
148 }
149
150 pub fn method_not_allowed(detail: impl Into<String>) -> Self {
151 Self::new(AppErrorKind::MethodNotAllowed, detail)
152 }
153
154 pub fn service_unavailable() -> Self {
160 Self::new(
161 AppErrorKind::ServiceUnavailable,
162 "Event store is temporarily unreachable; retry later",
163 )
164 }
165}
166
167pub const PROBLEM_JSON_CT: &str = "application/problem+json";
169
170pub fn problem_response(kind: AppErrorKind, detail: impl Into<String>) -> Response {
174 AppError::new(kind, detail).into_response()
175}
176
177#[derive(Debug, Serialize)]
179struct ProblemDetails<'a> {
180 #[serde(rename = "type")]
181 type_uri: &'a str,
182 title: &'a str,
183 status: u16,
184 detail: &'a str,
185}
186
187impl IntoResponse for AppError {
188 fn into_response(self) -> Response {
189 let status = self.kind.status();
190 let body = ProblemDetails {
191 type_uri: self.kind.type_uri(),
192 title: self.kind.title(),
193 status: status.as_u16(),
194 detail: &self.detail,
195 };
196 let mut resp = (status, Json(body)).into_response();
197 resp.headers_mut().insert(
199 axum::http::header::CONTENT_TYPE,
200 axum::http::HeaderValue::from_static("application/problem+json"),
201 );
202 resp
203 }
204}
205
206impl From<anyhow::Error> for AppError {
207 fn from(e: anyhow::Error) -> Self {
208 AppError::internal(format!("{e:#}"))
209 }
210}
211
212impl From<serde_json::Error> for AppError {
213 fn from(e: serde_json::Error) -> Self {
214 AppError::bad_request(format!("invalid json: {e}"))
215 }
216}
217
218pub async fn normalize_problem_response(resp: Response) -> Response {
239 let status = resp.status();
240
241 if !status.is_client_error() {
242 return resp;
243 }
244
245 let is_problem_json = resp
246 .headers()
247 .get(header::CONTENT_TYPE)
248 .and_then(|v| v.to_str().ok())
249 .map(|ct| ct.starts_with(PROBLEM_JSON_CT))
250 .unwrap_or(false);
251
252 if is_problem_json {
253 return resp;
254 }
255
256 let allow_header = resp.headers().get(header::ALLOW).cloned();
260
261 let (parts, body) = resp.into_parts();
262 let detail_bytes = to_bytes(body, 64 * 1024).await.unwrap_or_default();
266 let detail = std::str::from_utf8(&detail_bytes)
267 .unwrap_or("")
268 .trim()
269 .to_string();
270
271 let kind = match status {
272 StatusCode::BAD_REQUEST => AppErrorKind::BadRequest,
273 StatusCode::UNAUTHORIZED => AppErrorKind::Unauthorized,
274 StatusCode::NOT_FOUND => AppErrorKind::NotFound,
275 StatusCode::METHOD_NOT_ALLOWED => AppErrorKind::MethodNotAllowed,
276 StatusCode::CONFLICT => AppErrorKind::Conflict,
277 StatusCode::PAYLOAD_TOO_LARGE => AppErrorKind::PayloadTooLarge,
278 StatusCode::UNSUPPORTED_MEDIA_TYPE => AppErrorKind::UnsupportedMediaType,
279 _ => AppErrorKind::BadRequest,
282 };
283
284 let detail = if detail.is_empty() {
288 match status {
289 StatusCode::NOT_FOUND => "no route matched the request path".to_string(),
290 StatusCode::METHOD_NOT_ALLOWED => "HTTP method not allowed for this path".to_string(),
291 StatusCode::PAYLOAD_TOO_LARGE => "request body exceeds the per-route cap".to_string(),
292 _ => parts
293 .status
294 .canonical_reason()
295 .unwrap_or("client error")
296 .to_string(),
297 }
298 } else {
299 detail
300 };
301
302 let body = ProblemDetails {
303 type_uri: kind.type_uri(),
304 title: kind.title(),
305 status: status.as_u16(),
306 detail: &detail,
307 };
308 let body_bytes = serde_json::to_vec(&body)
309 .unwrap_or_else(|_| br#"{"type":"/problems/internal","title":"Internal Server Error","status":500,"detail":"failed to serialise problem document"}"#.to_vec());
310
311 let mut new = Response::builder()
312 .status(status)
313 .body(Body::from(body_bytes))
314 .expect("problem+json response build");
315
316 for (name, value) in parts.headers.iter() {
319 if name == header::CONTENT_TYPE || name == header::CONTENT_LENGTH {
320 continue;
321 }
322 new.headers_mut().append(name.clone(), value.clone());
323 }
324 new.headers_mut().insert(
325 header::CONTENT_TYPE,
326 HeaderValue::from_static(PROBLEM_JSON_CT),
327 );
328 if let Some(v) = allow_header {
332 new.headers_mut().insert(header::ALLOW, v);
333 }
334
335 new
336}