shopify-sdk 1.0.0

A Rust SDK for the Shopify API
Documentation
//! OAuth callback query parameter representation.
//!
//! This module provides the [`AuthQuery`] struct for representing the query
//! parameters received in an OAuth callback from Shopify.
//!
//! # Overview
//!
//! When a user authorizes your app, Shopify redirects them back to your
//! redirect URI with several query parameters including:
//! - `code`: The authorization code to exchange for an access token
//! - `shop`: The shop domain that authorized the app
//! - `state`: The state parameter for CSRF verification
//! - `timestamp`: When the authorization was granted
//! - `host`: Base64-encoded host for embedded apps
//! - `hmac`: HMAC signature for request verification
//!
//! # Example
//!
//! ```rust
//! use shopify_sdk::auth::oauth::AuthQuery;
//!
//! // Parse from incoming callback (typically via a web framework)
//! let query = AuthQuery::new(
//!     "authorization-code".to_string(),
//!     "example-shop.myshopify.com".to_string(),
//!     "1234567890".to_string(),
//!     "state-param".to_string(),
//!     "host-value".to_string(),
//!     "computed-hmac".to_string(),
//! );
//!
//! // The signable string is used for HMAC verification
//! let signable = query.to_signable_string();
//! ```

use serde::{Deserialize, Serialize};

/// OAuth callback query parameters from Shopify.
///
/// This struct represents all the query parameters that Shopify sends to your
/// redirect URI after a user authorizes your app. Use this with
/// [`validate_auth_callback`](crate::auth::oauth::validate_auth_callback) to
/// verify the callback and exchange the code for an access token.
///
/// # Fields
///
/// All fields are strings as received from the query string:
/// - `code`: Authorization code to exchange for tokens
/// - `shop`: Shop domain (e.g., "example.myshopify.com")
/// - `timestamp`: Unix timestamp of the authorization
/// - `state`: State parameter for CSRF verification
/// - `host`: Base64-encoded host for embedded apps
/// - `hmac`: HMAC signature for request validation
///
/// # Serialization
///
/// `AuthQuery` derives `Serialize` and `Deserialize` to facilitate parsing
/// from query strings using web frameworks like Axum or Actix-web.
///
/// # Example
///
/// ```rust
/// use shopify_sdk::auth::oauth::AuthQuery;
///
/// let query = AuthQuery::new(
///     "auth-code".to_string(),
///     "my-shop.myshopify.com".to_string(),
///     "1699999999".to_string(),
///     "csrf-state".to_string(),
///     "aG9zdC12YWx1ZQ==".to_string(),
///     "abc123def456".to_string(),
/// );
///
/// assert_eq!(query.code, "auth-code");
/// assert_eq!(query.shop, "my-shop.myshopify.com");
/// ```
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthQuery {
    /// The authorization code from Shopify.
    ///
    /// This code is exchanged for an access token via a POST request to
    /// `https://{shop}/admin/oauth/access_token`.
    pub code: String,

    /// The shop domain that authorized the app.
    ///
    /// This is the full domain (e.g., "example.myshopify.com").
    pub shop: String,

    /// Unix timestamp of when the authorization was granted.
    pub timestamp: String,

    /// The state parameter for CSRF protection.
    ///
    /// This should match the state generated by `begin_auth()`.
    pub state: String,

    /// Base64-encoded host for embedded apps.
    ///
    /// Used by Shopify's App Bridge for embedded app authentication.
    pub host: String,

    /// HMAC signature for verifying the request authenticity.
    ///
    /// This is computed by Shopify using your API secret key.
    pub hmac: String,
}

impl AuthQuery {
    /// Creates a new `AuthQuery` with all fields.
    ///
    /// # Arguments
    ///
    /// * `code` - Authorization code from Shopify
    /// * `shop` - Shop domain
    /// * `timestamp` - Unix timestamp string
    /// * `state` - CSRF state parameter
    /// * `host` - Base64-encoded host
    /// * `hmac` - HMAC signature
    ///
    /// # Example
    ///
    /// ```rust
    /// use shopify_sdk::auth::oauth::AuthQuery;
    ///
    /// let query = AuthQuery::new(
    ///     "code123".to_string(),
    ///     "shop.myshopify.com".to_string(),
    ///     "1700000000".to_string(),
    ///     "state456".to_string(),
    ///     "host789".to_string(),
    ///     "hmac012".to_string(),
    /// );
    /// ```
    #[must_use]
    pub const fn new(
        code: String,
        shop: String,
        timestamp: String,
        state: String,
        host: String,
        hmac: String,
    ) -> Self {
        Self {
            code,
            shop,
            timestamp,
            state,
            host,
            hmac,
        }
    }

    /// Converts the query parameters to a signable string for HMAC verification.
    ///
    /// This produces a string suitable for HMAC computation by:
    /// 1. Excluding the `hmac` parameter
    /// 2. Sorting remaining parameters alphabetically by key
    /// 3. URI-encoding each value
    /// 4. Joining as `key=value&key=value` format
    ///
    /// # Returns
    ///
    /// A string ready for HMAC-SHA256 computation.
    ///
    /// # Example
    ///
    /// ```rust
    /// use shopify_sdk::auth::oauth::AuthQuery;
    ///
    /// let query = AuthQuery::new(
    ///     "code123".to_string(),
    ///     "shop.myshopify.com".to_string(),
    ///     "1700000000".to_string(),
    ///     "state456".to_string(),
    ///     "host789".to_string(),
    ///     "hmac-ignored".to_string(),
    /// );
    ///
    /// let signable = query.to_signable_string();
    /// // Parameters are sorted alphabetically: code, host, shop, state, timestamp
    /// assert!(signable.starts_with("code="));
    /// assert!(signable.contains("&shop="));
    /// assert!(!signable.contains("hmac")); // hmac is excluded
    /// ```
    #[must_use]
    pub fn to_signable_string(&self) -> String {
        // Collect all parameters except hmac
        let mut params: Vec<(&str, &str)> = vec![
            ("code", &self.code),
            ("host", &self.host),
            ("shop", &self.shop),
            ("state", &self.state),
            ("timestamp", &self.timestamp),
        ];

        // Sort alphabetically by key (already sorted, but being explicit)
        params.sort_by_key(|(key, _)| *key);

        // Build the signable string with URI encoding
        params
            .iter()
            .map(|(key, value)| format!("{}={}", key, urlencoding::encode(value)))
            .collect::<Vec<_>>()
            .join("&")
    }
}

// Verify AuthQuery is Send + Sync at compile time
const _: fn() = || {
    const fn assert_send_sync<T: Send + Sync>() {}
    assert_send_sync::<AuthQuery>();
};

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_auth_query_creation_with_all_fields() {
        let query = AuthQuery::new(
            "abc123".to_string(),
            "my-shop.myshopify.com".to_string(),
            "1699999999".to_string(),
            "state-param".to_string(),
            "host-base64".to_string(),
            "hmac-value".to_string(),
        );

        assert_eq!(query.code, "abc123");
        assert_eq!(query.shop, "my-shop.myshopify.com");
        assert_eq!(query.timestamp, "1699999999");
        assert_eq!(query.state, "state-param");
        assert_eq!(query.host, "host-base64");
        assert_eq!(query.hmac, "hmac-value");
    }

    #[test]
    fn test_to_signable_string_sorts_alphabetically() {
        let query = AuthQuery::new(
            "z-code".to_string(),
            "a-shop.myshopify.com".to_string(),
            "1234567890".to_string(),
            "m-state".to_string(),
            "b-host".to_string(),
            "ignored-hmac".to_string(),
        );

        let signable = query.to_signable_string();

        // Should be sorted: code, host, shop, state, timestamp
        let parts: Vec<&str> = signable.split('&').collect();
        assert_eq!(parts.len(), 5);
        assert!(parts[0].starts_with("code="));
        assert!(parts[1].starts_with("host="));
        assert!(parts[2].starts_with("shop="));
        assert!(parts[3].starts_with("state="));
        assert!(parts[4].starts_with("timestamp="));
    }

    #[test]
    fn test_to_signable_string_uri_encodes_values() {
        let query = AuthQuery::new(
            "code with spaces".to_string(),
            "shop.myshopify.com".to_string(),
            "1234567890".to_string(),
            "state=special&chars".to_string(),
            "host+plus".to_string(),
            "hmac".to_string(),
        );

        let signable = query.to_signable_string();

        // Spaces should be encoded as %20
        assert!(signable.contains("code%20with%20spaces"));
        // Special characters should be encoded
        assert!(signable.contains("state%3Dspecial%26chars"));
        // Plus should be encoded as %2B
        assert!(signable.contains("host%2Bplus"));
    }

    #[test]
    fn test_to_signable_string_excludes_hmac() {
        let query = AuthQuery::new(
            "code".to_string(),
            "shop.myshopify.com".to_string(),
            "12345".to_string(),
            "state".to_string(),
            "host".to_string(),
            "this-should-not-appear".to_string(),
        );

        let signable = query.to_signable_string();

        assert!(!signable.contains("hmac"));
        assert!(!signable.contains("this-should-not-appear"));
    }

    #[test]
    fn test_auth_query_fields_match_expected_structure() {
        // Verify the struct has all expected public fields
        let query = AuthQuery {
            code: "c".to_string(),
            shop: "s".to_string(),
            timestamp: "t".to_string(),
            state: "st".to_string(),
            host: "h".to_string(),
            hmac: "hm".to_string(),
        };

        assert_eq!(query.code, "c");
        assert_eq!(query.shop, "s");
        assert_eq!(query.timestamp, "t");
        assert_eq!(query.state, "st");
        assert_eq!(query.host, "h");
        assert_eq!(query.hmac, "hm");
    }

    #[test]
    fn test_auth_query_serialization() {
        let query = AuthQuery::new(
            "code".to_string(),
            "shop.myshopify.com".to_string(),
            "12345".to_string(),
            "state".to_string(),
            "host".to_string(),
            "hmac".to_string(),
        );

        let json = serde_json::to_string(&query).unwrap();
        assert!(json.contains("\"code\":\"code\""));
        assert!(json.contains("\"shop\":\"shop.myshopify.com\""));
    }

    #[test]
    fn test_auth_query_deserialization() {
        let json = r#"{
            "code": "auth-code",
            "shop": "test.myshopify.com",
            "timestamp": "1700000000",
            "state": "test-state",
            "host": "test-host",
            "hmac": "test-hmac"
        }"#;

        let query: AuthQuery = serde_json::from_str(json).unwrap();
        assert_eq!(query.code, "auth-code");
        assert_eq!(query.shop, "test.myshopify.com");
        assert_eq!(query.hmac, "test-hmac");
    }

    #[test]
    fn test_auth_query_clone() {
        let query = AuthQuery::new(
            "code".to_string(),
            "shop".to_string(),
            "time".to_string(),
            "state".to_string(),
            "host".to_string(),
            "hmac".to_string(),
        );

        let cloned = query.clone();
        assert_eq!(query, cloned);
    }

    #[test]
    fn test_auth_query_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<AuthQuery>();
    }

    #[test]
    fn test_signable_string_format() {
        let query = AuthQuery::new(
            "0907a61c0c8d55e99db179b68161bc00".to_string(),
            "some-shop.myshopify.com".to_string(),
            "1337178173".to_string(),
            "123".to_string(),
            "dGVzdC5teXNob3BpZnkuY29tL2FkbWlu".to_string(),
            "expected-hmac".to_string(),
        );

        let signable = query.to_signable_string();

        // Verify format: key=value&key=value
        assert!(signable.contains("="));
        assert!(signable.contains("&"));

        // Verify all expected keys are present
        assert!(signable.contains("code="));
        assert!(signable.contains("host="));
        assert!(signable.contains("shop="));
        assert!(signable.contains("state="));
        assert!(signable.contains("timestamp="));
    }
}