Skip to main content

sdp_request_client/
error.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::TicketID;
5
6/// SDP API error codes
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub(crate) enum SdpErrorCode {
9    Success = 2000,
10    InvalidValue = 4001,
11    Forbidden = 4002,
12    ClosureRuleViolation = 4003,
13    Internal = 4004,
14    ReferenceExists = 4005,
15    NotFound = 4007,
16    NotUnique = 4008,
17    NonEditableField = 4009,
18    InternalField = 4010,
19    NoSuchField = 4011,
20    MissingMandatoryField = 4012,
21    UnsupportedContentType = 4013,
22    ReadOnlyField = 4014,
23    RateLimitExceeded = 4015,
24    AlreadyInTrash = 4016,
25    NotInTrash = 4017,
26    LicenseRestriction = 7001,
27    Unknown = 0,
28}
29
30impl From<u32> for SdpErrorCode {
31    fn from(code: u32) -> Self {
32        match code {
33            2000 => SdpErrorCode::Success,
34            4001 => SdpErrorCode::InvalidValue,
35            4002 => SdpErrorCode::Forbidden,
36            4003 => SdpErrorCode::ClosureRuleViolation,
37            4004 => SdpErrorCode::Internal,
38            4005 => SdpErrorCode::ReferenceExists,
39            4007 => SdpErrorCode::NotFound,
40            4008 => SdpErrorCode::NotUnique,
41            4009 => SdpErrorCode::NonEditableField,
42            4010 => SdpErrorCode::InternalField,
43            4011 => SdpErrorCode::NoSuchField,
44            4012 => SdpErrorCode::MissingMandatoryField,
45            4013 => SdpErrorCode::UnsupportedContentType,
46            4014 => SdpErrorCode::ReadOnlyField,
47            4015 => SdpErrorCode::RateLimitExceeded,
48            4016 => SdpErrorCode::AlreadyInTrash,
49            4017 => SdpErrorCode::NotInTrash,
50            7001 => SdpErrorCode::LicenseRestriction,
51            _ => SdpErrorCode::Unknown,
52        }
53    }
54}
55
56#[derive(Debug, Error)]
57pub enum Error {
58    #[error("HTTP error: {0}")]
59    Http(#[from] reqwest::Error),
60    #[error("Authentication failed: invalid or expired token")]
61    Unauthorized,
62    #[error("Permission denied: {0}")]
63    Forbidden(String),
64    #[error("Resource not found: {0}")]
65    NotFound(String),
66    #[error("request has been merged into parent {parent_request_id}: {message}")]
67    RequestMerged {
68        parent_request_id: TicketID,
69        message: String,
70    },
71    #[error("Invalid value: {0}")]
72    InvalidValue(String),
73    #[error("Resource already exists (not unique): {0}")]
74    NotUnique(String),
75    #[error("Cannot delete: resource is referenced elsewhere")]
76    ReferenceExists,
77    #[error("Missing mandatory field: {0}")]
78    MissingField(String),
79    #[error("Field is not editable: {0}")]
80    NotEditable(String),
81    #[error("Field does not exist: {0}")]
82    NoSuchField(String),
83    #[error("Closure rule violation: {0}")]
84    ClosureRuleViolation(String),
85    #[error("Rate limit exceeded")]
86    RateLimited,
87    #[error("License restriction: operation not allowed")]
88    LicenseRestricted,
89    #[error("SDP internal error")]
90    Internal,
91    #[error("Serialization error: {0}")]
92    Serialization(#[from] serde_json::Error),
93    #[error("URL parsing error: {0}")]
94    UrlParse(#[from] url::ParseError),
95    #[error("Form encoding error: {0}")]
96    FormEncoding(#[from] serde_urlencoded::ser::Error),
97    #[error("SDP error (code {code}): {message}")]
98    Sdp { code: u32, message: String },
99    #[error("Required field missing: {0}")]
100    FieldRequired(String),
101    #[error("{0}")]
102    Other(String),
103    #[error("io error: {0}")]
104    Io(#[from] std::io::Error),
105}
106
107#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
108#[serde(untagged)]
109pub(crate) enum SdpErrorMessage {
110    Merged {
111        parent_request: ParentRequest,
112        message: String,
113    },
114    Text(String),
115    Other(serde_json::Value),
116}
117
118impl SdpErrorMessage {
119    fn into_text(self) -> String {
120        match self {
121            Self::Merged { message, .. } | Self::Text(message) => message,
122            Self::Other(value) => value.to_string(),
123        }
124    }
125}
126
127#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
128pub(crate) struct ParentRequest {
129    id: TicketID,
130}
131
132impl Error {
133    pub(crate) fn from_sdp_message(code: u32, response: SdpErrorMessage) -> Self {
134        if let SdpErrorMessage::Merged {
135            parent_request,
136            message,
137        } = response
138        {
139            return Self::RequestMerged {
140                parent_request_id: parent_request.id,
141                message,
142            };
143        }
144
145        Self::from_sdp(code, response.into_text(), None)
146    }
147
148    /// Create an error from SDP response status code and message
149    #[must_use]
150    pub fn from_sdp(code: u32, message: String, field: Option<String>) -> Self {
151        let field_info = field.clone().unwrap_or_else(|| message.clone());
152
153        match SdpErrorCode::from(code) {
154            SdpErrorCode::InvalidValue => Error::InvalidValue(field_info),
155            SdpErrorCode::Forbidden => Error::Forbidden(field_info),
156            SdpErrorCode::ClosureRuleViolation => Error::ClosureRuleViolation(field_info),
157            SdpErrorCode::Internal => Error::Internal,
158            SdpErrorCode::ReferenceExists => Error::ReferenceExists,
159            SdpErrorCode::NotFound => Error::NotFound(field_info),
160            SdpErrorCode::NotUnique => Error::NotUnique(field_info),
161            SdpErrorCode::NonEditableField | SdpErrorCode::ReadOnlyField => {
162                Error::NotEditable(field_info)
163            }
164            SdpErrorCode::InternalField => {
165                Error::NotEditable(format!("internal field: {field_info}"))
166            }
167            SdpErrorCode::NoSuchField => Error::NoSuchField(field_info),
168            SdpErrorCode::MissingMandatoryField => Error::MissingField(field_info),
169            SdpErrorCode::RateLimitExceeded => Error::RateLimited,
170            SdpErrorCode::LicenseRestriction => Error::LicenseRestricted,
171            SdpErrorCode::AlreadyInTrash | SdpErrorCode::NotInTrash => Error::Sdp { code, message },
172            SdpErrorCode::UnsupportedContentType => Error::Sdp { code, message },
173            SdpErrorCode::Success => Error::Other("Unexpected success code in error path".into()),
174            SdpErrorCode::Unknown => Error::Sdp { code, message },
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn sdp_error_code_from_u32() {
185        assert_eq!(SdpErrorCode::from(2000), SdpErrorCode::Success);
186        assert_eq!(SdpErrorCode::from(4001), SdpErrorCode::InvalidValue);
187        assert_eq!(SdpErrorCode::from(4002), SdpErrorCode::Forbidden);
188        assert_eq!(SdpErrorCode::from(4003), SdpErrorCode::ClosureRuleViolation);
189        assert_eq!(SdpErrorCode::from(4004), SdpErrorCode::Internal);
190        assert_eq!(SdpErrorCode::from(4005), SdpErrorCode::ReferenceExists);
191        assert_eq!(SdpErrorCode::from(4007), SdpErrorCode::NotFound);
192        assert_eq!(SdpErrorCode::from(4008), SdpErrorCode::NotUnique);
193        assert_eq!(SdpErrorCode::from(4009), SdpErrorCode::NonEditableField);
194        assert_eq!(SdpErrorCode::from(4010), SdpErrorCode::InternalField);
195        assert_eq!(SdpErrorCode::from(4011), SdpErrorCode::NoSuchField);
196        assert_eq!(
197            SdpErrorCode::from(4012),
198            SdpErrorCode::MissingMandatoryField
199        );
200        assert_eq!(
201            SdpErrorCode::from(4013),
202            SdpErrorCode::UnsupportedContentType
203        );
204        assert_eq!(SdpErrorCode::from(4014), SdpErrorCode::ReadOnlyField);
205        assert_eq!(SdpErrorCode::from(4015), SdpErrorCode::RateLimitExceeded);
206        assert_eq!(SdpErrorCode::from(4016), SdpErrorCode::AlreadyInTrash);
207        assert_eq!(SdpErrorCode::from(4017), SdpErrorCode::NotInTrash);
208        assert_eq!(SdpErrorCode::from(7001), SdpErrorCode::LicenseRestriction);
209        assert_eq!(SdpErrorCode::from(9999), SdpErrorCode::Unknown);
210        assert_eq!(SdpErrorCode::from(0), SdpErrorCode::Unknown);
211    }
212
213    #[test]
214    fn error_from_sdp_maps_correctly() {
215        assert!(matches!(
216            Error::from_sdp(4001, "msg".into(), None),
217            Error::InvalidValue(_)
218        ));
219        assert!(matches!(
220            Error::from_sdp(4002, "msg".into(), None),
221            Error::Forbidden(_)
222        ));
223        assert!(matches!(
224            Error::from_sdp(4004, "msg".into(), None),
225            Error::Internal
226        ));
227        assert!(matches!(
228            Error::from_sdp(4005, "msg".into(), None),
229            Error::ReferenceExists
230        ));
231        assert!(matches!(
232            Error::from_sdp(4007, "msg".into(), None),
233            Error::NotFound(_)
234        ));
235        assert!(matches!(
236            Error::from_sdp(4009, "msg".into(), None),
237            Error::NotEditable(_)
238        ));
239        assert!(matches!(
240            Error::from_sdp(4014, "msg".into(), None),
241            Error::NotEditable(_)
242        ));
243        assert!(matches!(
244            Error::from_sdp(4012, "msg".into(), None),
245            Error::MissingField(_)
246        ));
247        assert!(matches!(
248            Error::from_sdp(4015, "msg".into(), None),
249            Error::RateLimited
250        ));
251        assert!(matches!(
252            Error::from_sdp(7001, "msg".into(), None),
253            Error::LicenseRestricted
254        ));
255        assert!(matches!(
256            Error::from_sdp(9999, "msg".into(), None),
257            Error::Sdp { .. }
258        ));
259    }
260
261    #[test]
262    fn error_from_sdp_uses_field_when_provided() {
263        let err = Error::from_sdp(4001, "message".into(), Some("field_name".into()));
264        match err {
265            Error::InvalidValue(s) => assert_eq!(s, "field_name"),
266            _ => panic!("expected InvalidValue"),
267        }
268    }
269
270    #[test]
271    fn error_from_sdp_uses_message_when_no_field() {
272        let err = Error::from_sdp(4001, "message".into(), None);
273        match err {
274            Error::InvalidValue(s) => assert_eq!(s, "message"),
275            _ => panic!("expected InvalidValue"),
276        }
277    }
278
279    #[test]
280    fn structured_merged_response_maps_to_request_merged() {
281        let response: SdpErrorMessage = serde_json::from_value(serde_json::json!({
282            "parent_request": { "id": 583415 },
283            "message": "Request Id(s) 583550 has been Merged with Parent Request 583415."
284        }))
285        .unwrap();
286
287        let error = Error::from_sdp_message(4007, response);
288
289        assert!(matches!(
290            error,
291            Error::RequestMerged {
292                parent_request_id: TicketID(583415),
293                message
294            } if message.contains("583550")
295        ));
296    }
297}