Skip to main content

shopify_api/
lib.rs

1use std::sync::{Arc, Mutex};
2
3use thiserror::Error;
4
5pub mod auth;
6pub mod graphql;
7pub mod schema;
8pub mod utils;
9#[cfg(feature = "webhooks")]
10pub mod webhooks;
11
12pub use auth::{ShopifyAuth, TokenData, TokenStore};
13pub use graphql::{
14    BulkConcurrencyOptions, BulkOperationPayload, BulkOperationsFilter, BulkWaitOptions,
15    GraphqlError, GraphqlResponse, ShopifyBulkOperation, ShopifyBulkStatus,
16};
17pub use schema::{download_public_admin_schema, SHOPIFY_DEV_ADMIN_SCHEMA_PROXY};
18
19pub const DEFAULT_API_VERSION: &str = "2026-04";
20pub const MIN_API_VERSION: &str = "2026-04";
21pub static VERSION: &str = "shopify_api/0.10";
22
23#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
24pub struct ApiVersion(String);
25
26impl ApiVersion {
27    pub fn new(version: impl Into<String>) -> Result<Self, ShopifyAPIError> {
28        let version = version.into();
29        validate_api_version(&version)?;
30        Ok(Self(version))
31    }
32
33    pub fn as_str(&self) -> &str {
34        &self.0
35    }
36}
37
38impl Default for ApiVersion {
39    fn default() -> Self {
40        Self(DEFAULT_API_VERSION.to_string())
41    }
42}
43
44impl std::fmt::Display for ApiVersion {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        self.0.fmt(f)
47    }
48}
49
50impl TryFrom<&str> for ApiVersion {
51    type Error = ShopifyAPIError;
52
53    fn try_from(value: &str) -> Result<Self, Self::Error> {
54        Self::new(value)
55    }
56}
57
58impl TryFrom<String> for ApiVersion {
59    type Error = ShopifyAPIError;
60
61    fn try_from(value: String) -> Result<Self, Self::Error> {
62        Self::new(value)
63    }
64}
65
66#[derive(Clone)]
67pub struct ShopifyConfig {
68    pub api_version: ApiVersion,
69    #[cfg(feature = "webhooks")]
70    pub shared_secret: Option<String>,
71    pub token_store: Option<Arc<dyn TokenStore>>,
72    pub token_refresh_leeway: chrono::Duration,
73    pub user_agent: String,
74}
75
76impl Default for ShopifyConfig {
77    fn default() -> Self {
78        Self {
79            api_version: ApiVersion::default(),
80            #[cfg(feature = "webhooks")]
81            shared_secret: None,
82            token_store: None,
83            token_refresh_leeway: chrono::Duration::minutes(5),
84            user_agent: VERSION.to_string(),
85        }
86    }
87}
88
89#[derive(Clone)]
90pub struct Shopify {
91    pub api_version: ApiVersion,
92    #[cfg(feature = "webhooks")]
93    pub(crate) shared_secret: Option<String>,
94    auth: Arc<Mutex<ShopifyAuth>>,
95    client: reqwest::Client,
96    query_url: String,
97    token_url: String,
98    shop: String,
99    shop_domain: String,
100    token_store: Option<Arc<dyn TokenStore>>,
101    token_refresh_leeway: chrono::Duration,
102}
103
104impl std::fmt::Debug for Shopify {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.debug_struct("Shopify")
107            .field("api_version", &self.api_version)
108            .field("query_url", &self.query_url)
109            .field("token_url", &self.token_url)
110            .field("shop", &self.shop)
111            .field("shop_domain", &self.shop_domain)
112            .finish_non_exhaustive()
113    }
114}
115
116#[derive(Debug, Error)]
117pub enum ShopifyAPIError {
118    #[error("connection failed")]
119    ConnectionFailed(#[from] reqwest::Error),
120
121    #[error("response body is broken")]
122    ResponseBroken,
123
124    #[error("not a JSON response: {0}")]
125    NotJson(String),
126
127    #[error("not wanted JSON format: {0}")]
128    NotWantedJsonFormat(String),
129
130    #[error("request throttled")]
131    Throttled,
132
133    #[error("JSON parsing error: {0}")]
134    JsonParseError(#[from] serde_json::Error),
135
136    #[error("invalid API version `{version}`: minimum supported version is {minimum}")]
137    InvalidApiVersion { version: String, minimum: String },
138
139    #[error("authentication error: {0}")]
140    Authentication(String),
141
142    #[error("GraphQL returned errors: {0:?}")]
143    GraphqlErrors(Vec<GraphqlError>),
144
145    #[error("missing GraphQL data")]
146    MissingGraphqlData,
147
148    #[error("invalid header value: {0}")]
149    InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
150
151    #[error("operation timed out: {0}")]
152    Timeout(String),
153
154    #[error("other error: {0}")]
155    Other(String),
156}
157
158impl Shopify {
159    pub fn new(
160        shop: impl AsRef<str>,
161        auth: ShopifyAuth,
162        config: ShopifyConfig,
163    ) -> Result<Self, ShopifyAPIError> {
164        let shop = shop.as_ref().to_string();
165        let shop_domain = normalize_shop_domain(&shop);
166        let query_url = format!(
167            "https://{}/admin/api/{}/graphql.json",
168            shop_domain, config.api_version
169        );
170        let token_url = format!("https://{}/admin/oauth/access_token", shop_domain);
171        let mut headers = reqwest::header::HeaderMap::new();
172        headers.insert(
173            reqwest::header::USER_AGENT,
174            reqwest::header::HeaderValue::from_str(&config.user_agent)?,
175        );
176        let client = reqwest::Client::builder()
177            .default_headers(headers)
178            .build()?;
179
180        Ok(Self {
181            api_version: config.api_version,
182            #[cfg(feature = "webhooks")]
183            shared_secret: config.shared_secret,
184            auth: Arc::new(Mutex::new(auth)),
185            client,
186            query_url,
187            token_url,
188            shop,
189            shop_domain,
190            token_store: config.token_store,
191            token_refresh_leeway: config.token_refresh_leeway,
192        })
193    }
194
195    pub fn get_shop(&self) -> &str {
196        &self.shop
197    }
198
199    pub fn shop_domain(&self) -> &str {
200        &self.shop_domain
201    }
202
203    pub fn get_query_url(&self) -> &str {
204        &self.query_url
205    }
206
207    pub fn token_url(&self) -> &str {
208        &self.token_url
209    }
210
211    pub(crate) fn client(&self) -> &reqwest::Client {
212        &self.client
213    }
214
215    pub fn replace_auth(&self, auth: ShopifyAuth) -> Result<(), ShopifyAPIError> {
216        let mut current = self
217            .auth
218            .lock()
219            .map_err(|_| ShopifyAPIError::Authentication("auth lock poisoned".to_string()))?;
220        *current = auth;
221        Ok(())
222    }
223}
224
225fn normalize_shop_domain(shop: &str) -> String {
226    let shop = shop.trim().trim_start_matches("https://");
227    let shop = shop.trim_start_matches("http://").trim_end_matches('/');
228    if shop.ends_with(".myshopify.com") {
229        shop.to_string()
230    } else {
231        format!("{shop}.myshopify.com")
232    }
233}
234
235fn validate_api_version(version: &str) -> Result<(), ShopifyAPIError> {
236    let parsed = parse_api_version(version);
237    let minimum = parse_api_version(MIN_API_VERSION);
238
239    match (parsed, minimum) {
240        (Some(parsed), Some(minimum)) if parsed >= minimum => Ok(()),
241        _ => Err(ShopifyAPIError::InvalidApiVersion {
242            version: version.to_string(),
243            minimum: MIN_API_VERSION.to_string(),
244        }),
245    }
246}
247
248fn parse_api_version(version: &str) -> Option<(u16, u8)> {
249    let (year, month) = version.split_once('-')?;
250    Some((year.parse().ok()?, month.parse().ok()?))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn api_version_rejects_versions_before_2026_04() {
259        assert!(ApiVersion::new("2025-10").is_err());
260        assert!(ApiVersion::new("2026-01").is_err());
261        assert_eq!(ApiVersion::new("2026-04").unwrap().as_str(), "2026-04");
262        assert_eq!(ApiVersion::new("2026-07").unwrap().as_str(), "2026-07");
263    }
264
265    #[test]
266    fn endpoints_are_built_from_normalized_shop_and_version() {
267        let shopify = Shopify::new(
268            "example",
269            ShopifyAuth::AccessToken("token".to_string()),
270            ShopifyConfig::default(),
271        )
272        .unwrap();
273
274        assert_eq!(shopify.shop_domain(), "example.myshopify.com");
275        assert_eq!(
276            shopify.get_query_url(),
277            "https://example.myshopify.com/admin/api/2026-04/graphql.json"
278        );
279        assert_eq!(
280            shopify.token_url(),
281            "https://example.myshopify.com/admin/oauth/access_token"
282        );
283    }
284}