use std::collections::HashMap;
use crate::auth::Session;
use crate::clients::errors::{HttpError, HttpResponseError, MaxHttpRetriesExceededError};
use crate::clients::http_request::HttpRequest;
use crate::clients::http_response::{ApiDeprecationInfo, HttpResponse};
use crate::config::{DeprecationCallback, ShopifyConfig};
pub const RETRY_WAIT_TIME: u64 = 1;
pub const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
pub struct HttpClient {
client: reqwest::Client,
base_uri: String,
base_path: String,
default_headers: HashMap<String, String>,
deprecation_callback: Option<DeprecationCallback>,
}
impl std::fmt::Debug for HttpClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HttpClient")
.field("client", &self.client)
.field("base_uri", &self.base_uri)
.field("base_path", &self.base_path)
.field("default_headers", &self.default_headers)
.field(
"deprecation_callback",
&self.deprecation_callback.as_ref().map(|_| "<callback>"),
)
.finish()
}
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<HttpClient>();
};
impl HttpClient {
#[must_use]
pub fn new(
base_path: impl Into<String>,
session: &Session,
config: Option<&ShopifyConfig>,
) -> Self {
let base_path = base_path.into();
let api_host = config.and_then(|c| c.host());
let default_shop_uri = || format!("https://{}", session.shop.as_ref());
let base_uri = api_host.map_or_else(default_shop_uri, |host| {
host.host_name()
.map_or_else(default_shop_uri, |host_name| format!("https://{host_name}"))
});
let user_agent_prefix = config
.and_then(ShopifyConfig::user_agent_prefix)
.map_or(String::new(), |prefix| format!("{prefix} | "));
let rust_version = env!("CARGO_PKG_RUST_VERSION");
let user_agent =
format!("{user_agent_prefix}Shopify API Library v{SDK_VERSION} | Rust {rust_version}");
let mut default_headers = HashMap::new();
default_headers.insert("User-Agent".to_string(), user_agent);
default_headers.insert("Accept".to_string(), "application/json".to_string());
if api_host.is_some() {
default_headers.insert("Host".to_string(), session.shop.as_ref().to_string());
}
if !session.access_token.is_empty() {
default_headers.insert(
"X-Shopify-Access-Token".to_string(),
session.access_token.clone(),
);
}
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.expect("Failed to create HTTP client");
let deprecation_callback = config.and_then(|c| c.deprecation_callback().cloned());
Self {
client,
base_uri,
base_path,
default_headers,
deprecation_callback,
}
}
#[must_use]
pub fn base_uri(&self) -> &str {
&self.base_uri
}
#[must_use]
pub fn base_path(&self) -> &str {
&self.base_path
}
#[must_use]
pub const fn default_headers(&self) -> &HashMap<String, String> {
&self.default_headers
}
pub async fn request(&self, request: HttpRequest) -> Result<HttpResponse, HttpError> {
request.verify()?;
let url = format!("{}{}/{}", self.base_uri, self.base_path, request.path);
let mut headers = self.default_headers.clone();
if let Some(body_type) = &request.body_type {
headers.insert(
"Content-Type".to_string(),
body_type.as_content_type().to_string(),
);
}
if let Some(extra) = &request.extra_headers {
for (key, value) in extra {
headers.insert(key.clone(), value.clone());
}
}
let mut tries: u32 = 0;
loop {
tries += 1;
let mut req_builder = match request.http_method {
crate::clients::http_request::HttpMethod::Get => self.client.get(&url),
crate::clients::http_request::HttpMethod::Post => self.client.post(&url),
crate::clients::http_request::HttpMethod::Put => self.client.put(&url),
crate::clients::http_request::HttpMethod::Delete => self.client.delete(&url),
};
for (key, value) in &headers {
req_builder = req_builder.header(key, value);
}
if let Some(query) = &request.query {
req_builder = req_builder.query(query);
}
if let Some(body) = &request.body {
req_builder = req_builder.body(body.to_string());
}
let res = req_builder.send().await?;
let code = res.status().as_u16();
let res_headers = Self::parse_response_headers(res.headers());
let body_text = res.text().await.unwrap_or_default();
let body = if body_text.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&body_text).unwrap_or_else(|_| {
if code >= 500 {
serde_json::json!({ "raw_body": body_text })
} else {
serde_json::json!({})
}
})
};
let response = HttpResponse::new(code, res_headers, body);
if let Some(reason) = response.deprecation_reason() {
tracing::warn!(
"Deprecated request to Shopify API at {}, received reason: {}",
request.path,
reason
);
if let Some(callback) = &self.deprecation_callback {
let info = ApiDeprecationInfo {
reason: reason.to_string(),
path: Some(request.path.clone()),
};
callback(&info);
}
}
if response.is_ok() {
return Ok(response);
}
let error_message = Self::serialize_error(&response);
let should_retry = code == 429 || code == 500;
if !should_retry {
return Err(HttpError::Response(HttpResponseError {
code,
message: error_message,
error_reference: response.request_id().map(String::from),
}));
}
if tries >= request.tries {
if request.tries == 1 {
return Err(HttpError::Response(HttpResponseError {
code,
message: error_message,
error_reference: response.request_id().map(String::from),
}));
}
return Err(HttpError::MaxRetries(MaxHttpRetriesExceededError {
code,
tries: request.tries,
message: error_message,
error_reference: response.request_id().map(String::from),
}));
}
let delay = Self::calculate_retry_delay(&response, code);
tokio::time::sleep(delay).await;
}
}
fn parse_response_headers(
headers: &reqwest::header::HeaderMap,
) -> HashMap<String, Vec<String>> {
let mut result: HashMap<String, Vec<String>> = HashMap::new();
for (name, value) in headers {
let key = name.as_str().to_lowercase();
let value = value.to_str().unwrap_or_default().to_string();
result.entry(key).or_default().push(value);
}
result
}
fn calculate_retry_delay(response: &HttpResponse, status: u16) -> std::time::Duration {
if status == 429 {
if let Some(retry_after) = response.retry_request_after {
return std::time::Duration::from_secs_f64(retry_after);
}
}
std::time::Duration::from_secs(RETRY_WAIT_TIME)
}
fn serialize_error(response: &HttpResponse) -> String {
let mut error_body = serde_json::Map::new();
if let Some(errors) = response.body.get("errors") {
error_body.insert("errors".to_string(), errors.clone());
}
if let Some(error) = response.body.get("error") {
error_body.insert("error".to_string(), error.clone());
}
if response.body.get("error").is_some() {
if let Some(desc) = response.body.get("error_description") {
error_body.insert("error_description".to_string(), desc.clone());
}
}
if let Some(request_id) = response.request_id() {
error_body.insert(
"error_reference".to_string(),
serde_json::json!(format!(
"If you report this error, please include this id: {request_id}."
)),
);
}
serde_json::to_string(&error_body).unwrap_or_else(|_| "{}".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthScopes;
use crate::config::{ApiKey, ApiSecretKey, ShopDomain};
fn create_test_session() -> Session {
Session::new(
"test-session".to_string(),
ShopDomain::new("test-shop").unwrap(),
"test-access-token".to_string(),
AuthScopes::new(),
false,
None,
)
}
#[test]
fn test_client_construction_with_session() {
let session = create_test_session();
let client = HttpClient::new("/admin/api/2024-10", &session, None);
assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
assert_eq!(client.base_path(), "/admin/api/2024-10");
}
#[test]
fn test_user_agent_header_format() {
let session = create_test_session();
let client = HttpClient::new("/admin/api/2024-10", &session, None);
let user_agent = client.default_headers().get("User-Agent").unwrap();
assert!(user_agent.contains("Shopify API Library v"));
assert!(user_agent.contains("Rust"));
}
#[test]
fn test_access_token_header_injection() {
let session = create_test_session();
let client = HttpClient::new("/admin/api/2024-10", &session, None);
assert_eq!(
client.default_headers().get("X-Shopify-Access-Token"),
Some(&"test-access-token".to_string())
);
}
#[test]
fn test_no_access_token_header_when_empty() {
let session = Session::new(
"test-session".to_string(),
ShopDomain::new("test-shop").unwrap(),
String::new(), AuthScopes::new(),
false,
None,
);
let client = HttpClient::new("/admin/api/2024-10", &session, None);
assert!(client
.default_headers()
.get("X-Shopify-Access-Token")
.is_none());
}
#[test]
fn test_accept_header_is_json() {
let session = create_test_session();
let client = HttpClient::new("/admin/api/2024-10", &session, None);
assert_eq!(
client.default_headers().get("Accept"),
Some(&"application/json".to_string())
);
}
#[test]
fn test_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<HttpClient>();
}
#[test]
fn test_base_uri_with_shop_domain() {
let session = create_test_session();
let client = HttpClient::new("/admin/api/2024-10", &session, None);
assert_eq!(client.base_uri(), "https://test-shop.myshopify.com");
}
#[test]
fn test_user_agent_with_prefix() {
let session = create_test_session();
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("test-key").unwrap())
.api_secret_key(ApiSecretKey::new("test-secret").unwrap())
.user_agent_prefix("MyApp/1.0")
.build()
.unwrap();
let client = HttpClient::new("/admin/api/2024-10", &session, Some(&config));
let user_agent = client.default_headers().get("User-Agent").unwrap();
assert!(user_agent.starts_with("MyApp/1.0 | "));
assert!(user_agent.contains("Shopify API Library"));
}
}