use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::{FileError, FraiseQLError};
#[derive(Debug, Serialize)]
#[non_exhaustive]
#[doc(hidden)] pub struct ErrorResponse {
pub error: String,
pub error_description: String,
pub error_code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_uri: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_after: Option<u64>,
}
impl ErrorResponse {
pub fn new(
error: impl Into<String>,
description: impl Into<String>,
code: impl Into<String>,
) -> Self {
let code = code.into();
Self {
error: error.into(),
error_description: description.into(),
error_uri: Some(format!("https://docs.fraiseql.dev/errors#{code}")),
error_code: code,
details: None,
retry_after: None,
}
}
#[must_use]
pub fn with_details(mut self, details: serde_json::Value) -> Self {
self.details = Some(details);
self
}
#[must_use]
pub const fn with_retry_after(mut self, seconds: u64) -> Self {
self.retry_after = Some(seconds);
self
}
}
impl IntoResponse for FraiseQLError {
#[allow(clippy::match_same_arms, unreachable_patterns)]
fn into_response(self) -> Response {
let error_code = self.error_code();
let http_status =
StatusCode::from_u16(self.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let (error_category, description, retry_after) = match &self {
FraiseQLError::Parse { .. }
| FraiseQLError::Validation { .. }
| FraiseQLError::UnknownField { .. }
| FraiseQLError::UnknownType { .. } => ("validation_error", self.to_string(), None),
FraiseQLError::Authentication { .. } | FraiseQLError::Auth(_) => {
("authentication_error", "Authentication failed".to_string(), None)
},
FraiseQLError::Authorization { .. } => {
("authorization_error", "Insufficient permissions".to_string(), None)
},
FraiseQLError::NotFound { .. } => ("not_found", "Resource not found".to_string(), None),
FraiseQLError::Conflict { .. } => ("conflict", self.to_string(), None),
FraiseQLError::RateLimited {
retry_after_secs, ..
} => ("rate_limited", "Rate limit exceeded".to_string(), Some(*retry_after_secs)),
FraiseQLError::Timeout { .. } | FraiseQLError::Cancelled { .. } => {
("timeout", "Request timed out".to_string(), None)
},
FraiseQLError::ServiceUnavailable { retry_after, .. } => (
"service_unavailable",
"Service temporarily unavailable".to_string(),
*retry_after,
),
FraiseQLError::Unsupported { .. } => ("unsupported", self.to_string(), None),
FraiseQLError::Webhook(_) => {
("webhook_error", "Webhook processing failed".to_string(), None)
},
FraiseQLError::File(e) => file_error_response(e),
FraiseQLError::Database { .. } => {
("database_error", "A database error occurred".to_string(), None)
},
FraiseQLError::ConnectionPool { .. } => {
("internal_error", "Connection pool exhausted".to_string(), None)
},
FraiseQLError::Configuration { .. } => {
("configuration_error", "A configuration error occurred".to_string(), None)
},
FraiseQLError::Observer(_) => {
("observer_error", "Observer processing failed".to_string(), None)
},
FraiseQLError::Internal { .. } => {
("internal_error", "An internal error occurred".to_string(), None)
},
_ => ("internal_error", "An internal error occurred".to_string(), None),
};
let mut body = ErrorResponse::new(error_category, description, error_code);
if let Some(secs) = retry_after {
body = body.with_retry_after(secs);
}
let mut resp = (http_status, Json(body)).into_response();
if let Some(secs) = retry_after {
if let Ok(value) = secs.to_string().parse() {
resp.headers_mut().insert("Retry-After", value);
}
}
resp
}
}
#[allow(clippy::match_same_arms, unreachable_patterns)]
fn file_error_response(e: &FileError) -> (&'static str, String, Option<u64>) {
match e {
FileError::TooLarge { size, max } => (
"file_error",
format!("File too large: {size} bytes exceeds maximum {max}"),
None,
),
FileError::InvalidType { .. } | FileError::MimeMismatch { .. } => {
("file_error", "Unsupported file type".to_string(), None)
},
FileError::NotFound { .. } => ("file_error", "File not found".to_string(), None),
FileError::VirusDetected { .. } => {
("file_error", "File failed security scan".to_string(), None)
},
FileError::QuotaExceeded => ("file_error", "Storage quota exceeded".to_string(), None),
FileError::Storage { .. } | FileError::Processing { .. } => {
("file_error", "File operation failed".to_string(), None)
},
FileError::PermissionDenied { .. } => ("file_error", "Permission denied".to_string(), None),
FileError::IoError { .. } | FileError::Backend { .. } => {
("file_error", "Storage backend error".to_string(), None)
},
FileError::InvalidKey { .. } => ("file_error", "Invalid storage key".to_string(), None),
FileError::NotImplemented { .. } | FileError::Unsupported { .. } => {
("file_error", "Operation not supported".to_string(), None)
},
FileError::SizeLimitExceeded { .. } => {
("file_error", "Upload exceeds size limit".to_string(), None)
},
FileError::MimeTypeNotAllowed { .. } => {
("file_error", "Content type not allowed".to_string(), None)
},
_ => ("file_error", "File operation failed".to_string(), None),
}
}
pub trait IntoHttpResponse {
fn into_http_response(self) -> Response;
}
impl<T> IntoHttpResponse for std::result::Result<T, FraiseQLError>
where
T: IntoResponse,
{
fn into_http_response(self) -> Response {
match self {
Ok(value) => value.into_response(),
Err(err) => err.into_response(),
}
}
}