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