use std::collections::HashMap;
use crate::auth::Session;
use crate::clients::rest::RestError;
use crate::clients::{DataType, HttpClient, HttpMethod, HttpRequest, HttpResponse};
use crate::config::{ApiVersion, ShopifyConfig};
#[derive(Debug)]
pub struct RestClient {
http_client: HttpClient,
api_version: ApiVersion,
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RestClient>();
};
impl RestClient {
pub fn new(session: &Session, config: Option<&ShopifyConfig>) -> Result<Self, RestError> {
let api_version = config.map_or_else(ApiVersion::latest, |c| c.api_version().clone());
Self::create_client(session, config, api_version)
}
pub fn with_version(
session: &Session,
config: Option<&ShopifyConfig>,
version: ApiVersion,
) -> Result<Self, RestError> {
let config_version = config.map(|c| c.api_version().clone());
if let Some(ref cfg_version) = config_version {
if &version == cfg_version {
tracing::debug!(
"Rest client has a redundant API version override to the default {}",
cfg_version
);
} else {
tracing::debug!(
"Rest client overriding default API version {} with {}",
cfg_version,
version
);
}
}
Self::create_client(session, config, version)
}
#[allow(clippy::unnecessary_wraps)]
fn create_client(
session: &Session,
config: Option<&ShopifyConfig>,
api_version: ApiVersion,
) -> Result<Self, RestError> {
tracing::warn!(
"The REST Admin API is deprecated. Consider migrating to GraphQL. See: https://www.shopify.com/ca/partners/blog/all-in-on-graphql"
);
let base_path = format!("/admin/api/{api_version}");
let http_client = HttpClient::new(base_path, session, config);
Ok(Self {
http_client,
api_version,
})
}
#[must_use]
pub const fn api_version(&self) -> &ApiVersion {
&self.api_version
}
pub async fn get(
&self,
path: &str,
query: Option<HashMap<String, String>>,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Get, path, None, query, None)
.await
}
pub async fn get_with_tries(
&self,
path: &str,
query: Option<HashMap<String, String>>,
tries: u32,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Get, path, None, query, Some(tries))
.await
}
pub async fn post(
&self,
path: &str,
body: serde_json::Value,
query: Option<HashMap<String, String>>,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Post, path, Some(body), query, None)
.await
}
pub async fn post_with_tries(
&self,
path: &str,
body: serde_json::Value,
query: Option<HashMap<String, String>>,
tries: u32,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Post, path, Some(body), query, Some(tries))
.await
}
pub async fn put(
&self,
path: &str,
body: serde_json::Value,
query: Option<HashMap<String, String>>,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Put, path, Some(body), query, None)
.await
}
pub async fn put_with_tries(
&self,
path: &str,
body: serde_json::Value,
query: Option<HashMap<String, String>>,
tries: u32,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Put, path, Some(body), query, Some(tries))
.await
}
pub async fn delete(
&self,
path: &str,
query: Option<HashMap<String, String>>,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Delete, path, None, query, None)
.await
}
pub async fn delete_with_tries(
&self,
path: &str,
query: Option<HashMap<String, String>>,
tries: u32,
) -> Result<HttpResponse, RestError> {
self.make_request(HttpMethod::Delete, path, None, query, Some(tries))
.await
}
async fn make_request(
&self,
method: HttpMethod,
path: &str,
body: Option<serde_json::Value>,
query: Option<HashMap<String, String>>,
tries: Option<u32>,
) -> Result<HttpResponse, RestError> {
let normalized_path = normalize_path(path)?;
let mut builder = HttpRequest::builder(method, &normalized_path);
if let Some(body_value) = body {
builder = builder.body(body_value).body_type(DataType::Json);
}
if let Some(query_params) = query {
builder = builder.query(query_params);
}
if let Some(t) = tries {
builder = builder.tries(t);
}
let request = builder.build().map_err(|e| RestError::Http(e.into()))?;
self.http_client.request(request).await.map_err(Into::into)
}
}
fn normalize_path(path: &str) -> Result<String, RestError> {
let path = path.trim_start_matches('/');
let path = path.strip_suffix(".json").unwrap_or(path);
if path.is_empty() {
return Err(RestError::InvalidPath {
path: String::new(),
});
}
Ok(format!("{path}.json"))
}
#[allow(dead_code)]
fn has_admin_prefix(path: &str) -> bool {
path.starts_with("admin/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::AuthScopes;
use crate::config::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_normalize_path_strips_leading_slash() {
let result = normalize_path("/products").unwrap();
assert_eq!(result, "products.json");
}
#[test]
fn test_normalize_path_strips_trailing_json() {
let result = normalize_path("products.json").unwrap();
assert_eq!(result, "products.json");
}
#[test]
fn test_normalize_path_strips_both_leading_slash_and_trailing_json() {
let result = normalize_path("/products.json").unwrap();
assert_eq!(result, "products.json");
}
#[test]
fn test_normalize_path_adds_json_suffix() {
let result = normalize_path("products").unwrap();
assert_eq!(result, "products.json");
}
#[test]
fn test_normalize_path_handles_nested_paths() {
let result = normalize_path("/admin/api/2024-10/products").unwrap();
assert_eq!(result, "admin/api/2024-10/products.json");
}
#[test]
fn test_normalize_path_handles_double_slashes() {
let result = normalize_path("//products").unwrap();
assert_eq!(result, "products.json");
}
#[test]
fn test_normalize_path_empty_path_returns_error() {
let result = normalize_path("");
assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
}
#[test]
fn test_normalize_path_only_slash_returns_error() {
let result = normalize_path("/");
assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
}
#[test]
fn test_normalize_path_only_json_returns_error() {
let result = normalize_path("/.json");
assert!(matches!(result, Err(RestError::InvalidPath { path }) if path.is_empty()));
}
#[test]
fn test_has_admin_prefix_returns_true() {
assert!(has_admin_prefix("admin/products.json"));
assert!(has_admin_prefix("admin/api/2024-10/products.json"));
}
#[test]
fn test_has_admin_prefix_returns_false() {
assert!(!has_admin_prefix("products.json"));
assert!(!has_admin_prefix("/admin/products.json")); }
#[test]
fn test_rest_client_new_creates_client_with_latest_version() {
let session = create_test_session();
let client = RestClient::new(&session, None).unwrap();
assert_eq!(client.api_version(), &ApiVersion::latest());
}
#[test]
fn test_rest_client_with_version_overrides_config() {
let session = create_test_session();
let client = RestClient::with_version(&session, None, ApiVersion::V2024_10).unwrap();
assert_eq!(client.api_version(), &ApiVersion::V2024_10);
}
#[test]
fn test_rest_client_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<RestClient>();
}
#[test]
fn test_rest_client_constructs_correct_base_path() {
let session = create_test_session();
let client = RestClient::with_version(&session, None, ApiVersion::V2024_10).unwrap();
assert_eq!(client.api_version(), &ApiVersion::V2024_10);
}
}