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}