Skip to main content

shopify_sdk/config/
mod.rs

1//! Configuration types for the Shopify API SDK.
2//!
3//! This module provides the core configuration types used to initialize
4//! and configure the SDK for API communication with Shopify.
5//!
6//! # Overview
7//!
8//! The main types in this module are:
9//!
10//! - [`ShopifyConfig`]: The main configuration struct holding all SDK settings
11//! - [`ShopifyConfigBuilder`]: A builder for constructing [`ShopifyConfig`] instances
12//! - [`ApiKey`]: A validated API key newtype
13//! - [`ApiSecretKey`]: A validated API secret key newtype with masked debug output
14//! - [`ShopDomain`]: A validated Shopify shop domain
15//! - [`HostUrl`]: A validated application host URL
16//! - [`ApiVersion`]: The Shopify API version to use
17//! - [`DeprecationCallback`]: Callback type for API deprecation notices
18//!
19//! # Example
20//!
21//! ```rust
22//! use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, ApiVersion};
23//!
24//! let config = ShopifyConfig::builder()
25//!     .api_key(ApiKey::new("my-api-key").unwrap())
26//!     .api_secret_key(ApiSecretKey::new("my-secret").unwrap())
27//!     .api_version(ApiVersion::latest())
28//!     .build()
29//!     .unwrap();
30//! ```
31
32mod newtypes;
33mod version;
34
35pub use newtypes::{ApiKey, ApiSecretKey, HostUrl, ShopDomain};
36pub use version::ApiVersion;
37
38// Re-export DeprecationCallback type (defined in this module)
39
40use crate::auth::AuthScopes;
41use crate::clients::ApiDeprecationInfo;
42use crate::error::ConfigError;
43use std::sync::Arc;
44
45/// Callback type for handling API deprecation notices.
46///
47/// This callback is invoked whenever the SDK receives a response with the
48/// `X-Shopify-API-Deprecated-Reason` header, indicating that the requested
49/// endpoint or API version is deprecated.
50///
51/// The callback receives an [`ApiDeprecationInfo`] struct containing the
52/// deprecation reason and the request path.
53///
54/// # Thread Safety
55///
56/// The callback must be `Send + Sync` to be safely shared across threads
57/// and async tasks.
58///
59/// # Example
60///
61/// ```rust
62/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, DeprecationCallback};
63/// use std::sync::Arc;
64///
65/// let callback: DeprecationCallback = Arc::new(|info| {
66///     eprintln!("Deprecation warning: {} at {:?}", info.reason, info.path);
67/// });
68///
69/// let config = ShopifyConfig::builder()
70///     .api_key(ApiKey::new("key").unwrap())
71///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
72///     .on_deprecation(|info| {
73///         println!("API deprecation: {}", info.reason);
74///     })
75///     .build()
76///     .unwrap();
77/// ```
78pub type DeprecationCallback = Arc<dyn Fn(&ApiDeprecationInfo) + Send + Sync>;
79
80/// Configuration for the Shopify API SDK.
81///
82/// This struct holds all configuration needed for SDK operations, including
83/// API credentials, OAuth scopes, and API version settings.
84///
85/// # Thread Safety
86///
87/// `ShopifyConfig` is `Clone`, `Send`, and `Sync`, making it safe to share
88/// across threads and async tasks.
89///
90/// # Key Rotation
91///
92/// The `old_api_secret_key` field supports seamless key rotation. When
93/// validating OAuth HMAC signatures, the SDK will try the primary key first,
94/// then fall back to the old key if configured. This allows in-flight OAuth
95/// flows to complete during key rotation.
96///
97/// # Example
98///
99/// ```rust
100/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
101///
102/// let config = ShopifyConfig::builder()
103///     .api_key(ApiKey::new("your-api-key").unwrap())
104///     .api_secret_key(ApiSecretKey::new("your-secret").unwrap())
105///     .is_embedded(true)
106///     .build()
107///     .unwrap();
108///
109/// assert!(config.is_embedded());
110/// ```
111#[derive(Clone)]
112pub struct ShopifyConfig {
113    api_key: ApiKey,
114    api_secret_key: ApiSecretKey,
115    old_api_secret_key: Option<ApiSecretKey>,
116    scopes: AuthScopes,
117    host: Option<HostUrl>,
118    api_version: ApiVersion,
119    is_embedded: bool,
120    user_agent_prefix: Option<String>,
121    deprecation_callback: Option<DeprecationCallback>,
122}
123
124impl std::fmt::Debug for ShopifyConfig {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("ShopifyConfig")
127            .field("api_key", &self.api_key)
128            .field("api_secret_key", &self.api_secret_key)
129            .field("old_api_secret_key", &self.old_api_secret_key)
130            .field("scopes", &self.scopes)
131            .field("host", &self.host)
132            .field("api_version", &self.api_version)
133            .field("is_embedded", &self.is_embedded)
134            .field("user_agent_prefix", &self.user_agent_prefix)
135            .field(
136                "deprecation_callback",
137                &self.deprecation_callback.as_ref().map(|_| "<callback>"),
138            )
139            .finish()
140    }
141}
142
143impl ShopifyConfig {
144    /// Creates a new builder for constructing a `ShopifyConfig`.
145    ///
146    /// # Example
147    ///
148    /// ```rust
149    /// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
150    ///
151    /// let config = ShopifyConfig::builder()
152    ///     .api_key(ApiKey::new("key").unwrap())
153    ///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
154    ///     .build()
155    ///     .unwrap();
156    /// ```
157    #[must_use]
158    pub fn builder() -> ShopifyConfigBuilder {
159        ShopifyConfigBuilder::new()
160    }
161
162    /// Returns the API key.
163    #[must_use]
164    pub const fn api_key(&self) -> &ApiKey {
165        &self.api_key
166    }
167
168    /// Returns the API secret key.
169    #[must_use]
170    pub const fn api_secret_key(&self) -> &ApiSecretKey {
171        &self.api_secret_key
172    }
173
174    /// Returns the old API secret key, if configured.
175    ///
176    /// This is used during key rotation to validate HMAC signatures
177    /// created with the previous secret key.
178    #[must_use]
179    pub const fn old_api_secret_key(&self) -> Option<&ApiSecretKey> {
180        self.old_api_secret_key.as_ref()
181    }
182
183    /// Returns the OAuth scopes.
184    #[must_use]
185    pub const fn scopes(&self) -> &AuthScopes {
186        &self.scopes
187    }
188
189    /// Returns the host URL, if configured.
190    #[must_use]
191    pub const fn host(&self) -> Option<&HostUrl> {
192        self.host.as_ref()
193    }
194
195    /// Returns the API version.
196    #[must_use]
197    pub const fn api_version(&self) -> &ApiVersion {
198        &self.api_version
199    }
200
201    /// Returns whether the app is embedded.
202    #[must_use]
203    pub const fn is_embedded(&self) -> bool {
204        self.is_embedded
205    }
206
207    /// Returns the user agent prefix, if configured.
208    #[must_use]
209    pub fn user_agent_prefix(&self) -> Option<&str> {
210        self.user_agent_prefix.as_deref()
211    }
212
213    /// Returns the deprecation callback, if configured.
214    ///
215    /// This callback is invoked when the SDK receives a response indicating
216    /// that an API endpoint is deprecated.
217    #[must_use]
218    pub fn deprecation_callback(&self) -> Option<&DeprecationCallback> {
219        self.deprecation_callback.as_ref()
220    }
221}
222
223// Verify ShopifyConfig is Send + Sync at compile time
224const _: fn() = || {
225    const fn assert_send_sync<T: Send + Sync>() {}
226    assert_send_sync::<ShopifyConfig>();
227};
228
229/// Builder for constructing [`ShopifyConfig`] instances.
230///
231/// This builder provides a fluent API for configuring the SDK. Required fields
232/// are `api_key` and `api_secret_key`. All other fields have sensible defaults.
233///
234/// # Defaults
235///
236/// - `api_version`: Latest stable version
237/// - `is_embedded`: `true`
238/// - `scopes`: Empty
239/// - `host`: `None`
240/// - `user_agent_prefix`: `None`
241/// - `old_api_secret_key`: `None`
242/// - `reject_deprecated_versions`: `false`
243///
244/// # Example
245///
246/// ```rust
247/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, ApiVersion, HostUrl};
248///
249/// let config = ShopifyConfig::builder()
250///     .api_key(ApiKey::new("key").unwrap())
251///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
252///     .api_version(ApiVersion::V2024_10)
253///     .host(HostUrl::new("https://myapp.example.com").unwrap())
254///     .is_embedded(false)
255///     .user_agent_prefix("MyApp/1.0")
256///     .build()
257///     .unwrap();
258/// ```
259#[derive(Default)]
260pub struct ShopifyConfigBuilder {
261    api_key: Option<ApiKey>,
262    api_secret_key: Option<ApiSecretKey>,
263    old_api_secret_key: Option<ApiSecretKey>,
264    scopes: Option<AuthScopes>,
265    host: Option<HostUrl>,
266    api_version: Option<ApiVersion>,
267    is_embedded: Option<bool>,
268    user_agent_prefix: Option<String>,
269    reject_deprecated_versions: bool,
270    deprecation_callback: Option<DeprecationCallback>,
271}
272
273impl std::fmt::Debug for ShopifyConfigBuilder {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.debug_struct("ShopifyConfigBuilder")
276            .field("api_key", &self.api_key)
277            .field("api_secret_key", &self.api_secret_key)
278            .field("old_api_secret_key", &self.old_api_secret_key)
279            .field("scopes", &self.scopes)
280            .field("host", &self.host)
281            .field("api_version", &self.api_version)
282            .field("is_embedded", &self.is_embedded)
283            .field("user_agent_prefix", &self.user_agent_prefix)
284            .field(
285                "reject_deprecated_versions",
286                &self.reject_deprecated_versions,
287            )
288            .field(
289                "deprecation_callback",
290                &self.deprecation_callback.as_ref().map(|_| "<callback>"),
291            )
292            .finish()
293    }
294}
295
296impl ShopifyConfigBuilder {
297    /// Creates a new builder with default values.
298    #[must_use]
299    pub fn new() -> Self {
300        Self::default()
301    }
302
303    /// Sets the API key (required).
304    #[must_use]
305    pub fn api_key(mut self, key: ApiKey) -> Self {
306        self.api_key = Some(key);
307        self
308    }
309
310    /// Sets the API secret key (required).
311    #[must_use]
312    pub fn api_secret_key(mut self, key: ApiSecretKey) -> Self {
313        self.api_secret_key = Some(key);
314        self
315    }
316
317    /// Sets the old API secret key for key rotation support.
318    ///
319    /// When validating OAuth HMAC signatures, the SDK will try the primary
320    /// secret key first, then fall back to this old key if validation fails.
321    /// This allows in-flight OAuth flows to complete during key rotation.
322    ///
323    /// # Example
324    ///
325    /// ```rust
326    /// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
327    ///
328    /// // During key rotation, configure both keys
329    /// let config = ShopifyConfig::builder()
330    ///     .api_key(ApiKey::new("key").unwrap())
331    ///     .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
332    ///     .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
333    ///     .build()
334    ///     .unwrap();
335    /// ```
336    #[must_use]
337    pub fn old_api_secret_key(mut self, key: ApiSecretKey) -> Self {
338        self.old_api_secret_key = Some(key);
339        self
340    }
341
342    /// Sets the OAuth scopes.
343    #[must_use]
344    pub fn scopes(mut self, scopes: AuthScopes) -> Self {
345        self.scopes = Some(scopes);
346        self
347    }
348
349    /// Sets the host URL.
350    #[must_use]
351    pub fn host(mut self, host: HostUrl) -> Self {
352        self.host = Some(host);
353        self
354    }
355
356    /// Sets the API version.
357    #[must_use]
358    pub fn api_version(mut self, version: ApiVersion) -> Self {
359        self.api_version = Some(version);
360        self
361    }
362
363    /// Sets whether the app is embedded in the Shopify admin.
364    #[must_use]
365    pub const fn is_embedded(mut self, embedded: bool) -> Self {
366        self.is_embedded = Some(embedded);
367        self
368    }
369
370    /// Sets the user agent prefix for HTTP requests.
371    #[must_use]
372    pub fn user_agent_prefix(mut self, prefix: impl Into<String>) -> Self {
373        self.user_agent_prefix = Some(prefix.into());
374        self
375    }
376
377    /// Sets whether to reject deprecated API versions.
378    ///
379    /// When `true`, [`build()`](Self::build) will return a
380    /// [`ConfigError::DeprecatedApiVersion`] error if the configured API version
381    /// is past Shopify's support window.
382    ///
383    /// When `false` (the default), deprecated versions will log a warning via
384    /// `tracing` but the configuration will still be created.
385    ///
386    /// # Example
387    ///
388    /// ```rust
389    /// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey, ApiVersion, ConfigError};
390    ///
391    /// // This will fail because V2024_01 is deprecated
392    /// let result = ShopifyConfig::builder()
393    ///     .api_key(ApiKey::new("key").unwrap())
394    ///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
395    ///     .api_version(ApiVersion::V2024_01)
396    ///     .reject_deprecated_versions(true)
397    ///     .build();
398    ///
399    /// assert!(matches!(result, Err(ConfigError::DeprecatedApiVersion { .. })));
400    /// ```
401    #[must_use]
402    pub const fn reject_deprecated_versions(mut self, reject: bool) -> Self {
403        self.reject_deprecated_versions = reject;
404        self
405    }
406
407    /// Sets a callback to be invoked when API deprecation notices are received.
408    ///
409    /// The callback is called whenever the SDK receives a response with the
410    /// `X-Shopify-API-Deprecated-Reason` header. This allows you to track
411    /// deprecated API usage in your monitoring systems.
412    ///
413    /// # Example
414    ///
415    /// ```rust
416    /// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
417    /// use std::sync::atomic::{AtomicUsize, Ordering};
418    /// use std::sync::Arc;
419    ///
420    /// let deprecation_count = Arc::new(AtomicUsize::new(0));
421    /// let count_clone = Arc::clone(&deprecation_count);
422    ///
423    /// let config = ShopifyConfig::builder()
424    ///     .api_key(ApiKey::new("key").unwrap())
425    ///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
426    ///     .on_deprecation(move |info| {
427    ///         count_clone.fetch_add(1, Ordering::SeqCst);
428    ///         eprintln!("Deprecated: {} at {:?}", info.reason, info.path);
429    ///     })
430    ///     .build()
431    ///     .unwrap();
432    /// ```
433    #[must_use]
434    pub fn on_deprecation<F>(mut self, callback: F) -> Self
435    where
436        F: Fn(&ApiDeprecationInfo) + Send + Sync + 'static,
437    {
438        self.deprecation_callback = Some(Arc::new(callback));
439        self
440    }
441
442    /// Builds the [`ShopifyConfig`], validating that required fields are set.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`ConfigError::MissingRequiredField`] if `api_key` or
447    /// `api_secret_key` are not set.
448    ///
449    /// Returns [`ConfigError::DeprecatedApiVersion`] if
450    /// [`reject_deprecated_versions(true)`](Self::reject_deprecated_versions) is set
451    /// and the configured API version is deprecated.
452    pub fn build(self) -> Result<ShopifyConfig, ConfigError> {
453        let api_key = self
454            .api_key
455            .ok_or(ConfigError::MissingRequiredField { field: "api_key" })?;
456        let api_secret_key = self
457            .api_secret_key
458            .ok_or(ConfigError::MissingRequiredField {
459                field: "api_secret_key",
460            })?;
461
462        let api_version = self.api_version.unwrap_or_else(ApiVersion::latest);
463
464        // Check for deprecated API version
465        if api_version.is_deprecated() {
466            if self.reject_deprecated_versions {
467                return Err(ConfigError::DeprecatedApiVersion {
468                    version: api_version.to_string(),
469                    latest: ApiVersion::latest().to_string(),
470                });
471            }
472            tracing::warn!(
473                version = %api_version,
474                latest = %ApiVersion::latest(),
475                "Using deprecated API version '{}'. Please upgrade to '{}' or a newer supported version.",
476                api_version,
477                ApiVersion::latest()
478            );
479        }
480
481        Ok(ShopifyConfig {
482            api_key,
483            api_secret_key,
484            old_api_secret_key: self.old_api_secret_key,
485            scopes: self.scopes.unwrap_or_default(),
486            host: self.host,
487            api_version,
488            is_embedded: self.is_embedded.unwrap_or(true),
489            user_agent_prefix: self.user_agent_prefix,
490            deprecation_callback: self.deprecation_callback,
491        })
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_builder_requires_api_key() {
501        let result = ShopifyConfigBuilder::new()
502            .api_secret_key(ApiSecretKey::new("secret").unwrap())
503            .build();
504
505        assert!(matches!(
506            result,
507            Err(ConfigError::MissingRequiredField { field: "api_key" })
508        ));
509    }
510
511    #[test]
512    fn test_builder_requires_api_secret_key() {
513        let result = ShopifyConfigBuilder::new()
514            .api_key(ApiKey::new("key").unwrap())
515            .build();
516
517        assert!(matches!(
518            result,
519            Err(ConfigError::MissingRequiredField {
520                field: "api_secret_key"
521            })
522        ));
523    }
524
525    #[test]
526    fn test_builder_provides_sensible_defaults() {
527        let config = ShopifyConfig::builder()
528            .api_key(ApiKey::new("key").unwrap())
529            .api_secret_key(ApiSecretKey::new("secret").unwrap())
530            .build()
531            .unwrap();
532
533        assert_eq!(config.api_version(), &ApiVersion::latest());
534        assert!(config.is_embedded());
535        assert!(config.scopes().is_empty());
536        assert!(config.host().is_none());
537        assert!(config.user_agent_prefix().is_none());
538        assert!(config.old_api_secret_key().is_none());
539    }
540
541    #[test]
542    fn test_config_is_send_sync() {
543        fn assert_send_sync<T: Send + Sync>() {}
544        assert_send_sync::<ShopifyConfig>();
545    }
546
547    #[test]
548    fn test_config_is_clone_and_debug() {
549        let config = ShopifyConfig::builder()
550            .api_key(ApiKey::new("key").unwrap())
551            .api_secret_key(ApiSecretKey::new("secret").unwrap())
552            .build()
553            .unwrap();
554
555        let cloned = config.clone();
556        assert_eq!(cloned.api_key(), config.api_key());
557
558        // Verify Debug works
559        let debug_str = format!("{:?}", config);
560        assert!(debug_str.contains("ShopifyConfig"));
561    }
562
563    #[test]
564    fn test_builder_with_all_optional_fields() {
565        let scopes: AuthScopes = "read_products,write_orders".parse().unwrap();
566        let host = HostUrl::new("https://myapp.example.com").unwrap();
567
568        let config = ShopifyConfig::builder()
569            .api_key(ApiKey::new("key").unwrap())
570            .api_secret_key(ApiSecretKey::new("secret").unwrap())
571            .scopes(scopes.clone())
572            .host(host.clone())
573            .api_version(ApiVersion::V2024_10)
574            .is_embedded(false)
575            .user_agent_prefix("MyApp/1.0")
576            .build()
577            .unwrap();
578
579        assert_eq!(config.api_version(), &ApiVersion::V2024_10);
580        assert!(!config.is_embedded());
581        assert_eq!(config.host(), Some(&host));
582        assert_eq!(config.user_agent_prefix(), Some("MyApp/1.0"));
583    }
584
585    #[test]
586    fn test_old_api_secret_key_configuration() {
587        let config = ShopifyConfig::builder()
588            .api_key(ApiKey::new("key").unwrap())
589            .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
590            .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
591            .build()
592            .unwrap();
593
594        assert!(config.old_api_secret_key().is_some());
595        assert_eq!(config.old_api_secret_key().unwrap().as_ref(), "old-secret");
596    }
597
598    #[test]
599    fn test_old_api_secret_key_is_optional() {
600        let config = ShopifyConfig::builder()
601            .api_key(ApiKey::new("key").unwrap())
602            .api_secret_key(ApiSecretKey::new("secret").unwrap())
603            .build()
604            .unwrap();
605
606        assert!(config.old_api_secret_key().is_none());
607    }
608
609    #[test]
610    fn test_build_allows_deprecated_version_by_default() {
611        // By default, deprecated versions should be allowed (with a warning)
612        let result = ShopifyConfig::builder()
613            .api_key(ApiKey::new("key").unwrap())
614            .api_secret_key(ApiSecretKey::new("secret").unwrap())
615            .api_version(ApiVersion::V2024_01)
616            .build();
617
618        assert!(result.is_ok());
619        assert_eq!(result.unwrap().api_version(), &ApiVersion::V2024_01);
620    }
621
622    #[test]
623    fn test_build_fails_when_reject_deprecated() {
624        let result = ShopifyConfig::builder()
625            .api_key(ApiKey::new("key").unwrap())
626            .api_secret_key(ApiSecretKey::new("secret").unwrap())
627            .api_version(ApiVersion::V2024_01)
628            .reject_deprecated_versions(true)
629            .build();
630
631        assert!(matches!(
632            result,
633            Err(ConfigError::DeprecatedApiVersion { version, latest })
634            if version == "2024-01" && latest == ApiVersion::latest().to_string()
635        ));
636    }
637
638    #[test]
639    fn test_build_succeeds_with_supported_version_when_reject_deprecated() {
640        let result = ShopifyConfig::builder()
641            .api_key(ApiKey::new("key").unwrap())
642            .api_secret_key(ApiSecretKey::new("secret").unwrap())
643            .api_version(ApiVersion::V2025_10)
644            .reject_deprecated_versions(true)
645            .build();
646
647        assert!(result.is_ok());
648    }
649
650    #[test]
651    fn test_build_succeeds_with_unstable_when_reject_deprecated() {
652        let result = ShopifyConfig::builder()
653            .api_key(ApiKey::new("key").unwrap())
654            .api_secret_key(ApiSecretKey::new("secret").unwrap())
655            .api_version(ApiVersion::Unstable)
656            .reject_deprecated_versions(true)
657            .build();
658
659        assert!(result.is_ok());
660    }
661}