Skip to main content

shopify_sdk/clients/graphql/
client.rs

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