shopify_sdk/clients/storefront/client.rs
1//! Storefront GraphQL client implementation for Shopify Storefront API.
2//!
3//! This module provides the [`StorefrontClient`] type for executing GraphQL queries
4//! against the Shopify Storefront API.
5//!
6//! # Storefront API vs Admin API
7//!
8//! The Storefront API is designed for building custom storefronts and differs
9//! from the Admin API in several key ways:
10//!
11//! - **Endpoint**: Uses `/api/{version}/graphql.json` (no `/admin` prefix)
12//! - **Authentication**: Uses storefront access tokens with different headers
13//! - **Access Level**: Limited to storefront data (products, collections, cart)
14//! - **Tokenless Access**: Supports unauthenticated access for basic features
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use shopify_sdk::{StorefrontClient, StorefrontToken, ShopDomain};
20//! use serde_json::json;
21//!
22//! // Create a shop domain
23//! let shop = ShopDomain::new("my-store").unwrap();
24//!
25//! // With a public token
26//! let token = StorefrontToken::Public("public-access-token".to_string());
27//! let client = StorefrontClient::new(&shop, Some(token), None);
28//!
29//! // Simple query
30//! let response = client.query("query { shop { name } }", None, None, None).await?;
31//!
32//! // Query with variables
33//! let response = client.query(
34//! "query GetProduct($handle: String!) { productByHandle(handle: $handle) { title } }",
35//! Some(json!({ "handle": "my-product" })),
36//! None,
37//! None
38//! ).await?;
39//! ```
40
41use std::collections::HashMap;
42
43use crate::clients::graphql::GraphqlError;
44use crate::clients::storefront::storefront_http::StorefrontHttpClient;
45use crate::clients::storefront::StorefrontToken;
46use crate::clients::{DataType, HttpMethod, HttpRequest, HttpResponse};
47use crate::config::{ApiVersion, ShopDomain, ShopifyConfig};
48
49/// GraphQL client for Shopify Storefront API.
50///
51/// Provides methods (`query`, `query_with_debug`) for executing GraphQL queries
52/// against the Storefront API with support for public tokens, private tokens,
53/// and tokenless access.
54///
55/// # Thread Safety
56///
57/// `StorefrontClient` is `Send + Sync`, making it safe to share across async tasks.
58///
59/// # Endpoint Format
60///
61/// The Storefront API uses a different endpoint format than the Admin API:
62/// - Storefront: `https://{shop}/api/{version}/graphql.json`
63/// - Admin: `https://{shop}/admin/api/{version}/graphql.json`
64///
65/// # Authentication
66///
67/// The client supports three authentication modes:
68///
69/// - **Public token**: Uses `X-Shopify-Storefront-Access-Token` header
70/// - **Private token**: Uses `Shopify-Storefront-Private-Token` header
71/// - **Tokenless**: No authentication header (limited access)
72///
73/// # Example
74///
75/// ```rust,ignore
76/// use shopify_sdk::{StorefrontClient, StorefrontToken, ShopDomain};
77/// use serde_json::json;
78///
79/// let shop = ShopDomain::new("my-store").unwrap();
80///
81/// // Public token access
82/// let token = StorefrontToken::Public("public-access-token".to_string());
83/// let client = StorefrontClient::new(&shop, Some(token), None);
84///
85/// // Tokenless access for basic features
86/// let client = StorefrontClient::new(&shop, None, None);
87///
88/// // Query with variables
89/// let response = client.query(
90/// "query GetProducts($first: Int!) { products(first: $first) { edges { node { title } } } }",
91/// Some(json!({ "first": 10 })),
92/// None,
93/// None
94/// ).await?;
95///
96/// // Check for GraphQL errors (HTTP 200 with errors in body)
97/// if let Some(errors) = response.body.get("errors") {
98/// println!("GraphQL errors: {}", errors);
99/// }
100/// ```
101#[derive(Debug)]
102pub struct StorefrontClient {
103 /// The internal HTTP client for making requests.
104 http_client: StorefrontHttpClient,
105 /// The API version being used.
106 api_version: ApiVersion,
107}
108
109// Verify StorefrontClient is Send + Sync at compile time
110const _: fn() = || {
111 const fn assert_send_sync<T: Send + Sync>() {}
112 assert_send_sync::<StorefrontClient>();
113};
114
115impl StorefrontClient {
116 /// Creates a new Storefront client for the given shop.
117 ///
118 /// This constructor uses the API version from the configuration, or
119 /// falls back to the latest stable version if not specified.
120 ///
121 /// Unlike [`GraphqlClient`](crate::clients::GraphqlClient), this constructor
122 /// takes a [`ShopDomain`] directly instead of a [`Session`](crate::auth::Session),
123 /// since Storefront API uses storefront tokens rather than admin tokens.
124 ///
125 /// # Arguments
126 ///
127 /// * `shop` - The shop domain for endpoint construction
128 /// * `token` - Optional storefront access token (`None` for tokenless access)
129 /// * `config` - Optional configuration for API version and other settings
130 ///
131 /// # Example
132 ///
133 /// ```rust
134 /// use shopify_sdk::{StorefrontClient, StorefrontToken, ShopDomain};
135 ///
136 /// let shop = ShopDomain::new("my-store").unwrap();
137 ///
138 /// // With public token
139 /// let token = StorefrontToken::Public("my-token".to_string());
140 /// let client = StorefrontClient::new(&shop, Some(token), None);
141 ///
142 /// // Tokenless access
143 /// let client = StorefrontClient::new(&shop, None, None);
144 /// ```
145 #[must_use]
146 #[allow(clippy::needless_pass_by_value)] // Taking ownership is intentional for ergonomic API
147 pub fn new(
148 shop: &ShopDomain,
149 token: Option<StorefrontToken>,
150 config: Option<&ShopifyConfig>,
151 ) -> Self {
152 let api_version = config.map_or_else(ApiVersion::latest, |c| c.api_version().clone());
153 Self::create_client(shop, token.as_ref(), config, api_version)
154 }
155
156 /// Creates a new Storefront client with a specific API version override.
157 ///
158 /// This constructor allows overriding the API version from configuration.
159 ///
160 /// # Arguments
161 ///
162 /// * `shop` - The shop domain for endpoint construction
163 /// * `token` - Optional storefront access token (`None` for tokenless access)
164 /// * `config` - Optional configuration for other settings
165 /// * `version` - The API version to use for requests
166 ///
167 /// # Example
168 ///
169 /// ```rust
170 /// use shopify_sdk::{StorefrontClient, StorefrontToken, ShopDomain, ApiVersion};
171 ///
172 /// let shop = ShopDomain::new("my-store").unwrap();
173 /// let token = StorefrontToken::Public("my-token".to_string());
174 ///
175 /// // Use a specific API version
176 /// let client = StorefrontClient::with_version(&shop, Some(token), None, ApiVersion::V2024_10);
177 /// assert_eq!(client.api_version(), &ApiVersion::V2024_10);
178 /// ```
179 #[must_use]
180 #[allow(clippy::needless_pass_by_value)] // Taking ownership is intentional for ergonomic API
181 pub fn with_version(
182 shop: &ShopDomain,
183 token: Option<StorefrontToken>,
184 config: Option<&ShopifyConfig>,
185 version: ApiVersion,
186 ) -> Self {
187 let config_version = config.map(|c| c.api_version().clone());
188
189 // Log debug message when overriding version (matching GraphqlClient pattern)
190 if let Some(ref cfg_version) = config_version {
191 if &version == cfg_version {
192 tracing::debug!(
193 "Storefront client has a redundant API version override to the default {}",
194 cfg_version
195 );
196 } else {
197 tracing::debug!(
198 "Storefront client overriding default API version {} with {}",
199 cfg_version,
200 version
201 );
202 }
203 }
204
205 Self::create_client(shop, token.as_ref(), config, version)
206 }
207
208 /// Internal helper to create the client with shared logic.
209 fn create_client(
210 shop: &ShopDomain,
211 token: Option<&StorefrontToken>,
212 config: Option<&ShopifyConfig>,
213 api_version: ApiVersion,
214 ) -> Self {
215 // Create internal HTTP client
216 let http_client = StorefrontHttpClient::new(shop, token, config, &api_version);
217
218 Self {
219 http_client,
220 api_version,
221 }
222 }
223
224 /// Returns the API version being used by this client.
225 #[must_use]
226 pub const fn api_version(&self) -> &ApiVersion {
227 &self.api_version
228 }
229
230 /// Executes a GraphQL query against the Storefront API.
231 ///
232 /// This method sends a POST request to the `graphql.json` endpoint with
233 /// the query and optional variables.
234 ///
235 /// # Arguments
236 ///
237 /// * `query` - The GraphQL query string
238 /// * `variables` - Optional variables for the query
239 /// * `headers` - Optional extra headers to include in the request
240 /// * `tries` - Optional number of retry attempts (default: 1, no retries)
241 ///
242 /// # Returns
243 ///
244 /// Returns the raw [`HttpResponse`] containing:
245 /// - `code`: HTTP status code (usually 200 for GraphQL)
246 /// - `headers`: Response headers
247 /// - `body`: JSON response with `data`, `errors`, and `extensions` fields
248 ///
249 /// # Errors
250 ///
251 /// Returns [`GraphqlError::Http`] for HTTP-level errors (network errors,
252 /// non-2xx responses, retry exhaustion).
253 ///
254 /// Note that GraphQL-level errors (user errors, validation errors) are
255 /// returned with HTTP 200 status and contained in `response.body["errors"]`.
256 ///
257 /// # Example
258 ///
259 /// ```rust,ignore
260 /// use shopify_sdk::StorefrontClient;
261 /// use serde_json::json;
262 ///
263 /// // Simple query
264 /// let response = client.query(
265 /// "query { shop { name } }",
266 /// None,
267 /// None,
268 /// None
269 /// ).await?;
270 ///
271 /// println!("Shop: {}", response.body["data"]["shop"]["name"]);
272 ///
273 /// // Query with variables and retries
274 /// let response = client.query(
275 /// "query GetProduct($handle: String!) { productByHandle(handle: $handle) { title } }",
276 /// Some(json!({ "handle": "my-product" })),
277 /// None,
278 /// Some(3) // Retry up to 3 times on 429/500
279 /// ).await?;
280 ///
281 /// // Check for GraphQL errors
282 /// if let Some(errors) = response.body.get("errors") {
283 /// println!("GraphQL errors: {}", errors);
284 /// }
285 /// ```
286 pub async fn query(
287 &self,
288 query: &str,
289 variables: Option<serde_json::Value>,
290 headers: Option<HashMap<String, String>>,
291 tries: Option<u32>,
292 ) -> Result<HttpResponse, GraphqlError> {
293 self.execute_query(query, variables, headers, tries, false)
294 .await
295 }
296
297 /// Executes a GraphQL query with debug mode enabled.
298 ///
299 /// This method is identical to [`query`](Self::query) but appends
300 /// `?debug=true` to the request URL, which causes Shopify to include
301 /// additional query cost and execution information in the response's
302 /// `extensions` field.
303 ///
304 /// # Arguments
305 ///
306 /// * `query` - The GraphQL query string
307 /// * `variables` - Optional variables for the query
308 /// * `headers` - Optional extra headers to include in the request
309 /// * `tries` - Optional number of retry attempts (default: 1, no retries)
310 ///
311 /// # Returns
312 ///
313 /// Returns the raw [`HttpResponse`] with additional debug information
314 /// in `response.body["extensions"]`.
315 ///
316 /// # Errors
317 ///
318 /// Returns [`GraphqlError::Http`] for HTTP-level errors.
319 ///
320 /// # Example
321 ///
322 /// ```rust,ignore
323 /// use shopify_sdk::StorefrontClient;
324 ///
325 /// let response = client.query_with_debug(
326 /// "query { products(first: 10) { edges { node { title } } } }",
327 /// None,
328 /// None,
329 /// None
330 /// ).await?;
331 ///
332 /// // Debug info available in extensions
333 /// if let Some(extensions) = response.body.get("extensions") {
334 /// println!("Query cost: {}", extensions["cost"]);
335 /// }
336 /// ```
337 pub async fn query_with_debug(
338 &self,
339 query: &str,
340 variables: Option<serde_json::Value>,
341 headers: Option<HashMap<String, String>>,
342 tries: Option<u32>,
343 ) -> Result<HttpResponse, GraphqlError> {
344 self.execute_query(query, variables, headers, tries, true)
345 .await
346 }
347
348 /// Internal helper to execute a GraphQL query with shared logic.
349 async fn execute_query(
350 &self,
351 query: &str,
352 variables: Option<serde_json::Value>,
353 headers: Option<HashMap<String, String>>,
354 tries: Option<u32>,
355 debug: bool,
356 ) -> Result<HttpResponse, GraphqlError> {
357 // Construct the request body
358 let body = serde_json::json!({
359 "query": query,
360 "variables": variables
361 });
362
363 // Build the request
364 let mut builder = HttpRequest::builder(HttpMethod::Post, "graphql.json")
365 .body(body)
366 .body_type(DataType::Json)
367 .tries(tries.unwrap_or(1));
368
369 // Add debug query parameter if requested
370 if debug {
371 builder = builder.query_param("debug", "true");
372 }
373
374 // Add extra headers if provided
375 if let Some(extra_headers) = headers {
376 builder = builder.extra_headers(extra_headers);
377 }
378
379 // Build and execute the request
380 let request = builder.build().map_err(|e| GraphqlError::Http(e.into()))?;
381 self.http_client.request(request).await.map_err(Into::into)
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 // === Construction Tests ===
390
391 #[test]
392 fn test_storefront_client_new_creates_client_with_latest_version() {
393 let shop = ShopDomain::new("test-shop").unwrap();
394 let client = StorefrontClient::new(&shop, None, None);
395
396 assert_eq!(client.api_version(), &ApiVersion::latest());
397 }
398
399 #[test]
400 fn test_storefront_client_new_with_public_token() {
401 let shop = ShopDomain::new("test-shop").unwrap();
402 let token = StorefrontToken::Public("test-token".to_string());
403 let client = StorefrontClient::new(&shop, Some(token), None);
404
405 assert_eq!(client.api_version(), &ApiVersion::latest());
406 }
407
408 #[test]
409 fn test_storefront_client_new_with_private_token() {
410 let shop = ShopDomain::new("test-shop").unwrap();
411 let token = StorefrontToken::Private("test-token".to_string());
412 let client = StorefrontClient::new(&shop, Some(token), None);
413
414 assert_eq!(client.api_version(), &ApiVersion::latest());
415 }
416
417 #[test]
418 fn test_storefront_client_new_tokenless() {
419 let shop = ShopDomain::new("test-shop").unwrap();
420 let client = StorefrontClient::new(&shop, None, None);
421
422 assert_eq!(client.api_version(), &ApiVersion::latest());
423 }
424
425 #[test]
426 fn test_storefront_client_with_version_overrides_config() {
427 let shop = ShopDomain::new("test-shop").unwrap();
428 let client = StorefrontClient::with_version(&shop, None, None, ApiVersion::V2024_10);
429
430 assert_eq!(client.api_version(), &ApiVersion::V2024_10);
431 }
432
433 #[test]
434 fn test_storefront_client_is_send_sync() {
435 fn assert_send_sync<T: Send + Sync>() {}
436 assert_send_sync::<StorefrontClient>();
437 }
438
439 #[test]
440 fn test_storefront_client_constructor_is_infallible() {
441 let shop = ShopDomain::new("test-shop").unwrap();
442 // This test verifies that new() returns Self directly, not Result
443 let _client: StorefrontClient = StorefrontClient::new(&shop, None, None);
444 // If this compiles, the constructor is infallible
445 }
446
447 #[test]
448 fn test_storefront_client_with_config_uses_config_version() {
449 use crate::config::{ApiKey, ApiSecretKey};
450
451 let shop = ShopDomain::new("test-shop").unwrap();
452 let config = ShopifyConfig::builder()
453 .api_key(ApiKey::new("test-key").unwrap())
454 .api_secret_key(ApiSecretKey::new("test-secret").unwrap())
455 .api_version(ApiVersion::V2024_10)
456 .build()
457 .unwrap();
458
459 let client = StorefrontClient::new(&shop, None, Some(&config));
460
461 assert_eq!(client.api_version(), &ApiVersion::V2024_10);
462 }
463
464 #[test]
465 fn test_storefront_client_with_version_logs_debug_when_overriding() {
466 use crate::config::{ApiKey, ApiSecretKey};
467
468 let shop = ShopDomain::new("test-shop").unwrap();
469 let config = ShopifyConfig::builder()
470 .api_key(ApiKey::new("test-key").unwrap())
471 .api_secret_key(ApiSecretKey::new("test-secret").unwrap())
472 .api_version(ApiVersion::V2024_10)
473 .build()
474 .unwrap();
475
476 // Override with a different version - this should log a debug message
477 let client =
478 StorefrontClient::with_version(&shop, None, Some(&config), ApiVersion::V2024_07);
479
480 assert_eq!(client.api_version(), &ApiVersion::V2024_07);
481 }
482
483 #[test]
484 fn test_api_version_accessor() {
485 let shop = ShopDomain::new("test-shop").unwrap();
486
487 let client_2024_10 =
488 StorefrontClient::with_version(&shop, None, None, ApiVersion::V2024_10);
489 let client_2024_07 =
490 StorefrontClient::with_version(&shop, None, None, ApiVersion::V2024_07);
491 let client_latest = StorefrontClient::new(&shop, None, None);
492
493 assert_eq!(client_2024_10.api_version(), &ApiVersion::V2024_10);
494 assert_eq!(client_2024_07.api_version(), &ApiVersion::V2024_07);
495 assert_eq!(client_latest.api_version(), &ApiVersion::latest());
496 }
497}