Skip to main content

concur_api/
client.rs

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
12/// Default HTTP timeout for API calls.
13const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
14
15/// Configuration for the SAP Concur client.
16#[derive(Debug, Clone)]
17pub struct ClientConfig {
18    /// Base URL for the data center (e.g. `https://us.api.concursolutions.com`).
19    pub base_url: String,
20    /// HTTP request timeout.
21    pub timeout: Duration,
22    /// Maximum number of retries for transient failures.
23    pub max_retries: u32,
24    /// Initial retry backoff.
25    pub retry_backoff: Duration,
26    /// Optional idempotency key for supported write operations.
27    pub idempotency_key: Option<String>,
28    /// Default `Accept-Language` header value.
29    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/// Shared HTTP client for SAP Concur APIs.
46#[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    /// Create a new client with the provided configuration.
55    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    /// Attach a token manager to the client.
68    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    /// Build a fully qualified URL from a path.
77    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    /// Start building an authenticated request.
83    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    /// Execute a request with retries and idempotency support.
104    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    /// Execute a request and return the raw response.
116    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    /// Execute a request and ignore the response body (for 204 No Content).
149    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            // Consume body to avoid connection pool issues.
155            let _ = response.text().await?;
156            Ok(())
157        }
158    }
159
160    /// Send a JSON body with the request.
161    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}