fraiseql_error/graphql_error.rs
1//! Canonical GraphQL protocol error types.
2//!
3//! Implements the [GraphQL specification](https://spec.graphql.org/October2021/#sec-Errors)
4//! error format used across the FraiseQL workspace — `WebSocket` subscription protocol
5//! (graphql-ws v5+), HTTP response bodies, and federation subgraph communication.
6//!
7//! # Structure
8//!
9//! ```json
10//! {
11//! "message": "Cannot query field 'id' on type 'User'.",
12//! "locations": [{ "line": 3, "column": 5 }],
13//! "path": ["user", "friends", 0, "name"],
14//! "extensions": { "code": "FIELD_NOT_FOUND" }
15//! }
16//! ```
17
18use std::collections::HashMap;
19
20use serde::{Deserialize, Serialize};
21
22/// Location in a GraphQL document (source text) where an error occurred.
23///
24/// Line and column are 1-indexed as required by the GraphQL specification.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct GraphQLErrorLocation {
27 /// Line number in the query document (1-indexed).
28 pub line: u32,
29 /// Column number in the query document (1-indexed).
30 pub column: u32,
31}
32
33/// A GraphQL protocol-level error as defined in the
34/// [GraphQL specification §7.1.2](https://spec.graphql.org/October2021/#sec-Errors).
35///
36/// This is the canonical wire format used by:
37/// - `WebSocket` subscription protocol (`graphql-ws` v5+) `error` messages
38/// - HTTP GraphQL response `errors` array
39/// - Federation subgraph HTTP responses
40///
41/// Crates that need HTTP-layer concerns (error codes, status codes, sanitization)
42/// build on top of this type rather than replacing it.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct GraphQLError {
45 /// Human-readable error message.
46 pub message: String,
47
48 /// Locations in the query document where the error occurred.
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub locations: Option<Vec<GraphQLErrorLocation>>,
51
52 /// Path into the response data where the error occurred.
53 ///
54 /// Elements are either string field names or integer list indices.
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub path: Option<Vec<serde_json::Value>>,
57
58 /// Arbitrary extension data (error codes, categories, etc.).
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub extensions: Option<HashMap<String, serde_json::Value>>,
61}
62
63impl GraphQLError {
64 /// Create a simple error with only a message.
65 #[must_use]
66 pub fn new(message: impl Into<String>) -> Self {
67 Self {
68 message: message.into(),
69 locations: None,
70 path: None,
71 extensions: None,
72 }
73 }
74
75 /// Create an error with a string extension code.
76 ///
77 /// This is the conventional way to attach a machine-readable error code:
78 ///
79 /// ```rust
80 /// use fraiseql_error::GraphQLError;
81 ///
82 /// let err = GraphQLError::with_code("Subscription not found", "SUBSCRIPTION_NOT_FOUND");
83 /// ```
84 #[must_use]
85 pub fn with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
86 let mut extensions = HashMap::new();
87 extensions.insert("code".to_string(), serde_json::json!(code.into()));
88 Self {
89 message: message.into(),
90 locations: None,
91 path: None,
92 extensions: Some(extensions),
93 }
94 }
95
96 /// Add a source location to this error.
97 #[must_use]
98 pub fn with_location(mut self, line: u32, column: u32) -> Self {
99 let loc = GraphQLErrorLocation { line, column };
100 self.locations.get_or_insert_with(Vec::new).push(loc);
101 self
102 }
103
104 /// Add a response path to this error.
105 #[must_use]
106 pub fn with_path(mut self, path: Vec<serde_json::Value>) -> Self {
107 self.path = Some(path);
108 self
109 }
110}