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}