From cd869445de2f50668111ec2944e9ca43665abe6a Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:27:17 +0200 Subject: [PATCH 1/3] feat: add Storefront GraphQL support --- CHANGELOG.md | 5 ++ README.md | 31 +++++++- src/auth.rs | 127 +++++++++++++++++++++++++++++++ src/graphql/bulk_query.rs | 8 ++ src/graphql/mod.rs | 28 +++---- src/lib.rs | 152 ++++++++++++++++++++++++++++++++++++-- src/schema.rs | 1 + src/webhooks/webhook.rs | 4 + 8 files changed, 333 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b81b775..71b1721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Add: Storefront GraphQL clients with tokenless, public-token, and private-token authentication. +- Preserve: existing `Shopify::new` behavior for Admin API clients. + ## 0.10.0 - Breaking: refactor the client around Shopify Admin GraphQL API `2026-04` and newer. diff --git a/README.md b/README.md index b61875d..9cd5981 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,14 @@ [![MIT licensed](https://img.shields.io/crates/l/shopify_api.svg)](./LICENSE.txt) [![CI](https://github.com/0xtlt/shopify_api/actions/workflows/ci.yml/badge.svg)](https://github.com/0xtlt/shopify_api/actions/workflows/ci.yml) -An ergonomic Shopify Admin GraphQL API client for Rust. +An ergonomic Shopify Admin and Storefront GraphQL API client for Rust. Version `0.10` is a breaking refactor for Shopify Admin API `2026-04` and newer: - GraphQL-first client - `2026-04` minimum API version - static access tokens, client credentials tokens, and expiring offline tokens +- Storefront tokenless, public-token, and private-token access - dynamic GraphQL schema download - Bulk operations using the 2026 `bulkOperation(id:)` and `bulkOperations` APIs @@ -75,6 +76,34 @@ let shopify = Shopify::new( The crate acquires and refreshes 24-hour client-credentials tokens automatically. Add a `TokenStore` in `ShopifyConfig` when your app needs to persist refreshed token data. +## Storefront GraphQL + +Use `Shopify::new_storefront` to target the Storefront GraphQL endpoint without changing +existing Admin API clients: + +```rust,no_run +use shopify_api::{Shopify, ShopifyConfig, StorefrontAuth}; + +let shopify = Shopify::new_storefront( + "my-shop", + StorefrontAuth::PrivateAccessToken("private-storefront-token".to_string()), + ShopifyConfig::default(), +)?; +# Ok::<(), shopify_api::ShopifyAPIError>(()) +``` + +`StorefrontAuth` supports: + +- `Tokenless` +- `PublicAccessToken`, sent as `X-Shopify-Storefront-Access-Token` +- `PrivateAccessToken`, sent as `Shopify-Storefront-Private-Token` + +All Storefront clients use: + +```txt +https://{shop}.myshopify.com/api/{version}/graphql.json +``` + ## Dynamic GraphQL Schema Public Shopify schema: diff --git a/src/auth.rs b/src/auth.rs index 283e607..dec4d3b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; use crate::{Shopify, ShopifyAPIError}; +pub(crate) const ADMIN_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Access-Token"; +pub(crate) const STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Storefront-Access-Token"; +pub(crate) const STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER: &str = "Shopify-Storefront-Private-Token"; + pub type TokenStoreFuture<'a> = Pin> + Send + 'a>>; @@ -69,6 +73,39 @@ pub enum ShopifyAuth { }, } +/// Authentication accepted by Shopify's Storefront GraphQL API. +#[derive(Clone, Eq, PartialEq)] +pub enum StorefrontAuth { + /// Send no access token. Shopify limits the fields available to tokenless requests. + Tokenless, + /// Authenticate with a public Storefront access token. + PublicAccessToken(String), + /// Authenticate with a private Storefront access token for server-side requests. + PrivateAccessToken(String), +} + +impl std::fmt::Debug for StorefrontAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorefrontAuth::Tokenless => f.write_str("Tokenless"), + StorefrontAuth::PublicAccessToken(_) => f + .debug_tuple("PublicAccessToken") + .field(&"") + .finish(), + StorefrontAuth::PrivateAccessToken(_) => f + .debug_tuple("PrivateAccessToken") + .field(&"") + .finish(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ApiAuth { + Admin(ShopifyAuth), + Storefront(StorefrontAuth), +} + impl std::fmt::Debug for ShopifyAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -149,6 +186,41 @@ impl Shopify { .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))? .clone(); + match auth { + ApiAuth::Admin(auth) => self.admin_access_token(auth).await, + ApiAuth::Storefront(StorefrontAuth::PublicAccessToken(token)) + | ApiAuth::Storefront(StorefrontAuth::PrivateAccessToken(token)) => Ok(token), + ApiAuth::Storefront(StorefrontAuth::Tokenless) => Err(ShopifyAPIError::Authentication( + "tokenless Storefront API client has no access token".to_string(), + )), + } + } + + pub(crate) async fn graphql_auth_header( + &self, + ) -> Result, ShopifyAPIError> { + let auth = self + .auth + .lock() + .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))? + .clone(); + + match auth { + ApiAuth::Admin(auth) => Ok(Some(( + ADMIN_ACCESS_TOKEN_HEADER, + self.admin_access_token(auth).await?, + ))), + ApiAuth::Storefront(StorefrontAuth::Tokenless) => Ok(None), + ApiAuth::Storefront(StorefrontAuth::PublicAccessToken(token)) => { + Ok(Some((STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, token))) + } + ApiAuth::Storefront(StorefrontAuth::PrivateAccessToken(token)) => { + Ok(Some((STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, token))) + } + } + } + + async fn admin_access_token(&self, auth: ShopifyAuth) -> Result { match auth { ShopifyAuth::AccessToken(token) => Ok(token), ShopifyAuth::ClientCredentials { @@ -224,6 +296,7 @@ impl Shopify { client_id: &str, client_secret: &str, ) -> Result { + self.ensure_admin_api("client credentials token exchange")?; let response = self .client() .post(self.token_url()) @@ -247,6 +320,7 @@ impl Shopify { client_secret: &str, refresh_token: &str, ) -> Result { + self.ensure_admin_api("offline token refresh")?; let response = self .client() .post(self.token_url()) @@ -290,4 +364,57 @@ mod tests { assert!(!token.expires_within(chrono::Duration::days(365))); } + + #[tokio::test] + async fn admin_graphql_requests_use_the_admin_header() { + let shopify = Shopify::new( + "example", + ShopifyAuth::AccessToken("admin-token".to_string()), + crate::ShopifyConfig::default(), + ) + .unwrap(); + + assert_eq!( + shopify.graphql_auth_header().await.unwrap(), + Some((ADMIN_ACCESS_TOKEN_HEADER, "admin-token".to_string())) + ); + } + + #[tokio::test] + async fn storefront_graphql_requests_use_the_selected_authentication() { + let public = Shopify::new_storefront( + "example", + StorefrontAuth::PublicAccessToken("public-token".to_string()), + crate::ShopifyConfig::default(), + ) + .unwrap(); + let private = Shopify::new_storefront( + "example", + StorefrontAuth::PrivateAccessToken("private-token".to_string()), + crate::ShopifyConfig::default(), + ) + .unwrap(); + let tokenless = Shopify::new_storefront( + "example", + StorefrontAuth::Tokenless, + crate::ShopifyConfig::default(), + ) + .unwrap(); + + assert_eq!( + public.graphql_auth_header().await.unwrap(), + Some(( + STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, + "public-token".to_string() + )) + ); + assert_eq!( + private.graphql_auth_header().await.unwrap(), + Some(( + STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, + "private-token".to_string() + )) + ); + assert_eq!(tokenless.graphql_auth_header().await.unwrap(), None); + } } diff --git a/src/graphql/bulk_query.rs b/src/graphql/bulk_query.rs index 8f4075c..5563395 100644 --- a/src/graphql/bulk_query.rs +++ b/src/graphql/bulk_query.rs @@ -211,6 +211,7 @@ impl Shopify { I: IntoIterator, S: AsRef, { + self.ensure_admin_api("bulk queries")?; let queries = queries.into_iter().collect::>(); if queries.len() > options.max_concurrent { return Err(ShopifyAPIError::Other(format!( @@ -238,6 +239,7 @@ impl Shopify { M: AsRef, P: AsRef, { + self.ensure_admin_api("bulk mutations")?; let mutations = mutations.into_iter().collect::>(); if mutations.len() > options.max_concurrent { return Err(ShopifyAPIError::Other(format!( @@ -270,6 +272,7 @@ impl Shopify { query: &str, group_objects: bool, ) -> Result { + self.ensure_admin_api("bulk queries")?; let data: RunBulkQueryData = self .graphql( r#" @@ -303,6 +306,7 @@ impl Shopify { mutation: &str, staged_upload_path: &str, ) -> Result { + self.ensure_admin_api("bulk mutations")?; let data: RunBulkMutationData = self .graphql( r#" @@ -338,6 +342,7 @@ impl Shopify { &self, id: &str, ) -> Result, ShopifyAPIError> { + self.ensure_admin_api("bulk operation lookup")?; let data: BulkOperationData = self .graphql( r#" @@ -366,6 +371,7 @@ impl Shopify { &self, filter: BulkOperationsFilter, ) -> Result { + self.ensure_admin_api("bulk operation listing")?; let first = if filter.first == 0 { 10 } else { filter.first }; let data: BulkOperationsData = self .graphql( @@ -409,6 +415,7 @@ impl Shopify { id: &str, options: BulkWaitOptions, ) -> Result { + self.ensure_admin_api("bulk operation polling")?; let started_at = Instant::now(); loop { @@ -436,6 +443,7 @@ impl Shopify { where T: Serialize, { + self.ensure_admin_api("staged uploads")?; let jsonl_data = data .iter() .map(serde_json::to_string) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index da7338a..1d5bbd9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -40,16 +40,16 @@ impl Shopify { where Variables: serde::Serialize, { - let token = self.access_token().await?; - let response = self + let request = self .client() .post(self.get_query_url()) .header("Content-Type", "application/json") - .header("X-Shopify-Access-Token", token) - .json(&GraphqlRequest { query, variables }) - .send() - .await? - .error_for_status()?; + .json(&GraphqlRequest { query, variables }); + let request = match self.graphql_auth_header().await? { + Some((name, value)) => request.header(name, value), + None => request, + }; + let response = request.send().await?.error_for_status()?; let status = response.status(); let body = response.text().await?; @@ -108,17 +108,17 @@ impl Shopify { &self, variables: Q::Variables, ) -> Result, ShopifyAPIError> { - let token = self.access_token().await?; let body = Q::build_query(variables); - let response = self + let request = self .client() .post(self.get_query_url()) .header("Content-Type", "application/json") - .header("X-Shopify-Access-Token", token) - .json(&body) - .send() - .await? - .error_for_status()?; + .json(&body); + let request = match self.graphql_auth_header().await? { + Some((name, value)) => request.header(name, value), + None => request, + }; + let response = request.send().await?.error_for_status()?; response .json::>() diff --git a/src/lib.rs b/src/lib.rs index fae6c0d..cf26837 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ pub mod utils; #[cfg(feature = "webhooks")] pub mod webhooks; -pub use auth::{ShopifyAuth, TokenData, TokenStore}; +pub use auth::{ShopifyAuth, StorefrontAuth, TokenData, TokenStore}; pub use graphql::{ BulkConcurrencyOptions, BulkOperationPayload, BulkOperationsFilter, BulkWaitOptions, GraphqlError, GraphqlResponse, ShopifyBulkOperation, ShopifyBulkStatus, @@ -63,6 +63,12 @@ impl TryFrom for ApiVersion { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ShopifyApi { + Admin, + Storefront, +} + #[derive(Clone)] pub struct ShopifyConfig { pub api_version: ApiVersion, @@ -89,9 +95,10 @@ impl Default for ShopifyConfig { #[derive(Clone)] pub struct Shopify { pub api_version: ApiVersion, + api: ShopifyApi, #[cfg(feature = "webhooks")] pub(crate) shared_secret: Option, - auth: Arc>, + auth: Arc>, client: reqwest::Client, query_url: String, token_url: String, @@ -105,6 +112,7 @@ impl std::fmt::Debug for Shopify { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Shopify") .field("api_version", &self.api_version) + .field("api", &self.api) .field("query_url", &self.query_url) .field("token_url", &self.token_url) .field("shop", &self.shop) @@ -151,6 +159,9 @@ pub enum ShopifyAPIError { #[error("operation timed out: {0}")] Timeout(String), + #[error("{operation} is only supported by the Admin API")] + AdminApiRequired { operation: &'static str }, + #[error("other error: {0}")] Other(String), } @@ -160,13 +171,43 @@ impl Shopify { shop: impl AsRef, auth: ShopifyAuth, config: ShopifyConfig, + ) -> Result { + Self::new_with_auth(shop, auth::ApiAuth::Admin(auth), config) + } + + /// Creates a client for Shopify's Storefront GraphQL API. + /// + /// The authentication mode determines whether requests are tokenless or use + /// Shopify's public or private Storefront access-token header. + pub fn new_storefront( + shop: impl AsRef, + auth: StorefrontAuth, + config: ShopifyConfig, + ) -> Result { + Self::new_with_auth(shop, auth::ApiAuth::Storefront(auth), config) + } + + fn new_with_auth( + shop: impl AsRef, + auth: auth::ApiAuth, + config: ShopifyConfig, ) -> Result { let shop = shop.as_ref().to_string(); let shop_domain = normalize_shop_domain(&shop); - let query_url = format!( - "https://{}/admin/api/{}/graphql.json", - shop_domain, config.api_version - ); + let api = match &auth { + auth::ApiAuth::Admin(_) => ShopifyApi::Admin, + auth::ApiAuth::Storefront(_) => ShopifyApi::Storefront, + }; + let query_url = match api { + ShopifyApi::Admin => format!( + "https://{}/admin/api/{}/graphql.json", + shop_domain, config.api_version + ), + ShopifyApi::Storefront => format!( + "https://{}/api/{}/graphql.json", + shop_domain, config.api_version + ), + }; let token_url = format!("https://{}/admin/oauth/access_token", shop_domain); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( @@ -179,6 +220,7 @@ impl Shopify { Ok(Self { api_version: config.api_version, + api, #[cfg(feature = "webhooks")] shared_secret: config.shared_secret, auth: Arc::new(Mutex::new(auth)), @@ -200,6 +242,10 @@ impl Shopify { &self.shop_domain } + pub fn api(&self) -> ShopifyApi { + self.api + } + pub fn get_query_url(&self) -> &str { &self.query_url } @@ -212,13 +258,45 @@ impl Shopify { &self.client } + pub(crate) fn ensure_admin_api(&self, operation: &'static str) -> Result<(), ShopifyAPIError> { + if self.api == ShopifyApi::Admin { + Ok(()) + } else { + Err(ShopifyAPIError::AdminApiRequired { operation }) + } + } + pub fn replace_auth(&self, auth: ShopifyAuth) -> Result<(), ShopifyAPIError> { let mut current = self .auth .lock() .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?; - *current = auth; - Ok(()) + match &mut *current { + auth::ApiAuth::Admin(current) => { + *current = auth; + Ok(()) + } + auth::ApiAuth::Storefront(_) => Err(ShopifyAPIError::Authentication( + "cannot replace Admin authentication on a Storefront API client".to_string(), + )), + } + } + + /// Replaces authentication on an existing Storefront API client. + pub fn replace_storefront_auth(&self, auth: StorefrontAuth) -> Result<(), ShopifyAPIError> { + let mut current = self + .auth + .lock() + .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?; + match &mut *current { + auth::ApiAuth::Storefront(current) => { + *current = auth; + Ok(()) + } + auth::ApiAuth::Admin(_) => Err(ShopifyAPIError::Authentication( + "cannot replace Storefront authentication on an Admin API client".to_string(), + )), + } } } @@ -272,6 +350,7 @@ mod tests { .unwrap(); assert_eq!(shopify.shop_domain(), "example.myshopify.com"); + assert_eq!(shopify.api(), ShopifyApi::Admin); assert_eq!( shopify.get_query_url(), "https://example.myshopify.com/admin/api/2026-04/graphql.json" @@ -281,4 +360,61 @@ mod tests { "https://example.myshopify.com/admin/oauth/access_token" ); } + + #[test] + fn storefront_endpoint_uses_the_storefront_graphql_path() { + let shopify = Shopify::new_storefront( + "https://example.myshopify.com/", + StorefrontAuth::Tokenless, + ShopifyConfig::default(), + ) + .unwrap(); + + assert_eq!(shopify.shop_domain(), "example.myshopify.com"); + assert_eq!(shopify.api(), ShopifyApi::Storefront); + assert_eq!( + shopify.get_query_url(), + "https://example.myshopify.com/api/2026-04/graphql.json" + ); + } + + #[test] + fn authentication_can_only_be_replaced_for_the_selected_api() { + let admin = Shopify::new( + "example", + ShopifyAuth::AccessToken("admin-token".to_string()), + ShopifyConfig::default(), + ) + .unwrap(); + let storefront = Shopify::new_storefront( + "example", + StorefrontAuth::Tokenless, + ShopifyConfig::default(), + ) + .unwrap(); + + assert!(admin + .replace_storefront_auth(StorefrontAuth::Tokenless) + .is_err()); + assert!(storefront + .replace_auth(ShopifyAuth::AccessToken("admin-token".to_string())) + .is_err()); + } + + #[test] + fn storefront_clients_reject_admin_only_operations() { + let storefront = Shopify::new_storefront( + "example", + StorefrontAuth::Tokenless, + ShopifyConfig::default(), + ) + .unwrap(); + + assert!(matches!( + storefront.ensure_admin_api("bulk operations"), + Err(ShopifyAPIError::AdminApiRequired { + operation: "bulk operations" + }) + )); + } } diff --git a/src/schema.rs b/src/schema.rs index 898b64c..9faa930 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -29,6 +29,7 @@ pub async fn download_public_admin_schema( impl Shopify { /// Downloads the Admin GraphQL schema through the authenticated shop endpoint. pub async fn download_admin_schema(&self) -> Result { + self.ensure_admin_api("Admin schema download")?; let response = self .graphql_raw(ADMIN_SCHEMA_INTROSPECTION_QUERY, &json!({})) .await?; diff --git a/src/webhooks/webhook.rs b/src/webhooks/webhook.rs index 6167da5..da4ca60 100644 --- a/src/webhooks/webhook.rs +++ b/src/webhooks/webhook.rs @@ -75,6 +75,7 @@ impl From for WebhookSubscription { impl Shopify { pub async fn list_webhooks(&self) -> Result, ShopifyAPIError> { + self.ensure_admin_api("webhook listing")?; let data: WebhookSubscriptionsData = self .graphql( r#" @@ -109,6 +110,7 @@ impl Shopify { topic: &str, format: &str, ) -> Result { + self.ensure_admin_api("webhook creation")?; let data: WebhookCreateData = self .graphql( r#" @@ -159,6 +161,7 @@ impl Shopify { } pub async fn delete_webhook(&self, webhook_id: &str) -> Result<(), ShopifyAPIError> { + self.ensure_admin_api("webhook deletion")?; let data: WebhookDeleteData = self .graphql( r#" @@ -193,6 +196,7 @@ impl Shopify { &self, desired_webhooks: Vec<(&str, &str, &str)>, ) -> Result<(), ShopifyAPIError> { + self.ensure_admin_api("webhook configuration")?; let existing_webhooks = self.list_webhooks().await?; let mut desired = desired_webhooks; From 203fbf2841e298a6e60ff420acc4f9a31bf8b52a Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:51:02 +0200 Subject: [PATCH 2/3] refactor: separate Storefront client type --- CHANGELOG.md | 2 +- README.md | 10 +- src/auth.rs | 127 ------------------ src/graphql/bulk_query.rs | 8 -- src/graphql/mod.rs | 264 +++++++++++++++++++++++++------------- src/lib.rs | 154 ++-------------------- src/schema.rs | 1 - src/storefront.rs | 248 +++++++++++++++++++++++++++++++++++ src/webhooks/webhook.rs | 4 - 9 files changed, 441 insertions(+), 377 deletions(-) create mode 100644 src/storefront.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b1721..fd98ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add: Storefront GraphQL clients with tokenless, public-token, and private-token authentication. +- Add: a separate `ShopifyStorefront` GraphQL client with tokenless, public-token, and private-token authentication. - Preserve: existing `Shopify::new` behavior for Admin API clients. ## 0.10.0 diff --git a/README.md b/README.md index 9cd5981..cfe7af7 100644 --- a/README.md +++ b/README.md @@ -78,16 +78,16 @@ The crate acquires and refreshes 24-hour client-credentials tokens automatically ## Storefront GraphQL -Use `Shopify::new_storefront` to target the Storefront GraphQL endpoint without changing -existing Admin API clients: +Use the separate `ShopifyStorefront` client to target the Storefront GraphQL endpoint. +Admin-only methods such as bulk operations are not exposed on this type: ```rust,no_run -use shopify_api::{Shopify, ShopifyConfig, StorefrontAuth}; +use shopify_api::{ShopifyStorefront, StorefrontAuth, StorefrontConfig}; -let shopify = Shopify::new_storefront( +let storefront = ShopifyStorefront::new( "my-shop", StorefrontAuth::PrivateAccessToken("private-storefront-token".to_string()), - ShopifyConfig::default(), + StorefrontConfig::default(), )?; # Ok::<(), shopify_api::ShopifyAPIError>(()) ``` diff --git a/src/auth.rs b/src/auth.rs index dec4d3b..283e607 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -5,10 +5,6 @@ use serde::{Deserialize, Serialize}; use crate::{Shopify, ShopifyAPIError}; -pub(crate) const ADMIN_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Access-Token"; -pub(crate) const STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Storefront-Access-Token"; -pub(crate) const STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER: &str = "Shopify-Storefront-Private-Token"; - pub type TokenStoreFuture<'a> = Pin> + Send + 'a>>; @@ -73,39 +69,6 @@ pub enum ShopifyAuth { }, } -/// Authentication accepted by Shopify's Storefront GraphQL API. -#[derive(Clone, Eq, PartialEq)] -pub enum StorefrontAuth { - /// Send no access token. Shopify limits the fields available to tokenless requests. - Tokenless, - /// Authenticate with a public Storefront access token. - PublicAccessToken(String), - /// Authenticate with a private Storefront access token for server-side requests. - PrivateAccessToken(String), -} - -impl std::fmt::Debug for StorefrontAuth { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StorefrontAuth::Tokenless => f.write_str("Tokenless"), - StorefrontAuth::PublicAccessToken(_) => f - .debug_tuple("PublicAccessToken") - .field(&"") - .finish(), - StorefrontAuth::PrivateAccessToken(_) => f - .debug_tuple("PrivateAccessToken") - .field(&"") - .finish(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum ApiAuth { - Admin(ShopifyAuth), - Storefront(StorefrontAuth), -} - impl std::fmt::Debug for ShopifyAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -186,41 +149,6 @@ impl Shopify { .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))? .clone(); - match auth { - ApiAuth::Admin(auth) => self.admin_access_token(auth).await, - ApiAuth::Storefront(StorefrontAuth::PublicAccessToken(token)) - | ApiAuth::Storefront(StorefrontAuth::PrivateAccessToken(token)) => Ok(token), - ApiAuth::Storefront(StorefrontAuth::Tokenless) => Err(ShopifyAPIError::Authentication( - "tokenless Storefront API client has no access token".to_string(), - )), - } - } - - pub(crate) async fn graphql_auth_header( - &self, - ) -> Result, ShopifyAPIError> { - let auth = self - .auth - .lock() - .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))? - .clone(); - - match auth { - ApiAuth::Admin(auth) => Ok(Some(( - ADMIN_ACCESS_TOKEN_HEADER, - self.admin_access_token(auth).await?, - ))), - ApiAuth::Storefront(StorefrontAuth::Tokenless) => Ok(None), - ApiAuth::Storefront(StorefrontAuth::PublicAccessToken(token)) => { - Ok(Some((STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, token))) - } - ApiAuth::Storefront(StorefrontAuth::PrivateAccessToken(token)) => { - Ok(Some((STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, token))) - } - } - } - - async fn admin_access_token(&self, auth: ShopifyAuth) -> Result { match auth { ShopifyAuth::AccessToken(token) => Ok(token), ShopifyAuth::ClientCredentials { @@ -296,7 +224,6 @@ impl Shopify { client_id: &str, client_secret: &str, ) -> Result { - self.ensure_admin_api("client credentials token exchange")?; let response = self .client() .post(self.token_url()) @@ -320,7 +247,6 @@ impl Shopify { client_secret: &str, refresh_token: &str, ) -> Result { - self.ensure_admin_api("offline token refresh")?; let response = self .client() .post(self.token_url()) @@ -364,57 +290,4 @@ mod tests { assert!(!token.expires_within(chrono::Duration::days(365))); } - - #[tokio::test] - async fn admin_graphql_requests_use_the_admin_header() { - let shopify = Shopify::new( - "example", - ShopifyAuth::AccessToken("admin-token".to_string()), - crate::ShopifyConfig::default(), - ) - .unwrap(); - - assert_eq!( - shopify.graphql_auth_header().await.unwrap(), - Some((ADMIN_ACCESS_TOKEN_HEADER, "admin-token".to_string())) - ); - } - - #[tokio::test] - async fn storefront_graphql_requests_use_the_selected_authentication() { - let public = Shopify::new_storefront( - "example", - StorefrontAuth::PublicAccessToken("public-token".to_string()), - crate::ShopifyConfig::default(), - ) - .unwrap(); - let private = Shopify::new_storefront( - "example", - StorefrontAuth::PrivateAccessToken("private-token".to_string()), - crate::ShopifyConfig::default(), - ) - .unwrap(); - let tokenless = Shopify::new_storefront( - "example", - StorefrontAuth::Tokenless, - crate::ShopifyConfig::default(), - ) - .unwrap(); - - assert_eq!( - public.graphql_auth_header().await.unwrap(), - Some(( - STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, - "public-token".to_string() - )) - ); - assert_eq!( - private.graphql_auth_header().await.unwrap(), - Some(( - STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, - "private-token".to_string() - )) - ); - assert_eq!(tokenless.graphql_auth_header().await.unwrap(), None); - } } diff --git a/src/graphql/bulk_query.rs b/src/graphql/bulk_query.rs index 5563395..8f4075c 100644 --- a/src/graphql/bulk_query.rs +++ b/src/graphql/bulk_query.rs @@ -211,7 +211,6 @@ impl Shopify { I: IntoIterator, S: AsRef, { - self.ensure_admin_api("bulk queries")?; let queries = queries.into_iter().collect::>(); if queries.len() > options.max_concurrent { return Err(ShopifyAPIError::Other(format!( @@ -239,7 +238,6 @@ impl Shopify { M: AsRef, P: AsRef, { - self.ensure_admin_api("bulk mutations")?; let mutations = mutations.into_iter().collect::>(); if mutations.len() > options.max_concurrent { return Err(ShopifyAPIError::Other(format!( @@ -272,7 +270,6 @@ impl Shopify { query: &str, group_objects: bool, ) -> Result { - self.ensure_admin_api("bulk queries")?; let data: RunBulkQueryData = self .graphql( r#" @@ -306,7 +303,6 @@ impl Shopify { mutation: &str, staged_upload_path: &str, ) -> Result { - self.ensure_admin_api("bulk mutations")?; let data: RunBulkMutationData = self .graphql( r#" @@ -342,7 +338,6 @@ impl Shopify { &self, id: &str, ) -> Result, ShopifyAPIError> { - self.ensure_admin_api("bulk operation lookup")?; let data: BulkOperationData = self .graphql( r#" @@ -371,7 +366,6 @@ impl Shopify { &self, filter: BulkOperationsFilter, ) -> Result { - self.ensure_admin_api("bulk operation listing")?; let first = if filter.first == 0 { 10 } else { filter.first }; let data: BulkOperationsData = self .graphql( @@ -415,7 +409,6 @@ impl Shopify { id: &str, options: BulkWaitOptions, ) -> Result { - self.ensure_admin_api("bulk operation polling")?; let started_at = Instant::now(); loop { @@ -443,7 +436,6 @@ impl Shopify { where T: Serialize, { - self.ensure_admin_api("staged uploads")?; let jsonl_data = data .iter() .map(serde_json::to_string) diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 1d5bbd9..f3a9645 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -3,7 +3,7 @@ pub mod types; use serde::{Deserialize, Serialize}; -use crate::{utils::ReadJsonTreeSteps, Shopify, ShopifyAPIError}; +use crate::{utils::ReadJsonTreeSteps, Shopify, ShopifyAPIError, ShopifyStorefront}; pub use bulk_query::*; @@ -31,102 +31,192 @@ struct GraphqlRequest<'a, Variables> { variables: &'a Variables, } -impl Shopify { - pub async fn graphql_raw( - &self, - query: &str, - variables: &Variables, - ) -> Result, ShopifyAPIError> - where - Variables: serde::Serialize, - { - let request = self - .client() - .post(self.get_query_url()) - .header("Content-Type", "application/json") - .json(&GraphqlRequest { query, variables }); - let request = match self.graphql_auth_header().await? { - Some((name, value)) => request.header(name, value), - None => request, - }; - let response = request.send().await?.error_for_status()?; - - let status = response.status(); - let body = response.text().await?; - log::debug!("shopify graphql response status: {status}"); - serde_json::from_str(&body).map_err(ShopifyAPIError::JsonParseError) +trait GraphqlTransport { + fn http_client(&self) -> &reqwest::Client; + fn query_url(&self) -> &str; + async fn auth_header(&self) -> Result, ShopifyAPIError>; +} + +impl GraphqlTransport for Shopify { + fn http_client(&self) -> &reqwest::Client { + self.client() } - pub async fn graphql( - &self, - query: &str, - variables: &Variables, - ) -> Result - where - ReturnType: serde::de::DeserializeOwned, - Variables: serde::Serialize, - { - let response = self.graphql_raw(query, variables).await?; - if let Some(errors) = response.errors { - if errors.iter().any(|error| { - error - .extensions - .as_ref() - .and_then(|v| v.get("code")) - .and_then(|v| v.as_str()) - == Some("THROTTLED") - }) { - return Err(ShopifyAPIError::Throttled); - } - return Err(ShopifyAPIError::GraphqlErrors(errors)); - } + fn query_url(&self) -> &str { + self.get_query_url() + } + + async fn auth_header(&self) -> Result, ShopifyAPIError> { + Ok(Some(("X-Shopify-Access-Token", self.access_token().await?))) + } +} - let data = response.data.ok_or(ShopifyAPIError::MissingGraphqlData)?; - serde_json::from_value(data).map_err(ShopifyAPIError::JsonParseError) +impl GraphqlTransport for ShopifyStorefront { + fn http_client(&self) -> &reqwest::Client { + self.client() } - pub async fn graphql_at_path( - &self, - query: &str, - variables: &Variables, - json_finder: &[ReadJsonTreeSteps<'_>], - ) -> Result - where - ReturnType: serde::de::DeserializeOwned, - Variables: serde::Serialize, - { - let data = self - .graphql::(query, variables) - .await?; - let value = crate::utils::read_json_tree(&data, json_finder) - .map_err(|_| ShopifyAPIError::NotWantedJsonFormat(data.to_string()))?; - serde_json::from_value(value.to_owned()).map_err(ShopifyAPIError::JsonParseError) + fn query_url(&self) -> &str { + self.get_query_url() } - #[cfg(feature = "graphql-client")] - pub async fn post_graphql( - &self, - variables: Q::Variables, - ) -> Result, ShopifyAPIError> { - let body = Q::build_query(variables); - let request = self - .client() - .post(self.get_query_url()) - .header("Content-Type", "application/json") - .json(&body); - let request = match self.graphql_auth_header().await? { - Some((name, value)) => request.header(name, value), - None => request, - }; - let response = request.send().await?.error_for_status()?; - - response - .json::>() - .await - .map_err(ShopifyAPIError::ConnectionFailed) + async fn auth_header(&self) -> Result, ShopifyAPIError> { + self.graphql_auth_header() } } +async fn graphql_raw_for( + client: &Client, + query: &str, + variables: &Variables, +) -> Result, ShopifyAPIError> +where + Client: GraphqlTransport, + Variables: serde::Serialize, +{ + let request = client + .http_client() + .post(client.query_url()) + .header("Content-Type", "application/json") + .json(&GraphqlRequest { query, variables }); + let request = match client.auth_header().await? { + Some((name, value)) => request.header(name, value), + None => request, + }; + let response = request.send().await?.error_for_status()?; + + let status = response.status(); + let body = response.text().await?; + log::debug!("shopify graphql response status: {status}"); + serde_json::from_str(&body).map_err(ShopifyAPIError::JsonParseError) +} + +async fn graphql_for( + client: &Client, + query: &str, + variables: &Variables, +) -> Result +where + Client: GraphqlTransport, + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, +{ + let response = graphql_raw_for(client, query, variables).await?; + if let Some(errors) = response.errors { + if errors.iter().any(|error| { + error + .extensions + .as_ref() + .and_then(|v| v.get("code")) + .and_then(|v| v.as_str()) + == Some("THROTTLED") + }) { + return Err(ShopifyAPIError::Throttled); + } + return Err(ShopifyAPIError::GraphqlErrors(errors)); + } + + let data = response.data.ok_or(ShopifyAPIError::MissingGraphqlData)?; + serde_json::from_value(data).map_err(ShopifyAPIError::JsonParseError) +} + +async fn graphql_at_path_for( + client: &Client, + query: &str, + variables: &Variables, + json_finder: &[ReadJsonTreeSteps<'_>], +) -> Result +where + Client: GraphqlTransport, + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, +{ + let data = graphql_for::<_, serde_json::Value, _>(client, query, variables).await?; + let value = crate::utils::read_json_tree(&data, json_finder) + .map_err(|_| ShopifyAPIError::NotWantedJsonFormat(data.to_string()))?; + serde_json::from_value(value.to_owned()).map_err(ShopifyAPIError::JsonParseError) +} + +#[cfg(feature = "graphql-client")] +async fn post_graphql_for( + client: &Client, + variables: Q::Variables, +) -> Result, ShopifyAPIError> +where + Client: GraphqlTransport, + Q: GraphQLQuery, +{ + let body = Q::build_query(variables); + let request = client + .http_client() + .post(client.query_url()) + .header("Content-Type", "application/json") + .json(&body); + let request = match client.auth_header().await? { + Some((name, value)) => request.header(name, value), + None => request, + }; + let response = request.send().await?.error_for_status()?; + + response + .json::>() + .await + .map_err(ShopifyAPIError::ConnectionFailed) +} + +macro_rules! impl_graphql_methods { + ($client:ty) => { + impl $client { + pub async fn graphql_raw( + &self, + query: &str, + variables: &Variables, + ) -> Result, ShopifyAPIError> + where + Variables: serde::Serialize, + { + graphql_raw_for(self, query, variables).await + } + + pub async fn graphql( + &self, + query: &str, + variables: &Variables, + ) -> Result + where + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, + { + graphql_for(self, query, variables).await + } + + pub async fn graphql_at_path( + &self, + query: &str, + variables: &Variables, + json_finder: &[ReadJsonTreeSteps<'_>], + ) -> Result + where + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, + { + graphql_at_path_for(self, query, variables, json_finder).await + } + + #[cfg(feature = "graphql-client")] + pub async fn post_graphql( + &self, + variables: Q::Variables, + ) -> Result, ShopifyAPIError> { + post_graphql_for::<_, Q>(self, variables).await + } + } + }; +} + +impl_graphql_methods!(Shopify); +impl_graphql_methods!(ShopifyStorefront); + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index cf26837..4304f88 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,16 +5,18 @@ use thiserror::Error; pub mod auth; pub mod graphql; pub mod schema; +pub mod storefront; pub mod utils; #[cfg(feature = "webhooks")] pub mod webhooks; -pub use auth::{ShopifyAuth, StorefrontAuth, TokenData, TokenStore}; +pub use auth::{ShopifyAuth, TokenData, TokenStore}; pub use graphql::{ BulkConcurrencyOptions, BulkOperationPayload, BulkOperationsFilter, BulkWaitOptions, GraphqlError, GraphqlResponse, ShopifyBulkOperation, ShopifyBulkStatus, }; pub use schema::{download_public_admin_schema, SHOPIFY_DEV_ADMIN_SCHEMA_PROXY}; +pub use storefront::{ShopifyStorefront, StorefrontAuth, StorefrontConfig}; pub const DEFAULT_API_VERSION: &str = "2026-04"; pub const MIN_API_VERSION: &str = "2026-04"; @@ -63,12 +65,6 @@ impl TryFrom for ApiVersion { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ShopifyApi { - Admin, - Storefront, -} - #[derive(Clone)] pub struct ShopifyConfig { pub api_version: ApiVersion, @@ -95,10 +91,9 @@ impl Default for ShopifyConfig { #[derive(Clone)] pub struct Shopify { pub api_version: ApiVersion, - api: ShopifyApi, #[cfg(feature = "webhooks")] pub(crate) shared_secret: Option, - auth: Arc>, + auth: Arc>, client: reqwest::Client, query_url: String, token_url: String, @@ -112,7 +107,6 @@ impl std::fmt::Debug for Shopify { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Shopify") .field("api_version", &self.api_version) - .field("api", &self.api) .field("query_url", &self.query_url) .field("token_url", &self.token_url) .field("shop", &self.shop) @@ -159,9 +153,6 @@ pub enum ShopifyAPIError { #[error("operation timed out: {0}")] Timeout(String), - #[error("{operation} is only supported by the Admin API")] - AdminApiRequired { operation: &'static str }, - #[error("other error: {0}")] Other(String), } @@ -171,43 +162,13 @@ impl Shopify { shop: impl AsRef, auth: ShopifyAuth, config: ShopifyConfig, - ) -> Result { - Self::new_with_auth(shop, auth::ApiAuth::Admin(auth), config) - } - - /// Creates a client for Shopify's Storefront GraphQL API. - /// - /// The authentication mode determines whether requests are tokenless or use - /// Shopify's public or private Storefront access-token header. - pub fn new_storefront( - shop: impl AsRef, - auth: StorefrontAuth, - config: ShopifyConfig, - ) -> Result { - Self::new_with_auth(shop, auth::ApiAuth::Storefront(auth), config) - } - - fn new_with_auth( - shop: impl AsRef, - auth: auth::ApiAuth, - config: ShopifyConfig, ) -> Result { let shop = shop.as_ref().to_string(); let shop_domain = normalize_shop_domain(&shop); - let api = match &auth { - auth::ApiAuth::Admin(_) => ShopifyApi::Admin, - auth::ApiAuth::Storefront(_) => ShopifyApi::Storefront, - }; - let query_url = match api { - ShopifyApi::Admin => format!( - "https://{}/admin/api/{}/graphql.json", - shop_domain, config.api_version - ), - ShopifyApi::Storefront => format!( - "https://{}/api/{}/graphql.json", - shop_domain, config.api_version - ), - }; + let query_url = format!( + "https://{}/admin/api/{}/graphql.json", + shop_domain, config.api_version + ); let token_url = format!("https://{}/admin/oauth/access_token", shop_domain); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( @@ -220,7 +181,6 @@ impl Shopify { Ok(Self { api_version: config.api_version, - api, #[cfg(feature = "webhooks")] shared_secret: config.shared_secret, auth: Arc::new(Mutex::new(auth)), @@ -242,10 +202,6 @@ impl Shopify { &self.shop_domain } - pub fn api(&self) -> ShopifyApi { - self.api - } - pub fn get_query_url(&self) -> &str { &self.query_url } @@ -258,45 +214,13 @@ impl Shopify { &self.client } - pub(crate) fn ensure_admin_api(&self, operation: &'static str) -> Result<(), ShopifyAPIError> { - if self.api == ShopifyApi::Admin { - Ok(()) - } else { - Err(ShopifyAPIError::AdminApiRequired { operation }) - } - } - pub fn replace_auth(&self, auth: ShopifyAuth) -> Result<(), ShopifyAPIError> { let mut current = self .auth .lock() .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?; - match &mut *current { - auth::ApiAuth::Admin(current) => { - *current = auth; - Ok(()) - } - auth::ApiAuth::Storefront(_) => Err(ShopifyAPIError::Authentication( - "cannot replace Admin authentication on a Storefront API client".to_string(), - )), - } - } - - /// Replaces authentication on an existing Storefront API client. - pub fn replace_storefront_auth(&self, auth: StorefrontAuth) -> Result<(), ShopifyAPIError> { - let mut current = self - .auth - .lock() - .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?; - match &mut *current { - auth::ApiAuth::Storefront(current) => { - *current = auth; - Ok(()) - } - auth::ApiAuth::Admin(_) => Err(ShopifyAPIError::Authentication( - "cannot replace Storefront authentication on an Admin API client".to_string(), - )), - } + *current = auth; + Ok(()) } } @@ -350,7 +274,6 @@ mod tests { .unwrap(); assert_eq!(shopify.shop_domain(), "example.myshopify.com"); - assert_eq!(shopify.api(), ShopifyApi::Admin); assert_eq!( shopify.get_query_url(), "https://example.myshopify.com/admin/api/2026-04/graphql.json" @@ -360,61 +283,4 @@ mod tests { "https://example.myshopify.com/admin/oauth/access_token" ); } - - #[test] - fn storefront_endpoint_uses_the_storefront_graphql_path() { - let shopify = Shopify::new_storefront( - "https://example.myshopify.com/", - StorefrontAuth::Tokenless, - ShopifyConfig::default(), - ) - .unwrap(); - - assert_eq!(shopify.shop_domain(), "example.myshopify.com"); - assert_eq!(shopify.api(), ShopifyApi::Storefront); - assert_eq!( - shopify.get_query_url(), - "https://example.myshopify.com/api/2026-04/graphql.json" - ); - } - - #[test] - fn authentication_can_only_be_replaced_for_the_selected_api() { - let admin = Shopify::new( - "example", - ShopifyAuth::AccessToken("admin-token".to_string()), - ShopifyConfig::default(), - ) - .unwrap(); - let storefront = Shopify::new_storefront( - "example", - StorefrontAuth::Tokenless, - ShopifyConfig::default(), - ) - .unwrap(); - - assert!(admin - .replace_storefront_auth(StorefrontAuth::Tokenless) - .is_err()); - assert!(storefront - .replace_auth(ShopifyAuth::AccessToken("admin-token".to_string())) - .is_err()); - } - - #[test] - fn storefront_clients_reject_admin_only_operations() { - let storefront = Shopify::new_storefront( - "example", - StorefrontAuth::Tokenless, - ShopifyConfig::default(), - ) - .unwrap(); - - assert!(matches!( - storefront.ensure_admin_api("bulk operations"), - Err(ShopifyAPIError::AdminApiRequired { - operation: "bulk operations" - }) - )); - } } diff --git a/src/schema.rs b/src/schema.rs index 9faa930..898b64c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -29,7 +29,6 @@ pub async fn download_public_admin_schema( impl Shopify { /// Downloads the Admin GraphQL schema through the authenticated shop endpoint. pub async fn download_admin_schema(&self) -> Result { - self.ensure_admin_api("Admin schema download")?; let response = self .graphql_raw(ADMIN_SCHEMA_INTROSPECTION_QUERY, &json!({})) .await?; diff --git a/src/storefront.rs b/src/storefront.rs new file mode 100644 index 0000000..ee568b0 --- /dev/null +++ b/src/storefront.rs @@ -0,0 +1,248 @@ +use std::sync::{Arc, Mutex}; + +use crate::{normalize_shop_domain, ApiVersion, ShopifyAPIError, VERSION}; + +pub(crate) const STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Storefront-Access-Token"; +pub(crate) const STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER: &str = "Shopify-Storefront-Private-Token"; + +/// Authentication accepted by Shopify's Storefront GraphQL API. +#[derive(Clone, Eq, PartialEq)] +pub enum StorefrontAuth { + /// Send no access token. Shopify limits the fields available to tokenless requests. + Tokenless, + /// Authenticate with a public Storefront access token. + PublicAccessToken(String), + /// Authenticate with a private Storefront access token for server-side requests. + PrivateAccessToken(String), +} + +impl std::fmt::Debug for StorefrontAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StorefrontAuth::Tokenless => f.write_str("Tokenless"), + StorefrontAuth::PublicAccessToken(_) => f + .debug_tuple("PublicAccessToken") + .field(&"") + .finish(), + StorefrontAuth::PrivateAccessToken(_) => f + .debug_tuple("PrivateAccessToken") + .field(&"") + .finish(), + } + } +} + +#[derive(Clone, Debug)] +pub struct StorefrontConfig { + pub api_version: ApiVersion, + pub user_agent: String, +} + +impl Default for StorefrontConfig { + fn default() -> Self { + Self { + api_version: ApiVersion::default(), + user_agent: VERSION.to_string(), + } + } +} + +/// A Shopify Storefront GraphQL API client. +/// +/// Admin-only operations such as bulk operations, webhook management, and Admin +/// schema download are intentionally unavailable on this type. +/// +/// ```compile_fail +/// use shopify_api::{ShopifyStorefront, StorefrontAuth, StorefrontConfig}; +/// +/// # async fn invalid() -> Result<(), shopify_api::ShopifyAPIError> { +/// let storefront = ShopifyStorefront::new( +/// "my-shop", +/// StorefrontAuth::Tokenless, +/// StorefrontConfig::default(), +/// )?; +/// storefront.run_bulk_query("{ products { edges { node { id } } } }").await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct ShopifyStorefront { + pub api_version: ApiVersion, + auth: Arc>, + client: reqwest::Client, + query_url: String, + shop: String, + shop_domain: String, +} + +impl ShopifyStorefront { + pub fn new( + shop: impl AsRef, + auth: StorefrontAuth, + config: StorefrontConfig, + ) -> Result { + let shop = shop.as_ref().to_string(); + let shop_domain = normalize_shop_domain(&shop); + let query_url = format!( + "https://{}/api/{}/graphql.json", + shop_domain, config.api_version + ); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::USER_AGENT, + reqwest::header::HeaderValue::from_str(&config.user_agent)?, + ); + let client = reqwest::Client::builder() + .default_headers(headers) + .build()?; + + Ok(Self { + api_version: config.api_version, + auth: Arc::new(Mutex::new(auth)), + client, + query_url, + shop, + shop_domain, + }) + } + + pub fn get_shop(&self) -> &str { + &self.shop + } + + pub fn shop_domain(&self) -> &str { + &self.shop_domain + } + + pub fn get_query_url(&self) -> &str { + &self.query_url + } + + pub fn replace_auth(&self, auth: StorefrontAuth) -> Result<(), ShopifyAPIError> { + let mut current = self.auth.lock().map_err(|_| { + ShopifyAPIError::Authentication("storefront auth lock poisoned".to_string()) + })?; + *current = auth; + Ok(()) + } + + pub(crate) fn client(&self) -> &reqwest::Client { + &self.client + } + + pub(crate) fn graphql_auth_header( + &self, + ) -> Result, ShopifyAPIError> { + let auth = self + .auth + .lock() + .map_err(|_| { + ShopifyAPIError::Authentication("storefront auth lock poisoned".to_string()) + })? + .clone(); + + Ok(match auth { + StorefrontAuth::Tokenless => None, + StorefrontAuth::PublicAccessToken(token) => { + Some((STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, token)) + } + StorefrontAuth::PrivateAccessToken(token) => { + Some((STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, token)) + } + }) + } +} + +impl std::fmt::Debug for ShopifyStorefront { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShopifyStorefront") + .field("api_version", &self.api_version) + .field("query_url", &self.query_url) + .field("shop", &self.shop) + .field("shop_domain", &self.shop_domain) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_uses_the_storefront_graphql_path() { + let storefront = ShopifyStorefront::new( + "https://example.myshopify.com/", + StorefrontAuth::Tokenless, + StorefrontConfig::default(), + ) + .unwrap(); + + assert_eq!(storefront.shop_domain(), "example.myshopify.com"); + assert_eq!( + storefront.get_query_url(), + "https://example.myshopify.com/api/2026-04/graphql.json" + ); + } + + #[test] + fn requests_use_the_selected_authentication() { + let public = ShopifyStorefront::new( + "example", + StorefrontAuth::PublicAccessToken("public-token".to_string()), + StorefrontConfig::default(), + ) + .unwrap(); + let private = ShopifyStorefront::new( + "example", + StorefrontAuth::PrivateAccessToken("private-token".to_string()), + StorefrontConfig::default(), + ) + .unwrap(); + let tokenless = ShopifyStorefront::new( + "example", + StorefrontAuth::Tokenless, + StorefrontConfig::default(), + ) + .unwrap(); + + assert_eq!( + public.graphql_auth_header().unwrap(), + Some(( + STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER, + "public-token".to_string() + )) + ); + assert_eq!( + private.graphql_auth_header().unwrap(), + Some(( + STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, + "private-token".to_string() + )) + ); + assert_eq!(tokenless.graphql_auth_header().unwrap(), None); + } + + #[test] + fn authentication_can_be_replaced() { + let storefront = ShopifyStorefront::new( + "example", + StorefrontAuth::Tokenless, + StorefrontConfig::default(), + ) + .unwrap(); + + storefront + .replace_auth(StorefrontAuth::PrivateAccessToken( + "private-token".to_string(), + )) + .unwrap(); + + assert_eq!( + storefront.graphql_auth_header().unwrap(), + Some(( + STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, + "private-token".to_string() + )) + ); + } +} diff --git a/src/webhooks/webhook.rs b/src/webhooks/webhook.rs index da4ca60..6167da5 100644 --- a/src/webhooks/webhook.rs +++ b/src/webhooks/webhook.rs @@ -75,7 +75,6 @@ impl From for WebhookSubscription { impl Shopify { pub async fn list_webhooks(&self) -> Result, ShopifyAPIError> { - self.ensure_admin_api("webhook listing")?; let data: WebhookSubscriptionsData = self .graphql( r#" @@ -110,7 +109,6 @@ impl Shopify { topic: &str, format: &str, ) -> Result { - self.ensure_admin_api("webhook creation")?; let data: WebhookCreateData = self .graphql( r#" @@ -161,7 +159,6 @@ impl Shopify { } pub async fn delete_webhook(&self, webhook_id: &str) -> Result<(), ShopifyAPIError> { - self.ensure_admin_api("webhook deletion")?; let data: WebhookDeleteData = self .graphql( r#" @@ -196,7 +193,6 @@ impl Shopify { &self, desired_webhooks: Vec<(&str, &str, &str)>, ) -> Result<(), ShopifyAPIError> { - self.ensure_admin_api("webhook configuration")?; let existing_webhooks = self.list_webhooks().await?; let mut desired = desired_webhooks; From 7738ca8b6544f9c617126e3619c1d77166e2a78e Mon Sep 17 00:00:00 2001 From: Thomas <31560900+0xtlt@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:15:11 +0200 Subject: [PATCH 3/3] fix: forward Storefront buyer IP per request --- CHANGELOG.md | 2 +- README.md | 31 ++++++- src/graphql/mod.rs | 131 +++++++++++++++++++++++++- src/lib.rs | 4 +- src/storefront.rs | 212 ++++++++++++++++++++++++++++++++++++++++--- tests/send_future.rs | 22 +++++ 6 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 tests/send_future.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index fd98ed3..d12c72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Add: a separate `ShopifyStorefront` GraphQL client with tokenless, public-token, and private-token authentication. +- Add: a separate `ShopifyStorefront` GraphQL client with tokenless, public-token, and private-token authentication, including per-request buyer IP forwarding. - Preserve: existing `Shopify::new` behavior for Admin API clients. ## 0.10.0 diff --git a/README.md b/README.md index cfe7af7..dc1cc3d 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,36 @@ let storefront = ShopifyStorefront::new( `StorefrontAuth` supports: -- `Tokenless` +- `Tokenless`, for the limited set of fields that Shopify makes available without a token; queries have a maximum complexity of 1,000 - `PublicAccessToken`, sent as `X-Shopify-Storefront-Access-Token` -- `PrivateAccessToken`, sent as `Shopify-Storefront-Private-Token` +- `PrivateAccessToken`, sent as `Shopify-Storefront-Private-Token`; keep this token secret and use it only from a server + +When a server-side private-token request originates from buyer traffic, pass the +trusted buyer IP per request: + +```rust,no_run +# use std::net::{IpAddr, Ipv4Addr}; +# use shopify_api::{ShopifyAPIError, ShopifyStorefront, StorefrontAuth, StorefrontConfig, StorefrontRequestContext}; +# async fn example() -> Result<(), ShopifyAPIError> { +# let storefront = ShopifyStorefront::new( +# "my-shop", +# StorefrontAuth::PrivateAccessToken("private-storefront-token".to_string()), +# StorefrontConfig::default(), +# )?; +let context = StorefrontRequestContext::new(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10))); +let data: serde_json::Value = storefront + .graphql_with_context( + "query { shop { name } }", + &serde_json::json!({}), + &context, + ) + .await?; +# Ok(()) +# } +``` + +This sends `Shopify-Storefront-Buyer-IP`. Requests such as static site builds +that aren't associated with a buyer can use the regular GraphQL methods. All Storefront clients use: diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index f3a9645..49a2624 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -3,7 +3,9 @@ pub mod types; use serde::{Deserialize, Serialize}; -use crate::{utils::ReadJsonTreeSteps, Shopify, ShopifyAPIError, ShopifyStorefront}; +use crate::{ + utils::ReadJsonTreeSteps, Shopify, ShopifyAPIError, ShopifyStorefront, StorefrontRequestContext, +}; pub use bulk_query::*; @@ -70,6 +72,19 @@ async fn graphql_raw_for( query: &str, variables: &Variables, ) -> Result, ShopifyAPIError> +where + Client: GraphqlTransport, + Variables: serde::Serialize, +{ + graphql_raw_for_with_headers(client, query, variables, reqwest::header::HeaderMap::new()).await +} + +async fn graphql_raw_for_with_headers( + client: &Client, + query: &str, + variables: &Variables, + headers: reqwest::header::HeaderMap, +) -> Result, ShopifyAPIError> where Client: GraphqlTransport, Variables: serde::Serialize, @@ -78,6 +93,7 @@ where .http_client() .post(client.query_url()) .header("Content-Type", "application/json") + .headers(headers) .json(&GraphqlRequest { query, variables }); let request = match client.auth_header().await? { Some((name, value)) => request.header(name, value), @@ -101,7 +117,21 @@ where ReturnType: serde::de::DeserializeOwned, Variables: serde::Serialize, { - let response = graphql_raw_for(client, query, variables).await?; + graphql_for_with_headers(client, query, variables, reqwest::header::HeaderMap::new()).await +} + +async fn graphql_for_with_headers( + client: &Client, + query: &str, + variables: &Variables, + headers: reqwest::header::HeaderMap, +) -> Result +where + Client: GraphqlTransport, + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, +{ + let response = graphql_raw_for_with_headers(client, query, variables, headers).await?; if let Some(errors) = response.errors { if errors.iter().any(|error| { error @@ -131,7 +161,31 @@ where ReturnType: serde::de::DeserializeOwned, Variables: serde::Serialize, { - let data = graphql_for::<_, serde_json::Value, _>(client, query, variables).await?; + graphql_at_path_for_with_headers( + client, + query, + variables, + json_finder, + reqwest::header::HeaderMap::new(), + ) + .await +} + +async fn graphql_at_path_for_with_headers( + client: &Client, + query: &str, + variables: &Variables, + json_finder: &[ReadJsonTreeSteps<'_>], + headers: reqwest::header::HeaderMap, +) -> Result +where + Client: GraphqlTransport, + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, +{ + let data = + graphql_for_with_headers::<_, serde_json::Value, _>(client, query, variables, headers) + .await?; let value = crate::utils::read_json_tree(&data, json_finder) .map_err(|_| ShopifyAPIError::NotWantedJsonFormat(data.to_string()))?; serde_json::from_value(value.to_owned()).map_err(ShopifyAPIError::JsonParseError) @@ -142,6 +196,20 @@ async fn post_graphql_for( client: &Client, variables: Q::Variables, ) -> Result, ShopifyAPIError> +where + Client: GraphqlTransport, + Q: GraphQLQuery, +{ + post_graphql_for_with_headers::(client, variables, reqwest::header::HeaderMap::new()) + .await +} + +#[cfg(feature = "graphql-client")] +async fn post_graphql_for_with_headers( + client: &Client, + variables: Q::Variables, + headers: reqwest::header::HeaderMap, +) -> Result, ShopifyAPIError> where Client: GraphqlTransport, Q: GraphQLQuery, @@ -151,6 +219,7 @@ where .http_client() .post(client.query_url()) .header("Content-Type", "application/json") + .headers(headers) .json(&body); let request = match client.auth_header().await? { Some((name, value)) => request.header(name, value), @@ -217,6 +286,62 @@ macro_rules! impl_graphql_methods { impl_graphql_methods!(Shopify); impl_graphql_methods!(ShopifyStorefront); +impl ShopifyStorefront { + /// Sends a raw Storefront GraphQL request on behalf of a buyer. + pub async fn graphql_raw_with_context( + &self, + query: &str, + variables: &Variables, + context: &StorefrontRequestContext, + ) -> Result, ShopifyAPIError> + where + Variables: serde::Serialize, + { + graphql_raw_for_with_headers(self, query, variables, context.headers()?).await + } + + /// Sends a Storefront GraphQL request on behalf of a buyer. + pub async fn graphql_with_context( + &self, + query: &str, + variables: &Variables, + context: &StorefrontRequestContext, + ) -> Result + where + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, + { + graphql_for_with_headers(self, query, variables, context.headers()?).await + } + + /// Sends a Storefront GraphQL request on behalf of a buyer and reads a + /// nested value from the returned data. + pub async fn graphql_at_path_with_context( + &self, + query: &str, + variables: &Variables, + json_finder: &[ReadJsonTreeSteps<'_>], + context: &StorefrontRequestContext, + ) -> Result + where + ReturnType: serde::de::DeserializeOwned, + Variables: serde::Serialize, + { + graphql_at_path_for_with_headers(self, query, variables, json_finder, context.headers()?) + .await + } + + /// Sends a typed Storefront GraphQL request on behalf of a buyer. + #[cfg(feature = "graphql-client")] + pub async fn post_graphql_with_context( + &self, + variables: Q::Variables, + context: &StorefrontRequestContext, + ) -> Result, ShopifyAPIError> { + post_graphql_for_with_headers::<_, Q>(self, variables, context.headers()?).await + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 4304f88..f999907 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,9 @@ pub use graphql::{ GraphqlError, GraphqlResponse, ShopifyBulkOperation, ShopifyBulkStatus, }; pub use schema::{download_public_admin_schema, SHOPIFY_DEV_ADMIN_SCHEMA_PROXY}; -pub use storefront::{ShopifyStorefront, StorefrontAuth, StorefrontConfig}; +pub use storefront::{ + ShopifyStorefront, StorefrontAuth, StorefrontConfig, StorefrontRequestContext, +}; pub const DEFAULT_API_VERSION: &str = "2026-04"; pub const MIN_API_VERSION: &str = "2026-04"; diff --git a/src/storefront.rs b/src/storefront.rs index ee568b0..29783fa 100644 --- a/src/storefront.rs +++ b/src/storefront.rs @@ -1,9 +1,13 @@ -use std::sync::{Arc, Mutex}; +use std::{ + net::IpAddr, + sync::{Arc, Mutex}, +}; use crate::{normalize_shop_domain, ApiVersion, ShopifyAPIError, VERSION}; pub(crate) const STOREFRONT_PUBLIC_ACCESS_TOKEN_HEADER: &str = "X-Shopify-Storefront-Access-Token"; pub(crate) const STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER: &str = "Shopify-Storefront-Private-Token"; +pub(crate) const STOREFRONT_BUYER_IP_HEADER: &str = "Shopify-Storefront-Buyer-IP"; /// Authentication accepted by Shopify's Storefront GraphQL API. #[derive(Clone, Eq, PartialEq)] @@ -47,6 +51,36 @@ impl Default for StorefrontConfig { } } +/// Per-request context for Storefront API calls made on behalf of a buyer. +/// +/// Shopify requires the buyer IP on server-side requests that use a private +/// Storefront token and originate from buyer traffic. Static builds and other +/// requests that aren't associated with a buyer can use the regular GraphQL +/// methods without a context. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StorefrontRequestContext { + buyer_ip: IpAddr, +} + +impl StorefrontRequestContext { + pub fn new(buyer_ip: IpAddr) -> Self { + Self { buyer_ip } + } + + pub fn buyer_ip(&self) -> IpAddr { + self.buyer_ip + } + + pub(crate) fn headers(&self) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + STOREFRONT_BUYER_IP_HEADER, + reqwest::header::HeaderValue::from_str(&self.buyer_ip.to_string())?, + ); + Ok(headers) + } +} + /// A Shopify Storefront GraphQL API client. /// /// Admin-only operations such as bulk operations, webhook management, and Admin @@ -167,6 +201,90 @@ impl std::fmt::Debug for ShopifyStorefront { #[cfg(test)] mod tests { use super::*; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::oneshot, + }; + + async fn mock_graphql_server() -> (String, oneshot::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (request_sender, request_receiver) = oneshot::channel(); + + tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let expected_length = loop { + let count = stream.read(&mut buffer).await.unwrap(); + assert!(count > 0, "connection closed before request was complete"); + request.extend_from_slice(&buffer[..count]); + + let Some(headers_end) = request.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + break headers_end + 4 + content_length; + }; + + while request.len() < expected_length { + let count = stream.read(&mut buffer).await.unwrap(); + assert!( + count > 0, + "connection closed before request body was complete" + ); + request.extend_from_slice(&buffer[..count]); + } + + request_sender + .send(String::from_utf8(request).unwrap()) + .unwrap(); + stream + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 20\r\nconnection: close\r\n\r\n{\"data\":{\"shop\":{}}}", + ) + .await + .unwrap(); + }); + + ( + format!("http://{address}/api/2026-04/graphql.json"), + request_receiver, + ) + } + + async fn capture_graphql_request( + auth: StorefrontAuth, + context: Option<&StorefrontRequestContext>, + ) -> String { + let (query_url, request_receiver) = mock_graphql_server().await; + let mut storefront = + ShopifyStorefront::new("example", auth, StorefrontConfig::default()).unwrap(); + storefront.query_url = query_url; + + match context { + Some(context) => storefront + .graphql_raw_with_context("query { shop { name } }", &(), context) + .await + .unwrap(), + None => storefront + .graphql_raw("query { shop { name } }", &()) + .await + .unwrap(), + }; + + request_receiver.await.unwrap() + } #[test] fn endpoint_uses_the_storefront_graphql_path() { @@ -222,14 +340,46 @@ mod tests { assert_eq!(tokenless.graphql_auth_header().unwrap(), None); } - #[test] - fn authentication_can_be_replaced() { - let storefront = ShopifyStorefront::new( + #[tokio::test] + async fn graphql_requests_send_the_selected_authentication_and_buyer_ip() { + let public_request = capture_graphql_request( + StorefrontAuth::PublicAccessToken("public-token".to_string()), + None, + ) + .await; + let tokenless_request = capture_graphql_request(StorefrontAuth::Tokenless, None).await; + let context = StorefrontRequestContext::new("203.0.113.10".parse::().unwrap()); + let private_request = capture_graphql_request( + StorefrontAuth::PrivateAccessToken("private-token".to_string()), + Some(&context), + ) + .await; + + assert!(public_request.starts_with("POST /api/2026-04/graphql.json HTTP/1.1\r\n")); + assert!(public_request + .to_ascii_lowercase() + .contains("x-shopify-storefront-access-token: public-token\r\n")); + assert!(!tokenless_request + .to_ascii_lowercase() + .contains("shopify-storefront")); + assert!(private_request + .to_ascii_lowercase() + .contains("shopify-storefront-private-token: private-token\r\n")); + assert!(private_request + .to_ascii_lowercase() + .contains("shopify-storefront-buyer-ip: 203.0.113.10\r\n")); + } + + #[tokio::test] + async fn authentication_replacement_changes_the_sent_header() { + let (query_url, request_receiver) = mock_graphql_server().await; + let mut storefront = ShopifyStorefront::new( "example", StorefrontAuth::Tokenless, StorefrontConfig::default(), ) .unwrap(); + storefront.query_url = query_url; storefront .replace_auth(StorefrontAuth::PrivateAccessToken( @@ -237,12 +387,52 @@ mod tests { )) .unwrap(); - assert_eq!( - storefront.graphql_auth_header().unwrap(), - Some(( - STOREFRONT_PRIVATE_ACCESS_TOKEN_HEADER, - "private-token".to_string() - )) - ); + storefront + .graphql_raw("query { shop { name } }", &()) + .await + .unwrap(); + let request = request_receiver.await.unwrap().to_ascii_lowercase(); + + assert!(request.contains("shopify-storefront-private-token: private-token\r\n")); + } + + #[cfg(feature = "graphql-client")] + struct TestQuery; + + #[cfg(feature = "graphql-client")] + impl graphql_client::GraphQLQuery for TestQuery { + type Variables = (); + type ResponseData = serde_json::Value; + + fn build_query(variables: Self::Variables) -> graphql_client::QueryBody { + graphql_client::QueryBody { + variables, + query: "query TestQuery { shop { name } }", + operation_name: "TestQuery", + } + } + } + + #[cfg(feature = "graphql-client")] + #[tokio::test] + async fn typed_graphql_requests_send_the_buyer_ip() { + let (query_url, request_receiver) = mock_graphql_server().await; + let mut storefront = ShopifyStorefront::new( + "example", + StorefrontAuth::PrivateAccessToken("private-token".to_string()), + StorefrontConfig::default(), + ) + .unwrap(); + storefront.query_url = query_url; + let context = StorefrontRequestContext::new("2001:db8::1".parse::().unwrap()); + + storefront + .post_graphql_with_context::((), &context) + .await + .unwrap(); + let request = request_receiver.await.unwrap().to_ascii_lowercase(); + + assert!(request.contains("shopify-storefront-private-token: private-token\r\n")); + assert!(request.contains("shopify-storefront-buyer-ip: 2001:db8::1\r\n")); } } diff --git a/tests/send_future.rs b/tests/send_future.rs new file mode 100644 index 0000000..877456e --- /dev/null +++ b/tests/send_future.rs @@ -0,0 +1,22 @@ +use shopify_api::{Shopify, ShopifyAuth, ShopifyConfig}; + +fn assert_send(_: T) {} + +#[test] +fn admin_graphql_futures_remain_send() { + let shopify = Shopify::new( + "example", + ShopifyAuth::AccessToken("token".to_string()), + ShopifyConfig::default(), + ) + .unwrap(); + let variables = serde_json::json!({}); + + assert_send(shopify.graphql_raw("query { shop { id } }", &variables)); + assert_send(shopify.graphql::("query { shop { id } }", &variables)); + assert_send(shopify.graphql_at_path::( + "query { shop { id } }", + &variables, + &[], + )); +}