1use axum::{
9 Json,
10 http::StatusCode,
11 response::{IntoResponse, Response},
12};
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
17#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
18#[non_exhaustive]
19pub enum ErrorCode {
20 ValidationError,
22 ParseError,
24 RequestError,
26 Unauthenticated,
28 Forbidden,
30 InternalServerError,
32 DatabaseError,
34 Timeout,
36 RateLimitExceeded,
38 NotFound,
40 Conflict,
42 CircuitBreakerOpen,
44 PersistedQueryNotFound,
46 PersistedQueryMismatch,
48 ForbiddenQuery,
50 DocumentNotFound,
52}
53
54impl ErrorCode {
55 #[must_use]
66 pub const fn status_code(self) -> StatusCode {
67 match self {
68 Self::ValidationError | Self::ParseError | Self::PersistedQueryNotFound => {
72 StatusCode::OK
73 },
74 Self::RequestError
77 | Self::PersistedQueryMismatch
78 | Self::ForbiddenQuery
79 | Self::DocumentNotFound => StatusCode::BAD_REQUEST,
80 Self::Unauthenticated => StatusCode::UNAUTHORIZED,
81 Self::Forbidden => StatusCode::FORBIDDEN,
82 Self::NotFound => StatusCode::NOT_FOUND,
83 Self::Conflict => StatusCode::CONFLICT,
84 Self::RateLimitExceeded => StatusCode::TOO_MANY_REQUESTS,
85 Self::Timeout => StatusCode::REQUEST_TIMEOUT,
86 Self::InternalServerError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
87 Self::CircuitBreakerOpen => StatusCode::SERVICE_UNAVAILABLE,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize)]
94pub struct ErrorLocation {
95 pub line: usize,
97 pub column: usize,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct GraphQLError {
104 pub message: String,
106
107 pub code: ErrorCode,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub locations: Option<Vec<ErrorLocation>>,
113
114 #[serde(skip_serializing_if = "Option::is_none")]
116 pub path: Option<Vec<String>>,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub extensions: Option<ErrorExtensions>,
121}
122
123#[derive(Debug, Clone, Serialize)]
125pub struct ErrorExtensions {
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub category: Option<String>,
129
130 #[serde(skip_serializing_if = "Option::is_none")]
132 pub status: Option<u16>,
133
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub request_id: Option<String>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub retry_after_secs: Option<u64>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
146 pub detail: Option<String>,
147}
148
149#[derive(Debug, Serialize)]
151pub struct ErrorResponse {
152 pub errors: Vec<GraphQLError>,
154}
155
156impl GraphQLError {
157 pub fn new(message: impl Into<String>, code: ErrorCode) -> Self {
159 Self {
160 message: message.into(),
161 code,
162 locations: None,
163 path: None,
164 extensions: None,
165 }
166 }
167
168 #[must_use]
170 pub fn with_location(mut self, line: usize, column: usize) -> Self {
171 self.locations = Some(vec![ErrorLocation { line, column }]);
172 self
173 }
174
175 #[must_use]
177 pub fn with_path(mut self, path: Vec<String>) -> Self {
178 self.path = Some(path);
179 self
180 }
181
182 #[must_use]
184 pub fn with_extensions(mut self, extensions: ErrorExtensions) -> Self {
185 self.extensions = Some(extensions);
186 self
187 }
188
189 #[must_use]
191 pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
192 let request_id = request_id.into();
193 let extensions = self.extensions.take().unwrap_or(ErrorExtensions {
194 category: None,
195 status: None,
196 request_id: None,
197 retry_after_secs: None,
198 detail: None,
199 });
200
201 self.extensions = Some(ErrorExtensions {
202 request_id: Some(request_id),
203 ..extensions
204 });
205 self
206 }
207
208 pub fn validation(message: impl Into<String>) -> Self {
210 Self::new(message, ErrorCode::ValidationError)
211 }
212
213 pub fn parse(message: impl Into<String>) -> Self {
215 Self::new(message, ErrorCode::ParseError)
216 }
217
218 pub fn request(message: impl Into<String>) -> Self {
220 Self::new(message, ErrorCode::RequestError)
221 }
222
223 pub fn database(message: impl Into<String>) -> Self {
225 Self::new(message, ErrorCode::DatabaseError)
226 }
227
228 pub fn internal(message: impl Into<String>) -> Self {
230 Self::new(message, ErrorCode::InternalServerError)
231 }
232
233 #[doc(hidden)]
242 #[must_use]
243 pub fn execution(message: &str) -> Self {
244 Self::new(message, ErrorCode::InternalServerError)
245 }
246
247 #[must_use]
249 pub fn unauthenticated() -> Self {
250 Self::new("Authentication required", ErrorCode::Unauthenticated)
251 }
252
253 #[must_use]
255 pub fn forbidden() -> Self {
256 Self::new("Access denied", ErrorCode::Forbidden)
257 }
258
259 pub fn not_found(message: impl Into<String>) -> Self {
261 Self::new(message, ErrorCode::NotFound)
262 }
263
264 pub fn timeout(operation: impl Into<String>) -> Self {
266 Self::new(format!("{} exceeded timeout", operation.into()), ErrorCode::Timeout)
267 }
268
269 pub fn rate_limited(message: impl Into<String>) -> Self {
271 Self::new(message, ErrorCode::RateLimitExceeded)
272 }
273
274 #[must_use]
280 pub fn from_fraiseql_error(err: &fraiseql_core::error::FraiseQLError) -> Self {
281 use fraiseql_core::error::FraiseQLError as E;
282 match err {
283 E::Database { .. } | E::ConnectionPool { .. } => Self::database(err.to_string()),
284 E::Parse { .. } => Self::parse(err.to_string()),
285 E::Validation { .. } | E::UnknownField { .. } | E::UnknownType { .. } => {
286 Self::validation(err.to_string())
287 },
288 E::NotFound { .. } => Self::not_found(err.to_string()),
289 E::Conflict { .. } => Self::new(err.to_string(), ErrorCode::Conflict),
290 E::Authorization { .. } => Self::forbidden(),
291 E::Authentication { .. } => Self::unauthenticated(),
292 E::Timeout { .. } => Self::new(err.to_string(), ErrorCode::Timeout),
293 E::RateLimited { message, .. } => Self::rate_limited(message.clone()),
294 _ => Self::internal(err.to_string()),
296 }
297 }
298
299 #[must_use]
301 pub fn persisted_query_not_found() -> Self {
302 Self::new("PersistedQueryNotFound", ErrorCode::PersistedQueryNotFound)
303 }
304
305 #[must_use]
307 pub fn persisted_query_mismatch() -> Self {
308 Self::new("provided sha does not match query", ErrorCode::PersistedQueryMismatch)
309 }
310
311 #[must_use]
313 pub fn forbidden_query() -> Self {
314 Self::new(
315 "Raw queries are not permitted. Send a documentId instead.",
316 ErrorCode::ForbiddenQuery,
317 )
318 }
319
320 pub fn document_not_found(doc_id: impl Into<String>) -> Self {
322 Self::new(format!("Unknown document: {}", doc_id.into()), ErrorCode::DocumentNotFound)
323 }
324
325 #[must_use]
329 pub fn circuit_breaker_open(entity: &str, retry_after_secs: u64) -> Self {
330 Self::new(
331 format!(
332 "Federation entity '{entity}' is temporarily unavailable. \
333 Please retry after {retry_after_secs} seconds."
334 ),
335 ErrorCode::CircuitBreakerOpen,
336 )
337 .with_extensions(ErrorExtensions {
338 category: Some("CIRCUIT_BREAKER".to_string()),
339 status: Some(503),
340 request_id: None,
341 retry_after_secs: Some(retry_after_secs),
342 detail: None,
343 })
344 }
345}
346
347impl ErrorResponse {
348 #[must_use]
350 pub const fn new(errors: Vec<GraphQLError>) -> Self {
351 Self { errors }
352 }
353
354 #[must_use]
356 pub fn from_error(error: GraphQLError) -> Self {
357 Self {
358 errors: vec![error],
359 }
360 }
361}
362
363impl IntoResponse for ErrorResponse {
364 fn into_response(self) -> Response {
365 let status = self
366 .errors
367 .first()
368 .map_or(StatusCode::INTERNAL_SERVER_ERROR, |e| e.code.status_code());
369
370 let retry_after = self
371 .errors
372 .first()
373 .and_then(|e| e.extensions.as_ref())
374 .and_then(|ext| ext.retry_after_secs);
375
376 let mut response = (status, Json(self)).into_response();
377
378 if let Some(secs) = retry_after {
379 if let Ok(value) = secs.to_string().parse() {
380 response.headers_mut().insert(axum::http::header::RETRY_AFTER, value);
381 }
382 }
383
384 response
385 }
386}
387
388impl From<GraphQLError> for ErrorResponse {
389 fn from(error: GraphQLError) -> Self {
390 Self::from_error(error)
391 }
392}