Skip to main content

spikard_graphql/
error.rs

1//! GraphQL error types and handling
2//!
3//! Provides error types compatible with async-graphql and HTTP response conversion.
4//! All errors follow the GraphQL specification error format with extensions for
5//! HTTP integration.
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9use thiserror::Error;
10
11/// Result type alias for GraphQL operations
12pub type Result<T> = std::result::Result<T, GraphQLError>;
13
14/// Errors that can occur during GraphQL operations
15///
16/// These errors are compatible with async-graphql error handling and can be
17/// converted to structured HTTP responses matching the project's error fixtures.
18#[derive(Error, Debug, Clone, Serialize, Deserialize)]
19pub enum GraphQLError {
20    /// Error during schema execution
21    ///
22    /// Occurs when the GraphQL executor encounters a runtime error during query execution.
23    #[error("execution error: {0}")]
24    ExecutionError(String),
25
26    /// Error during schema building
27    ///
28    /// Occurs when schema construction fails due to invalid definitions or conflicts.
29    #[error("schema build error: {0}")]
30    SchemaBuildError(String),
31
32    /// Error during request handling
33    ///
34    /// Occurs when the HTTP request cannot be properly handled or parsed.
35    #[error("request handling error: {0}")]
36    RequestHandlingError(String),
37
38    /// Serialization error
39    ///
40    /// Occurs during JSON serialization/deserialization of GraphQL values.
41    #[error("serialization error: {0}")]
42    SerializationError(String),
43
44    /// JSON parsing error
45    ///
46    /// Occurs when JSON input cannot be parsed.
47    #[error("JSON error: {0}")]
48    JsonError(String),
49
50    /// GraphQL validation error
51    ///
52    /// Occurs when a GraphQL query fails schema validation.
53    #[error("GraphQL validation error: {0}")]
54    ValidationError(String),
55
56    /// GraphQL parse error
57    ///
58    /// Occurs when the GraphQL query string cannot be parsed.
59    #[error("GraphQL parse error: {0}")]
60    ParseError(String),
61
62    /// Authentication error
63    ///
64    /// Occurs when request authentication fails.
65    #[error("Authentication error: {0}")]
66    AuthenticationError(String),
67
68    /// Authorization error
69    ///
70    /// Occurs when user lacks required permissions.
71    #[error("Authorization error: {0}")]
72    AuthorizationError(String),
73
74    /// Not found error
75    ///
76    /// Occurs when a requested resource is not found.
77    #[error("Not found: {0}")]
78    NotFound(String),
79
80    /// Rate limit error
81    ///
82    /// Occurs when rate limit is exceeded.
83    #[error("Rate limit exceeded: {0}")]
84    RateLimitExceeded(String),
85
86    /// Invalid input error with validation details
87    ///
88    /// Occurs during input validation with detailed error information.
89    #[error("Invalid input: {message}")]
90    InvalidInput {
91        /// Error message
92        message: String,
93        /// Validation error details
94        #[source]
95        details: Option<Box<Self>>,
96    },
97
98    /// Query complexity limit exceeded
99    ///
100    /// Occurs when a GraphQL query exceeds the configured complexity limit.
101    #[error("Query complexity limit exceeded")]
102    ComplexityLimitExceeded,
103
104    /// Query depth limit exceeded
105    ///
106    /// Occurs when a GraphQL query exceeds the configured depth limit.
107    #[error("Query depth limit exceeded")]
108    DepthLimitExceeded,
109
110    /// Internal server error
111    ///
112    /// Occurs when an unexpected internal error happens.
113    #[error("Internal server error: {0}")]
114    InternalError(String),
115}
116
117impl GraphQLError {
118    /// Convert error to HTTP status code
119    ///
120    /// Maps GraphQL error types to appropriate HTTP status codes:
121    /// - 400: Bad Request for parse/request-handling errors
122    /// - 401: Unauthorized for authentication errors
123    /// - 403: Forbidden for authorization errors
124    /// - 404: Not Found for resource not found
125    /// - 422: Unprocessable Entity for validation failures
126    /// - 429: Too Many Requests for rate limit errors
127    /// - 500: Internal Server Error for schema/serialization/internal errors
128    /// - 200: OK for GraphQL execution errors returned in GraphQL response body
129    ///
130    /// # Examples
131    ///
132    /// ```ignore
133    /// use spikard_graphql::error::GraphQLError;
134    ///
135    /// let error = GraphQLError::AuthenticationError("Invalid token".to_string());
136    /// assert_eq!(error.status_code(), 401);
137    ///
138    /// let error = GraphQLError::ExecutionError("Query failed".to_string());
139    /// assert_eq!(error.status_code(), 200); // GraphQL spec: errors return 200 with errors in body
140    /// ```
141    #[must_use]
142    pub const fn status_code(&self) -> u16 {
143        match self {
144            Self::ParseError(_) | Self::JsonError(_) | Self::RequestHandlingError(_) => 400,
145            Self::ValidationError(_)
146            | Self::InvalidInput { .. }
147            | Self::ComplexityLimitExceeded
148            | Self::DepthLimitExceeded => 422,
149            Self::AuthenticationError(_) => 401,
150            Self::AuthorizationError(_) => 403,
151            Self::NotFound(_) => 404,
152            Self::RateLimitExceeded(_) => 429,
153            Self::ExecutionError(_) => 200, // GraphQL execution errors return 200 with errors in body
154            Self::SchemaBuildError(_) | Self::SerializationError(_) | Self::InternalError(_) => 500,
155        }
156    }
157
158    /// Convert error to GraphQL error response JSON
159    ///
160    /// Returns a JSON object matching the GraphQL spec error format with
161    /// structured extensions for HTTP integration.
162    ///
163    /// # Format
164    ///
165    /// ```json
166    /// {
167    ///   "errors": [
168    ///     {
169    ///       "message": "error message",
170    ///       "extensions": {
171    ///         "code": "ERROR_CODE",
172    ///         "status": 400,
173    ///         "type": "https://spikard.dev/errors/..."
174    ///       }
175    ///     }
176    ///   ]
177    /// }
178    /// ```
179    ///
180    /// # Examples
181    ///
182    /// ```ignore
183    /// use spikard_graphql::error::GraphQLError;
184    ///
185    /// let error = GraphQLError::ValidationError("Invalid query".to_string());
186    /// let json = error.to_graphql_response();
187    /// assert!(json["errors"].is_array());
188    /// ```
189    #[must_use]
190    pub fn to_graphql_response(&self) -> Value {
191        json!({
192            "errors": [{
193                "message": self.to_string(),
194                "extensions": {
195                    "code": self.error_code(),
196                    "status": self.status_code(),
197                    "type": self.error_type_uri()
198                }
199            }]
200        })
201    }
202
203    /// Convert error to structured HTTP error response
204    ///
205    /// Returns a JSON object matching the project's error fixture format,
206    /// suitable for direct HTTP response conversion.
207    ///
208    /// # Format
209    ///
210    /// ```json
211    /// {
212    ///   "type": "https://spikard.dev/errors/...",
213    ///   "title": "Error Title",
214    ///   "status": 422,
215    ///   "detail": "error message",
216    ///   "errors": [
217    ///     {
218    ///       "type": "error_code",
219    ///       "message": "error message"
220    ///     }
221    ///   ]
222    /// }
223    /// ```
224    ///
225    /// # Examples
226    ///
227    /// ```ignore
228    /// use spikard_graphql::error::GraphQLError;
229    ///
230    /// let error = GraphQLError::ValidationError("Invalid query".to_string());
231    /// let json = error.to_http_response();
232    /// assert_eq!(json["status"], 422);
233    /// ```
234    #[must_use]
235    pub fn to_http_response(&self) -> Value {
236        let status = self.status_code();
237        let title = match self {
238            Self::ParseError(_) | Self::JsonError(_) | Self::RequestHandlingError(_) => "Bad Request",
239            Self::ValidationError(_)
240            | Self::InvalidInput { .. }
241            | Self::ComplexityLimitExceeded
242            | Self::DepthLimitExceeded => "Validation Failed",
243            Self::AuthenticationError(_) => "Unauthorized",
244            Self::AuthorizationError(_) => "Forbidden",
245            Self::NotFound(_) => "Not Found",
246            Self::RateLimitExceeded(_) => "Too Many Requests",
247            Self::ExecutionError(_) => "Execution Error",
248            Self::SchemaBuildError(_) | Self::SerializationError(_) | Self::InternalError(_) => "Internal Server Error",
249        };
250
251        json!({
252            "type": self.error_type_uri(),
253            "title": title,
254            "status": status,
255            "detail": self.to_string(),
256            "errors": [{
257                "type": self.error_code(),
258                "message": self.to_string()
259            }]
260        })
261    }
262
263    /// Whether the error condition is transient and may succeed on retry.
264    ///
265    /// Returns true for upstream/infrastructure failures (rate limit, internal
266    /// error) and false for client-input errors (validation, parse, auth).
267    /// Bindings forward this signal to retry/back-off logic.
268    #[must_use]
269    pub const fn is_transient(&self) -> bool {
270        matches!(
271            self,
272            Self::RateLimitExceeded(_) | Self::InternalError(_) | Self::ExecutionError(_)
273        )
274    }
275
276    /// Stable machine-readable error type identifier (`SCREAMING_SNAKE_CASE`).
277    ///
278    /// Public alias for the same codes returned by [`Self::error_code`], kept
279    /// available to bindings that surface the identifier alongside the
280    /// human-readable message.
281    #[must_use]
282    pub const fn error_type(&self) -> &'static str {
283        self.error_code()
284    }
285
286    /// Get the error code suitable for machine parsing
287    ///
288    /// Returns a screaming `SNAKE_CASE` error code that identifies the error type.
289    #[must_use]
290    pub const fn error_code(&self) -> &'static str {
291        match self {
292            Self::ParseError(_) => "GRAPHQL_PARSE_ERROR",
293            Self::JsonError(_) => "JSON_ERROR",
294            Self::ValidationError(_) => "GRAPHQL_VALIDATION_FAILED",
295            Self::ExecutionError(_) => "GRAPHQL_EXECUTION_ERROR",
296            Self::SchemaBuildError(_) => "GRAPHQL_SCHEMA_BUILD_ERROR",
297            Self::RequestHandlingError(_) => "REQUEST_HANDLING_ERROR",
298            Self::SerializationError(_) => "SERIALIZATION_ERROR",
299            Self::AuthenticationError(_) => "AUTHENTICATION_FAILED",
300            Self::AuthorizationError(_) => "AUTHORIZATION_FAILED",
301            Self::NotFound(_) => "NOT_FOUND",
302            Self::RateLimitExceeded(_) => "RATE_LIMIT_EXCEEDED",
303            Self::InvalidInput { .. } => "VALIDATION_ERROR",
304            Self::ComplexityLimitExceeded => "GRAPHQL_COMPLEXITY_LIMIT_EXCEEDED",
305            Self::DepthLimitExceeded => "GRAPHQL_DEPTH_LIMIT_EXCEEDED",
306            Self::InternalError(_) => "INTERNAL_SERVER_ERROR",
307        }
308    }
309
310    /// Get the error type URI for structured error responses
311    ///
312    /// Returns a URI identifying the error type, following RFC 7231 conventions.
313    const fn error_type_uri(&self) -> &'static str {
314        match self {
315            Self::ParseError(_) => "https://spikard.dev/errors/graphql-parse-error",
316            Self::JsonError(_) => "https://spikard.dev/errors/json-error",
317            Self::ValidationError(_) => "https://spikard.dev/errors/graphql-validation-error",
318            Self::ExecutionError(_) => "https://spikard.dev/errors/graphql-execution-error",
319            Self::SchemaBuildError(_) => "https://spikard.dev/errors/schema-build-error",
320            Self::RequestHandlingError(_) => "https://spikard.dev/errors/request-handling-error",
321            Self::SerializationError(_) => "https://spikard.dev/errors/serialization-error",
322            Self::AuthenticationError(_) => "https://spikard.dev/errors/authentication-error",
323            Self::AuthorizationError(_) => "https://spikard.dev/errors/authorization-error",
324            Self::NotFound(_) => "https://spikard.dev/errors/not-found",
325            Self::RateLimitExceeded(_) => "https://spikard.dev/errors/rate-limit-exceeded",
326            Self::InvalidInput { .. } => "https://spikard.dev/errors/validation-error",
327            Self::ComplexityLimitExceeded => "https://spikard.dev/errors/complexity-limit-exceeded",
328            Self::DepthLimitExceeded => "https://spikard.dev/errors/depth-limit-exceeded",
329            Self::InternalError(_) => "https://spikard.dev/errors/internal-server-error",
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_status_code_parse_error() {
340        let error = GraphQLError::ParseError("Invalid syntax".to_string());
341        assert_eq!(error.status_code(), 400);
342    }
343
344    #[test]
345    fn test_status_code_validation_error() {
346        let error = GraphQLError::ValidationError("Invalid query".to_string());
347        assert_eq!(error.status_code(), 422);
348    }
349
350    #[test]
351    fn test_status_code_authentication_error() {
352        let error = GraphQLError::AuthenticationError("Invalid token".to_string());
353        assert_eq!(error.status_code(), 401);
354    }
355
356    #[test]
357    fn test_status_code_authorization_error() {
358        let error = GraphQLError::AuthorizationError("Forbidden".to_string());
359        assert_eq!(error.status_code(), 403);
360    }
361
362    #[test]
363    fn test_status_code_not_found() {
364        let error = GraphQLError::NotFound("User not found".to_string());
365        assert_eq!(error.status_code(), 404);
366    }
367
368    #[test]
369    fn test_status_code_rate_limit() {
370        let error = GraphQLError::RateLimitExceeded("Too many requests".to_string());
371        assert_eq!(error.status_code(), 429);
372    }
373
374    #[test]
375    fn test_status_code_execution_error() {
376        let error = GraphQLError::ExecutionError("Query execution failed".to_string());
377        assert_eq!(error.status_code(), 200); // GraphQL spec
378    }
379
380    #[test]
381    fn test_to_graphql_response_structure() {
382        let error = GraphQLError::ValidationError("Invalid query".to_string());
383        let response = error.to_graphql_response();
384
385        assert!(response["errors"].is_array());
386        assert_eq!(response["errors"].as_array().unwrap().len(), 1);
387        assert!(response["errors"][0]["message"].is_string());
388        assert!(response["errors"][0]["extensions"]["code"].is_string());
389        assert_eq!(response["errors"][0]["extensions"]["code"], "GRAPHQL_VALIDATION_FAILED");
390        assert_eq!(response["errors"][0]["extensions"]["status"], 422);
391    }
392
393    #[test]
394    fn test_to_http_response_structure() {
395        let error = GraphQLError::AuthenticationError("Invalid token".to_string());
396        let response = error.to_http_response();
397
398        assert_eq!(response["status"], 401);
399        assert_eq!(response["title"], "Unauthorized");
400        assert!(response["type"].is_string());
401        assert!(response["errors"].is_array());
402        assert_eq!(response["errors"][0]["type"], "AUTHENTICATION_FAILED");
403    }
404
405    #[test]
406    fn test_error_code_serialization() {
407        let error = GraphQLError::InvalidInput {
408            message: "Field required".to_string(),
409            details: None,
410        };
411        assert_eq!(error.error_code(), "VALIDATION_ERROR");
412    }
413
414    #[test]
415    fn test_error_type_uri_parse_error() {
416        let error = GraphQLError::ParseError("Invalid".to_string());
417        assert_eq!(error.error_type_uri(), "https://spikard.dev/errors/graphql-parse-error");
418    }
419
420    #[test]
421    fn test_json_error_creation() {
422        let json_error = GraphQLError::JsonError("Invalid JSON".to_string());
423        assert_eq!(json_error.error_code(), "JSON_ERROR");
424        assert_eq!(json_error.status_code(), 400);
425    }
426
427    #[test]
428    fn test_error_message_display() {
429        let error = GraphQLError::ExecutionError("Query failed".to_string());
430        assert_eq!(error.to_string(), "execution error: Query failed");
431    }
432
433    #[test]
434    fn test_invalid_input_error_with_details() {
435        let detail_error = Box::new(GraphQLError::ValidationError("Field required".to_string()));
436        let error = GraphQLError::InvalidInput {
437            message: "Invalid input provided".to_string(),
438            details: Some(detail_error),
439        };
440
441        let response = error.to_http_response();
442        assert_eq!(response["status"], 422);
443        assert_eq!(response["title"], "Validation Failed");
444    }
445
446    #[test]
447    fn test_rate_limit_error_status() {
448        let error = GraphQLError::RateLimitExceeded("Limit: 100 requests/min".to_string());
449        let response = error.to_http_response();
450        assert_eq!(response["status"], 429);
451        assert_eq!(response["title"], "Too Many Requests");
452    }
453
454    #[test]
455    fn test_not_found_error_conversion() {
456        let error = GraphQLError::NotFound("Product ID 123 not found".to_string());
457        let response = error.to_http_response();
458        assert_eq!(response["status"], 404);
459        assert_eq!(response["title"], "Not Found");
460        assert_eq!(response["errors"][0]["type"], "NOT_FOUND");
461    }
462
463    #[test]
464    fn test_schema_build_error() {
465        let error = GraphQLError::SchemaBuildError("Duplicate type definition".to_string());
466        assert_eq!(error.status_code(), 500);
467        let response = error.to_graphql_response();
468        assert_eq!(
469            response["errors"][0]["extensions"]["code"],
470            "GRAPHQL_SCHEMA_BUILD_ERROR"
471        );
472    }
473
474    #[test]
475    fn test_complexity_limit_exceeded_status_code() {
476        let error = GraphQLError::ComplexityLimitExceeded;
477        assert_eq!(error.status_code(), 422);
478        assert_eq!(error.error_code(), "GRAPHQL_COMPLEXITY_LIMIT_EXCEEDED");
479    }
480
481    #[test]
482    fn test_depth_limit_exceeded_status_code() {
483        let error = GraphQLError::DepthLimitExceeded;
484        assert_eq!(error.status_code(), 422);
485        assert_eq!(error.error_code(), "GRAPHQL_DEPTH_LIMIT_EXCEEDED");
486    }
487
488    #[test]
489    fn test_complexity_limit_exceeded_response() {
490        let error = GraphQLError::ComplexityLimitExceeded;
491        let response = error.to_graphql_response();
492        assert_eq!(
493            response["errors"][0]["extensions"]["code"],
494            "GRAPHQL_COMPLEXITY_LIMIT_EXCEEDED"
495        );
496        assert_eq!(response["errors"][0]["extensions"]["status"], 422);
497    }
498
499    #[test]
500    fn test_depth_limit_exceeded_response() {
501        let error = GraphQLError::DepthLimitExceeded;
502        let response = error.to_graphql_response();
503        assert_eq!(
504            response["errors"][0]["extensions"]["code"],
505            "GRAPHQL_DEPTH_LIMIT_EXCEEDED"
506        );
507        assert_eq!(response["errors"][0]["extensions"]["status"], 422);
508    }
509
510    #[test]
511    fn test_complexity_limit_exceeded_error_type_uri() {
512        let error = GraphQLError::ComplexityLimitExceeded;
513        assert_eq!(
514            error.error_type_uri(),
515            "https://spikard.dev/errors/complexity-limit-exceeded"
516        );
517    }
518
519    #[test]
520    fn test_depth_limit_exceeded_error_type_uri() {
521        let error = GraphQLError::DepthLimitExceeded;
522        assert_eq!(
523            error.error_type_uri(),
524            "https://spikard.dev/errors/depth-limit-exceeded"
525        );
526    }
527
528    #[test]
529    fn test_all_error_codes_are_static() {
530        let errors = vec![
531            GraphQLError::ParseError(String::new()),
532            GraphQLError::JsonError(String::new()),
533            GraphQLError::ValidationError(String::new()),
534            GraphQLError::ExecutionError(String::new()),
535            GraphQLError::SchemaBuildError(String::new()),
536            GraphQLError::RequestHandlingError(String::new()),
537            GraphQLError::SerializationError(String::new()),
538            GraphQLError::AuthenticationError(String::new()),
539            GraphQLError::AuthorizationError(String::new()),
540            GraphQLError::NotFound(String::new()),
541            GraphQLError::RateLimitExceeded(String::new()),
542            GraphQLError::InvalidInput {
543                message: String::new(),
544                details: None,
545            },
546            GraphQLError::InternalError(String::new()),
547        ];
548
549        for error in errors {
550            let code = error.error_code();
551            let response = error.to_graphql_response();
552            assert_eq!(response["errors"][0]["extensions"]["code"].as_str(), Some(code));
553        }
554    }
555}