1use axum::{
2 Json,
3 http::StatusCode,
4 response::{IntoResponse, Response},
5};
6use serde::Serialize;
7
8use crate::{FileError, FraiseQLError};
9
10#[derive(Debug, Serialize)]
18#[non_exhaustive]
19#[doc(hidden)] pub struct ErrorResponse {
21 pub error: String,
23 pub error_description: String,
25 pub error_code: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub error_uri: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub details: Option<serde_json::Value>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub retry_after: Option<u64>,
37}
38
39impl ErrorResponse {
40 pub fn new(
45 error: impl Into<String>,
46 description: impl Into<String>,
47 code: impl Into<String>,
48 ) -> Self {
49 let code = code.into();
50 Self {
51 error: error.into(),
52 error_description: description.into(),
53 error_uri: Some(format!("https://docs.fraiseql.dev/errors#{code}")),
54 error_code: code,
55 details: None,
56 retry_after: None,
57 }
58 }
59
60 #[must_use]
62 pub fn with_details(mut self, details: serde_json::Value) -> Self {
63 self.details = Some(details);
64 self
65 }
66
67 #[must_use]
69 pub const fn with_retry_after(mut self, seconds: u64) -> Self {
70 self.retry_after = Some(seconds);
71 self
72 }
73}
74
75impl IntoResponse for FraiseQLError {
76 #[allow(clippy::match_same_arms, unreachable_patterns)]
85 fn into_response(self) -> Response {
86 let error_code = self.error_code();
87 let http_status =
88 StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
89
90 let (error_category, description, retry_after) = match &self {
95 FraiseQLError::Parse { .. }
96 | FraiseQLError::Validation { .. }
97 | FraiseQLError::UnknownField { .. }
98 | FraiseQLError::UnknownType { .. } => ("validation_error", self.to_string(), None),
99 FraiseQLError::Authentication { .. } | FraiseQLError::Auth(_) => {
100 ("authentication_error", "Authentication failed".to_string(), None)
101 },
102 FraiseQLError::Authorization { .. } => {
103 ("authorization_error", "Insufficient permissions".to_string(), None)
104 },
105 FraiseQLError::NotFound { .. } => ("not_found", "Resource not found".to_string(), None),
106 FraiseQLError::Conflict { .. } => ("conflict", self.to_string(), None),
107 FraiseQLError::RateLimited {
108 retry_after_secs, ..
109 } => ("rate_limited", "Rate limit exceeded".to_string(), Some(*retry_after_secs)),
110 FraiseQLError::Timeout { .. } | FraiseQLError::Cancelled { .. } => {
111 ("timeout", "Request timed out".to_string(), None)
112 },
113 FraiseQLError::ServiceUnavailable { retry_after, .. } => (
114 "service_unavailable",
115 "Service temporarily unavailable".to_string(),
116 *retry_after,
117 ),
118 FraiseQLError::Unsupported { .. } => ("unsupported", self.to_string(), None),
119 FraiseQLError::Webhook(_) => {
120 ("webhook_error", "Webhook processing failed".to_string(), None)
121 },
122 FraiseQLError::File(e) => file_error_response(e),
123 FraiseQLError::Database { .. } => {
126 ("database_error", "A database error occurred".to_string(), None)
127 },
128 FraiseQLError::ConnectionPool { .. } => {
129 ("internal_error", "Connection pool exhausted".to_string(), None)
130 },
131 FraiseQLError::Configuration { .. } => {
132 ("configuration_error", "A configuration error occurred".to_string(), None)
133 },
134 FraiseQLError::Observer(_) => {
135 ("observer_error", "Observer processing failed".to_string(), None)
136 },
137 FraiseQLError::Internal { .. } => {
138 ("internal_error", "An internal error occurred".to_string(), None)
139 },
140 _ => ("internal_error", "An internal error occurred".to_string(), None),
147 };
148
149 let mut body = ErrorResponse::new(error_category, description, error_code);
150 if let Some(secs) = retry_after {
151 body = body.with_retry_after(secs);
152 }
153
154 let mut resp = (http_status, Json(body)).into_response();
155 if let Some(secs) = retry_after {
156 if let Ok(value) = secs.to_string().parse() {
157 resp.headers_mut().insert("Retry-After", value);
158 }
159 }
160 resp
161 }
162}
163
164#[allow(clippy::match_same_arms, unreachable_patterns)]
176fn file_error_response(e: &FileError) -> (&'static str, String, Option<u64>) {
177 match e {
178 FileError::TooLarge { size, max } => (
179 "file_error",
180 format!("File too large: {size} bytes exceeds maximum {max}"),
181 None,
182 ),
183 FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
184 ("file_error", "Unsupported file type".to_string(), None)
185 },
186 FileError::NotFound { .. } => ("file_error", "File not found".to_string(), None),
187 FileError::VirusDetected { .. } => {
188 ("file_error", "File failed security scan".to_string(), None)
189 },
190 FileError::QuotaExceeded => ("file_error", "Storage quota exceeded".to_string(), None),
191 FileError::Storage { .. } | FileError::Processing { .. } => {
192 ("file_error", "File operation failed".to_string(), None)
193 },
194 FileError::PermissionDenied { .. } => ("file_error", "Permission denied".to_string(), None),
197 FileError::IoError { .. } | FileError::Backend { .. } => {
198 ("file_error", "Storage backend error".to_string(), None)
199 },
200 FileError::InvalidKey { .. } => ("file_error", "Invalid storage key".to_string(), None),
201 FileError::NotImplemented { .. } | FileError::Unsupported { .. } => {
202 ("file_error", "Operation not supported".to_string(), None)
203 },
204 FileError::SizeLimitExceeded { .. } => {
205 ("file_error", "Upload exceeds size limit".to_string(), None)
206 },
207 FileError::MimeTypeNotAllowed { .. } => {
208 ("file_error", "Content type not allowed".to_string(), None)
209 },
210 _ => ("file_error", "File operation failed".to_string(), None),
214 }
215}
216
217pub trait IntoHttpResponse {
223 fn into_http_response(self) -> Response;
229}
230
231impl<T> IntoHttpResponse for std::result::Result<T, FraiseQLError>
232where
233 T: IntoResponse,
234{
235 fn into_http_response(self) -> Response {
236 match self {
237 Ok(value) => value.into_response(),
238 Err(err) => err.into_response(),
239 }
240 }
241}