1use std::collections::HashMap;
7
8use crate::auth::Session;
9use crate::clients::errors::{HttpError, HttpResponseError, MaxHttpRetriesExceededError};
10use crate::clients::http_request::HttpRequest;
11use crate::clients::http_response::{ApiDeprecationInfo, HttpResponse};
12use crate::config::{DeprecationCallback, ShopifyConfig};
13
14pub const RETRY_WAIT_TIME: u64 = 1;
16
17pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20pub struct HttpClient {
55 client: reqwest::Client,
57 base_uri: String,
59 base_path: String,
61 default_headers: HashMap<String, String>,
63 deprecation_callback: Option<DeprecationCallback>,
65}
66
67impl std::fmt::Debug for HttpClient {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("HttpClient")
70 .field("client", &self.client)
71 .field("base_uri", &self.base_uri)
72 .field("base_path", &self.base_path)
73 .field("default_headers", &self.default_headers)
74 .field(
75 "deprecation_callback",
76 &self.deprecation_callback.as_ref().map(|_| "<callback>"),
77 )
78 .finish()
79 }
80}
81
82const _: fn() = || {
84 const fn assert_send_sync<T: Send + Sync>() {}
85 assert_send_sync::<HttpClient>();
86};
87
88impl HttpClient {
89 #[must_use]
120 pub fn new(
121 base_path: impl Into<String>,
122 session: &Session,
123 config: Option<&ShopifyConfig>,
124 ) -> Self {
125 let base_path = base_path.into();
126
127 let api_host = config.and_then(|c| c.host());
129 let default_shop_uri = || format!("https://{}", session.shop.as_ref());
130 let base_uri = api_host.map_or_else(default_shop_uri, |host| {
131 host.host_name()
132 .map_or_else(default_shop_uri, |host_name| format!("https://{host_name}"))
133 });
134
135 let user_agent_prefix = config
137 .and_then(ShopifyConfig::user_agent_prefix)
138 .map_or(String::new(), |prefix| format!("{prefix} | "));
139 let rust_version = env!("CARGO_PKG_RUST_VERSION");
140 let user_agent =
141 format!("{user_agent_prefix}Shopify API Library v{SDK_VERSION} | Rust {rust_version}");
142
143 let mut default_headers = HashMap::new();
145 default_headers.insert("User-Agent".to_string(), user_agent);
146 default_headers.insert("Accept".to_string(), "application/json".to_string());
147
148 if api_host.is_some() {
150 default_headers.insert("Host".to_string(), session.shop.as_ref().to_string());
151 }
152
153 if !session.access_token.is_empty() {
155 default_headers.insert(
156 "X-Shopify-Access-Token".to_string(),
157 session.access_token.clone(),
158 );
159 }
160
161 let client = reqwest::Client::builder()
163 .use_rustls_tls()
164 .build()
165 .expect("Failed to create HTTP client");
166
167 let deprecation_callback = config.and_then(|c| c.deprecation_callback().cloned());
169
170 Self {
171 client,
172 base_uri,
173 base_path,
174 default_headers,
175 deprecation_callback,
176 }
177 }
178
179 #[must_use]
181 pub fn base_uri(&self) -> &str {
182 &self.base_uri
183 }
184
185 #[must_use]
187 pub fn base_path(&self) -> &str {
188 &self.base_path
189 }
190
191 #[must_use]
193 pub const fn default_headers(&self) -> &HashMap<String, String> {
194 &self.default_headers
195 }
196
197 pub async fn request(&self, request: HttpRequest) -> Result<HttpResponse, HttpError> {
229 request.verify()?;
231
232 let url = format!("{}{}/{}", self.base_uri, self.base_path, request.path);
234
235 let mut headers = self.default_headers.clone();
237 if let Some(body_type) = &request.body_type {
238 headers.insert(
239 "Content-Type".to_string(),
240 body_type.as_content_type().to_string(),
241 );
242 }
243 if let Some(extra) = &request.extra_headers {
244 for (key, value) in extra {
245 headers.insert(key.clone(), value.clone());
246 }
247 }
248
249 let mut tries: u32 = 0;
251 loop {
252 tries += 1;
253
254 let mut req_builder = match request.http_method {
256 crate::clients::http_request::HttpMethod::Get => self.client.get(&url),
257 crate::clients::http_request::HttpMethod::Post => self.client.post(&url),
258 crate::clients::http_request::HttpMethod::Put => self.client.put(&url),
259 crate::clients::http_request::HttpMethod::Delete => self.client.delete(&url),
260 };
261
262 for (key, value) in &headers {
264 req_builder = req_builder.header(key, value);
265 }
266
267 if let Some(query) = &request.query {
269 req_builder = req_builder.query(query);
270 }
271
272 if let Some(body) = &request.body {
274 req_builder = req_builder.body(body.to_string());
275 }
276
277 let res = req_builder.send().await?;
279
280 let code = res.status().as_u16();
282 let res_headers = Self::parse_response_headers(res.headers());
283 let body_text = res.text().await.unwrap_or_default();
284
285 let body = if body_text.is_empty() {
287 serde_json::json!({})
288 } else {
289 serde_json::from_str(&body_text).unwrap_or_else(|_| {
290 if code >= 500 {
292 serde_json::json!({ "raw_body": body_text })
293 } else {
294 serde_json::json!({})
295 }
296 })
297 };
298
299 let response = HttpResponse::new(code, res_headers, body);
300
301 if let Some(reason) = response.deprecation_reason() {
303 tracing::warn!(
304 "Deprecated request to Shopify API at {}, received reason: {}",
305 request.path,
306 reason
307 );
308
309 if let Some(callback) = &self.deprecation_callback {
311 let info = ApiDeprecationInfo {
312 reason: reason.to_string(),
313 path: Some(request.path.clone()),
314 };
315 callback(&info);
316 }
317 }
318
319 if response.is_ok() {
321 return Ok(response);
322 }
323
324 let error_message = Self::serialize_error(&response);
326
327 let should_retry = code == 429 || code == 500;
329 if !should_retry {
330 return Err(HttpError::Response(HttpResponseError {
331 code,
332 message: error_message,
333 error_reference: response.request_id().map(String::from),
334 }));
335 }
336
337 if tries >= request.tries {
339 if request.tries == 1 {
340 return Err(HttpError::Response(HttpResponseError {
341 code,
342 message: error_message,
343 error_reference: response.request_id().map(String::from),
344 }));
345 }
346 return Err(HttpError::MaxRetries(MaxHttpRetriesExceededError {
347 code,
348 tries: request.tries,
349 message: error_message,
350 error_reference: response.request_id().map(String::from),
351 }));
352 }
353
354 let delay = Self::calculate_retry_delay(&response, code);
356 tokio::time::sleep(delay).await;
357 }
358 }
359
360 fn parse_response_headers(
362 headers: &reqwest::header::HeaderMap,
363 ) -> HashMap<String, Vec<String>> {
364 let mut result: HashMap<String, Vec<String>> = HashMap::new();
365 for (name, value) in headers {
366 let key = name.as_str().to_lowercase();
367 let value = value.to_str().unwrap_or_default().to_string();
368 result.entry(key).or_default().push(value);
369 }
370 result
371 }
372
373 fn calculate_retry_delay(response: &HttpResponse, status: u16) -> std::time::Duration {
375 if status == 429 {
378 if let Some(retry_after) = response.retry_request_after {
379 return std::time::Duration::from_secs_f64(retry_after);
380 }
381 }
382 std::time::Duration::from_secs(RETRY_WAIT_TIME)
383 }
384
385 fn serialize_error(response: &HttpResponse) -> String {
387 let mut error_body = serde_json::Map::new();
388
389 if let Some(errors) = response.body.get("errors") {
390 error_body.insert("errors".to_string(), errors.clone());
391 }
392 if let Some(error) = response.body.get("error") {
393 error_body.insert("error".to_string(), error.clone());
394 }
395 if response.body.get("error").is_some() {
396 if let Some(desc) = response.body.get("error_description") {
397 error_body.insert("error_description".to_string(), desc.clone());
398 }
399 }
400
401 if let Some(request_id) = response.request_id() {
402 error_body.insert(
403 "error_reference".to_string(),
404 serde_json::json!(format!(
405 "If you report this error, please include this id: {request_id}."
406 )),
407 );
408 }
409
410 serde_json::to_string(&error_body).unwrap_or_else(|_| "{}".to_string())
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::auth::AuthScopes;
418 use crate::config::{ApiKey, ApiSecretKey, ShopDomain};
419
420 fn create_test_session() -> Session {
421 Session::new(
422 "test-session".to_string(),
423 ShopDomain::new("test-shop").unwrap(),
424 "test-access-token".to_string(),
425 AuthScopes::new(),
426 false,
427 None,
428 )
429 }
430
431 #[test]
432 fn test_client_construction_with_session() {
433 let session = create_test_session();
434 let client = HttpClient::new("/admin/api/2024-10", &session, None);
435
436 assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
437 assert_eq!(client.base_path(), "/admin/api/2024-10");
438 }
439
440 #[test]
441 fn test_user_agent_header_format() {
442 let session = create_test_session();
443 let client = HttpClient::new("/admin/api/2024-10", &session, None);
444
445 let user_agent = client.default_headers().get("User-Agent").unwrap();
446 assert!(user_agent.contains("Shopify API Library v"));
447 assert!(user_agent.contains("Rust"));
448 }
449
450 #[test]
451 fn test_access_token_header_injection() {
452 let session = create_test_session();
453 let client = HttpClient::new("/admin/api/2024-10", &session, None);
454
455 assert_eq!(
456 client.default_headers().get("X-Shopify-Access-Token"),
457 Some(&"test-access-token".to_string())
458 );
459 }
460
461 #[test]
462 fn test_no_access_token_header_when_empty() {
463 let session = Session::new(
464 "test-session".to_string(),
465 ShopDomain::new("test-shop").unwrap(),
466 String::new(), AuthScopes::new(),
468 false,
469 None,
470 );
471 let client = HttpClient::new("/admin/api/2024-10", &session, None);
472
473 assert!(client
474 .default_headers()
475 .get("X-Shopify-Access-Token")
476 .is_none());
477 }
478
479 #[test]
480 fn test_accept_header_is_json() {
481 let session = create_test_session();
482 let client = HttpClient::new("/admin/api/2024-10", &session, None);
483
484 assert_eq!(
485 client.default_headers().get("Accept"),
486 Some(&"application/json".to_string())
487 );
488 }
489
490 #[test]
491 fn test_client_is_send_sync() {
492 fn assert_send_sync<T: Send + Sync>() {}
493 assert_send_sync::<HttpClient>();
494 }
495
496 #[test]
497 fn test_base_uri_with_shop_domain() {
498 let session = create_test_session();
499 let client = HttpClient::new("/admin/api/2024-10", &session, None);
500
501 assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
502 }
503
504 #[test]
505 fn test_user_agent_with_prefix() {
506 let session = create_test_session();
507 let config = ShopifyConfig::builder()
508 .api_key(ApiKey::new("test-key").unwrap())
509 .api_secret_key(ApiSecretKey::new("test-secret").unwrap())
510 .user_agent_prefix("MyApp/1.0")
511 .build()
512 .unwrap();
513
514 let client = HttpClient::new("/admin/api/2024-10", &session, Some(&config));
515
516 let user_agent = client.default_headers().get("User-Agent").unwrap();
517 assert!(user_agent.starts_with("MyApp/1.0 | "));
518 assert!(user_agent.contains("Shopify API Library"));
519 }
520}