Skip to main content

xero_rs/
error.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::time::Duration;
4
5use miette::Diagnostic;
6use oauth2::HttpClientError;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use uuid::Uuid;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct OAuth2ErrorResponse {}
13
14impl oauth2::ErrorResponse for OAuth2ErrorResponse {}
15
16impl fmt::Display for OAuth2ErrorResponse {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        write!(f, "OAuth2 error occurred")
19    }
20}
21
22#[derive(Debug, Clone, Deserialize)]
23#[serde(tag = "Type", rename_all = "PascalCase")]
24#[allow(clippy::module_name_repetitions)]
25pub enum ErrorType {
26    ValidationException {
27        #[serde(default)]
28        elements: Vec<ValidationExceptionElement>,
29        #[serde(default)]
30        timesheets: Option<Vec<TimesheetValidationError>>,
31    },
32    PostDataInvalidException,
33    QueryParseException,
34    ObjectNotFoundException,
35    OrganisationOfflineException,
36    UnauthorisedException,
37    NoDataProcessedException,
38    UnsupportedMediaTypeException,
39    MethodNotAllowedException,
40    InternalServerException,
41    NotImplementedException,
42    NotAvailableException,
43    RateLimitExceededException,
44    SystemUnavailableException,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48#[serde(rename_all = "PascalCase")]
49#[allow(clippy::module_name_repetitions)]
50pub struct ValidationError {
51    pub message: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(tag = "Type", rename_all = "UPPERCASE")]
56pub enum ValidationExceptionElementObject {
57    #[serde(rename_all = "PascalCase")]
58    PurchaseOrder {
59        #[serde(rename = "PurchaseOrderID")]
60        purchase_order_id: Uuid,
61    },
62}
63
64#[derive(Debug, Clone, Deserialize)]
65#[serde(rename_all = "PascalCase")]
66pub struct ValidationExceptionElement {
67    pub validation_errors: Vec<ValidationError>,
68    #[serde(flatten)]
69    pub object: ValidationExceptionElementObject,
70}
71
72#[derive(Debug, Clone, Deserialize)]
73#[serde(rename_all = "PascalCase")]
74#[allow(dead_code)]
75pub struct Response {
76    pub error_number: u64,
77    pub message: String,
78    #[serde(flatten)]
79    pub error: ErrorType,
80}
81
82impl fmt::Display for Response {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(
85            f,
86            "Xero API Error ({}): {}",
87            self.error_number, self.message
88        )?;
89
90        // Add additional details based on error type
91        match &self.error {
92            ErrorType::ValidationException {
93                elements,
94                timesheets,
95            } => {
96                if !elements.is_empty() {
97                    write!(f, "\nValidation errors:")?;
98                    for element in elements {
99                        for error in &element.validation_errors {
100                            write!(f, "\n  - {}", error.message)?;
101                        }
102                    }
103                }
104                if let Some(timesheet_errors) = timesheets {
105                    if !timesheet_errors.is_empty() {
106                        write!(f, "\nTimesheet errors:")?;
107                        for ts_error in timesheet_errors {
108                            for error in &ts_error.validation_errors {
109                                write!(f, "\n  - {}", error.message)?;
110                            }
111                        }
112                    }
113                }
114            }
115            ErrorType::QueryParseException => {
116                write!(f, "\nThe query string could not be parsed. Check for missing quotes or invalid syntax.")?;
117            }
118            _ => {}
119        }
120
121        Ok(())
122    }
123}
124
125#[derive(Debug, Clone, Deserialize)]
126#[serde(rename_all = "PascalCase")]
127#[allow(dead_code)]
128pub struct ForbiddenResponse {
129    r#type: Option<String>,
130    title: String,
131    status: u16,
132    detail: String,
133    instance: Uuid,
134    extensions: HashMap<String, String>,
135}
136
137#[derive(Debug, Clone, Deserialize)]
138#[serde(rename_all = "PascalCase")]
139pub struct TimesheetValidationError {
140    #[serde(default)]
141    pub validation_errors: Vec<ValidationError>,
142    #[serde(rename = "EmployeeID")]
143    pub employee_id: Option<Uuid>,
144    pub start_date: Option<String>,
145    pub end_date: Option<String>,
146    pub status: Option<String>,
147    pub hours: Option<f64>,
148    #[serde(default)]
149    pub timesheet_lines: Vec<serde_json::Value>,
150}
151
152/// Errors that can occur when interacting with the Xero API.
153#[derive(Debug, Error, Diagnostic)]
154pub enum Error {
155    #[error("error making request: {0:?}")]
156    #[diagnostic(
157        code(xero_rs::request_error),
158        help("Check your network connection and Xero API availability")
159    )]
160    Request(#[source] reqwest::Error),
161
162    #[error("invalid filename")]
163    #[diagnostic(
164        code(xero_rs::invalid_filename),
165        help("Ensure the filename is valid and compatible with Xero API requirements")
166    )]
167    InvalidFilename,
168
169    #[error("attachment too large")]
170    #[diagnostic(
171        code(xero_rs::attachment_too_large),
172        help("Reduce the attachment size to comply with Xero API limits")
173    )]
174    AttachmentTooLarge,
175
176    #[error("error decoding response: {0:?}")]
177    #[diagnostic(
178        code(xero_rs::deserialization_error),
179        help("The API returned data in an unexpected format")
180    )]
181    DeserializationError(#[source] serde_json::Error, Option<String>),
182
183    #[error("object not found: {entity} (url: {url})")]
184    #[diagnostic(
185        code(xero_rs::not_found),
186        help("Verify that the {entity} exists and that you have permission to access it")
187    )]
188    NotFound {
189        entity: String,
190        url: String,
191        status_code: reqwest::StatusCode,
192        response_body: Option<String>,
193    },
194
195    #[error("endpoint could not be parsed as a URL")]
196    #[diagnostic(
197        code(xero_rs::invalid_endpoint),
198        help("Check that the API endpoint URL is correctly formatted")
199    )]
200    InvalidEndpoint,
201
202    /// A standard error returned while interacting with the API such as a `ValidationException`.
203    #[error("{0}")]
204    #[diagnostic(
205        code(xero_rs::api_validation),
206        help("Review the validation errors returned by the Xero API")
207    )]
208    API(Response),
209
210    /// An error returned when the user is forbidden by something like an unsuccessful
211    /// authentication.
212    #[error("encountered forbidden response: {0:#?}")]
213    #[diagnostic(
214        code(xero_rs::forbidden),
215        help("Check your authentication credentials and permissions for the requested resource")
216    )]
217    Forbidden(Box<ForbiddenResponse>),
218
219    /// An error returned during `OAuth2` operations
220    #[error("oauth2 error: {0:?}")]
221    #[diagnostic(
222        code(xero_rs::oauth2_error),
223        help("Verify your OAuth2 configuration and credentials")
224    )]
225    OAuth2(oauth2::RequestTokenError<HttpClientError<reqwest::Error>, OAuth2ErrorResponse>),
226
227    /// Rate limit exceeded (HTTP 429 Too Many Requests)
228    #[error("rate limit exceeded: retry after {retry_after:?}")]
229    #[diagnostic(
230        code(xero_rs::rate_limit_exceeded),
231        help("The Xero API rate limit has been exceeded. Wait and retry, or implement request throttling.")
232    )]
233    RateLimitExceeded {
234        retry_after: Option<Duration>,
235        status_code: reqwest::StatusCode,
236        url: String,
237        response_body: Option<String>,
238    },
239}
240
241impl From<reqwest::Error> for Error {
242    fn from(e: reqwest::Error) -> Self {
243        Self::Request(e)
244    }
245}
246
247impl From<serde_json::Error> for Error {
248    fn from(e: serde_json::Error) -> Self {
249        Self::DeserializationError(e, None)
250    }
251}
252
253impl From<oauth2::RequestTokenError<HttpClientError<reqwest::Error>, OAuth2ErrorResponse>>
254    for Error
255{
256    fn from(
257        e: oauth2::RequestTokenError<HttpClientError<reqwest::Error>, OAuth2ErrorResponse>,
258    ) -> Self {
259        Self::OAuth2(e)
260    }
261}
262
263impl From<ForbiddenResponse> for Error {
264    fn from(response: ForbiddenResponse) -> Self {
265        Self::Forbidden(Box::new(response))
266    }
267}
268
269/// Type alias for results from this crate.
270///
271/// This is already a Miette diagnostic result due to the implementation of
272/// the Diagnostic trait for the Error type.
273pub type Result<O> = std::result::Result<O, Error>;
274
275/// Macro to handle common error mapping patterns
276#[macro_export]
277macro_rules! handle_api_response {
278    ($response:expr, $entity_type:expr) => {
279        match $response {
280            Ok(response) => Ok(response),
281            Err(e) => {
282                tracing::error!("API error for {}: {:?}", $entity_type, e);
283                Err(e)
284            }
285        }
286    };
287}