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}