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_api::{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("reject_deprecated_versions", &self.reject_deprecated_versions)
285            .field(
286                "deprecation_callback",
287                &self.deprecation_callback.as_ref().map(|_| "<callback>"),
288            )
289            .finish()
290    }
291}
292
293impl ShopifyConfigBuilder {
294    /// Creates a new builder with default values.
295    #[must_use]
296    pub fn new() -> Self {
297        Self::default()
298    }
299
300    /// Sets the API key (required).
301    #[must_use]
302    pub fn api_key(mut self, key: ApiKey) -> Self {
303        self.api_key = Some(key);
304        self
305    }
306
307    /// Sets the API secret key (required).
308    #[must_use]
309    pub fn api_secret_key(mut self, key: ApiSecretKey) -> Self {
310        self.api_secret_key = Some(key);
311        self
312    }
313
314    /// Sets the old API secret key for key rotation support.
315    ///
316    /// When validating OAuth HMAC signatures, the SDK will try the primary
317    /// secret key first, then fall back to this old key if validation fails.
318    /// This allows in-flight OAuth flows to complete during key rotation.
319    ///
320    /// # Example
321    ///
322    /// ```rust
323    /// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
324    ///
325    /// // During key rotation, configure both keys
326    /// let config = ShopifyConfig::builder()
327    ///     .api_key(ApiKey::new("key").unwrap())
328    ///     .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
329    ///     .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
330    ///     .build()
331    ///     .unwrap();
332    /// ```
333    #[must_use]
334    pub fn old_api_secret_key(mut self, key: ApiSecretKey) -> Self {
335        self.old_api_secret_key = Some(key);
336        self
337    }
338
339    /// Sets the OAuth scopes.
340    #[must_use]
341    pub fn scopes(mut self, scopes: AuthScopes) -> Self {
342        self.scopes = Some(scopes);
343        self
344    }
345
346    /// Sets the host URL.
347    #[must_use]
348    pub fn host(mut self, host: HostUrl) -> Self {
349        self.host = Some(host);
350        self
351    }
352
353    /// Sets the API version.
354    #[must_use]
355    pub fn api_version(mut self, version: ApiVersion) -> Self {
356        self.api_version = Some(version);
357        self
358    }
359
360    /// Sets whether the app is embedded in the Shopify admin.
361    #[must_use]
362    pub const fn is_embedded(mut self, embedded: bool) -> Self {
363        self.is_embedded = Some(embedded);
364        self
365    }
366
367    /// Sets the user agent prefix for HTTP requests.
368    #[must_use]
369    pub fn user_agent_prefix(mut self, prefix: impl Into<String>) -> Self {
370        self.user_agent_prefix = Some(prefix.into());
371        self
372    }
373
374    /// Sets whether to reject deprecated API versions.
375    ///
376    /// When `true`, [`build()`](Self::build) will return a
377    /// [`ConfigError::DeprecatedApiVersion`] error if the configured API version
378    /// is past Shopify's support window.
379    ///
380    /// When `false` (the default), deprecated versions will log a warning via
381    /// `tracing` but the configuration will still be created.
382    ///
383    /// # Example
384    ///
385    /// ```rust
386    /// use shopify_api::{ShopifyConfig, ApiKey, ApiSecretKey, ApiVersion, ConfigError};
387    ///
388    /// // This will fail because V2024_01 is deprecated
389    /// let result = ShopifyConfig::builder()
390    ///     .api_key(ApiKey::new("key").unwrap())
391    ///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
392    ///     .api_version(ApiVersion::V2024_01)
393    ///     .reject_deprecated_versions(true)
394    ///     .build();
395    ///
396    /// assert!(matches!(result, Err(ConfigError::DeprecatedApiVersion { .. })));
397    /// ```
398    #[must_use]
399    pub const fn reject_deprecated_versions(mut self, reject: bool) -> Self {
400        self.reject_deprecated_versions = reject;
401        self
402    }
403
404    /// Sets a callback to be invoked when API deprecation notices are received.
405    ///
406    /// The callback is called whenever the SDK receives a response with the
407    /// `X-Shopify-API-Deprecated-Reason` header. This allows you to track
408    /// deprecated API usage in your monitoring systems.
409    ///
410    /// # Example
411    ///
412    /// ```rust
413    /// use shopify_api::{ShopifyConfig, ApiKey, ApiSecretKey};
414    /// use std::sync::atomic::{AtomicUsize, Ordering};
415    /// use std::sync::Arc;
416    ///
417    /// let deprecation_count = Arc::new(AtomicUsize::new(0));
418    /// let count_clone = Arc::clone(&deprecation_count);
419    ///
420    /// let config = ShopifyConfig::builder()
421    ///     .api_key(ApiKey::new("key").unwrap())
422    ///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
423    ///     .on_deprecation(move |info| {
424    ///         count_clone.fetch_add(1, Ordering::SeqCst);
425    ///         eprintln!("Deprecated: {} at {:?}", info.reason, info.path);
426    ///     })
427    ///     .build()
428    ///     .unwrap();
429    /// ```
430    #[must_use]
431    pub fn on_deprecation<F>(mut self, callback: F) -> Self
432    where
433        F: Fn(&ApiDeprecationInfo) + Send + Sync + 'static,
434    {
435        self.deprecation_callback = Some(Arc::new(callback));
436        self
437    }
438
439    /// Builds the [`ShopifyConfig`], validating that required fields are set.
440    ///
441    /// # Errors
442    ///
443    /// Returns [`ConfigError::MissingRequiredField`] if `api_key` or
444    /// `api_secret_key` are not set.
445    ///
446    /// Returns [`ConfigError::DeprecatedApiVersion`] if
447    /// [`reject_deprecated_versions(true)`](Self::reject_deprecated_versions) is set
448    /// and the configured API version is deprecated.
449    pub fn build(self) -> Result<ShopifyConfig, ConfigError> {
450        let api_key = self
451            .api_key
452            .ok_or(ConfigError::MissingRequiredField { field: "api_key" })?;
453        let api_secret_key = self
454            .api_secret_key
455            .ok_or(ConfigError::MissingRequiredField {
456                field: "api_secret_key",
457            })?;
458
459        let api_version = self.api_version.unwrap_or_else(ApiVersion::latest);
460
461        // Check for deprecated API version
462        if api_version.is_deprecated() {
463            if self.reject_deprecated_versions {
464                return Err(ConfigError::DeprecatedApiVersion {
465                    version: api_version.to_string(),
466                    latest: ApiVersion::latest().to_string(),
467                });
468            }
469            tracing::warn!(
470                version = %api_version,
471                latest = %ApiVersion::latest(),
472                "Using deprecated API version '{}'. Please upgrade to '{}' or a newer supported version.",
473                api_version,
474                ApiVersion::latest()
475            );
476        }
477
478        Ok(ShopifyConfig {
479            api_key,
480            api_secret_key,
481            old_api_secret_key: self.old_api_secret_key,
482            scopes: self.scopes.unwrap_or_default(),
483            host: self.host,
484            api_version,
485            is_embedded: self.is_embedded.unwrap_or(true),
486            user_agent_prefix: self.user_agent_prefix,
487            deprecation_callback: self.deprecation_callback,
488        })
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_builder_requires_api_key() {
498        let result = ShopifyConfigBuilder::new()
499            .api_secret_key(ApiSecretKey::new("secret").unwrap())
500            .build();
501
502        assert!(matches!(
503            result,
504            Err(ConfigError::MissingRequiredField { field: "api_key" })
505        ));
506    }
507
508    #[test]
509    fn test_builder_requires_api_secret_key() {
510        let result = ShopifyConfigBuilder::new()
511            .api_key(ApiKey::new("key").unwrap())
512            .build();
513
514        assert!(matches!(
515            result,
516            Err(ConfigError::MissingRequiredField {
517                field: "api_secret_key"
518            })
519        ));
520    }
521
522    #[test]
523    fn test_builder_provides_sensible_defaults() {
524        let config = ShopifyConfig::builder()
525            .api_key(ApiKey::new("key").unwrap())
526            .api_secret_key(ApiSecretKey::new("secret").unwrap())
527            .build()
528            .unwrap();
529
530        assert_eq!(config.api_version(), &ApiVersion::latest());
531        assert!(config.is_embedded());
532        assert!(config.scopes().is_empty());
533        assert!(config.host().is_none());
534        assert!(config.user_agent_prefix().is_none());
535        assert!(config.old_api_secret_key().is_none());
536    }
537
538    #[test]
539    fn test_config_is_send_sync() {
540        fn assert_send_sync<T: Send + Sync>() {}
541        assert_send_sync::<ShopifyConfig>();
542    }
543
544    #[test]
545    fn test_config_is_clone_and_debug() {
546        let config = ShopifyConfig::builder()
547            .api_key(ApiKey::new("key").unwrap())
548            .api_secret_key(ApiSecretKey::new("secret").unwrap())
549            .build()
550            .unwrap();
551
552        let cloned = config.clone();
553        assert_eq!(cloned.api_key(), config.api_key());
554
555        // Verify Debug works
556        let debug_str = format!("{:?}", config);
557        assert!(debug_str.contains("ShopifyConfig"));
558    }
559
560    #[test]
561    fn test_builder_with_all_optional_fields() {
562        let scopes: AuthScopes = "read_products,write_orders".parse().unwrap();
563        let host = HostUrl::new("https://myapp.example.com").unwrap();
564
565        let config = ShopifyConfig::builder()
566            .api_key(ApiKey::new("key").unwrap())
567            .api_secret_key(ApiSecretKey::new("secret").unwrap())
568            .scopes(scopes.clone())
569            .host(host.clone())
570            .api_version(ApiVersion::V2024_10)
571            .is_embedded(false)
572            .user_agent_prefix("MyApp/1.0")
573            .build()
574            .unwrap();
575
576        assert_eq!(config.api_version(), &ApiVersion::V2024_10);
577        assert!(!config.is_embedded());
578        assert_eq!(config.host(), Some(&host));
579        assert_eq!(config.user_agent_prefix(), Some("MyApp/1.0"));
580    }
581
582    #[test]
583    fn test_old_api_secret_key_configuration() {
584        let config = ShopifyConfig::builder()
585            .api_key(ApiKey::new("key").unwrap())
586            .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
587            .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
588            .build()
589            .unwrap();
590
591        assert!(config.old_api_secret_key().is_some());
592        assert_eq!(config.old_api_secret_key().unwrap().as_ref(), "old-secret");
593    }
594
595    #[test]
596    fn test_old_api_secret_key_is_optional() {
597        let config = ShopifyConfig::builder()
598            .api_key(ApiKey::new("key").unwrap())
599            .api_secret_key(ApiSecretKey::new("secret").unwrap())
600            .build()
601            .unwrap();
602
603        assert!(config.old_api_secret_key().is_none());
604    }
605
606    #[test]
607    fn test_build_allows_deprecated_version_by_default() {
608        // By default, deprecated versions should be allowed (with a warning)
609        let result = ShopifyConfig::builder()
610            .api_key(ApiKey::new("key").unwrap())
611            .api_secret_key(ApiSecretKey::new("secret").unwrap())
612            .api_version(ApiVersion::V2024_01)
613            .build();
614
615        assert!(result.is_ok());
616        assert_eq!(result.unwrap().api_version(), &ApiVersion::V2024_01);
617    }
618
619    #[test]
620    fn test_build_fails_when_reject_deprecated() {
621        let result = ShopifyConfig::builder()
622            .api_key(ApiKey::new("key").unwrap())
623            .api_secret_key(ApiSecretKey::new("secret").unwrap())
624            .api_version(ApiVersion::V2024_01)
625            .reject_deprecated_versions(true)
626            .build();
627
628        assert!(matches!(
629            result,
630            Err(ConfigError::DeprecatedApiVersion { version, latest })
631            if version == "2024-01" && latest == ApiVersion::latest().to_string()
632        ));
633    }
634
635    #[test]
636    fn test_build_succeeds_with_supported_version_when_reject_deprecated() {
637        let result = ShopifyConfig::builder()
638            .api_key(ApiKey::new("key").unwrap())
639            .api_secret_key(ApiSecretKey::new("secret").unwrap())
640            .api_version(ApiVersion::V2025_10)
641            .reject_deprecated_versions(true)
642            .build();
643
644        assert!(result.is_ok());
645    }
646
647    #[test]
648    fn test_build_succeeds_with_unstable_when_reject_deprecated() {
649        let result = ShopifyConfig::builder()
650            .api_key(ApiKey::new("key").unwrap())
651            .api_secret_key(ApiSecretKey::new("secret").unwrap())
652            .api_version(ApiVersion::Unstable)
653            .reject_deprecated_versions(true)
654            .build();
655
656        assert!(result.is_ok());
657    }
658}