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";
#[derive(Debug, Serialize)]
struct AscClaims {
iss: String,
iat: u64,
exp: u64,
aud: String,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AscResponse<T> {
pub data: T,
}
#[derive(Debug, Deserialize)]
pub(crate) struct AscListResponse<T> {
pub data: Vec<T>,
}
#[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,
}
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 {
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,
})
}
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,
})
}
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, 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))
}
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)
}
}
}