1use crate::auth::TokenManager;
2use crate::error::{ConcurApiError, ConcurError, Result};
3use reqwest::{Method, RequestBuilder, Response, StatusCode, header};
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use std::sync::Arc;
7use std::time::Duration;
8use tokio::time::sleep;
9use tracing::{debug, warn};
10use url::Url;
11
12const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
14
15#[derive(Debug, Clone)]
17pub struct ClientConfig {
18 pub base_url: String,
20 pub timeout: Duration,
22 pub max_retries: u32,
24 pub retry_backoff: Duration,
26 pub idempotency_key: Option<String>,
28 pub accept_language: Option<String>,
30}
31
32impl Default for ClientConfig {
33 fn default() -> Self {
34 Self {
35 base_url: "https://us.api.concursolutions.com".to_string(),
36 timeout: DEFAULT_TIMEOUT,
37 max_retries: 3,
38 retry_backoff: Duration::from_millis(500),
39 idempotency_key: None,
40 accept_language: None,
41 }
42 }
43}
44
45#[derive(Clone)]
47pub struct ConcurClient {
48 pub(crate) http: reqwest::Client,
49 pub(crate) config: ClientConfig,
50 pub(crate) token_manager: Option<Arc<dyn TokenManager + Send + Sync>>,
51}
52
53impl ConcurClient {
54 pub fn new(config: ClientConfig) -> Result<Self> {
56 let http = reqwest::Client::builder()
57 .timeout(config.timeout)
58 .http2_prior_knowledge()
59 .build()?;
60 Ok(Self {
61 http,
62 config,
63 token_manager: None,
64 })
65 }
66
67 pub fn with_token_manager<T>(mut self, manager: T) -> Self
69 where
70 T: TokenManager + Send + Sync + 'static,
71 {
72 self.token_manager = Some(Arc::new(manager));
73 self
74 }
75
76 pub fn url(&self, path: &str) -> Result<Url> {
78 let base = Url::parse(&self.config.base_url)?;
79 base.join(path).map_err(Into::into)
80 }
81
82 pub async fn request(&self, method: Method, path: &str) -> Result<RequestBuilder> {
84 let url = self.url(path)?;
85 let mut builder = self.http.request(method, url);
86
87 builder = builder
88 .header(header::ACCEPT, "application/json")
89 .header("concur-correlationid", uuid::Uuid::new_v4().to_string());
90
91 if let Some(lang) = &self.config.accept_language {
92 builder = builder.header(header::ACCEPT_LANGUAGE, lang);
93 }
94
95 if let Some(token_manager) = &self.token_manager {
96 let token = token_manager.access_token().await?;
97 builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token));
98 }
99
100 Ok(builder)
101 }
102
103 pub async fn execute<T>(&self, builder: RequestBuilder) -> Result<T>
105 where
106 T: DeserializeOwned,
107 {
108 self.execute_with_raw(builder)
109 .await?
110 .json::<T>()
111 .await
112 .map_err(Into::into)
113 }
114
115 pub async fn execute_with_raw(&self, builder: RequestBuilder) -> Result<Response> {
117 let mut attempts = 0u32;
118 let mut backoff = self.config.retry_backoff;
119
120 loop {
121 attempts += 1;
122 let request = builder.try_clone().ok_or_else(|| {
123 ConcurError::Other("Request body cannot be cloned for retry".to_string())
124 })?;
125
126 debug!(attempt = attempts, "Executing SAP Concur request");
127 let response = request.send().await?;
128
129 let status = response.status();
130 if status.is_success() {
131 return Ok(response);
132 }
133
134 if attempts > self.config.max_retries || !is_retryable(status) {
135 return Err(parse_error(response).await);
136 }
137
138 warn!(
139 status = %status,
140 attempt = attempts,
141 "Retryable SAP Concur response, backing off"
142 );
143 sleep(backoff).await;
144 backoff *= 2;
145 }
146 }
147
148 pub async fn execute_unit(&self, builder: RequestBuilder) -> Result<()> {
150 let response = self.execute_with_raw(builder).await?;
151 if response.status() == StatusCode::NO_CONTENT {
152 Ok(())
153 } else {
154 let _ = response.text().await?;
156 Ok(())
157 }
158 }
159
160 pub fn with_json<B: Serialize + ?Sized>(
162 builder: RequestBuilder,
163 body: &B,
164 ) -> Result<RequestBuilder> {
165 Ok(builder
166 .header(header::CONTENT_TYPE, "application/json")
167 .json(body))
168 }
169}
170
171fn is_retryable(status: StatusCode) -> bool {
172 matches!(
173 status,
174 StatusCode::REQUEST_TIMEOUT
175 | StatusCode::TOO_MANY_REQUESTS
176 | StatusCode::INTERNAL_SERVER_ERROR
177 | StatusCode::BAD_GATEWAY
178 | StatusCode::SERVICE_UNAVAILABLE
179 | StatusCode::GATEWAY_TIMEOUT
180 )
181}
182
183async fn parse_error(response: Response) -> ConcurError {
184 let status = response.status();
185 let correlation_id = response
186 .headers()
187 .get("concur-correlationid")
188 .and_then(|v| v.to_str().ok())
189 .map(String::from);
190
191 let text = match response.text().await {
192 Ok(t) => t,
193 Err(e) => {
194 return ConcurError::Http(e).with_correlation_id(correlation_id.unwrap_or_default());
195 }
196 };
197
198 let message = serde_json::from_str::<ConcurApiError>(&text)
199 .map(|e| e.to_string())
200 .unwrap_or_else(|_| text);
201
202 ConcurError::Api {
203 status,
204 message,
205 correlation_id,
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use crate::auth::StaticToken;
213 use wiremock::matchers::{bearer_token, header, method, path};
214 use wiremock::{Mock, MockServer, ResponseTemplate};
215
216 #[test]
217 fn retryable_status_codes() {
218 assert!(is_retryable(StatusCode::REQUEST_TIMEOUT));
219 assert!(is_retryable(StatusCode::TOO_MANY_REQUESTS));
220 assert!(is_retryable(StatusCode::INTERNAL_SERVER_ERROR));
221 assert!(!is_retryable(StatusCode::BAD_REQUEST));
222 assert!(!is_retryable(StatusCode::NOT_FOUND));
223 }
224
225 #[tokio::test]
226 async fn request_includes_auth_and_correlation_headers() {
227 let server = MockServer::start().await;
228
229 Mock::given(method("GET"))
230 .and(path("/api/v4.1/users"))
231 .and(bearer_token("test-token"))
232 .and(header("Accept", "application/json"))
233 .and(wiremock::matchers::header_exists("concur-correlationid"))
234 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "1"})))
235 .mount(&server)
236 .await;
237
238 let config = ClientConfig {
239 base_url: server.uri(),
240 ..Default::default()
241 };
242
243 let client = ConcurClient::new(config)
244 .unwrap()
245 .with_token_manager(StaticToken::new("test-token"));
246
247 let request = client
248 .request(Method::GET, "/api/v4.1/users")
249 .await
250 .unwrap();
251 let result: serde_json::Value = client.execute(request).await.unwrap();
252
253 assert_eq!(result["id"], "1");
254 }
255
256 #[tokio::test]
257 async fn retries_on_500_and_succeeds() {
258 let server = MockServer::start().await;
259
260 Mock::given(method("GET"))
261 .and(path("/retry"))
262 .respond_with(ResponseTemplate::new(500))
263 .up_to_n_times(1)
264 .mount(&server)
265 .await;
266
267 Mock::given(method("GET"))
268 .and(path("/retry"))
269 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
270 .mount(&server)
271 .await;
272
273 let config = ClientConfig {
274 base_url: server.uri(),
275 retry_backoff: Duration::from_millis(10),
276 ..Default::default()
277 };
278
279 let client = ConcurClient::new(config).unwrap();
280 let request = client.request(Method::GET, "/retry").await.unwrap();
281 let result: serde_json::Value = client.execute(request).await.unwrap();
282
283 assert_eq!(result["ok"], true);
284 }
285}