diff --git a/CHANGELOG.md b/CHANGELOG.md index b81b775..d12c72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- 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 - Breaking: refactor the client around Shopify Admin GraphQL API `2026-04` and newer. diff --git a/README.md b/README.md index b61875d..dc1cc3d 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,61 @@ 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 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::{ShopifyStorefront, StorefrontAuth, StorefrontConfig}; + +let storefront = ShopifyStorefront::new( + "my-shop", + StorefrontAuth::PrivateAccessToken("private-storefront-token".to_string()), + StorefrontConfig::default(), +)?; +# Ok::<(), shopify_api::ShopifyAPIError>(()) +``` + +`StorefrontAuth` supports: + +- `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`; 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: + +```txt +https://{shop}.myshopify.com/api/{version}/graphql.json +``` + ## Dynamic GraphQL Schema Public Shopify schema: diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index da7338a..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}; +use crate::{ + utils::ReadJsonTreeSteps, Shopify, ShopifyAPIError, ShopifyStorefront, StorefrontRequestContext, +}; pub use bulk_query::*; @@ -31,99 +33,312 @@ struct GraphqlRequest<'a, Variables> { variables: &'a Variables, } -impl Shopify { - pub async fn graphql_raw( +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() + } + + 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?))) + } +} + +impl GraphqlTransport for ShopifyStorefront { + fn http_client(&self) -> &reqwest::Client { + self.client() + } + + fn query_url(&self) -> &str { + self.get_query_url() + } + + 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, +{ + 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, +{ + let request = client + .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), + 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, +{ + 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 + .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, +{ + 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) +} + +#[cfg(feature = "graphql-client")] +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, +{ + let body = Q::build_query(variables); + let request = client + .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), + 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); + +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, { - let token = self.access_token().await?; - let response = 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()?; - - 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) + graphql_raw_for_with_headers(self, query, variables, context.headers()?).await } - pub async fn graphql( + /// 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, { - 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)); - } - - let data = response.data.ok_or(ShopifyAPIError::MissingGraphqlData)?; - serde_json::from_value(data).map_err(ShopifyAPIError::JsonParseError) + graphql_for_with_headers(self, query, variables, context.headers()?).await } - pub async fn graphql_at_path( + /// 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, { - 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) + 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( + pub async fn post_graphql_with_context( &self, variables: Q::Variables, + context: &StorefrontRequestContext, ) -> Result, ShopifyAPIError> { - let token = self.access_token().await?; - let body = Q::build_query(variables); - let response = 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()?; - - response - .json::>() - .await - .map_err(ShopifyAPIError::ConnectionFailed) + post_graphql_for_with_headers::<_, Q>(self, variables, context.headers()?).await } } diff --git a/src/lib.rs b/src/lib.rs index fae6c0d..f999907 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ use thiserror::Error; pub mod auth; pub mod graphql; pub mod schema; +pub mod storefront; pub mod utils; #[cfg(feature = "webhooks")] pub mod webhooks; @@ -15,6 +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, 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 new file mode 100644 index 0000000..29783fa --- /dev/null +++ b/src/storefront.rs @@ -0,0 +1,438 @@ +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)] +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(), + } + } +} + +/// 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 +/// 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::*; + 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() { + 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); + } + + #[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( + "private-token".to_string(), + )) + .unwrap(); + + 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, + &[], + )); +}