shopify-client 1.2.0

Type-safe, async Rust client for the Shopify Admin and Storefront APIs
Documentation
pub mod types;

pub use types::AccessTokenResponse;

use crate::common::http::http_client;
use crate::common::types::APIError;

const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
const ID_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:id_token";
const OFFLINE_ACCESS_TOKEN_TYPE: &str = "urn:shopify:params:oauth:token-type:offline-access-token";

pub async fn exchange_session_token(
    shop_url: &str,
    id_token: &str,
    client_id: &str,
    client_secret: &str,
) -> Result<AccessTokenResponse, APIError> {
    let body = serde_json::json!({
        "client_id": client_id,
        "client_secret": client_secret,
        "grant_type": TOKEN_EXCHANGE_GRANT_TYPE,
        "subject_token": id_token,
        "subject_token_type": ID_TOKEN_TYPE,
        "requested_token_type": OFFLINE_ACCESS_TOKEN_TYPE,
    });

    request_access_token(shop_url, &body).await
}

pub async fn exchange_code(
    shop_url: &str,
    code: &str,
    client_id: &str,
    client_secret: &str,
) -> Result<AccessTokenResponse, APIError> {
    let body = serde_json::json!({
        "client_id": client_id,
        "client_secret": client_secret,
        "code": code,
    });

    request_access_token(shop_url, &body).await
}

async fn request_access_token(
    shop_url: &str,
    body: &serde_json::Value,
) -> Result<AccessTokenResponse, APIError> {
    let endpoint = format!(
        "{}/admin/oauth/access_token",
        shop_url.trim_end_matches('/')
    );

    let response = http_client()
        .post(&endpoint)
        .header("Content-Type", "application/json")
        .json(body)
        .send()
        .await
        .map_err(|_| APIError::NetworkError)?;

    let status = response.status();
    let response_text = response.text().await.map_err(|_| APIError::FailedToParse)?;

    if !status.is_success() {
        return Err(APIError::ServerError {
            errors: format!("{}: {}", status, response_text),
        });
    }

    serde_json::from_str::<AccessTokenResponse>(&response_text).map_err(|_| APIError::FailedToParse)
}

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

    #[test]
    fn deserialize_access_token_response_with_scope() {
        let json = r#"{"access_token":"shpat_abc123","scope":"read_products,write_products"}"#;
        let resp: AccessTokenResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.access_token, "shpat_abc123");
        assert_eq!(resp.scope.as_deref(), Some("read_products,write_products"));
    }

    #[test]
    fn deserialize_access_token_response_without_scope() {
        let json = r#"{"access_token":"shpat_abc123"}"#;
        let resp: AccessTokenResponse = serde_json::from_str(json).unwrap();
        assert_eq!(resp.access_token, "shpat_abc123");
        assert_eq!(resp.scope, None);
    }
}