Skip to main content

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}