1use axum::{
2 Json,
3 http::StatusCode,
4 response::{IntoResponse, Response},
5};
6use serde::Serialize;
7
8use crate::{AuthError, FileError, RuntimeError, WebhookError};
9
10#[derive(Debug, Serialize)]
12pub struct ErrorResponse {
13 pub error: String,
14 pub error_description: String,
15 pub error_code: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub error_uri: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub details: Option<serde_json::Value>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub retry_after: Option<u64>,
22}
23
24impl ErrorResponse {
25 pub fn new(
26 error: impl Into<String>,
27 description: impl Into<String>,
28 code: impl Into<String>,
29 ) -> Self {
30 let code = code.into();
31 Self {
32 error: error.into(),
33 error_description: description.into(),
34 error_uri: Some(format!("https://docs.fraiseql.dev/errors#{}", code)),
35 error_code: code,
36 details: None,
37 retry_after: None,
38 }
39 }
40
41 pub fn with_details(mut self, details: serde_json::Value) -> Self {
42 self.details = Some(details);
43 self
44 }
45
46 pub const fn with_retry_after(mut self, seconds: u64) -> Self {
47 self.retry_after = Some(seconds);
48 self
49 }
50}
51
52impl IntoResponse for RuntimeError {
53 fn into_response(self) -> Response {
54 let error_code = self.error_code();
55
56 let (status, response) = match &self {
57 RuntimeError::Config(_) => (
58 StatusCode::INTERNAL_SERVER_ERROR,
59 ErrorResponse::new("configuration_error", self.to_string(), error_code),
60 ),
61
62 RuntimeError::Auth(e) => {
63 let status = match e {
64 AuthError::InsufficientPermissions { .. } | AuthError::AccountLocked { .. } => {
65 StatusCode::FORBIDDEN
66 },
67 _ => StatusCode::UNAUTHORIZED,
68 };
69 (status, ErrorResponse::new("authentication_error", self.to_string(), error_code))
70 },
71
72 RuntimeError::Webhook(e) => {
73 let status = match e {
74 WebhookError::InvalidSignature => StatusCode::UNAUTHORIZED,
75 WebhookError::DuplicateEvent { .. } => StatusCode::OK,
76 _ => StatusCode::BAD_REQUEST,
77 };
78 (status, ErrorResponse::new("webhook_error", self.to_string(), error_code))
79 },
80
81 RuntimeError::File(e) => {
82 let status = match e {
83 FileError::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
84 FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
85 StatusCode::UNSUPPORTED_MEDIA_TYPE
86 },
87 FileError::NotFound { .. } => StatusCode::NOT_FOUND,
88 FileError::VirusDetected { .. } => StatusCode::UNPROCESSABLE_ENTITY,
89 FileError::QuotaExceeded => StatusCode::INSUFFICIENT_STORAGE,
90 _ => StatusCode::BAD_REQUEST,
91 };
92 (status, ErrorResponse::new("file_error", self.to_string(), error_code))
93 },
94
95 RuntimeError::Notification(e) => {
96 use crate::NotificationError::{
97 CircuitOpen, InvalidInput, ProviderRateLimited, ProviderUnavailable,
98 };
99 let status = match e {
100 CircuitOpen { .. } | ProviderUnavailable { .. } => {
101 StatusCode::SERVICE_UNAVAILABLE
102 },
103 ProviderRateLimited { .. } => StatusCode::TOO_MANY_REQUESTS,
104 InvalidInput { .. } => StatusCode::BAD_REQUEST,
105 _ => StatusCode::INTERNAL_SERVER_ERROR,
106 };
107 (status, ErrorResponse::new("notification_error", self.to_string(), error_code))
108 },
109
110 RuntimeError::RateLimited { retry_after } => {
111 let mut resp =
112 ErrorResponse::new("rate_limited", "Rate limit exceeded", error_code);
113 if let Some(secs) = retry_after {
114 resp = resp.with_retry_after(*secs);
115 }
116 (StatusCode::TOO_MANY_REQUESTS, resp)
117 },
118
119 RuntimeError::ServiceUnavailable { retry_after, .. } => {
120 let mut resp =
121 ErrorResponse::new("service_unavailable", self.to_string(), error_code);
122 if let Some(secs) = retry_after {
123 resp = resp.with_retry_after(*secs);
124 }
125 (StatusCode::SERVICE_UNAVAILABLE, resp)
126 },
127
128 RuntimeError::NotFound { .. } => (
129 StatusCode::NOT_FOUND,
130 ErrorResponse::new("not_found", self.to_string(), error_code),
131 ),
132
133 RuntimeError::Database(_) => (
134 StatusCode::INTERNAL_SERVER_ERROR,
135 ErrorResponse::new("database_error", "A database error occurred", error_code),
136 ),
137
138 _ => (
139 StatusCode::INTERNAL_SERVER_ERROR,
140 ErrorResponse::new("internal_error", "An internal error occurred", error_code),
141 ),
142 };
143
144 let mut resp = (status, Json(response)).into_response();
146 if let Some(retry_after) = self.retry_after_header() {
147 resp.headers_mut().insert("Retry-After", retry_after.parse().unwrap());
148 }
149
150 resp
151 }
152}
153
154impl RuntimeError {
155 fn retry_after_header(&self) -> Option<String> {
156 match self {
157 Self::RateLimited {
158 retry_after: Some(secs),
159 }
160 | Self::ServiceUnavailable {
161 retry_after: Some(secs),
162 ..
163 } => Some(secs.to_string()),
164 _ => None,
165 }
166 }
167}
168
169pub trait IntoHttpResponse {
171 fn into_http_response(self) -> Response;
172}
173
174impl<T> IntoHttpResponse for Result<T, RuntimeError>
175where
176 T: IntoResponse,
177{
178 fn into_http_response(self) -> Response {
179 match self {
180 Ok(value) => value.into_response(),
181 Err(err) => err.into_response(),
182 }
183 }
184}