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 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#[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 #[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 #[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 #[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 #[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
269pub type Result<O> = std::result::Result<O, Error>;
274
275#[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}