Skip to main content

fraiseql_error/
http.rs

1use axum::{
2    Json,
3    http::StatusCode,
4    response::{IntoResponse, Response},
5};
6use serde::Serialize;
7
8use crate::{FileError, FraiseQLError};
9
10/// Standardised JSON error body returned by all FraiseQL HTTP endpoints.
11///
12/// The shape follows the OAuth 2.0 error response convention so that clients
13/// can handle errors uniformly regardless of which handler produced them.
14///
15/// Fields that are `None` are omitted from the serialised JSON to keep
16/// responses compact.
17#[derive(Debug, Serialize)]
18#[non_exhaustive]
19#[doc(hidden)] // Internal-pub: used by fraiseql-error's IntoResponse impl; adopters consume the JSON wire format, not the Rust type. Intentionally NOT re-exported from the umbrella crate.
20pub struct ErrorResponse {
21    /// Short machine-readable error category (e.g. `"authentication_error"`).
22    pub error:             String,
23    /// Human-readable description safe to display to end-users.
24    pub error_description: String,
25    /// Stable, fine-grained error code for programmatic handling (e.g.
26    /// `"token_expired"`).
27    pub error_code:        String,
28    /// URL to the documentation page for this error code, if available.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub error_uri:         Option<String>,
31    /// Additional structured details about the error (omitted when `None`).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub details:           Option<serde_json::Value>,
34    /// Number of seconds the client should wait before retrying, if applicable.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub retry_after:       Option<u64>,
37}
38
39impl ErrorResponse {
40    /// Construct a minimal error response with the three required fields.
41    ///
42    /// `error_uri` is populated automatically from `code` using the FraiseQL
43    /// documentation base URL. `details` and `retry_after` are `None`.
44    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    /// Attach arbitrary structured details to the response and return `self`.
61    #[must_use]
62    pub fn with_details(mut self, details: serde_json::Value) -> Self {
63        self.details = Some(details);
64        self
65    }
66
67    /// Set the `retry_after` field (in seconds) and return `self`.
68    #[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    // Reason: the trailing `_` arm in the inner match intentionally duplicates
77    // the `Internal` arm (silencing `match_same_arms`) and is currently
78    // unreachable within `fraiseql-error` because every existing variant is
79    // explicitly enumerated (silencing `unreachable_patterns`). The wildcard
80    // becomes reachable as soon as a future variant is added to the
81    // `#[non_exhaustive]` enum and gives that variant the safe generic
82    // response. See docs/history/IMPROVEMENTS.md F055.
83    // Reason: deliberate duplicate arms — non_exhaustive forward-compat guard (F055), see above
84    #[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        // SECURITY: messages here are deliberately generic — raw error
91        // details (database messages, config values, provider endpoints,
92        // internal paths) are kept server-side and surfaced only in
93        // structured logs via the `Display`/`source` chain.
94        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            // SECURITY: database/config/storage/internal/observer details
124            // must not leak to clients.
125            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            // SECURITY: `FraiseQLError` is `#[non_exhaustive]`. A future
141            // variant added without updating this match will silently fall
142            // through to this generic 500 Internal Server Error response
143            // instead of leaking implementation details (or, worse, returning
144            // a misleading 2xx). `status_code()` and `error_code()` have
145            // matching fallbacks — keep all three in lockstep.
146            _ => ("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/// Return the (category, message, `retry_after`) tuple for a [`FileError`].
165///
166/// `TooLarge` is the only variant whose message is safe to forward to the
167/// client (the size limits help the caller correct the request).
168// Reason: `FileError` is `#[non_exhaustive]`; the trailing `_` arm is the
169// security fallback for any future variant added without updating this match.
170// The in-crate match currently enumerates every variant, so the wildcard is
171// unreachable today (`unreachable_patterns`) and structurally duplicates the
172// generic arms (`match_same_arms`). Same defence-in-depth rationale as the
173// `FraiseQLError::IntoResponse` and `status_code` matches in this file.
174// Reason: deliberate duplicate arms — non_exhaustive forward-compat guard (F055), see above
175#[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        // F050 backend-classification variants. Bodies are deliberately generic
195        // (the typed status-code routing happens in `FraiseQLError::status_code`).
196        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        // SECURITY: `FileError` is `#[non_exhaustive]`. A future variant added
211        // without updating this match falls through to the generic file-error
212        // response.
213        _ => ("file_error", "File operation failed".to_string(), None),
214    }
215}
216
217/// Convenience trait that allows returning `Result<T, FraiseQLError>` from axum
218/// handlers by converting it directly into an HTTP [`Response`].
219///
220/// Import this trait and call `.into_http_response()` on any
221/// `Result<impl IntoResponse, FraiseQLError>` value.
222pub trait IntoHttpResponse {
223    /// Convert this result into an axum [`Response`].
224    ///
225    /// On success the inner value is serialised via its own [`IntoResponse`]
226    /// implementation. On error the [`FraiseQLError`] is converted to a JSON
227    /// error body with the appropriate HTTP status code.
228    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}