Skip to main content

cirrus/
error.rs

1//! Error types for the Cirrus SDK.
2//!
3//! Salesforce REST endpoints return errors as a JSON array of objects with a
4//! consistent shape (`message`, `errorCode`, optional `fields`), regardless of
5//! the success-response shape. [`SalesforceError`] models that shape, and
6//! [`CirrusError::Api`] carries the parsed array along with the HTTP
7//! status. OAuth token endpoints use a different shape (`error` /
8//! `error_description`), surfaced via [`CirrusError::OAuth`].
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Specialized `Result` type for Cirrus operations.
14pub type CirrusResult<T> = Result<T, CirrusError>;
15
16/// A single Salesforce API error entry.
17///
18/// Salesforce REST endpoints return errors as a JSON array of these objects.
19/// The shape is schema-independent and applies to every REST resource.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SalesforceError {
22    /// Human-readable description of the error.
23    pub message: String,
24    /// Salesforce-defined error code (e.g. `INVALID_FIELD`, `NOT_FOUND`).
25    #[serde(rename = "errorCode")]
26    pub error_code: String,
27    /// Field names involved in the error, when applicable (validation errors).
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub fields: Vec<String>,
30}
31
32/// Errors produced by the Cirrus client.
33#[derive(Debug, Error)]
34pub enum CirrusError {
35    /// A required builder field was not set.
36    #[error("missing required builder field: {0}")]
37    MissingField(&'static str),
38
39    /// Failed to construct the underlying HTTP client.
40    #[error("failed to construct HTTP client: {0}")]
41    HttpClient(#[source] reqwest::Error),
42
43    /// Network or transport-level HTTP failure.
44    #[error("HTTP request failed: {0}")]
45    Http(#[from] reqwest::Error),
46
47    /// Salesforce returned a non-2xx response. `errors` holds the parsed
48    /// Salesforce error array; if the body could not be parsed as the
49    /// canonical shape, the raw body is in `raw`.
50    #[error("Salesforce API error (status {status}): {}", display_errors(.errors, .raw))]
51    Api {
52        /// HTTP status code returned by Salesforce.
53        status: u16,
54        /// Parsed Salesforce error entries. Empty if the body was not parseable
55        /// as the canonical error array.
56        errors: Vec<SalesforceError>,
57        /// Raw response body. Populated when `errors` is empty so callers can
58        /// see what came back.
59        raw: Option<String>,
60    },
61
62    /// OAuth token endpoint returned an error response (`error` /
63    /// `error_description` shape).
64    #[error("OAuth error: {error}{}", .error_description.as_deref().map(|d| format!(" — {d}")).unwrap_or_default())]
65    OAuth {
66        error: String,
67        error_description: Option<String>,
68    },
69
70    /// An auth implementation failed to produce a token (network, expired
71    /// credentials, signing failure, etc.). Carries the underlying message.
72    #[error("authentication failed: {0}")]
73    Auth(String),
74
75    /// JSON serialization or deserialization failure.
76    #[error("serialization error: {0}")]
77    Serialization(#[from] serde_json::Error),
78
79    /// URL parsing failure (instance URL, redirect URI, etc.).
80    #[error("invalid URL: {0}")]
81    Url(#[from] url::ParseError),
82
83    /// Header value rejected by reqwest (invalid bytes, etc.).
84    #[error("invalid header value: {0}")]
85    InvalidHeader(String),
86
87    /// Response could not be interpreted as the requested type or shape.
88    #[error("invalid response: {0}")]
89    InvalidResponse(String),
90}
91
92fn display_errors(errors: &[SalesforceError], raw: &Option<String>) -> String {
93    if errors.is_empty() {
94        return raw
95            .as_deref()
96            .map(|r| format!("<unparsed body: {r}>"))
97            .unwrap_or_else(|| "<no body>".to_string());
98    }
99    errors
100        .iter()
101        .map(|e| {
102            if e.fields.is_empty() {
103                format!("[{}] {}", e.error_code, e.message)
104            } else {
105                format!(
106                    "[{}] {} (fields: {})",
107                    e.error_code,
108                    e.message,
109                    e.fields.join(", ")
110                )
111            }
112        })
113        .collect::<Vec<_>>()
114        .join("; ")
115}
116
117#[cfg(test)]
118#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn display_errors_formats_single_error() {
124        let err = CirrusError::Api {
125            status: 400,
126            errors: vec![SalesforceError {
127                message: "Required field missing".to_string(),
128                error_code: "REQUIRED_FIELD_MISSING".to_string(),
129                fields: vec!["Name".to_string()],
130            }],
131            raw: None,
132        };
133        let msg = err.to_string();
134        assert!(msg.contains("400"));
135        assert!(msg.contains("REQUIRED_FIELD_MISSING"));
136        assert!(msg.contains("Name"));
137    }
138
139    #[test]
140    fn display_errors_falls_back_to_raw_body() {
141        let err = CirrusError::Api {
142            status: 500,
143            errors: vec![],
144            raw: Some("Internal Server Error".to_string()),
145        };
146        let msg = err.to_string();
147        assert!(msg.contains("500"));
148        assert!(msg.contains("Internal Server Error"));
149    }
150
151    #[test]
152    fn salesforce_error_deserializes_canonical_shape() {
153        let json = r#"[{"message":"bad","errorCode":"INVALID_FIELD","fields":["Foo"]}]"#;
154        let parsed: Vec<SalesforceError> = serde_json::from_str(json).unwrap();
155        assert_eq!(parsed.len(), 1);
156        assert_eq!(parsed[0].error_code, "INVALID_FIELD");
157        assert_eq!(parsed[0].fields, vec!["Foo"]);
158    }
159
160    #[test]
161    fn salesforce_error_handles_missing_fields() {
162        let json = r#"[{"message":"bad","errorCode":"INVALID_SESSION_ID"}]"#;
163        let parsed: Vec<SalesforceError> = serde_json::from_str(json).unwrap();
164        assert_eq!(parsed.len(), 1);
165        assert!(parsed[0].fields.is_empty());
166    }
167}