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}