cnctd_appstore 0.1.0

App Store Connect API client for TestFlight, builds, and submissions
Documentation
use anyhow::{anyhow, Result};
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};

pub(crate) const BASE_URL: &str = "https://api.appstoreconnect.apple.com/v1";

/// JWT claims for App Store Connect API auth
#[derive(Debug, Serialize)]
struct AscClaims {
    iss: String,
    iat: u64,
    exp: u64,
    aud: String,
}

/// Response wrapper from App Store Connect API
#[derive(Debug, Deserialize)]
pub(crate) struct AscResponse<T> {
    pub data: T,
}

/// List response wrapper (multiple items)
#[derive(Debug, Deserialize)]
pub(crate) struct AscListResponse<T> {
    pub data: Vec<T>,
}

/// Error response from App Store Connect API
#[derive(Debug, Deserialize)]
pub(crate) struct AscErrorResponse {
    pub errors: Vec<AscError>,
}

#[derive(Debug, Deserialize)]
#[allow(dead_code)]
pub(crate) struct AscError {
    pub status: String,
    pub title: String,
    pub detail: String,
}

/// App Store Connect client for API interactions
pub struct AppStoreConnectClient {
    pub(crate) issuer_id: String,
    pub(crate) key_id: String,
    pub(crate) encoding_key: EncodingKey,
    pub(crate) client: Client,
    pub(crate) default_beta_group_id: Option<String>,
}

impl AppStoreConnectClient {
    /// Create a new client from environment variables
    pub fn new() -> Result<Self> {
        let issuer_id = env::var("ASC_ISSUER_ID").map_err(|_| anyhow!("ASC_ISSUER_ID not set"))?;
        let key_id = env::var("ASC_KEY_ID").map_err(|_| anyhow!("ASC_KEY_ID not set"))?;
        let private_key = env::var("ASC_PRIVATE_KEY")
            .map_err(|_| anyhow!("ASC_PRIVATE_KEY not set"))?
            .replace("\\n", "\n");
        let default_beta_group_id = env::var("ASC_BETA_GROUP_ID").ok();

        let encoding_key = EncodingKey::from_ec_pem(private_key.as_bytes())
            .map_err(|e| anyhow!("Failed to parse ASC private key: {}", e))?;

        Ok(Self {
            issuer_id,
            key_id,
            encoding_key,
            client: Client::new(),
            default_beta_group_id,
        })
    }

    /// Create a new client from explicit credentials (no env vars needed)
    pub fn with_credentials(
        issuer_id: String,
        key_id: String,
        private_key_pem: &str,
        default_beta_group_id: Option<String>,
    ) -> Result<Self> {
        let encoding_key = EncodingKey::from_ec_pem(private_key_pem.as_bytes())
            .map_err(|e| anyhow!("Failed to parse ASC private key: {}", e))?;

        Ok(Self {
            issuer_id,
            key_id,
            encoding_key,
            client: Client::new(),
            default_beta_group_id,
        })
    }

    /// Generate a short-lived JWT for API authentication
    pub(crate) fn generate_token(&self) -> Result<String> {
        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map_err(|e| anyhow!("System time error: {}", e))?
            .as_secs();

        let claims = AscClaims {
            iss: self.issuer_id.clone(),
            iat: now,
            exp: now + 20 * 60, // 20 minutes (Apple max)
            aud: "appstoreconnect-v1".to_string(),
        };

        let mut header = Header::new(Algorithm::ES256);
        header.kid = Some(self.key_id.clone());

        encode(&header, &claims, &self.encoding_key)
            .map_err(|e| anyhow!("Failed to generate ASC JWT: {}", e))
    }

    /// Parse an error response body into a readable message
    pub(crate) fn parse_error(status: reqwest::StatusCode, body: &str) -> String {
        if let Ok(errors) = serde_json::from_str::<AscErrorResponse>(body) {
            errors
                .errors
                .first()
                .map(|e| format!("{}: {}", e.title, e.detail))
                .unwrap_or_else(|| format!("Unknown error ({})", status))
        } else {
            format!("API error ({}): {}", status, body)
        }
    }
}