fraiseql_error/core_error.rs
1//! Core error types for FraiseQL operations.
2//!
3//! This module provides the primary error enum `FraiseQLError` used throughout
4//! the FraiseQL compilation and execution pipeline.
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Result type alias for FraiseQL operations.
10pub type Result<T> = std::result::Result<T, FraiseQLError>;
11
12/// Main error type for FraiseQL operations.
13///
14/// All errors in the core library are converted to this type.
15/// Language bindings convert this to their native error types.
16///
17/// # Error Categories
18///
19/// Errors are organized by domain:
20///
21/// ## GraphQL Errors
22/// - `Parse` — Malformed GraphQL syntax
23/// - `Validation` — Schema validation failures
24/// - `UnknownField` — Field doesn't exist on type
25/// - `UnknownType` — Type doesn't exist in schema
26///
27/// ## Database Errors
28/// - `Database` — PostgreSQL/MySQL/SQLite errors (includes SQL state code)
29/// - `ConnectionPool` — Connection pool exhausted or unavailable
30/// - `Timeout` — Query exceeded configured timeout
31/// - `Cancelled` — Query was cancelled by caller
32///
33/// ## Authorization/Security Errors
34/// - `Authorization` — User lacks permission for operation
35/// - `Authentication` — Invalid/expired JWT token
36/// - `RateLimited` — Too many requests (includes retry-after)
37///
38/// ## Resource Errors
39/// - `NotFound` — Resource doesn't exist (404)
40/// - `Conflict` — Operation would violate constraints (409)
41///
42/// ## Configuration Errors
43/// - `Configuration` — Invalid setup/configuration
44/// - `Unsupported` — Operation not supported by current database backend
45///
46/// ## Internal Errors
47/// - `Internal` — Unexpected internal failures
48///
49/// # Stability
50///
51/// This enum is marked `#[non_exhaustive]` to allow adding new error variants
52/// in future minor versions without breaking backward compatibility.
53///
54/// External `match` expressions must include a wildcard `_` arm:
55///
56/// ```rust
57/// use fraiseql_error::FraiseQLError;
58///
59/// fn describe(e: &FraiseQLError) -> &'static str {
60/// match e {
61/// FraiseQLError::Parse { .. } => "parse error",
62/// FraiseQLError::Validation { .. } => "validation error",
63/// _ => "other error", // required: FraiseQLError is #[non_exhaustive]
64/// }
65/// }
66/// ```
67///
68/// The following would **not** compile, because the `#[non_exhaustive]`
69/// attribute forces downstream crates to handle the possibility of new
70/// variants — a wildcard arm is required even when every currently-defined
71/// variant is enumerated:
72///
73/// ```compile_fail
74/// use fraiseql_error::FraiseQLError;
75///
76/// fn describe(e: &FraiseQLError) -> &'static str {
77/// // Missing wildcard arm: rejected by rustc even though it lists
78/// // a few real variants — the `#[non_exhaustive]` attribute makes
79/// // the enum effectively open from any downstream crate's point
80/// // of view.
81/// match e {
82/// FraiseQLError::Parse { .. } => "parse",
83/// FraiseQLError::Validation { .. } => "validation",
84/// FraiseQLError::Database { .. } => "database",
85/// }
86/// }
87/// ```
88#[derive(Error, Debug)]
89#[non_exhaustive]
90pub enum FraiseQLError {
91 // ========================================================================
92 // GraphQL Errors
93 // ========================================================================
94 /// GraphQL parsing error.
95 #[error("Parse error at {location}: {message}")]
96 Parse {
97 /// Error message describing the parse failure.
98 message: String,
99 /// Location in the query where the error occurred.
100 location: String,
101 },
102
103 /// GraphQL validation error.
104 #[error("Validation error: {message}")]
105 Validation {
106 /// Error message describing the validation failure.
107 message: String,
108 /// Path to the field with the error (e.g., "user.posts.0.title").
109 path: Option<String>,
110 },
111
112 /// Unknown field error.
113 #[error("Unknown field '{field}' on type '{type_name}'")]
114 UnknownField {
115 /// The field name that was not found.
116 field: String,
117 /// The type on which the field was queried.
118 type_name: String,
119 },
120
121 /// Unknown type error.
122 #[error("Unknown type '{type_name}'")]
123 UnknownType {
124 /// The type name that was not found.
125 type_name: String,
126 },
127
128 // ========================================================================
129 // Database Errors
130 // ========================================================================
131 /// Database operation error.
132 #[error("Database error: {message}")]
133 Database {
134 /// Error message from the database.
135 message: String,
136 /// SQL state code if available (e.g., "23505" for unique violation).
137 sql_state: Option<String>,
138 },
139
140 /// Connection pool error.
141 #[error("Connection pool error: {message}")]
142 ConnectionPool {
143 /// Error message.
144 message: String,
145 },
146
147 /// Query timeout error.
148 #[error("Query timeout after {timeout_ms}ms")]
149 Timeout {
150 /// Timeout duration in milliseconds.
151 timeout_ms: u64,
152 /// The query that timed out (truncated if too long).
153 query: Option<String>,
154 },
155
156 /// Query cancellation error.
157 #[error("Query cancelled: {reason}")]
158 Cancelled {
159 /// Query identifier for tracking/logging.
160 query_id: String,
161 /// Reason for cancellation.
162 reason: String,
163 },
164
165 // ========================================================================
166 // Authorization Errors
167 // ========================================================================
168 /// Authorization error.
169 #[error("Authorization error: {message}")]
170 Authorization {
171 /// Error message.
172 message: String,
173 /// The action that was denied.
174 action: Option<String>,
175 /// The resource that was being accessed.
176 resource: Option<String>,
177 },
178
179 /// Authentication error.
180 #[error("Authentication error: {message}")]
181 Authentication {
182 /// Error message.
183 message: String,
184 },
185
186 /// Rate limiting error.
187 #[error("Rate limit exceeded: {message}")]
188 RateLimited {
189 /// Error message.
190 message: String,
191 /// Number of seconds to wait before retrying.
192 retry_after_secs: u64,
193 },
194
195 // ========================================================================
196 // Resource Errors
197 // ========================================================================
198 /// Resource not found error.
199 #[error("{resource_type} not found: {identifier}")]
200 NotFound {
201 /// Type of resource (e.g., "User", "Post").
202 resource_type: String,
203 /// Identifier that was looked up.
204 identifier: String,
205 },
206
207 /// Conflict error.
208 #[error("Conflict: {message}")]
209 Conflict {
210 /// Error message.
211 message: String,
212 },
213
214 // ========================================================================
215 // Configuration Errors
216 // ========================================================================
217 /// Configuration error.
218 #[error("Configuration error: {message}")]
219 Configuration {
220 /// Error message.
221 message: String,
222 },
223
224 /// Unsupported operation error.
225 #[error("Unsupported operation: {message}")]
226 Unsupported {
227 /// Error message describing what is not supported.
228 message: String,
229 },
230
231 /// The service is temporarily unavailable (e.g. tenant suspended).
232 ///
233 /// Maps to HTTP 503. `retry_after` is the number of seconds to wait, if known.
234 #[error("Service unavailable: {message}")]
235 ServiceUnavailable {
236 /// Human-readable reason for the unavailability.
237 message: String,
238 /// Number of seconds to wait before retrying, if known.
239 retry_after: Option<u64>,
240 },
241
242 // ========================================================================
243 // Domain Subsystem Errors (composed via `From` impls in subsystem crates)
244 // ========================================================================
245 /// An authentication or authorisation error originating from the auth subsystem.
246 ///
247 /// The boxed source is the subsystem-specific error type (e.g.
248 /// `fraiseql_auth::AuthError`). To preserve subsystem vocabulary while
249 /// keeping `fraiseql-error` a leaf crate, the boxed payload is
250 /// type-erased here; subsystem crates provide their own
251 /// `impl From<SubsystemError> for FraiseQLError` (the sqlx pattern).
252 ///
253 /// `#[source]` is explicit: `thiserror` 2.x does not auto-detect a single
254 /// tuple field as the source, and downstream chain-walkers (`tracing`,
255 /// `miette`, `anyhow`) rely on `Error::source()` returning the underlying
256 /// subsystem error rather than `None`.
257 ///
258 /// # Pattern-matching on the inner error
259 ///
260 /// Because the payload is boxed and `dyn`-erased, downstream `match`
261 /// statements cannot bind on subsystem variants directly. Recover the
262 /// concrete type via [`std::error::Error::source`] + `downcast_ref`:
263 ///
264 /// ```ignore
265 /// use std::error::Error;
266 /// use fraiseql_error::FraiseQLError;
267 /// use fraiseql_auth::AuthError;
268 ///
269 /// if let FraiseQLError::Auth(_) = &err {
270 /// if let Some(inner) = err.source().and_then(|s| s.downcast_ref::<AuthError>()) {
271 /// match inner {
272 /// AuthError::TokenExpired => {/* handle */},
273 /// _ => {},
274 /// }
275 /// }
276 /// }
277 /// ```
278 #[error("Auth error: {0}")]
279 Auth(#[source] Box<dyn std::error::Error + Send + Sync>),
280
281 /// A webhook-processing error originating from the webhook subsystem.
282 ///
283 /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
284 /// that variant for the `downcast_ref` recovery pattern on the boxed
285 /// `fraiseql_webhooks::WebhookError`.
286 #[error("Webhook error: {0}")]
287 Webhook(#[source] Box<dyn std::error::Error + Send + Sync>),
288
289 /// An observer subsystem error (event dispatch, action execution, retry exhaustion).
290 ///
291 /// `#[source]` is explicit for the same reason as [`Self::Auth`]; see
292 /// that variant for the `downcast_ref` recovery pattern on the boxed
293 /// `fraiseql_observers::ObserverError`.
294 #[error("Observer error: {0}")]
295 Observer(#[source] Box<dyn std::error::Error + Send + Sync>),
296
297 /// A file-handling error (size limit, unsupported type, virus scan, quota).
298 ///
299 /// Unlike `Auth`, `Webhook`, and `Observer`, the file-domain vocabulary
300 /// lives inside `fraiseql-error` itself ([`crate::FileError`]) because no
301 /// subsystem crate owns it — file operations are spread across
302 /// `fraiseql-storage` and `fraiseql-server/storage`.
303 #[error("File error: {0}")]
304 File(#[from] crate::FileError),
305
306 // ========================================================================
307 // Internal Errors
308 // ========================================================================
309 /// Internal error.
310 #[error("Internal error: {message}")]
311 Internal {
312 /// Error message.
313 message: String,
314 /// Optional source error for debugging.
315 #[source]
316 source: Option<Box<dyn std::error::Error + Send + Sync>>,
317 },
318}
319
320impl FraiseQLError {
321 /// Create a parse error.
322 #[must_use]
323 pub fn parse(message: impl Into<String>) -> Self {
324 Self::Parse {
325 message: message.into(),
326 location: "unknown".to_string(),
327 }
328 }
329
330 /// Create a parse error with location.
331 #[must_use]
332 pub fn parse_at(message: impl Into<String>, location: impl Into<String>) -> Self {
333 Self::Parse {
334 message: message.into(),
335 location: location.into(),
336 }
337 }
338
339 /// Create a validation error.
340 #[must_use]
341 pub fn validation(message: impl Into<String>) -> Self {
342 Self::Validation {
343 message: message.into(),
344 path: None,
345 }
346 }
347
348 /// Create a validation error with path.
349 #[must_use]
350 pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
351 Self::Validation {
352 message: message.into(),
353 path: Some(path.into()),
354 }
355 }
356
357 /// Create a database error.
358 #[must_use]
359 pub fn database(message: impl Into<String>) -> Self {
360 Self::Database {
361 message: message.into(),
362 sql_state: None,
363 }
364 }
365
366 /// Create an authorization error.
367 #[must_use]
368 pub fn unauthorized(message: impl Into<String>) -> Self {
369 Self::Authorization {
370 message: message.into(),
371 action: None,
372 resource: None,
373 }
374 }
375
376 /// Create a not found error.
377 #[must_use]
378 pub fn not_found(resource_type: impl Into<String>, identifier: impl Into<String>) -> Self {
379 Self::NotFound {
380 resource_type: resource_type.into(),
381 identifier: identifier.into(),
382 }
383 }
384
385 /// Create a configuration error.
386 #[must_use]
387 pub fn config(message: impl Into<String>) -> Self {
388 Self::Configuration {
389 message: message.into(),
390 }
391 }
392
393 /// Create an internal error.
394 #[must_use]
395 pub fn internal(message: impl Into<String>) -> Self {
396 Self::Internal {
397 message: message.into(),
398 source: None,
399 }
400 }
401
402 /// Create a cancellation error.
403 #[must_use]
404 pub fn cancelled(query_id: impl Into<String>, reason: impl Into<String>) -> Self {
405 Self::Cancelled {
406 query_id: query_id.into(),
407 reason: reason.into(),
408 }
409 }
410
411 /// Check if this is a client error (4xx equivalent).
412 ///
413 /// Derived from [`Self::status_code`] so the answer stays consistent
414 /// with the variant's actual HTTP routing (notably for `File(_)`, which
415 /// straddles 4xx and 5xx after F050).
416 #[must_use]
417 pub const fn is_client_error(&self) -> bool {
418 let code = self.status_code();
419 code >= 400 && code < 500
420 }
421
422 /// Check if this is a server error (5xx equivalent).
423 ///
424 /// Derived from [`Self::status_code`] for the same consistency reason
425 /// as [`Self::is_client_error`].
426 #[must_use]
427 pub const fn is_server_error(&self) -> bool {
428 let code = self.status_code();
429 code >= 500 && code < 600
430 }
431
432 /// Check if this error is retryable.
433 #[must_use]
434 pub const fn is_retryable(&self) -> bool {
435 matches!(
436 self,
437 Self::ConnectionPool { .. }
438 | Self::Timeout { .. }
439 | Self::Cancelled { .. }
440 | Self::ServiceUnavailable { .. }
441 )
442 }
443
444 /// Get HTTP status code equivalent.
445 ///
446 /// # Future variants
447 ///
448 /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_ => 500` arm
449 /// is a deliberate safety net so a new variant added without updating
450 /// this match still gets a defined (and *safe*) HTTP status — generic
451 /// 500 Internal Server Error — rather than leaking implementation
452 /// details to the client by returning the wrong code.
453 // Reason: the trailing wildcard arm intentionally duplicates the 500
454 // server-error arm above (silencing `match_same_arms`), and is currently
455 // unreachable within this crate because the match enumerates every
456 // existing variant (silencing `unreachable_patterns`). The duplication is
457 // the security guarantee: when a future variant is added to this
458 // `#[non_exhaustive]` enum, the wildcard becomes reachable and prevents a
459 // wrong-status leak. See IMPROVEMENTS.md F055.
460 #[allow(clippy::match_same_arms, unreachable_patterns)]
461 #[must_use]
462 pub const fn status_code(&self) -> u16 {
463 match self {
464 Self::Parse { .. }
465 | Self::Validation { .. }
466 | Self::UnknownField { .. }
467 | Self::UnknownType { .. }
468 | Self::Webhook(_) => 400,
469 // File is per-variant: validation failures stay 400, backend
470 // failures escalate to 5xx, NotFound → 404, PermissionDenied →
471 // 403 (matches the legacy `storage_error_response` routing of
472 // `FraiseQLError::Storage` which F050 replaces).
473 Self::File(e) => e.status_code(),
474 Self::Authentication { .. } | Self::Auth(_) => 401,
475 Self::Authorization { .. } => 403,
476 Self::NotFound { .. } => 404,
477 Self::Conflict { .. } => 409,
478 Self::RateLimited { .. } => 429,
479 Self::Timeout { .. } | Self::Cancelled { .. } => 408,
480 Self::Database { .. }
481 | Self::ConnectionPool { .. }
482 | Self::Configuration { .. }
483 | Self::Internal { .. }
484 | Self::Observer(_) => 500,
485 Self::Unsupported { .. } => 501,
486 Self::ServiceUnavailable { .. } => 503,
487 // SECURITY: any future variant defaults to 500 Internal Server
488 // Error until explicitly mapped, so we never accidentally return
489 // an inappropriate status (e.g. 200, 401) to clients.
490 _ => 500,
491 }
492 }
493
494 /// Get error code for GraphQL response.
495 ///
496 /// # Future variants
497 ///
498 /// `FraiseQLError` is `#[non_exhaustive]`. The trailing `_` arm returns
499 /// `"INTERNAL_SERVER_ERROR"` so a new variant added without updating
500 /// this match still receives a stable (if generic) error code.
501 // Reason: see `status_code` — same security-defence rationale; same two
502 // lints (`match_same_arms` for the duplicated arm body, `unreachable_patterns`
503 // because the in-crate match currently covers every variant).
504 #[allow(clippy::match_same_arms, unreachable_patterns)]
505 #[must_use]
506 pub const fn error_code(&self) -> &'static str {
507 match self {
508 Self::Parse { .. } => "GRAPHQL_PARSE_FAILED",
509 Self::Validation { .. } => "GRAPHQL_VALIDATION_FAILED",
510 Self::UnknownField { .. } => "UNKNOWN_FIELD",
511 Self::UnknownType { .. } => "UNKNOWN_TYPE",
512 Self::Database { .. } => "DATABASE_ERROR",
513 Self::ConnectionPool { .. } => "CONNECTION_POOL_ERROR",
514 Self::Timeout { .. } => "TIMEOUT",
515 Self::Cancelled { .. } => "CANCELLED",
516 Self::Authorization { .. } => "FORBIDDEN",
517 Self::Authentication { .. } => "UNAUTHENTICATED",
518 Self::Auth(_) => "AUTH_ERROR",
519 Self::Webhook(_) => "WEBHOOK_ERROR",
520 Self::Observer(_) => "OBSERVER_ERROR",
521 Self::File(_) => "FILE_ERROR",
522 Self::RateLimited { .. } => "RATE_LIMITED",
523 Self::NotFound { .. } => "NOT_FOUND",
524 Self::Conflict { .. } => "CONFLICT",
525 Self::Configuration { .. } => "CONFIGURATION_ERROR",
526 Self::Unsupported { .. } => "UNSUPPORTED_OPERATION",
527 Self::ServiceUnavailable { .. } => "SERVICE_UNAVAILABLE",
528 Self::Internal { .. } => "INTERNAL_SERVER_ERROR",
529 // SECURITY: see `status_code` — fallback to the safe generic
530 // category until the new variant is explicitly classified.
531 _ => "INTERNAL_SERVER_ERROR",
532 }
533 }
534
535 /// Create an unknown field error with helpful suggestions.
536 #[must_use]
537 pub fn unknown_field_with_suggestion(
538 field: impl Into<String>,
539 type_name: impl Into<String>,
540 available_fields: &[&str],
541 ) -> Self {
542 let field = field.into();
543 let type_name = type_name.into();
544
545 let suggestion = available_fields
546 .iter()
547 .map(|f| (*f, Self::levenshtein_distance(&field, f)))
548 .filter(|(_, distance)| *distance <= 2)
549 .min_by_key(|(_, distance)| *distance)
550 .map(|(f, _)| f);
551
552 if let Some(suggested_field) = suggestion {
553 Self::UnknownField {
554 field: format!("{field} (did you mean '{suggested_field}'?)"),
555 type_name,
556 }
557 } else {
558 Self::UnknownField { field, type_name }
559 }
560 }
561
562 fn levenshtein_distance(s1: &str, s2: &str) -> usize {
563 let chars2: Vec<char> = s2.chars().collect();
564 let len2 = chars2.len();
565
566 // Two-row rolling buffer eliminates the nested `Vec<Vec<_>>` indexing.
567 // `prev[j]` = distance(s1[..i], s2[..j])
568 // `curr[j]` = distance(s1[..i+1], s2[..j])
569 // Both rows have `len2 + 1` entries; iteration is bounded by their
570 // length, so every access uses `.get()?` with an `unreachable_or_zero`
571 // saturating fallback (provably unreachable in this control flow).
572 let mut prev: Vec<usize> = (0..=len2).collect();
573 let mut curr: Vec<usize> = vec![0; len2 + 1];
574
575 for (i, c1) in s1.chars().enumerate() {
576 // Initialise the leftmost column for this row.
577 // Reason: `curr` is sized `len2 + 1 >= 1`, so index 0 is always valid.
578 if let Some(slot) = curr.get_mut(0) {
579 *slot = i + 1;
580 }
581
582 for (j, &c2) in chars2.iter().enumerate() {
583 let cost = usize::from(c1 != c2);
584 // All four lookups read positions in `[0, len2]`, which are
585 // valid by construction (`prev`/`curr` both have len `len2+1`,
586 // and `j` ranges over `0..len2`). The `.get()` + `unwrap_or(0)`
587 // pattern keeps the function panic-free without changing the
588 // computed result.
589 let deletion = prev.get(j + 1).copied().unwrap_or(0).saturating_add(1);
590 let insertion = curr.get(j).copied().unwrap_or(0).saturating_add(1);
591 let substitution = prev.get(j).copied().unwrap_or(0).saturating_add(cost);
592 let value = deletion.min(insertion).min(substitution);
593 if let Some(slot) = curr.get_mut(j + 1) {
594 *slot = value;
595 }
596 }
597
598 std::mem::swap(&mut prev, &mut curr);
599 }
600
601 // Final answer sits at `prev[len2]` after the last swap.
602 // Reason: `prev` is always sized `len2 + 1`, so this index is valid.
603 prev.get(len2).copied().unwrap_or(0)
604 }
605
606 /// Create a database error from PostgreSQL error code.
607 #[must_use]
608 pub fn from_postgres_code(code: &str, message: impl Into<String>) -> Self {
609 let message = message.into();
610 match code {
611 "42P01" => Self::Database {
612 message: "The table or view you're querying doesn't exist. \
613 Check that the schema is compiled and the database is initialized."
614 .to_string(),
615 sql_state: Some(code.to_string()),
616 },
617 "42703" => Self::Database {
618 message: "A column referenced in the query doesn't exist in the table. \
619 This may indicate the database schema is out of sync with the compiled schema."
620 .to_string(),
621 sql_state: Some(code.to_string()),
622 },
623 "23505" => Self::Conflict {
624 message: "A unique constraint was violated. This value already exists in the database.".to_string(),
625 },
626 "23503" => Self::Conflict {
627 message: "A foreign key constraint was violated. The referenced record doesn't exist."
628 .to_string(),
629 },
630 "23502" => Self::Conflict {
631 message: "A NOT NULL constraint was violated. The field cannot be empty.".to_string(),
632 },
633 "22P02" => Self::Validation {
634 message: "Invalid input value. The provided value doesn't match the expected data type.".to_string(),
635 path: None,
636 },
637 _ => Self::Database {
638 message,
639 sql_state: Some(code.to_string()),
640 },
641 }
642 }
643
644 /// Create a rate limit error with retry information.
645 #[must_use]
646 pub fn rate_limited_with_retry(retry_after_secs: u64) -> Self {
647 Self::RateLimited {
648 message: format!(
649 "Rate limit exceeded. Please try again in {retry_after_secs} seconds. \
650 For permanent increases, contact support."
651 ),
652 retry_after_secs,
653 }
654 }
655
656 /// Create an authentication error with context.
657 #[must_use]
658 pub fn auth_error(reason: impl Into<String>) -> Self {
659 Self::Authentication {
660 message: reason.into(),
661 }
662 }
663}
664
665impl From<serde_json::Error> for FraiseQLError {
666 fn from(e: serde_json::Error) -> Self {
667 Self::Parse {
668 message: e.to_string(),
669 location: format!("line {}, column {}", e.line(), e.column()),
670 }
671 }
672}
673
674impl From<std::io::Error> for FraiseQLError {
675 fn from(e: std::io::Error) -> Self {
676 Self::Internal {
677 message: format!("I/O error: {e}"),
678 source: Some(Box::new(e)),
679 }
680 }
681}
682
683impl From<std::env::VarError> for FraiseQLError {
684 fn from(e: std::env::VarError) -> Self {
685 Self::Configuration {
686 message: format!("Environment variable error: {e}"),
687 }
688 }
689}
690
691/// Extension trait for adding context to errors.
692pub trait ErrorContext<T> {
693 /// Add context to an error.
694 ///
695 /// # Errors
696 ///
697 /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
698 /// given message.
699 fn context(self, message: impl Into<String>) -> Result<T>;
700
701 /// Add context lazily (only computed on error).
702 ///
703 /// # Errors
704 ///
705 /// Returns `Err` if the original value was `Err`, wrapping it in an `Internal` error with the
706 /// context message.
707 fn with_context<F, M>(self, f: F) -> Result<T>
708 where
709 F: FnOnce() -> M,
710 M: Into<String>;
711}
712
713impl<T, E: Into<FraiseQLError>> ErrorContext<T> for std::result::Result<T, E> {
714 fn context(self, message: impl Into<String>) -> Result<T> {
715 self.map_err(|e| {
716 let inner = e.into();
717 FraiseQLError::Internal {
718 message: format!("{}: {inner}", message.into()),
719 source: None,
720 }
721 })
722 }
723
724 fn with_context<F, M>(self, f: F) -> Result<T>
725 where
726 F: FnOnce() -> M,
727 M: Into<String>,
728 {
729 self.map_err(|e| {
730 let inner = e.into();
731 FraiseQLError::Internal {
732 message: format!("{}: {inner}", f().into()),
733 source: None,
734 }
735 })
736 }
737}
738
739/// A validation error for a specific field in an input object.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ValidationFieldError {
742 /// Path to the field that failed validation.
743 pub field: String,
744 /// Type of validation rule that failed.
745 pub rule_type: String,
746 /// Human-readable error message.
747 pub message: String,
748}
749
750impl ValidationFieldError {
751 /// Create a new validation field error.
752 #[must_use]
753 pub fn new(
754 field: impl Into<String>,
755 rule_type: impl Into<String>,
756 message: impl Into<String>,
757 ) -> Self {
758 Self {
759 field: field.into(),
760 rule_type: rule_type.into(),
761 message: message.into(),
762 }
763 }
764}
765
766impl std::fmt::Display for ValidationFieldError {
767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
768 write!(f, "{} ({}): {}", self.field, self.rule_type, self.message)
769 }
770}
771
772#[cfg(test)]
773mod tests;