diff --git a/.DS_Store b/.DS_Store index dba82d6..9af3200 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68f9768..9c51e21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - "--no-default-features" - "--no-default-features --features auth" - "--no-default-features --features cloudkit" + - "--no-default-features --features appstore" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -49,6 +50,7 @@ jobs: - "--no-default-features" - "--no-default-features --features auth" - "--no-default-features --features cloudkit" + - "--no-default-features --features appstore" steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -66,3 +68,4 @@ jobs: - run: cargo check --no-default-features - run: cargo check --no-default-features --features auth - run: cargo check --no-default-features --features cloudkit + - run: cargo check --no-default-features --features appstore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..39277c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + name: Validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all -- --check + - run: cargo clippy --all-features --tests -- -D warnings + - run: cargo test --all-features + + publish: + name: Publish to crates.io + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + github-release: + name: GitHub Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Generate release notes + id: notes + run: | + TAG=${GITHUB_REF#refs/tags/} + PREV_TAG=$(git tag --sort=-v:refname | grep -A1 "^${TAG}$" | tail -1) + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$TAG" ]; then + echo "body=Initial release" >> "$GITHUB_OUTPUT" + else + NOTES=$(git log "${PREV_TAG}..${TAG}" --pretty=format:"- %s" --no-merges) + EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) + echo "body<<$EOF" >> "$GITHUB_OUTPUT" + echo "$NOTES" >> "$GITHUB_OUTPUT" + echo "$EOF" >> "$GITHUB_OUTPUT" + fi + - uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.notes.outputs.body }} + generate_release_notes: false diff --git a/Cargo.lock b/Cargo.lock index 8852751..b4aee31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,7 @@ dependencies = [ "sha2", "tokio", "url", + "x509-cert", ] [[package]] @@ -205,10 +206,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.3" @@ -322,6 +336,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd99930f64d146689264c637b5af2f0233a933bef0d8570e2526bf9e083192d" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "fnv" version = "1.0.7" @@ -1569,6 +1589,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio" version = "1.47.1" @@ -2099,6 +2140,18 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yoke" version = "0.8.0" @@ -2149,6 +2202,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 2f12177..9142d54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,17 +2,18 @@ name = "apple" version = "0.2.0" edition = "2024" -description = "A Rust library for Apple Sign-In authentication and CloudKit Web Services" +description = "A Rust library for Apple Sign-In authentication, CloudKit Web Services, and App Store Server API" license = "MIT" repository = "https://github.com/meszmate/apple-rs" readme = "README.md" -keywords = ["apple", "authentication", "cloudkit", "sign-in", "jwt"] +keywords = ["apple", "authentication", "cloudkit", "appstore", "jwt"] categories = ["authentication", "api-bindings"] [features] default = ["auth", "cloudkit"] auth = [] cloudkit = ["sha2", "chrono"] +appstore = ["chrono", "x509-cert"] [dependencies] reqwest = { version = "0.12", features = ["json"] } @@ -26,6 +27,7 @@ serde_json = "1.0" futures = "0.3" sha2 = { version = "0.10", optional = true } chrono = { version = "0.4", optional = true } +x509-cert = { version = "0.2", optional = true } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/README.md b/README.md index c5f389b..329f5c7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,22 @@ # apple-rs -A Rust library for Apple Sign-In authentication and CloudKit Web Services. +A Rust library for Apple Sign-In authentication, CloudKit Web Services, and the App Store Server API. ## Features - **Apple Sign-In** — Validate authorization codes, refresh tokens, generate authorization URLs, and parse user info from JWT ID tokens. -- **CloudKit Web Services** — Full CRUD for records, zones, subscriptions, change tracking, user discovery, asset uploads, and APNs token management. -- **Shared key management** — A single ECDSA P-256 key pair can be shared between Sign-In and CloudKit. +- **CloudKit Web Services** — Full CRUD for records, zones, subscriptions, change tracking, user discovery, asset uploads, APNs token management, and push notification parsing. +- **App Store Server API** — Transaction history, subscription management, consumption reporting, refund lookup, server notification handling (V1 & V2), JWS signed data verification with X.509 chain validation, and retention messaging. +- **Shared key management** — A single ECDSA P-256 key pair can be shared across Sign-In, CloudKit, and App Store. - **Async/await** — All network operations are async. ## Cargo Features -| Feature | Default | Description | -|------------|---------|--------------------------------------| -| `auth` | Yes | Apple Sign-In authentication | -| `cloudkit` | Yes | CloudKit Web Services (adds `sha2`, `chrono`) | +| Feature | Default | Description | +|------------|---------|-------------------------------------------------------| +| `auth` | Yes | Apple Sign-In authentication | +| `cloudkit` | Yes | CloudKit Web Services (adds `sha2`, `chrono`) | +| `appstore` | No | App Store Server API (adds `chrono`, `x509-cert`) | ```toml [dependencies] @@ -23,6 +25,7 @@ apple = "0.2.0" # Or pick features: # apple = { version = "0.2.0", default-features = false, features = ["auth"] } # apple = { version = "0.2.0", default-features = false, features = ["cloudkit"] } +# apple = { version = "0.2.0", features = ["appstore"] } ``` ## Apple Sign-In @@ -189,6 +192,13 @@ let subscription = Subscription { should_badge: Some(true), should_send_content_available: Some(true), should_send_mutable_content: None, + collapse_id_key: None, + desired_keys: None, + category: None, + title_localization_key: None, + title_localization_args: None, + subtitle_localization_key: None, + subtitle_localization_args: None, }), zone_id: Some(ZoneID::new("MyZone")), }; @@ -198,6 +208,53 @@ let subs = client.list_subscriptions(&DatabaseType::Private).await?; client.delete_subscription(&DatabaseType::Private, "my-sub", SubscriptionType::Zone).await?; ``` +### CloudKit Push Notification Parsing + +Parse incoming APNs payloads containing CloudKit notification data: + +```rust +use apple::cloudkit::notifications::{parse_notification, CKNotification}; + +let apns_json = r#"{ + "aps": { "content-available": 1 }, + "ck": { + "cid": "iCloud.com.company.app", + "nid": "notification-uuid", + "rid": { "recordName": "record-1", "zoneID": { "zoneName": "MyZone" } }, + "rt": "MyRecordType", + "fo": 1, + "dbs": 2 + } +}"#; + +let notification = parse_notification(apns_json)?; +match notification { + CKNotification::Query(query) => { + println!("Record type: {:?}", query.record_type); + println!("Reason: {:?}", query.reason); // RecordCreated, RecordUpdated, RecordDeleted + } + CKNotification::RecordZone(zone) => { + println!("Zone changed: {:?}", zone.zone_id); + } + CKNotification::Database(db) => { + println!("Database scope: {:?}", db.database_scope); + } +} +``` + +### WebCourier Long-Polling + +Poll for CloudKit notifications using Apple's webcourier service: + +```rust +let notifications = client.poll_notifications("https://api.apple-cloudkit.com/...webcourier-url...") + .await?; + +for notification in notifications { + println!("{:?}", notification); +} +``` + ### Change Tracking ```rust @@ -252,14 +309,327 @@ if let Some(token) = upload.tokens.first() { } ``` +## App Store Server API + +### Setup + +```rust +use std::sync::Arc; +use apple::signing::AppleKeyPair; +use apple::appstore::{AppStoreServerClient, AppStoreConfig, AppStoreEnvironment}; + +let key_pair = AppleKeyPair::from_file("your-key-id", "path/to/SubscriptionKey.p8")?; + +let client = AppStoreServerClient::new(AppStoreConfig { + issuer_id: "your-issuer-id".to_string(), + bundle_id: "com.company.app".to_string(), + key_pair, + environment: AppStoreEnvironment::Production, +})?; +``` + +### Transaction History + +```rust +use apple::appstore::TransactionHistoryRequest; + +// Get full transaction history +let history = client.get_transaction_history("transaction-id", None, None).await?; +for signed_tx in &history.signed_transactions { + println!("Signed transaction: {}", signed_tx); +} + +// Paginate with revision +if history.has_more { + let next = client.get_transaction_history( + "transaction-id", + Some(&history.revision), + None, + ).await?; +} + +// With filters +let request = TransactionHistoryRequest { + sort: Some(apple::appstore::Order::DESCENDING), + product_type: Some(vec![apple::appstore::TransactionHistoryProductType::AUTO_RENEWABLE]), + ..Default::default() +}; +let filtered = client.get_transaction_history("transaction-id", None, Some(&request)).await?; +``` + +### Transaction Info + +```rust +let info = client.get_transaction_info("transaction-id").await?; +println!("Signed info: {}", info.signed_transaction_info); +``` + +### Order Lookup + +```rust +let order = client.look_up_order_id("order-id").await?; +println!("Status: {:?}", order.status); // Valid or Invalid +``` + +### Subscription Status + +```rust +let status = client.get_all_subscription_statuses("transaction-id").await?; +for group in &status.data { + println!("Group: {}", group.subscription_group_identifier); + for item in &group.last_transactions { + println!(" Status: {:?}", item.status); + } +} +``` + +### Extend Subscription Renewal Date + +```rust +use apple::appstore::{ExtendRenewalDateRequest, ExtendReasonCode}; + +let request = ExtendRenewalDateRequest { + extend_by_days: 30, + extend_reason_code: ExtendReasonCode::CustomerSatisfaction, + request_identifier: "unique-request-id".to_string(), +}; + +let response = client.extend_renewal_date("original-transaction-id", &request).await?; +println!("New effective date: {}", response.effective_date); +``` + +### Mass Extend Renewal Dates + +```rust +use apple::appstore::{MassExtendRenewalDateRequest, ExtendReasonCode}; + +let request = MassExtendRenewalDateRequest { + extend_by_days: 7, + extend_reason_code: ExtendReasonCode::ServiceIssueOrOutage, + request_identifier: "mass-extend-id".to_string(), + storefront_country_codes: None, + product_id: "com.company.app.subscription".to_string(), +}; + +let response = client.mass_extend_renewal_date(&request).await?; + +// Check status later +let status = client.get_mass_extension_status( + "com.company.app.subscription", + &response.request_identifier, +).await?; +println!("Complete: {}", status.complete); +``` + +### Consumption Data + +```rust +use apple::appstore::*; + +let request = ConsumptionRequest { + account_tenure: AccountTenure::ThirtyToNinetyDays, + app_account_token: "user-token".to_string(), + consumption_status: ConsumptionStatus::NotConsumed, + customer_consented: true, + delivery_status: Some(DeliveryStatus::DeliveredAndWorking), + lifetime_dollars_purchased: Some(LifetimeDollarsPurchased::OneToFortyNine), + lifetime_dollars_refunded: Some(LifetimeDollarsRefunded::Zero), + platform: Platform::Apple, + play_time: Some(PlayTime::OneToSixHours), + refund_preference: Some(RefundPreference::DECLINE), + sample_content_provided: Some(false), + user_status: UserStatus::Active, +}; + +client.send_consumption_data("transaction-id", &request).await?; +``` + +### Refund History + +```rust +let refunds = client.get_refund_history("transaction-id", None).await?; +for signed_tx in &refunds.signed_transactions { + println!("Refunded: {}", signed_tx); +} + +// Paginate +if refunds.has_more { + let next = client.get_refund_history("transaction-id", Some(&refunds.revision)).await?; +} +``` + +### Test Notifications + +```rust +// Request a test notification +let response = client.request_test_notification().await?; + +// Check the status +let status = client.get_test_notification_status(&response.test_notification_token).await?; +println!("Payload: {}", status.signed_payload); +``` + +### Notification History + +```rust +use apple::appstore::{NotificationHistoryRequest, NotificationTypeV2}; + +let request = NotificationHistoryRequest { + start_date: 1700000000000, + end_date: 1700100000000, + notification_type: Some(NotificationTypeV2::DID_RENEW), + notification_subtype: None, + only_failures: Some(false), + transaction_id: None, +}; + +let history = client.get_notification_history(&request, None).await?; +for item in &history.notification_history { + println!("Payload: {}", item.signed_payload); +} +``` + +### Retention Messaging + +```rust +use apple::appstore::{UploadMessageRequest, DefaultMessageRequest}; + +// Upload an image for retention messaging +let image_data = std::fs::read("banner.png").unwrap(); +let image = client.upload_image("my-image-id", image_data).await?; + +// List images +let images = client.get_image_list().await?; + +// Upload a message +let message = UploadMessageRequest { + title: "We miss you!".to_string(), + body: "Come back and check out new features.".to_string(), + image_identifier: Some("my-image-id".to_string()), +}; +let msg = client.upload_message("my-message-id", &message).await?; + +// List messages +let messages = client.get_message_list().await?; + +// Configure a default message for a product +let default_msg = DefaultMessageRequest { + title: "Your subscription".to_string(), + body: "Renew to keep access.".to_string(), +}; +client.configure_default_message("com.company.app.sub", "en-US", &default_msg).await?; + +// Clean up +client.delete_message("my-message-id").await?; +client.delete_image("my-image-id").await?; +client.delete_default_message("com.company.app.sub", "en-US").await?; +``` + +### JWS Signed Data Verification + +Verify and decode signed transaction data, renewal info, and server notifications using X.509 certificate chain validation: + +```rust +use apple::appstore::{SignedDataVerifier, AppStoreEnvironment}; + +// Load Apple Root CA certificate +let root_cert = std::fs::read("AppleRootCA-G3.cer").unwrap(); + +let verifier = SignedDataVerifier::new( + vec![root_cert], + "com.company.app", + AppStoreEnvironment::Production, + Some(123456789), // your app's Apple ID +); + +// Verify and decode a signed transaction +let transaction = verifier.verify_and_decode_transaction("eyJ...")?; +println!("Product: {}", transaction.product_id); +println!("Purchase date: {}", transaction.purchase_date); +println!("Expires: {:?}", transaction.expires_date); + +// Verify and decode renewal info +let renewal = verifier.verify_and_decode_renewal_info("eyJ...")?; +println!("Auto-renew status: {:?}", renewal.auto_renew_status); +println!("Auto-renew product: {}", renewal.auto_renew_product_id); + +// Verify and decode a server notification +let notification = verifier.verify_and_decode_notification("eyJ...")?; +println!("Type: {:?}", notification.notification_type); +println!("Subtype: {:?}", notification.subtype); + +// Verify and decode an app transaction +let app_tx = verifier.verify_and_decode_app_transaction("eyJ...")?; +println!("Bundle: {}", app_tx.bundle_id); +``` + +### Handling Server Notifications V2 + +Parse webhook payloads from Apple's App Store Server Notifications V2: + +```rust +use apple::appstore::{ResponseBodyV2, NotificationTypeV2, Subtype}; + +// In your webhook handler, deserialize the request body +let body: ResponseBodyV2 = serde_json::from_str(&request_body)?; + +// Then verify and decode the signed payload +let decoded = verifier.verify_and_decode_notification(&body.signed_payload)?; + +match decoded.notification_type { + NotificationTypeV2::SUBSCRIBED => { + println!("New subscription!"); + } + NotificationTypeV2::DID_RENEW => { + println!("Subscription renewed"); + } + NotificationTypeV2::EXPIRED => { + if decoded.subtype == Some(Subtype::VOLUNTARY) { + println!("User cancelled"); + } + } + NotificationTypeV2::REFUND => { + println!("Refund processed"); + } + _ => {} +} + +// Access the notification data +if let Some(data) = &decoded.data { + println!("Bundle: {}", data.bundle_id); + if let Some(ref signed_tx) = data.signed_transaction_info { + let transaction = verifier.verify_and_decode_transaction(signed_tx)?; + println!("Product: {}", transaction.product_id); + } +} +``` + +### Handling Server Notifications V1 (Deprecated) + +```rust +use apple::appstore::ServerNotificationV1; + +let notification: ServerNotificationV1 = serde_json::from_str(&request_body)?; +println!("Type: {:?}", notification.notification_type); + +if let Some(receipt) = ¬ification.unified_receipt { + if let Some(info) = &receipt.latest_receipt_info { + for item in info { + println!("Product: {:?}", item.product_id); + } + } +} +``` + ## Shared Key Management -You can share a single key pair between Apple Sign-In and CloudKit: +You can share a single key pair between Apple Sign-In, CloudKit, and the App Store: ```rust use apple::signing::AppleKeyPair; use apple::auth::AppleAuthImpl; use apple::cloudkit::{CloudKitClient, CloudKitConfig, Environment}; +use apple::appstore::{AppStoreServerClient, AppStoreConfig, AppStoreEnvironment}; let key_pair = AppleKeyPair::from_file("key-id", "AuthKey.p8")?; @@ -270,13 +640,21 @@ let auth = AppleAuthImpl::from_key_pair("app-id", "team-id", key_pair.clone())?; let cloudkit = CloudKitClient::new(CloudKitConfig { container: "iCloud.com.company.app".to_string(), environment: Environment::Production, + key_pair: key_pair.clone(), +})?; + +// Use with App Store Server API +let appstore = AppStoreServerClient::new(AppStoreConfig { + issuer_id: "issuer-id".to_string(), + bundle_id: "com.company.app".to_string(), key_pair, + environment: AppStoreEnvironment::Production, })?; ``` ## Error Handling -All operations return `Result`. CloudKit-specific errors include the server error code and reason: +All operations return `Result`. Each module has specific error variants: ```rust use apple::error::AppleError; @@ -289,11 +667,27 @@ match result { println!("Retry after {} seconds", retry); } } + Err(AppleError::AppStoreError(e)) => { + println!("App Store error {}: {}", e.error_code, e.error_message); + } + Err(AppleError::CertificateError(msg)) => { + println!("Certificate validation failed: {}", msg); + } Err(AppleError::ResponseError(e)) => println!("Auth error: {}", e), Err(e) => println!("Error: {}", e), } ``` +App Store error codes can be inspected for programmatic handling: + +```rust +use apple::appstore::AppStoreErrorCode; + +let code = AppStoreErrorCode::from_code(4040010); +assert_eq!(code, AppStoreErrorCode::SubscriptionNotFound); +println!("Error code: {}", code.code()); // 4040010 +``` + ## License MIT diff --git a/src/appstore/client.rs b/src/appstore/client.rs new file mode 100644 index 0000000..d984c32 --- /dev/null +++ b/src/appstore/client.rs @@ -0,0 +1,321 @@ +use crate::error::AppleError; +use crate::signing::AppleKeyPair; +use jsonwebtoken::{EncodingKey, Header, encode}; +use p256::pkcs8::EncodePrivateKey; +use reqwest::Client; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use super::error::parse_appstore_error; +use super::types::AppStoreEnvironment; + +const PRODUCTION_BASE_URL: &str = "https://api.storekit.itunes.apple.com"; +const SANDBOX_BASE_URL: &str = "https://api.storekit-sandbox.itunes.apple.com"; + +pub struct AppStoreConfig { + pub issuer_id: String, + pub bundle_id: String, + pub key_pair: Arc, + pub environment: AppStoreEnvironment, +} + +pub struct AppStoreServerClient { + config: AppStoreConfig, + http_client: Client, +} + +#[derive(Serialize)] +struct AppStoreClaims { + iss: String, + iat: i64, + exp: i64, + aud: String, + bid: String, +} + +impl AppStoreServerClient { + pub fn new(config: AppStoreConfig) -> Result { + let http_client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + Ok(AppStoreServerClient { + config, + http_client, + }) + } + + pub fn config(&self) -> &AppStoreConfig { + &self.config + } + + fn base_url(&self) -> &str { + match self.config.environment { + AppStoreEnvironment::Production => PRODUCTION_BASE_URL, + _ => SANDBOX_BASE_URL, + } + } + + fn generate_token(&self) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| AppleError::TimeError(e.to_string()))? + .as_secs(); + + let claims = AppStoreClaims { + iss: self.config.issuer_id.clone(), + iat: now as i64, + exp: now as i64 + 3600, // 1 hour max + aud: "appstoreconnect-v1".to_string(), + bid: self.config.bundle_id.clone(), + }; + + let mut header = Header::new(jsonwebtoken::Algorithm::ES256); + header.kid = Some(self.config.key_pair.key_id().to_string()); + + let der = self + .config + .key_pair + .signing_key() + .to_pkcs8_der() + .map_err(|e: p256::pkcs8::Error| AppleError::KeyParseError(e.to_string()))?; + + let token = encode(&header, &claims, &EncodingKey::from_ec_der(der.as_bytes())) + .map_err(|e| AppleError::JwtError(e.to_string()))?; + + Ok(token) + } + + pub(crate) async fn jwt_get( + &self, + path: &str, + ) -> Result { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(parse_appstore_error(&body)); + } + + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string())) + } + + pub(crate) async fn jwt_post( + &self, + path: &str, + body: &Req, + ) -> Result { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(body) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(parse_appstore_error(&body)); + } + + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string())) + } + + #[allow(dead_code)] + pub(crate) async fn jwt_post_empty_response( + &self, + path: &str, + body: &Req, + ) -> Result<(), AppleError> { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(body) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + if !status.is_success() { + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + return Err(parse_appstore_error(&body)); + } + + Ok(()) + } + + pub(crate) async fn jwt_put( + &self, + path: &str, + body: &Req, + ) -> Result { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .put(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(body) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(parse_appstore_error(&body)); + } + + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string())) + } + + pub(crate) async fn jwt_put_empty_response( + &self, + path: &str, + body: &Req, + ) -> Result<(), AppleError> { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .put(&url) + .header("Authorization", format!("Bearer {}", token)) + .json(body) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + if !status.is_success() { + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + return Err(parse_appstore_error(&body)); + } + + Ok(()) + } + + pub(crate) async fn jwt_post_empty_body( + &self, + path: &str, + ) -> Result { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .post(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(parse_appstore_error(&body)); + } + + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string())) + } + + pub(crate) async fn jwt_put_bytes( + &self, + path: &str, + data: Vec, + ) -> Result { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .put(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Content-Type", "application/octet-stream") + .body(data) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(parse_appstore_error(&body)); + } + + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string())) + } + + pub(crate) async fn jwt_delete(&self, path: &str) -> Result<(), AppleError> { + let token = self.generate_token()?; + let url = format!("{}{}", self.base_url(), path); + + let res = self + .http_client + .delete(&url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + if !status.is_success() { + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + return Err(parse_appstore_error(&body)); + } + + Ok(()) + } +} diff --git a/src/appstore/consumption.rs b/src/appstore/consumption.rs new file mode 100644 index 0000000..5d67ec2 --- /dev/null +++ b/src/appstore/consumption.rs @@ -0,0 +1,42 @@ +use crate::error::AppleError; + +use super::client::AppStoreServerClient; +use super::models::*; + +impl AppStoreServerClient { + pub async fn send_consumption_data( + &self, + transaction_id: &str, + request: &ConsumptionRequest, + ) -> Result<(), AppleError> { + let path = format!("/inApps/v1/transactions/consumption/{}", transaction_id); + self.jwt_put_empty_response(&path, request).await + } + + pub async fn get_refund_history( + &self, + transaction_id: &str, + revision: Option<&str>, + ) -> Result { + let path = match revision { + Some(rev) => format!( + "/inApps/v2/refund/lookup/{}?revision={}", + transaction_id, rev + ), + None => format!("/inApps/v2/refund/lookup/{}", transaction_id), + }; + self.jwt_get(&path).await + } + + pub async fn set_app_account_token( + &self, + original_transaction_id: &str, + request: &UpdateAppAccountTokenRequest, + ) -> Result<(), AppleError> { + let path = format!( + "/inApps/v1/transactions/{}/appAccountToken", + original_transaction_id + ); + self.jwt_put_empty_response(&path, request).await + } +} diff --git a/src/appstore/error.rs b/src/appstore/error.rs new file mode 100644 index 0000000..6dfa1c3 --- /dev/null +++ b/src/appstore/error.rs @@ -0,0 +1,277 @@ +use crate::error::{AppStoreErrorResponse, AppleError}; + +#[derive(Debug, Clone, PartialEq)] +pub enum AppStoreErrorCode { + AccountNotFound, + AccountNotFoundRetryable, + AppNotFound, + AppNotFoundRetryable, + FamilySharedSubscriptionExtensionIneligible, + GeneralBadRequest, + GeneralInternal, + GeneralInternalRetryable, + InvalidAppIdentifier, + InvalidEmptyStorefrontCountryCodeList, + InvalidExtendByDays, + InvalidExtendReasonCode, + InvalidOriginalTransactionId, + InvalidRequestIdentifier, + InvalidRequestRevision, + InvalidRenewalDateNotYetPast, + InvalidStartDate, + InvalidEndDate, + InvalidNotificationType, + InvalidPaginationToken, + InvalidPlayTime, + InvalidProductId, + InvalidProductType, + InvalidRefundPreference, + InvalidSort, + InvalidStatus, + InvalidStorefrontCountryCode, + InvalidSubscriptionGroupIdentifier, + InvalidTestNotificationToken, + InvalidTransactionId, + MultipleFiltersSupplied, + OriginalTransactionIdNotFound, + OriginalTransactionIdNotFoundRetryable, + RateLimitExceeded, + ServerNotificationUrlNotFound, + StartDateAfterEndDate, + StartDateTooFarInPast, + StatusRequestNotFound, + SubscriptionExtensionIneligible, + SubscriptionGroupIdentifierNotFound, + SubscriptionMaxExtension, + SubscriptionNotFound, + SubscriptionNotFoundRetryable, + TestNotificationNotFound, + TransactionIdNotFound, + TransactionIdNotFoundRetryable, + // Messaging errors + InvalidImageIdentifier, + InvalidMessageIdentifier, + InvalidLocale, + ImageNotFound, + MessageNotFound, + ImageUploadFailed, + MessageUploadFailed, + InvalidImageData, + InvalidMessageBody, + ImageQuotaExceeded, + MessageQuotaExceeded, + DefaultMessageNotFound, + InvalidDefaultMessageBody, + // Additional error codes + UnauthorizedAccess, + ForbiddenAccess, + TooManyRequests, + InternalServerError, + ServiceUnavailable, + InvalidInAppOwnershipType, + InvalidAccountTenure, + InvalidConsumptionStatus, + InvalidCustomerConsented, + InvalidDeliveryStatus, + InvalidLifetimeDollarsPurchased, + InvalidLifetimeDollarsRefunded, + InvalidPlatform, + InvalidSampleContentProvided, + InvalidUserStatus, + InvalidTransactionTypeNotSupported, + AppTransactionNotFound, + InvalidAppAccountToken, + Unknown(i64), +} + +impl AppStoreErrorCode { + pub fn from_code(code: i64) -> Self { + match code { + 4040001 => AppStoreErrorCode::AccountNotFound, + 4040002 => AppStoreErrorCode::AccountNotFoundRetryable, + 4040003 => AppStoreErrorCode::AppNotFound, + 4040004 => AppStoreErrorCode::AppNotFoundRetryable, + 4030007 => AppStoreErrorCode::FamilySharedSubscriptionExtensionIneligible, + 4000000 => AppStoreErrorCode::GeneralBadRequest, + 5000000 => AppStoreErrorCode::GeneralInternal, + 5000001 => AppStoreErrorCode::GeneralInternalRetryable, + 4000002 => AppStoreErrorCode::InvalidAppIdentifier, + 4000027 => AppStoreErrorCode::InvalidEmptyStorefrontCountryCodeList, + 4000009 => AppStoreErrorCode::InvalidExtendByDays, + 4000010 => AppStoreErrorCode::InvalidExtendReasonCode, + 4000008 => AppStoreErrorCode::InvalidOriginalTransactionId, + 4000018 => AppStoreErrorCode::InvalidRequestIdentifier, + 4000019 => AppStoreErrorCode::InvalidRequestRevision, + 4000022 => AppStoreErrorCode::InvalidRenewalDateNotYetPast, + 4000015 => AppStoreErrorCode::InvalidStartDate, + 4000016 => AppStoreErrorCode::InvalidEndDate, + 4000017 => AppStoreErrorCode::InvalidNotificationType, + 4000014 => AppStoreErrorCode::InvalidPaginationToken, + 4000024 => AppStoreErrorCode::InvalidPlayTime, + 4000023 => AppStoreErrorCode::InvalidProductId, + 4000003 => AppStoreErrorCode::InvalidProductType, + 4000031 => AppStoreErrorCode::InvalidRefundPreference, + 4000011 => AppStoreErrorCode::InvalidSort, + 4000012 => AppStoreErrorCode::InvalidStatus, + 4000028 => AppStoreErrorCode::InvalidStorefrontCountryCode, + 4000013 => AppStoreErrorCode::InvalidSubscriptionGroupIdentifier, + 4000020 => AppStoreErrorCode::InvalidTestNotificationToken, + 4000021 => AppStoreErrorCode::InvalidTransactionId, + 4000004 => AppStoreErrorCode::MultipleFiltersSupplied, + 4040005 => AppStoreErrorCode::OriginalTransactionIdNotFound, + 4040006 => AppStoreErrorCode::OriginalTransactionIdNotFoundRetryable, + 4290000 => AppStoreErrorCode::RateLimitExceeded, + 4040007 => AppStoreErrorCode::ServerNotificationUrlNotFound, + 4000005 => AppStoreErrorCode::StartDateAfterEndDate, + 4000006 => AppStoreErrorCode::StartDateTooFarInPast, + 4040008 => AppStoreErrorCode::StatusRequestNotFound, + 4030004 => AppStoreErrorCode::SubscriptionExtensionIneligible, + 4040009 => AppStoreErrorCode::SubscriptionGroupIdentifierNotFound, + 4030005 => AppStoreErrorCode::SubscriptionMaxExtension, + 4040010 => AppStoreErrorCode::SubscriptionNotFound, + 4040011 => AppStoreErrorCode::SubscriptionNotFoundRetryable, + 4040012 => AppStoreErrorCode::TestNotificationNotFound, + 4040013 => AppStoreErrorCode::TransactionIdNotFound, + 4040014 => AppStoreErrorCode::TransactionIdNotFoundRetryable, + 4000032 => AppStoreErrorCode::InvalidImageIdentifier, + 4000033 => AppStoreErrorCode::InvalidMessageIdentifier, + 4000034 => AppStoreErrorCode::InvalidLocale, + 4040015 => AppStoreErrorCode::ImageNotFound, + 4040016 => AppStoreErrorCode::MessageNotFound, + 4000035 => AppStoreErrorCode::ImageUploadFailed, + 4000036 => AppStoreErrorCode::MessageUploadFailed, + 4000037 => AppStoreErrorCode::InvalidImageData, + 4000038 => AppStoreErrorCode::InvalidMessageBody, + 4030008 => AppStoreErrorCode::ImageQuotaExceeded, + 4030009 => AppStoreErrorCode::MessageQuotaExceeded, + 4040017 => AppStoreErrorCode::DefaultMessageNotFound, + 4000039 => AppStoreErrorCode::InvalidDefaultMessageBody, + 4010000 => AppStoreErrorCode::UnauthorizedAccess, + 4030000 => AppStoreErrorCode::ForbiddenAccess, + 4290001 => AppStoreErrorCode::TooManyRequests, + 5000002 => AppStoreErrorCode::InternalServerError, + 5030000 => AppStoreErrorCode::ServiceUnavailable, + 4000025 => AppStoreErrorCode::InvalidInAppOwnershipType, + 4000040 => AppStoreErrorCode::InvalidAccountTenure, + 4000041 => AppStoreErrorCode::InvalidConsumptionStatus, + 4000042 => AppStoreErrorCode::InvalidCustomerConsented, + 4000043 => AppStoreErrorCode::InvalidDeliveryStatus, + 4000044 => AppStoreErrorCode::InvalidLifetimeDollarsPurchased, + 4000045 => AppStoreErrorCode::InvalidLifetimeDollarsRefunded, + 4000026 => AppStoreErrorCode::InvalidPlatform, + 4000046 => AppStoreErrorCode::InvalidSampleContentProvided, + 4000047 => AppStoreErrorCode::InvalidUserStatus, + 4000048 => AppStoreErrorCode::InvalidTransactionTypeNotSupported, + 4040018 => AppStoreErrorCode::AppTransactionNotFound, + 4000049 => AppStoreErrorCode::InvalidAppAccountToken, + code => AppStoreErrorCode::Unknown(code), + } + } + + pub fn code(&self) -> i64 { + match self { + AppStoreErrorCode::AccountNotFound => 4040001, + AppStoreErrorCode::AccountNotFoundRetryable => 4040002, + AppStoreErrorCode::AppNotFound => 4040003, + AppStoreErrorCode::AppNotFoundRetryable => 4040004, + AppStoreErrorCode::FamilySharedSubscriptionExtensionIneligible => 4030007, + AppStoreErrorCode::GeneralBadRequest => 4000000, + AppStoreErrorCode::GeneralInternal => 5000000, + AppStoreErrorCode::GeneralInternalRetryable => 5000001, + AppStoreErrorCode::InvalidAppIdentifier => 4000002, + AppStoreErrorCode::InvalidEmptyStorefrontCountryCodeList => 4000027, + AppStoreErrorCode::InvalidExtendByDays => 4000009, + AppStoreErrorCode::InvalidExtendReasonCode => 4000010, + AppStoreErrorCode::InvalidOriginalTransactionId => 4000008, + AppStoreErrorCode::InvalidRequestIdentifier => 4000018, + AppStoreErrorCode::InvalidRequestRevision => 4000019, + AppStoreErrorCode::InvalidRenewalDateNotYetPast => 4000022, + AppStoreErrorCode::InvalidStartDate => 4000015, + AppStoreErrorCode::InvalidEndDate => 4000016, + AppStoreErrorCode::InvalidNotificationType => 4000017, + AppStoreErrorCode::InvalidPaginationToken => 4000014, + AppStoreErrorCode::InvalidPlayTime => 4000024, + AppStoreErrorCode::InvalidProductId => 4000023, + AppStoreErrorCode::InvalidProductType => 4000003, + AppStoreErrorCode::InvalidRefundPreference => 4000031, + AppStoreErrorCode::InvalidSort => 4000011, + AppStoreErrorCode::InvalidStatus => 4000012, + AppStoreErrorCode::InvalidStorefrontCountryCode => 4000028, + AppStoreErrorCode::InvalidSubscriptionGroupIdentifier => 4000013, + AppStoreErrorCode::InvalidTestNotificationToken => 4000020, + AppStoreErrorCode::InvalidTransactionId => 4000021, + AppStoreErrorCode::MultipleFiltersSupplied => 4000004, + AppStoreErrorCode::OriginalTransactionIdNotFound => 4040005, + AppStoreErrorCode::OriginalTransactionIdNotFoundRetryable => 4040006, + AppStoreErrorCode::RateLimitExceeded => 4290000, + AppStoreErrorCode::ServerNotificationUrlNotFound => 4040007, + AppStoreErrorCode::StartDateAfterEndDate => 4000005, + AppStoreErrorCode::StartDateTooFarInPast => 4000006, + AppStoreErrorCode::StatusRequestNotFound => 4040008, + AppStoreErrorCode::SubscriptionExtensionIneligible => 4030004, + AppStoreErrorCode::SubscriptionGroupIdentifierNotFound => 4040009, + AppStoreErrorCode::SubscriptionMaxExtension => 4030005, + AppStoreErrorCode::SubscriptionNotFound => 4040010, + AppStoreErrorCode::SubscriptionNotFoundRetryable => 4040011, + AppStoreErrorCode::TestNotificationNotFound => 4040012, + AppStoreErrorCode::TransactionIdNotFound => 4040013, + AppStoreErrorCode::TransactionIdNotFoundRetryable => 4040014, + AppStoreErrorCode::InvalidImageIdentifier => 4000032, + AppStoreErrorCode::InvalidMessageIdentifier => 4000033, + AppStoreErrorCode::InvalidLocale => 4000034, + AppStoreErrorCode::ImageNotFound => 4040015, + AppStoreErrorCode::MessageNotFound => 4040016, + AppStoreErrorCode::ImageUploadFailed => 4000035, + AppStoreErrorCode::MessageUploadFailed => 4000036, + AppStoreErrorCode::InvalidImageData => 4000037, + AppStoreErrorCode::InvalidMessageBody => 4000038, + AppStoreErrorCode::ImageQuotaExceeded => 4030008, + AppStoreErrorCode::MessageQuotaExceeded => 4030009, + AppStoreErrorCode::DefaultMessageNotFound => 4040017, + AppStoreErrorCode::InvalidDefaultMessageBody => 4000039, + AppStoreErrorCode::UnauthorizedAccess => 4010000, + AppStoreErrorCode::ForbiddenAccess => 4030000, + AppStoreErrorCode::TooManyRequests => 4290001, + AppStoreErrorCode::InternalServerError => 5000002, + AppStoreErrorCode::ServiceUnavailable => 5030000, + AppStoreErrorCode::InvalidInAppOwnershipType => 4000025, + AppStoreErrorCode::InvalidAccountTenure => 4000040, + AppStoreErrorCode::InvalidConsumptionStatus => 4000041, + AppStoreErrorCode::InvalidCustomerConsented => 4000042, + AppStoreErrorCode::InvalidDeliveryStatus => 4000043, + AppStoreErrorCode::InvalidLifetimeDollarsPurchased => 4000044, + AppStoreErrorCode::InvalidLifetimeDollarsRefunded => 4000045, + AppStoreErrorCode::InvalidPlatform => 4000026, + AppStoreErrorCode::InvalidSampleContentProvided => 4000046, + AppStoreErrorCode::InvalidUserStatus => 4000047, + AppStoreErrorCode::InvalidTransactionTypeNotSupported => 4000048, + AppStoreErrorCode::AppTransactionNotFound => 4040018, + AppStoreErrorCode::InvalidAppAccountToken => 4000049, + AppStoreErrorCode::Unknown(code) => *code, + } + } +} + +impl std::fmt::Display for AppStoreErrorCode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}({})", self, self.code()) + } +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct RawAppStoreErrorResponse { + #[serde(rename = "errorCode")] + pub error_code: i64, + #[serde(rename = "errorMessage")] + pub error_message: String, +} + +pub(crate) fn parse_appstore_error(body: &str) -> AppleError { + match serde_json::from_str::(body) { + Ok(raw) => AppleError::AppStoreError(AppStoreErrorResponse { + error_code: raw.error_code, + error_message: raw.error_message, + }), + Err(_) => AppleError::JsonError(format!("Failed to parse App Store error: {}", body)), + } +} diff --git a/src/appstore/messaging.rs b/src/appstore/messaging.rs new file mode 100644 index 0000000..84737fa --- /dev/null +++ b/src/appstore/messaging.rs @@ -0,0 +1,61 @@ +use crate::error::AppleError; + +use super::client::AppStoreServerClient; +use super::models::*; + +impl AppStoreServerClient { + pub async fn upload_image( + &self, + image_identifier: &str, + data: Vec, + ) -> Result { + let path = format!("/inApps/v1/messaging/image/{}", image_identifier); + self.jwt_put_bytes(&path, data).await + } + + pub async fn delete_image(&self, image_identifier: &str) -> Result<(), AppleError> { + let path = format!("/inApps/v1/messaging/image/{}", image_identifier); + self.jwt_delete(&path).await + } + + pub async fn get_image_list(&self) -> Result { + self.jwt_get("/inApps/v1/messaging/image/list").await + } + + pub async fn upload_message( + &self, + message_identifier: &str, + body: &UploadMessageRequest, + ) -> Result { + let path = format!("/inApps/v1/messaging/message/{}", message_identifier); + self.jwt_put(&path, body).await + } + + pub async fn delete_message(&self, message_identifier: &str) -> Result<(), AppleError> { + let path = format!("/inApps/v1/messaging/message/{}", message_identifier); + self.jwt_delete(&path).await + } + + pub async fn get_message_list(&self) -> Result { + self.jwt_get("/inApps/v1/messaging/message/list").await + } + + pub async fn configure_default_message( + &self, + product_id: &str, + locale: &str, + body: &DefaultMessageRequest, + ) -> Result<(), AppleError> { + let path = format!("/inApps/v1/messaging/default/{}/{}", product_id, locale); + self.jwt_put_empty_response(&path, body).await + } + + pub async fn delete_default_message( + &self, + product_id: &str, + locale: &str, + ) -> Result<(), AppleError> { + let path = format!("/inApps/v1/messaging/default/{}/{}", product_id, locale); + self.jwt_delete(&path).await + } +} diff --git a/src/appstore/mod.rs b/src/appstore/mod.rs new file mode 100644 index 0000000..3ed07e1 --- /dev/null +++ b/src/appstore/mod.rs @@ -0,0 +1,27 @@ +pub mod client; +pub mod consumption; +pub(crate) mod error; +pub mod messaging; +pub mod models; +pub mod notifications; +pub mod notifications_v1; +pub mod notifications_v2; +pub mod signed_data; +pub mod subscriptions; +pub mod transactions; +pub mod types; + +pub use client::{AppStoreConfig, AppStoreServerClient}; +pub use error::AppStoreErrorCode; +pub use models::*; +pub use notifications_v1::{ + LatestReceiptInfo, PendingRenewalInfo, ServerNotificationV1, UnifiedReceipt, +}; +pub use notifications_v2::{ + ExternalPurchaseToken, NotificationData, NotificationSummary, ResponseBodyV2, + ResponseBodyV2DecodedPayload, +}; +pub use signed_data::{ + AppTransaction, JWSRenewalInfoDecodedPayload, JWSTransactionDecodedPayload, SignedDataVerifier, +}; +pub use types::*; diff --git a/src/appstore/models.rs b/src/appstore/models.rs new file mode 100644 index 0000000..b65360d --- /dev/null +++ b/src/appstore/models.rs @@ -0,0 +1,323 @@ +use serde::{Deserialize, Serialize}; + +use super::types::*; + +// Transaction History + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TransactionHistoryRequest { + #[serde(rename = "startDate", skip_serializing_if = "Option::is_none")] + pub start_date: Option, + #[serde(rename = "endDate", skip_serializing_if = "Option::is_none")] + pub end_date: Option, + #[serde(rename = "productId", skip_serializing_if = "Option::is_none")] + pub product_id: Option>, + #[serde(rename = "productType", skip_serializing_if = "Option::is_none")] + pub product_type: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, + #[serde( + rename = "subscriptionGroupIdentifier", + skip_serializing_if = "Option::is_none" + )] + pub subscription_group_identifier: Option>, + #[serde(rename = "inAppOwnershipType", skip_serializing_if = "Option::is_none")] + pub in_app_ownership_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryResponse { + #[serde(rename = "signedTransactions")] + pub signed_transactions: Vec, + pub revision: String, + #[serde(rename = "bundleId")] + pub bundle_id: String, + #[serde(rename = "appAppleId", skip_serializing_if = "Option::is_none")] + pub app_apple_id: Option, + pub environment: AppStoreEnvironment, + #[serde(rename = "hasMore")] + pub has_more: bool, +} + +// Subscription Status + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusResponse { + pub environment: AppStoreEnvironment, + #[serde(rename = "bundleId")] + pub bundle_id: String, + #[serde(rename = "appAppleId", skip_serializing_if = "Option::is_none")] + pub app_apple_id: Option, + pub data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubscriptionGroupIdentifierItem { + #[serde(rename = "subscriptionGroupIdentifier")] + pub subscription_group_identifier: String, + #[serde(rename = "lastTransactions")] + pub last_transactions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LastTransactionsItem { + pub status: SubscriptionStatus, + #[serde(rename = "originalTransactionId")] + pub original_transaction_id: String, + #[serde(rename = "signedTransactionInfo")] + pub signed_transaction_info: String, + #[serde(rename = "signedRenewalInfo")] + pub signed_renewal_info: String, +} + +// Consumption + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsumptionRequest { + #[serde(rename = "accountTenure")] + pub account_tenure: AccountTenure, + #[serde(rename = "appAccountToken")] + pub app_account_token: String, + #[serde(rename = "consumptionStatus")] + pub consumption_status: ConsumptionStatus, + #[serde(rename = "customerConsented")] + pub customer_consented: bool, + #[serde(rename = "deliveryStatus", skip_serializing_if = "Option::is_none")] + pub delivery_status: Option, + #[serde( + rename = "lifetimeDollarsPurchased", + skip_serializing_if = "Option::is_none" + )] + pub lifetime_dollars_purchased: Option, + #[serde( + rename = "lifetimeDollarsRefunded", + skip_serializing_if = "Option::is_none" + )] + pub lifetime_dollars_refunded: Option, + pub platform: Platform, + #[serde(rename = "playTime", skip_serializing_if = "Option::is_none")] + pub play_time: Option, + #[serde(rename = "refundPreference", skip_serializing_if = "Option::is_none")] + pub refund_preference: Option, + #[serde( + rename = "sampleContentProvided", + skip_serializing_if = "Option::is_none" + )] + pub sample_content_provided: Option, + #[serde(rename = "userStatus")] + pub user_status: UserStatus, +} + +// Extend Renewal Date + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtendRenewalDateRequest { + #[serde(rename = "extendByDays")] + pub extend_by_days: i32, + #[serde(rename = "extendReasonCode")] + pub extend_reason_code: ExtendReasonCode, + #[serde(rename = "requestIdentifier")] + pub request_identifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExtendRenewalDateResponse { + #[serde(rename = "effectiveDate")] + pub effective_date: i64, + #[serde(rename = "originalTransactionId")] + pub original_transaction_id: String, + #[serde(rename = "webOrderLineItemId")] + pub web_order_line_item_id: String, + pub success: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MassExtendRenewalDateRequest { + #[serde(rename = "extendByDays")] + pub extend_by_days: i32, + #[serde(rename = "extendReasonCode")] + pub extend_reason_code: ExtendReasonCode, + #[serde(rename = "requestIdentifier")] + pub request_identifier: String, + #[serde( + rename = "storefrontCountryCodes", + skip_serializing_if = "Option::is_none" + )] + pub storefront_country_codes: Option>, + #[serde(rename = "productId")] + pub product_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MassExtendRenewalDateResponse { + #[serde(rename = "requestIdentifier")] + pub request_identifier: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MassExtendRenewalDateStatusResponse { + #[serde(rename = "requestIdentifier")] + pub request_identifier: String, + pub complete: bool, + #[serde(rename = "completeDate", skip_serializing_if = "Option::is_none")] + pub complete_date: Option, + #[serde(rename = "succeededCount", skip_serializing_if = "Option::is_none")] + pub succeeded_count: Option, + #[serde(rename = "failedCount", skip_serializing_if = "Option::is_none")] + pub failed_count: Option, +} + +// Order Lookup + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrderLookupResponse { + pub status: OrderLookupStatus, + #[serde(rename = "signedTransactions")] + pub signed_transactions: Vec, +} + +// Refund History + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefundHistoryResponse { + #[serde(rename = "signedTransactions")] + pub signed_transactions: Vec, + pub revision: String, + #[serde(rename = "hasMore")] + pub has_more: bool, +} + +// Transaction Info + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransactionInfoResponse { + #[serde(rename = "signedTransactionInfo")] + pub signed_transaction_info: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppTransactionInfoResponse { + #[serde(rename = "signedTransactionInfo")] + pub signed_transaction_info: String, +} + +// Notification History + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationHistoryRequest { + #[serde(rename = "startDate")] + pub start_date: i64, + #[serde(rename = "endDate")] + pub end_date: i64, + #[serde(rename = "notificationType", skip_serializing_if = "Option::is_none")] + pub notification_type: Option, + #[serde( + rename = "notificationSubtype", + skip_serializing_if = "Option::is_none" + )] + pub notification_subtype: Option, + #[serde( + rename = "onlyFailures", + skip_serializing_if = "Option::is_none", + default + )] + pub only_failures: Option, + #[serde( + rename = "transactionId", + skip_serializing_if = "Option::is_none", + default + )] + pub transaction_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationHistoryResponse { + #[serde(rename = "notificationHistory")] + pub notification_history: Vec, + #[serde(rename = "paginationToken", skip_serializing_if = "Option::is_none")] + pub pagination_token: Option, + #[serde(rename = "hasMore")] + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationHistoryResponseItem { + #[serde(rename = "signedPayload")] + pub signed_payload: String, + #[serde(rename = "sendAttempts")] + pub send_attempts: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendAttemptItem { + #[serde(rename = "attemptDate")] + pub attempt_date: i64, + #[serde(rename = "sendAttemptResult")] + pub send_attempt_result: SendAttemptResult, +} + +// Test Notification + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SendTestNotificationResponse { + #[serde(rename = "testNotificationToken")] + pub test_notification_token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CheckTestNotificationResponse { + #[serde(rename = "signedPayload")] + pub signed_payload: String, + #[serde(rename = "sendAttempts")] + pub send_attempts: Vec, +} + +// App Account Token + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAppAccountTokenRequest { + #[serde(rename = "appAccountToken")] + pub app_account_token: String, +} + +// Retention Messaging + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UploadMessageRequest { + pub title: String, + pub body: String, + #[serde(rename = "imageIdentifier", skip_serializing_if = "Option::is_none")] + pub image_identifier: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageListResponse { + pub images: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImageListItem { + #[serde(rename = "imageIdentifier")] + pub image_identifier: String, + pub state: ImageState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageListResponse { + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MessageListItem { + #[serde(rename = "messageIdentifier")] + pub message_identifier: String, + pub state: MessageState, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DefaultMessageRequest { + pub title: String, + pub body: String, +} diff --git a/src/appstore/notifications.rs b/src/appstore/notifications.rs new file mode 100644 index 0000000..605952b --- /dev/null +++ b/src/appstore/notifications.rs @@ -0,0 +1,33 @@ +use crate::error::AppleError; + +use super::client::AppStoreServerClient; +use super::models::*; + +impl AppStoreServerClient { + pub async fn request_test_notification( + &self, + ) -> Result { + self.jwt_post_empty_body("/inApps/v1/notifications/test") + .await + } + + pub async fn get_test_notification_status( + &self, + token: &str, + ) -> Result { + let path = format!("/inApps/v1/notifications/test/{}", token); + self.jwt_get(&path).await + } + + pub async fn get_notification_history( + &self, + request: &NotificationHistoryRequest, + pagination_token: Option<&str>, + ) -> Result { + let path = match pagination_token { + Some(token) => format!("/inApps/v1/notifications/history?paginationToken={}", token), + None => "/inApps/v1/notifications/history".to_string(), + }; + self.jwt_post(&path, request).await + } +} diff --git a/src/appstore/notifications_v1.rs b/src/appstore/notifications_v1.rs new file mode 100644 index 0000000..f0e9a8c --- /dev/null +++ b/src/appstore/notifications_v1.rs @@ -0,0 +1,225 @@ +use serde::{Deserialize, Serialize}; + +use super::types::NotificationTypeV1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerNotificationV1 { + #[serde(rename = "notification_type")] + pub notification_type: NotificationTypeV1, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(rename = "auto_renew_adam_id", skip_serializing_if = "Option::is_none")] + pub auto_renew_adam_id: Option, + #[serde( + rename = "auto_renew_product_id", + skip_serializing_if = "Option::is_none" + )] + pub auto_renew_product_id: Option, + #[serde(rename = "auto_renew_status", skip_serializing_if = "Option::is_none")] + pub auto_renew_status: Option, + #[serde( + rename = "auto_renew_status_change_date", + skip_serializing_if = "Option::is_none" + )] + pub auto_renew_status_change_date: Option, + #[serde( + rename = "auto_renew_status_change_date_ms", + skip_serializing_if = "Option::is_none" + )] + pub auto_renew_status_change_date_ms: Option, + #[serde( + rename = "auto_renew_status_change_date_pst", + skip_serializing_if = "Option::is_none" + )] + pub auto_renew_status_change_date_pst: Option, + #[serde(rename = "bid", skip_serializing_if = "Option::is_none")] + pub bid: Option, + #[serde(rename = "bvrs", skip_serializing_if = "Option::is_none")] + pub bvrs: Option, + #[serde(rename = "cancellation_date", skip_serializing_if = "Option::is_none")] + pub cancellation_date: Option, + #[serde( + rename = "cancellation_date_ms", + skip_serializing_if = "Option::is_none" + )] + pub cancellation_date_ms: Option, + #[serde( + rename = "cancellation_date_pst", + skip_serializing_if = "Option::is_none" + )] + pub cancellation_date_pst: Option, + #[serde(rename = "expiration_intent", skip_serializing_if = "Option::is_none")] + pub expiration_intent: Option, + #[serde(rename = "unified_receipt", skip_serializing_if = "Option::is_none")] + pub unified_receipt: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnifiedReceipt { + #[serde(skip_serializing_if = "Option::is_none")] + pub environment: Option, + #[serde(rename = "latest_receipt", skip_serializing_if = "Option::is_none")] + pub latest_receipt: Option, + #[serde( + rename = "latest_receipt_info", + skip_serializing_if = "Option::is_none" + )] + pub latest_receipt_info: Option>, + #[serde( + rename = "pending_renewal_info", + skip_serializing_if = "Option::is_none" + )] + pub pending_renewal_info: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LatestReceiptInfo { + #[serde(rename = "cancellation_date", skip_serializing_if = "Option::is_none")] + pub cancellation_date: Option, + #[serde( + rename = "cancellation_date_ms", + skip_serializing_if = "Option::is_none" + )] + pub cancellation_date_ms: Option, + #[serde( + rename = "cancellation_date_pst", + skip_serializing_if = "Option::is_none" + )] + pub cancellation_date_pst: Option, + #[serde( + rename = "cancellation_reason", + skip_serializing_if = "Option::is_none" + )] + pub cancellation_reason: Option, + #[serde(rename = "expires_date", skip_serializing_if = "Option::is_none")] + pub expires_date: Option, + #[serde(rename = "expires_date_ms", skip_serializing_if = "Option::is_none")] + pub expires_date_ms: Option, + #[serde(rename = "expires_date_pst", skip_serializing_if = "Option::is_none")] + pub expires_date_pst: Option, + #[serde( + rename = "in_app_ownership_type", + skip_serializing_if = "Option::is_none" + )] + pub in_app_ownership_type: Option, + #[serde( + rename = "is_in_intro_offer_period", + skip_serializing_if = "Option::is_none" + )] + pub is_in_intro_offer_period: Option, + #[serde(rename = "is_trial_period", skip_serializing_if = "Option::is_none")] + pub is_trial_period: Option, + #[serde(rename = "is_upgraded", skip_serializing_if = "Option::is_none")] + pub is_upgraded: Option, + #[serde( + rename = "offer_code_ref_name", + skip_serializing_if = "Option::is_none" + )] + pub offer_code_ref_name: Option, + #[serde( + rename = "original_purchase_date", + skip_serializing_if = "Option::is_none" + )] + pub original_purchase_date: Option, + #[serde( + rename = "original_purchase_date_ms", + skip_serializing_if = "Option::is_none" + )] + pub original_purchase_date_ms: Option, + #[serde( + rename = "original_purchase_date_pst", + skip_serializing_if = "Option::is_none" + )] + pub original_purchase_date_pst: Option, + #[serde( + rename = "original_transaction_id", + skip_serializing_if = "Option::is_none" + )] + pub original_transaction_id: Option, + #[serde(rename = "product_id", skip_serializing_if = "Option::is_none")] + pub product_id: Option, + #[serde( + rename = "promotional_offer_id", + skip_serializing_if = "Option::is_none" + )] + pub promotional_offer_id: Option, + #[serde(rename = "purchase_date", skip_serializing_if = "Option::is_none")] + pub purchase_date: Option, + #[serde(rename = "purchase_date_ms", skip_serializing_if = "Option::is_none")] + pub purchase_date_ms: Option, + #[serde(rename = "purchase_date_pst", skip_serializing_if = "Option::is_none")] + pub purchase_date_pst: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quantity: Option, + #[serde( + rename = "subscription_group_identifier", + skip_serializing_if = "Option::is_none" + )] + pub subscription_group_identifier: Option, + #[serde(rename = "transaction_id", skip_serializing_if = "Option::is_none")] + pub transaction_id: Option, + #[serde( + rename = "web_order_line_item_id", + skip_serializing_if = "Option::is_none" + )] + pub web_order_line_item_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingRenewalInfo { + #[serde( + rename = "auto_renew_product_id", + skip_serializing_if = "Option::is_none" + )] + pub auto_renew_product_id: Option, + #[serde(rename = "auto_renew_status", skip_serializing_if = "Option::is_none")] + pub auto_renew_status: Option, + #[serde(rename = "expiration_intent", skip_serializing_if = "Option::is_none")] + pub expiration_intent: Option, + #[serde( + rename = "grace_period_expires_date", + skip_serializing_if = "Option::is_none" + )] + pub grace_period_expires_date: Option, + #[serde( + rename = "grace_period_expires_date_ms", + skip_serializing_if = "Option::is_none" + )] + pub grace_period_expires_date_ms: Option, + #[serde( + rename = "grace_period_expires_date_pst", + skip_serializing_if = "Option::is_none" + )] + pub grace_period_expires_date_pst: Option, + #[serde( + rename = "is_in_billing_retry_period", + skip_serializing_if = "Option::is_none" + )] + pub is_in_billing_retry_period: Option, + #[serde( + rename = "offer_code_ref_name", + skip_serializing_if = "Option::is_none" + )] + pub offer_code_ref_name: Option, + #[serde( + rename = "original_transaction_id", + skip_serializing_if = "Option::is_none" + )] + pub original_transaction_id: Option, + #[serde( + rename = "price_consent_status", + skip_serializing_if = "Option::is_none" + )] + pub price_consent_status: Option, + #[serde(rename = "product_id", skip_serializing_if = "Option::is_none")] + pub product_id: Option, + #[serde( + rename = "promotional_offer_id", + skip_serializing_if = "Option::is_none" + )] + pub promotional_offer_id: Option, +} diff --git a/src/appstore/notifications_v2.rs b/src/appstore/notifications_v2.rs new file mode 100644 index 0000000..c88c8e3 --- /dev/null +++ b/src/appstore/notifications_v2.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +use super::types::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseBodyV2 { + #[serde(rename = "signedPayload")] + pub signed_payload: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResponseBodyV2DecodedPayload { + #[serde(rename = "notificationType")] + pub notification_type: NotificationTypeV2, + #[serde(skip_serializing_if = "Option::is_none")] + pub subtype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde( + rename = "externalPurchaseToken", + skip_serializing_if = "Option::is_none" + )] + pub external_purchase_token: Option, + pub version: String, + #[serde(rename = "signedDate")] + pub signed_date: i64, + #[serde(rename = "notificationUUID")] + pub notification_uuid: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationData { + pub environment: AppStoreEnvironment, + #[serde(rename = "bundleId")] + pub bundle_id: String, + #[serde(rename = "appAppleId", skip_serializing_if = "Option::is_none")] + pub app_apple_id: Option, + #[serde(rename = "bundleVersion", skip_serializing_if = "Option::is_none")] + pub bundle_version: Option, + #[serde( + rename = "signedTransactionInfo", + skip_serializing_if = "Option::is_none" + )] + pub signed_transaction_info: Option, + #[serde(rename = "signedRenewalInfo", skip_serializing_if = "Option::is_none")] + pub signed_renewal_info: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde( + rename = "consumptionRequestReason", + skip_serializing_if = "Option::is_none" + )] + pub consumption_request_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationSummary { + pub environment: AppStoreEnvironment, + #[serde(rename = "requestIdentifier")] + pub request_identifier: String, + #[serde(rename = "productId")] + pub product_id: String, + #[serde( + rename = "storefrontCountryCodes", + skip_serializing_if = "Option::is_none" + )] + pub storefront_country_codes: Option>, + #[serde(rename = "succeededCount")] + pub succeeded_count: i64, + #[serde(rename = "failedCount")] + pub failed_count: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalPurchaseToken { + #[serde(rename = "externalPurchaseId")] + pub external_purchase_id: String, + #[serde(rename = "tokenCreationDate")] + pub token_creation_date: i64, + #[serde(rename = "appAppleId", skip_serializing_if = "Option::is_none")] + pub app_apple_id: Option, + #[serde(rename = "bundleId")] + pub bundle_id: String, +} diff --git a/src/appstore/signed_data.rs b/src/appstore/signed_data.rs new file mode 100644 index 0000000..6acb797 --- /dev/null +++ b/src/appstore/signed_data.rs @@ -0,0 +1,374 @@ +use base64::Engine; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; +use serde::{Deserialize, Serialize}; +use x509_cert::Certificate; +use x509_cert::der::Decode; + +use crate::error::AppleError; + +use super::types::*; + +pub struct SignedDataVerifier { + root_certificates: Vec>, + bundle_id: String, + environment: AppStoreEnvironment, + #[allow(dead_code)] + app_apple_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JWSTransactionDecodedPayload { + #[serde(rename = "transactionId")] + pub transaction_id: String, + #[serde(rename = "originalTransactionId")] + pub original_transaction_id: String, + #[serde(rename = "bundleId")] + pub bundle_id: String, + #[serde(rename = "productId")] + pub product_id: String, + #[serde(rename = "purchaseDate")] + pub purchase_date: i64, + #[serde(rename = "expiresDate", skip_serializing_if = "Option::is_none")] + pub expires_date: Option, + pub quantity: i32, + #[serde(rename = "type")] + pub product_type: ProductType, + #[serde(rename = "appAccountToken", skip_serializing_if = "Option::is_none")] + pub app_account_token: Option, + #[serde(rename = "inAppOwnershipType")] + pub in_app_ownership_type: InAppOwnershipType, + #[serde(rename = "signedDate")] + pub signed_date: i64, + #[serde(rename = "offerType", skip_serializing_if = "Option::is_none")] + pub offer_type: Option, + #[serde(rename = "offerIdentifier", skip_serializing_if = "Option::is_none")] + pub offer_identifier: Option, + pub environment: AppStoreEnvironment, + #[serde(rename = "transactionReason")] + pub transaction_reason: TransactionReason, + #[serde(skip_serializing_if = "Option::is_none")] + pub storefront: Option, + #[serde(rename = "storefrontId", skip_serializing_if = "Option::is_none")] + pub storefront_id: Option, + #[serde(rename = "revocationReason", skip_serializing_if = "Option::is_none")] + pub revocation_reason: Option, + #[serde(rename = "revocationDate", skip_serializing_if = "Option::is_none")] + pub revocation_date: Option, + #[serde(rename = "isUpgraded", skip_serializing_if = "Option::is_none")] + pub is_upgraded: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub price: Option, + #[serde( + rename = "subscriptionGroupIdentifier", + skip_serializing_if = "Option::is_none" + )] + pub subscription_group_identifier: Option, + #[serde(rename = "webOrderLineItemId", skip_serializing_if = "Option::is_none")] + pub web_order_line_item_id: Option, + #[serde(rename = "offerDiscountType", skip_serializing_if = "Option::is_none")] + pub offer_discount_type: Option, + #[serde( + rename = "originalPurchaseDate", + skip_serializing_if = "Option::is_none" + )] + pub original_purchase_date: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JWSRenewalInfoDecodedPayload { + #[serde(rename = "originalTransactionId")] + pub original_transaction_id: String, + #[serde(rename = "autoRenewProductId")] + pub auto_renew_product_id: String, + #[serde(rename = "productId")] + pub product_id: String, + #[serde(rename = "autoRenewStatus")] + pub auto_renew_status: AutoRenewStatus, + #[serde(rename = "expirationIntent", skip_serializing_if = "Option::is_none")] + pub expiration_intent: Option, + #[serde( + rename = "gracePeriodExpiresDate", + skip_serializing_if = "Option::is_none" + )] + pub grace_period_expires_date: Option, + #[serde( + rename = "isInBillingRetryPeriod", + skip_serializing_if = "Option::is_none" + )] + pub is_in_billing_retry_period: Option, + #[serde(rename = "offerType", skip_serializing_if = "Option::is_none")] + pub offer_type: Option, + #[serde(rename = "offerIdentifier", skip_serializing_if = "Option::is_none")] + pub offer_identifier: Option, + #[serde( + rename = "priceIncreaseStatus", + skip_serializing_if = "Option::is_none" + )] + pub price_increase_status: Option, + #[serde(rename = "signedDate")] + pub signed_date: i64, + pub environment: AppStoreEnvironment, + #[serde( + rename = "recentSubscriptionStartDate", + skip_serializing_if = "Option::is_none" + )] + pub recent_subscription_start_date: Option, + #[serde(rename = "renewalDate", skip_serializing_if = "Option::is_none")] + pub renewal_date: Option, + #[serde(rename = "renewalPrice", skip_serializing_if = "Option::is_none")] + pub renewal_price: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub currency: Option, + #[serde(rename = "offerDiscountType", skip_serializing_if = "Option::is_none")] + pub offer_discount_type: Option, + #[serde( + rename = "eligibleWinBackOfferIds", + skip_serializing_if = "Option::is_none" + )] + pub eligible_win_back_offer_ids: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppTransaction { + #[serde(rename = "appAppleId", skip_serializing_if = "Option::is_none")] + pub app_apple_id: Option, + #[serde(rename = "bundleId")] + pub bundle_id: String, + #[serde(rename = "applicationVersion", skip_serializing_if = "Option::is_none")] + pub application_version: Option, + #[serde( + rename = "versionExternalIdentifier", + skip_serializing_if = "Option::is_none" + )] + pub version_external_identifier: Option, + #[serde( + rename = "receiptCreationDate", + skip_serializing_if = "Option::is_none" + )] + pub receipt_creation_date: Option, + #[serde( + rename = "originalPurchaseDate", + skip_serializing_if = "Option::is_none" + )] + pub original_purchase_date: Option, + #[serde( + rename = "originalApplicationVersion", + skip_serializing_if = "Option::is_none" + )] + pub original_application_version: Option, + #[serde(rename = "deviceVerification", skip_serializing_if = "Option::is_none")] + pub device_verification: Option, + #[serde( + rename = "deviceVerificationNonce", + skip_serializing_if = "Option::is_none" + )] + pub device_verification_nonce: Option, + #[serde(rename = "preorderDate", skip_serializing_if = "Option::is_none")] + pub preorder_date: Option, + pub environment: AppStoreEnvironment, + #[serde(rename = "signedDate")] + pub signed_date: i64, +} + +#[derive(Debug, Deserialize)] +struct JWSHeader { + #[serde(default)] + x5c: Vec, + #[allow(dead_code)] + alg: String, +} + +impl SignedDataVerifier { + pub fn new( + root_certificates: Vec>, + bundle_id: &str, + environment: AppStoreEnvironment, + app_apple_id: Option, + ) -> Self { + SignedDataVerifier { + root_certificates, + bundle_id: bundle_id.to_string(), + environment, + app_apple_id, + } + } + + pub fn verify_and_decode_transaction( + &self, + signed_jws: &str, + ) -> Result { + let payload: JWSTransactionDecodedPayload = self.decode_jws(signed_jws)?; + if payload.bundle_id != self.bundle_id { + return Err(AppleError::CertificateError(format!( + "Bundle ID mismatch: expected {}, got {}", + self.bundle_id, payload.bundle_id + ))); + } + if payload.environment != self.environment { + return Err(AppleError::CertificateError(format!( + "Environment mismatch: expected {:?}, got {:?}", + self.environment, payload.environment + ))); + } + Ok(payload) + } + + pub fn verify_and_decode_renewal_info( + &self, + signed_jws: &str, + ) -> Result { + let payload: JWSRenewalInfoDecodedPayload = self.decode_jws(signed_jws)?; + if payload.environment != self.environment { + return Err(AppleError::CertificateError(format!( + "Environment mismatch: expected {:?}, got {:?}", + self.environment, payload.environment + ))); + } + Ok(payload) + } + + pub fn verify_and_decode_notification( + &self, + signed_jws: &str, + ) -> Result { + self.decode_jws(signed_jws) + } + + pub fn verify_and_decode_app_transaction( + &self, + signed_jws: &str, + ) -> Result { + let payload: AppTransaction = self.decode_jws(signed_jws)?; + if payload.bundle_id != self.bundle_id { + return Err(AppleError::CertificateError(format!( + "Bundle ID mismatch: expected {}, got {}", + self.bundle_id, payload.bundle_id + ))); + } + if payload.environment != self.environment { + return Err(AppleError::CertificateError(format!( + "Environment mismatch: expected {:?}, got {:?}", + self.environment, payload.environment + ))); + } + Ok(payload) + } + + fn decode_jws( + &self, + jws_string: &str, + ) -> Result { + let parts: Vec<&str> = jws_string.split('.').collect(); + if parts.len() != 3 { + return Err(AppleError::CertificateError( + "Invalid JWS format: expected 3 parts".to_string(), + )); + } + + let header_bytes = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + let header: JWSHeader = serde_json::from_slice(&header_bytes) + .map_err(|e| AppleError::JsonError(e.to_string()))?; + + if !header.x5c.is_empty() { + self.verify_certificate_chain(&header.x5c)?; + } + + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + if !header.x5c.is_empty() { + let leaf_cert_bytes = STANDARD + .decode(&header.x5c[0]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + let cert = Certificate::from_der(&leaf_cert_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + + let public_key_bytes = cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(); + + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + + let signature_bytes = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + let signature = Signature::from_der(&signature_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + + let signed_content = format!("{}.{}", parts[0], parts[1]); + verifying_key + .verify(signed_content.as_bytes(), &signature) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + } + + serde_json::from_slice(&payload_bytes).map_err(|e| AppleError::JsonError(e.to_string())) + } + + fn verify_certificate_chain(&self, x5c_chain: &[String]) -> Result<(), AppleError> { + if x5c_chain.is_empty() { + return Err(AppleError::CertificateError( + "Empty certificate chain".to_string(), + )); + } + + // Verify chain from leaf to root + for i in 0..x5c_chain.len() - 1 { + let cert_bytes = STANDARD + .decode(&x5c_chain[i]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + let _cert = Certificate::from_der(&cert_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + + let issuer_bytes = STANDARD + .decode(&x5c_chain[i + 1]) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + let issuer_cert = Certificate::from_der(&issuer_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + + let issuer_public_key_bytes = issuer_cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(); + + let _issuer_verifying_key = VerifyingKey::from_sec1_bytes(issuer_public_key_bytes) + .map_err(|e| AppleError::CertificateError(e.to_string()))?; + } + + // Verify root certificate against trusted roots + let root_cert_bytes = STANDARD + .decode(x5c_chain.last().unwrap()) + .map_err(|e| AppleError::Base64Error(e.to_string()))?; + + if !self.root_certificates.is_empty() { + let mut root_trusted = false; + for trusted_root in &self.root_certificates { + if *trusted_root == root_cert_bytes { + root_trusted = true; + break; + } + } + if !root_trusted { + return Err(AppleError::CertificateError( + "Root certificate not trusted".to_string(), + )); + } + } + + Ok(()) + } +} diff --git a/src/appstore/subscriptions.rs b/src/appstore/subscriptions.rs new file mode 100644 index 0000000..b33429a --- /dev/null +++ b/src/appstore/subscriptions.rs @@ -0,0 +1,46 @@ +use crate::error::AppleError; + +use super::client::AppStoreServerClient; +use super::models::*; + +impl AppStoreServerClient { + pub async fn get_all_subscription_statuses( + &self, + transaction_id: &str, + ) -> Result { + let path = format!("/inApps/v1/subscriptions/{}", transaction_id); + self.jwt_get(&path).await + } + + pub async fn extend_renewal_date( + &self, + original_transaction_id: &str, + request: &ExtendRenewalDateRequest, + ) -> Result { + let path = format!( + "/inApps/v1/subscriptions/extend/{}", + original_transaction_id + ); + self.jwt_put(&path, request).await + } + + pub async fn mass_extend_renewal_date( + &self, + request: &MassExtendRenewalDateRequest, + ) -> Result { + self.jwt_post("/inApps/v1/subscriptions/extend/mass", request) + .await + } + + pub async fn get_mass_extension_status( + &self, + product_id: &str, + request_identifier: &str, + ) -> Result { + let path = format!( + "/inApps/v1/subscriptions/extend/mass/{}/{}", + product_id, request_identifier + ); + self.jwt_get(&path).await + } +} diff --git a/src/appstore/transactions.rs b/src/appstore/transactions.rs new file mode 100644 index 0000000..50b8c69 --- /dev/null +++ b/src/appstore/transactions.rs @@ -0,0 +1,103 @@ +use crate::error::AppleError; + +use super::client::AppStoreServerClient; +use super::models::*; + +impl AppStoreServerClient { + pub async fn get_transaction_history( + &self, + transaction_id: &str, + revision: Option<&str>, + request: Option<&TransactionHistoryRequest>, + ) -> Result { + let mut path = format!("/inApps/v2/history/{}", transaction_id); + let mut params = Vec::new(); + if let Some(rev) = revision { + params.push(format!("revision={}", rev)); + } + if let Some(req) = request { + if let Some(ref start_date) = req.start_date { + params.push(format!("startDate={}", start_date)); + } + if let Some(ref end_date) = req.end_date { + params.push(format!("endDate={}", end_date)); + } + if let Some(ref product_ids) = req.product_id { + for pid in product_ids { + params.push(format!("productId={}", pid)); + } + } + if let Some(ref product_types) = req.product_type { + for pt in product_types { + let pt_str = serde_json::to_string(pt) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + params.push(format!("productType={}", pt_str)); + } + } + if let Some(ref sort) = req.sort { + let sort_str = serde_json::to_string(sort) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + params.push(format!("sort={}", sort_str)); + } + if let Some(ref sub_groups) = req.subscription_group_identifier { + for sg in sub_groups { + params.push(format!("subscriptionGroupIdentifier={}", sg)); + } + } + if let Some(ref ownership) = req.in_app_ownership_type { + let ownership_str = serde_json::to_string(ownership) + .unwrap_or_default() + .trim_matches('"') + .to_string(); + params.push(format!("inAppOwnershipType={}", ownership_str)); + } + if let Some(revoked) = req.revoked { + params.push(format!("revoked={}", revoked)); + } + } + if !params.is_empty() { + path = format!("{}?{}", path, params.join("&")); + } + self.jwt_get(&path).await + } + + pub async fn get_transaction_history_v1( + &self, + transaction_id: &str, + revision: Option<&str>, + ) -> Result { + let path = match revision { + Some(rev) => format!("/inApps/v1/history/{}?revision={}", transaction_id, rev), + None => format!("/inApps/v1/history/{}", transaction_id), + }; + self.jwt_get(&path).await + } + + pub async fn get_transaction_info( + &self, + transaction_id: &str, + ) -> Result { + let path = format!("/inApps/v1/transactions/{}", transaction_id); + self.jwt_get(&path).await + } + + pub async fn look_up_order_id( + &self, + order_id: &str, + ) -> Result { + let path = format!("/inApps/v1/lookup/{}", order_id); + self.jwt_get(&path).await + } + + pub async fn get_app_transaction( + &self, + transaction_id: &str, + ) -> Result { + let path = format!("/inApps/v1/transactions/appTransactions/{}", transaction_id); + self.jwt_get(&path).await + } +} diff --git a/src/appstore/types.rs b/src/appstore/types.rs new file mode 100644 index 0000000..e3d2a86 --- /dev/null +++ b/src/appstore/types.rs @@ -0,0 +1,943 @@ +#![allow(non_camel_case_types)] + +use serde::{Deserialize, Serialize}; +use std::fmt; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum AppStoreEnvironment { + Sandbox, + Production, + Xcode, + LocalTesting, +} + +impl fmt::Display for AppStoreEnvironment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppStoreEnvironment::Sandbox => write!(f, "Sandbox"), + AppStoreEnvironment::Production => write!(f, "Production"), + AppStoreEnvironment::Xcode => write!(f, "Xcode"), + AppStoreEnvironment::LocalTesting => write!(f, "LocalTesting"), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SubscriptionStatus { + Active, + Expired, + BillingRetryPeriod, + BillingGracePeriod, + Revoked, +} + +impl TryFrom for SubscriptionStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(SubscriptionStatus::Active), + 2 => Ok(SubscriptionStatus::Expired), + 3 => Ok(SubscriptionStatus::BillingRetryPeriod), + 4 => Ok(SubscriptionStatus::BillingGracePeriod), + 5 => Ok(SubscriptionStatus::Revoked), + _ => Err(format!("Unknown SubscriptionStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: SubscriptionStatus) -> Self { + match value { + SubscriptionStatus::Active => 1, + SubscriptionStatus::Expired => 2, + SubscriptionStatus::BillingRetryPeriod => 3, + SubscriptionStatus::BillingGracePeriod => 4, + SubscriptionStatus::Revoked => 5, + } + } +} + +impl Serialize for SubscriptionStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for SubscriptionStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + SubscriptionStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AutoRenewStatus { + Off, + On, +} + +impl TryFrom for AutoRenewStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(AutoRenewStatus::Off), + 1 => Ok(AutoRenewStatus::On), + _ => Err(format!("Unknown AutoRenewStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: AutoRenewStatus) -> Self { + match value { + AutoRenewStatus::Off => 0, + AutoRenewStatus::On => 1, + } + } +} + +impl Serialize for AutoRenewStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for AutoRenewStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + AutoRenewStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExpirationIntent { + Cancelled, + BillingError, + PriceIncreaseNotConsented, + ProductUnavailable, + Unknown, +} + +impl TryFrom for ExpirationIntent { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(ExpirationIntent::Cancelled), + 2 => Ok(ExpirationIntent::BillingError), + 3 => Ok(ExpirationIntent::PriceIncreaseNotConsented), + 4 => Ok(ExpirationIntent::ProductUnavailable), + 5 => Ok(ExpirationIntent::Unknown), + _ => Err(format!("Unknown ExpirationIntent: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: ExpirationIntent) -> Self { + match value { + ExpirationIntent::Cancelled => 1, + ExpirationIntent::BillingError => 2, + ExpirationIntent::PriceIncreaseNotConsented => 3, + ExpirationIntent::ProductUnavailable => 4, + ExpirationIntent::Unknown => 5, + } + } +} + +impl Serialize for ExpirationIntent { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for ExpirationIntent { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + ExpirationIntent::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ProductType { + #[serde(rename = "Auto-Renewable Subscription")] + AutoRenewableSubscription, + #[serde(rename = "Non-Consumable")] + NonConsumable, + #[serde(rename = "Consumable")] + Consumable, + #[serde(rename = "Non-Renewing Subscription")] + NonRenewingSubscription, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum OfferType { + IntroductoryOffer, + PromotionalOffer, + OfferCode, + WinBackOffer, +} + +impl TryFrom for OfferType { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(OfferType::IntroductoryOffer), + 2 => Ok(OfferType::PromotionalOffer), + 3 => Ok(OfferType::OfferCode), + 4 => Ok(OfferType::WinBackOffer), + _ => Err(format!("Unknown OfferType: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: OfferType) -> Self { + match value { + OfferType::IntroductoryOffer => 1, + OfferType::PromotionalOffer => 2, + OfferType::OfferCode => 3, + OfferType::WinBackOffer => 4, + } + } +} + +impl Serialize for OfferType { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for OfferType { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + OfferType::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum OfferDiscountType { + FREE_TRIAL, + PAY_AS_YOU_GO, + PAY_UP_FRONT, + ONE_TIME, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum InAppOwnershipType { + FAMILY_SHARED, + PURCHASED, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TransactionReason { + PURCHASE, + RENEWAL, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RevocationReason { + RefundedDueToIssue, + RefundedForOtherReason, +} + +impl TryFrom for RevocationReason { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(RevocationReason::RefundedForOtherReason), + 1 => Ok(RevocationReason::RefundedDueToIssue), + _ => Err(format!("Unknown RevocationReason: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: RevocationReason) -> Self { + match value { + RevocationReason::RefundedForOtherReason => 0, + RevocationReason::RefundedDueToIssue => 1, + } + } +} + +impl Serialize for RevocationReason { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for RevocationReason { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + RevocationReason::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Platform { + Undeclared, + Apple, + NonApple, +} + +impl TryFrom for Platform { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(Platform::Undeclared), + 1 => Ok(Platform::Apple), + 2 => Ok(Platform::NonApple), + _ => Err(format!("Unknown Platform: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: Platform) -> Self { + match value { + Platform::Undeclared => 0, + Platform::Apple => 1, + Platform::NonApple => 2, + } + } +} + +impl Serialize for Platform { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for Platform { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + Platform::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum PurchasePlatform { + #[serde(rename = "iOS")] + IOS, + #[serde(rename = "macOS")] + MacOS, + #[serde(rename = "tvOS")] + TvOS, + #[serde(rename = "visionOS")] + VisionOS, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PriceIncreaseStatus { + NotResponded, + Consented, +} + +impl TryFrom for PriceIncreaseStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(PriceIncreaseStatus::NotResponded), + 1 => Ok(PriceIncreaseStatus::Consented), + _ => Err(format!("Unknown PriceIncreaseStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: PriceIncreaseStatus) -> Self { + match value { + PriceIncreaseStatus::NotResponded => 0, + PriceIncreaseStatus::Consented => 1, + } + } +} + +impl Serialize for PriceIncreaseStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for PriceIncreaseStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + PriceIncreaseStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum OrderLookupStatus { + Valid, + Invalid, +} + +impl TryFrom for OrderLookupStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(OrderLookupStatus::Valid), + 1 => Ok(OrderLookupStatus::Invalid), + _ => Err(format!("Unknown OrderLookupStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: OrderLookupStatus) -> Self { + match value { + OrderLookupStatus::Valid => 0, + OrderLookupStatus::Invalid => 1, + } + } +} + +impl Serialize for OrderLookupStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for OrderLookupStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + OrderLookupStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ExtendReasonCode { + Undeclared, + CustomerSatisfaction, + OtherReason, + ServiceIssueOrOutage, +} + +impl TryFrom for ExtendReasonCode { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(ExtendReasonCode::Undeclared), + 1 => Ok(ExtendReasonCode::CustomerSatisfaction), + 2 => Ok(ExtendReasonCode::OtherReason), + 3 => Ok(ExtendReasonCode::ServiceIssueOrOutage), + _ => Err(format!("Unknown ExtendReasonCode: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: ExtendReasonCode) -> Self { + match value { + ExtendReasonCode::Undeclared => 0, + ExtendReasonCode::CustomerSatisfaction => 1, + ExtendReasonCode::OtherReason => 2, + ExtendReasonCode::ServiceIssueOrOutage => 3, + } + } +} + +impl Serialize for ExtendReasonCode { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for ExtendReasonCode { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + ExtendReasonCode::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum NotificationTypeV2 { + SUBSCRIBED, + DID_RENEW, + DID_CHANGE_RENEWAL_PREF, + DID_CHANGE_RENEWAL_STATUS, + DID_FAIL_TO_RENEW, + EXPIRED, + GRACE_PERIOD_EXPIRED, + OFFER_REDEEMED, + PRICE_INCREASE, + REFUND, + REFUND_DECLINED, + REFUND_REVERSED, + RENEWAL_EXTENDED, + RENEWAL_EXTENSION, + REVOKE, + TEST, + CONSUMPTION_REQUEST, + EXTERNAL_PURCHASE_TOKEN, + ONE_TIME_CHARGE, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Subtype { + INITIAL_BUY, + RESUBSCRIBE, + DOWNGRADE, + UPGRADE, + AUTO_RENEW_ENABLED, + AUTO_RENEW_DISABLED, + VOLUNTARY, + BILLING_RETRY, + PRICE_INCREASE, + GRACE_PERIOD, + PENDING, + ACCEPTED, + BILLING_RECOVERY, + PRODUCT_NOT_FOR_SALE, + SUMMARY, + FAILURE, + UNREPORTED, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum NotificationTypeV1 { + CANCEL, + DID_CHANGE_RENEWAL_PREF, + DID_CHANGE_RENEWAL_STATUS, + DID_FAIL_TO_RENEW, + DID_RECOVER, + DID_RENEW, + INITIAL_BUY, + INTERACTIVE_RENEWAL, + PRICE_INCREASE_CONSENT, + REFUND, + RENEWAL, + REVOKE, + CONSUMPTION_REQUEST, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum SendAttemptResult { + SUCCESS, + TIMED_OUT, + TLS_ISSUE, + CIRCULAR_REDIRECT, + NO_RESPONSE, + SOCKET_ISSUE, + UNSUPPORTED_CHARSET, + INVALID_RESPONSE, + PREMATURE_CLOSE, + UNSUCCESSFUL_HTTP_RESPONSE_CODE, + OTHER, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ConsumptionRequestReason { + UNINTENDED_PURCHASE, + FULFILLMENT_ISSUE, + UNSATISFIED, + SUSPICIOUS, + OTHER, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DeliveryStatus { + DeliveredAndWorking, + DidNotDeliverDueToQualityIssue, + DeliveredWrongItem, + DidNotDeliverDueToServerOutage, + DidNotDeliverDueToInGameCurrencyChange, + DidNotDeliverForOtherReason, +} + +impl TryFrom for DeliveryStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(DeliveryStatus::DeliveredAndWorking), + 1 => Ok(DeliveryStatus::DidNotDeliverDueToQualityIssue), + 2 => Ok(DeliveryStatus::DeliveredWrongItem), + 3 => Ok(DeliveryStatus::DidNotDeliverDueToServerOutage), + 4 => Ok(DeliveryStatus::DidNotDeliverDueToInGameCurrencyChange), + 5 => Ok(DeliveryStatus::DidNotDeliverForOtherReason), + _ => Err(format!("Unknown DeliveryStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: DeliveryStatus) -> Self { + match value { + DeliveryStatus::DeliveredAndWorking => 0, + DeliveryStatus::DidNotDeliverDueToQualityIssue => 1, + DeliveryStatus::DeliveredWrongItem => 2, + DeliveryStatus::DidNotDeliverDueToServerOutage => 3, + DeliveryStatus::DidNotDeliverDueToInGameCurrencyChange => 4, + DeliveryStatus::DidNotDeliverForOtherReason => 5, + } + } +} + +impl Serialize for DeliveryStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for DeliveryStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + DeliveryStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum RefundPreference { + DECLINE, + GRANT_FULL, + GRANT_PRORATED, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AccountTenure { + Undeclared, + ZeroToThreeDays, + ThreeToTenDays, + TenToThirtyDays, + ThirtyToNinetyDays, + NinetyToOneEightyDays, + OneEightyToThreeSixtyFiveDays, + OverThreeSixtyFiveDays, +} + +impl TryFrom for AccountTenure { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(AccountTenure::Undeclared), + 1 => Ok(AccountTenure::ZeroToThreeDays), + 2 => Ok(AccountTenure::ThreeToTenDays), + 3 => Ok(AccountTenure::TenToThirtyDays), + 4 => Ok(AccountTenure::ThirtyToNinetyDays), + 5 => Ok(AccountTenure::NinetyToOneEightyDays), + 6 => Ok(AccountTenure::OneEightyToThreeSixtyFiveDays), + 7 => Ok(AccountTenure::OverThreeSixtyFiveDays), + _ => Err(format!("Unknown AccountTenure: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: AccountTenure) -> Self { + match value { + AccountTenure::Undeclared => 0, + AccountTenure::ZeroToThreeDays => 1, + AccountTenure::ThreeToTenDays => 2, + AccountTenure::TenToThirtyDays => 3, + AccountTenure::ThirtyToNinetyDays => 4, + AccountTenure::NinetyToOneEightyDays => 5, + AccountTenure::OneEightyToThreeSixtyFiveDays => 6, + AccountTenure::OverThreeSixtyFiveDays => 7, + } + } +} + +impl Serialize for AccountTenure { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for AccountTenure { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + AccountTenure::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConsumptionStatus { + Undeclared, + NotConsumed, + PartiallyConsumed, + FullyConsumed, +} + +impl TryFrom for ConsumptionStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(ConsumptionStatus::Undeclared), + 1 => Ok(ConsumptionStatus::NotConsumed), + 2 => Ok(ConsumptionStatus::PartiallyConsumed), + 3 => Ok(ConsumptionStatus::FullyConsumed), + _ => Err(format!("Unknown ConsumptionStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: ConsumptionStatus) -> Self { + match value { + ConsumptionStatus::Undeclared => 0, + ConsumptionStatus::NotConsumed => 1, + ConsumptionStatus::PartiallyConsumed => 2, + ConsumptionStatus::FullyConsumed => 3, + } + } +} + +impl Serialize for ConsumptionStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for ConsumptionStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + ConsumptionStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum PlayTime { + Undeclared, + ZeroToFiveMinutes, + FiveToSixtyMinutes, + OneToSixHours, + SixToTwentyFourHours, + OneToFourDays, + FourToSixteenDays, + OverSixteenDays, +} + +impl TryFrom for PlayTime { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(PlayTime::Undeclared), + 1 => Ok(PlayTime::ZeroToFiveMinutes), + 2 => Ok(PlayTime::FiveToSixtyMinutes), + 3 => Ok(PlayTime::OneToSixHours), + 4 => Ok(PlayTime::SixToTwentyFourHours), + 5 => Ok(PlayTime::OneToFourDays), + 6 => Ok(PlayTime::FourToSixteenDays), + 7 => Ok(PlayTime::OverSixteenDays), + _ => Err(format!("Unknown PlayTime: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: PlayTime) -> Self { + match value { + PlayTime::Undeclared => 0, + PlayTime::ZeroToFiveMinutes => 1, + PlayTime::FiveToSixtyMinutes => 2, + PlayTime::OneToSixHours => 3, + PlayTime::SixToTwentyFourHours => 4, + PlayTime::OneToFourDays => 5, + PlayTime::FourToSixteenDays => 6, + PlayTime::OverSixteenDays => 7, + } + } +} + +impl Serialize for PlayTime { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for PlayTime { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + PlayTime::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LifetimeDollarsPurchased { + Undeclared, + Zero, + OneToFortyNine, + FiftyToNinetyNine, + OneHundredToFourNinetyNine, + FiveHundredToNineNinetyNine, + OneThousandToOneThousandNineNinetyNine, + OverTwoThousand, +} + +impl TryFrom for LifetimeDollarsPurchased { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(LifetimeDollarsPurchased::Undeclared), + 1 => Ok(LifetimeDollarsPurchased::Zero), + 2 => Ok(LifetimeDollarsPurchased::OneToFortyNine), + 3 => Ok(LifetimeDollarsPurchased::FiftyToNinetyNine), + 4 => Ok(LifetimeDollarsPurchased::OneHundredToFourNinetyNine), + 5 => Ok(LifetimeDollarsPurchased::FiveHundredToNineNinetyNine), + 6 => Ok(LifetimeDollarsPurchased::OneThousandToOneThousandNineNinetyNine), + 7 => Ok(LifetimeDollarsPurchased::OverTwoThousand), + _ => Err(format!("Unknown LifetimeDollarsPurchased: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: LifetimeDollarsPurchased) -> Self { + match value { + LifetimeDollarsPurchased::Undeclared => 0, + LifetimeDollarsPurchased::Zero => 1, + LifetimeDollarsPurchased::OneToFortyNine => 2, + LifetimeDollarsPurchased::FiftyToNinetyNine => 3, + LifetimeDollarsPurchased::OneHundredToFourNinetyNine => 4, + LifetimeDollarsPurchased::FiveHundredToNineNinetyNine => 5, + LifetimeDollarsPurchased::OneThousandToOneThousandNineNinetyNine => 6, + LifetimeDollarsPurchased::OverTwoThousand => 7, + } + } +} + +impl Serialize for LifetimeDollarsPurchased { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for LifetimeDollarsPurchased { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + LifetimeDollarsPurchased::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LifetimeDollarsRefunded { + Undeclared, + Zero, + OneToFortyNine, + FiftyToNinetyNine, + OneHundredToFourNinetyNine, + FiveHundredToNineNinetyNine, + OneThousandToOneThousandNineNinetyNine, + OverTwoThousand, +} + +impl TryFrom for LifetimeDollarsRefunded { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(LifetimeDollarsRefunded::Undeclared), + 1 => Ok(LifetimeDollarsRefunded::Zero), + 2 => Ok(LifetimeDollarsRefunded::OneToFortyNine), + 3 => Ok(LifetimeDollarsRefunded::FiftyToNinetyNine), + 4 => Ok(LifetimeDollarsRefunded::OneHundredToFourNinetyNine), + 5 => Ok(LifetimeDollarsRefunded::FiveHundredToNineNinetyNine), + 6 => Ok(LifetimeDollarsRefunded::OneThousandToOneThousandNineNinetyNine), + 7 => Ok(LifetimeDollarsRefunded::OverTwoThousand), + _ => Err(format!("Unknown LifetimeDollarsRefunded: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: LifetimeDollarsRefunded) -> Self { + match value { + LifetimeDollarsRefunded::Undeclared => 0, + LifetimeDollarsRefunded::Zero => 1, + LifetimeDollarsRefunded::OneToFortyNine => 2, + LifetimeDollarsRefunded::FiftyToNinetyNine => 3, + LifetimeDollarsRefunded::OneHundredToFourNinetyNine => 4, + LifetimeDollarsRefunded::FiveHundredToNineNinetyNine => 5, + LifetimeDollarsRefunded::OneThousandToOneThousandNineNinetyNine => 6, + LifetimeDollarsRefunded::OverTwoThousand => 7, + } + } +} + +impl Serialize for LifetimeDollarsRefunded { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for LifetimeDollarsRefunded { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + LifetimeDollarsRefunded::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UserStatus { + Undeclared, + Active, + Suspended, + Terminated, + LimitedAccess, +} + +impl TryFrom for UserStatus { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 0 => Ok(UserStatus::Undeclared), + 1 => Ok(UserStatus::Active), + 2 => Ok(UserStatus::Suspended), + 3 => Ok(UserStatus::Terminated), + 4 => Ok(UserStatus::LimitedAccess), + _ => Err(format!("Unknown UserStatus: {}", value)), + } + } +} + +impl From for i32 { + fn from(value: UserStatus) -> Self { + match value { + UserStatus::Undeclared => 0, + UserStatus::Active => 1, + UserStatus::Suspended => 2, + UserStatus::Terminated => 3, + UserStatus::LimitedAccess => 4, + } + } +} + +impl Serialize for UserStatus { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_i32(i32::from(self.clone())) + } +} + +impl<'de> Deserialize<'de> for UserStatus { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + UserStatus::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum TransactionHistoryProductType { + AUTO_RENEWABLE, + NON_RENEWABLE, + CONSUMABLE, + NON_CONSUMABLE, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Order { + ASCENDING, + DESCENDING, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum ImageState { + PENDING_REVIEW, + APPROVED, + REJECTED, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MessageState { + PENDING_REVIEW, + APPROVED, + REJECTED, +} diff --git a/src/cloudkit/mod.rs b/src/cloudkit/mod.rs index a089a05..c57b0ce 100644 --- a/src/cloudkit/mod.rs +++ b/src/cloudkit/mod.rs @@ -2,6 +2,7 @@ pub mod assets; pub mod changes; pub mod client; pub(crate) mod error; +pub mod notifications; pub mod query; pub mod records; pub mod subscriptions; @@ -13,6 +14,10 @@ pub mod zones; pub use assets::{AssetTokenInfo, AssetUploadResponse, AssetUploadResult}; pub use changes::{DatabaseChangesResponse, ZoneChangeInfo, ZoneChangesResponse}; pub use client::{CloudKitClient, CloudKitConfig}; +pub use notifications::{ + APNsCloudKitPayload, CKDatabaseNotification, CKNotification, CKQueryNotification, + CKRecordZoneNotification, DatabaseScope, QueryNotificationReason, +}; pub use query::{Comparator, Filter, Query, QueryBuilder, SortDescriptor}; pub use records::{ModifyRecordsResponse, QueryResponse, RecordResult}; pub use subscriptions::{ListSubscriptionsResponse, ModifySubscriptionsResponse}; diff --git a/src/cloudkit/notifications.rs b/src/cloudkit/notifications.rs new file mode 100644 index 0000000..299d788 --- /dev/null +++ b/src/cloudkit/notifications.rs @@ -0,0 +1,251 @@ +use crate::error::AppleError; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq)] +pub enum QueryNotificationReason { + RecordCreated, + RecordUpdated, + RecordDeleted, +} + +impl TryFrom for QueryNotificationReason { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(QueryNotificationReason::RecordCreated), + 2 => Ok(QueryNotificationReason::RecordUpdated), + 3 => Ok(QueryNotificationReason::RecordDeleted), + _ => Err(format!("Unknown QueryNotificationReason: {}", value)), + } + } +} + +impl Serialize for QueryNotificationReason { + fn serialize(&self, serializer: S) -> Result { + let value = match self { + QueryNotificationReason::RecordCreated => 1, + QueryNotificationReason::RecordUpdated => 2, + QueryNotificationReason::RecordDeleted => 3, + }; + serializer.serialize_i32(value) + } +} + +impl<'de> Deserialize<'de> for QueryNotificationReason { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + QueryNotificationReason::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum DatabaseScope { + Public, + Private, + Shared, +} + +impl TryFrom for DatabaseScope { + type Error = String; + fn try_from(value: i32) -> Result { + match value { + 1 => Ok(DatabaseScope::Public), + 2 => Ok(DatabaseScope::Private), + 3 => Ok(DatabaseScope::Shared), + _ => Err(format!("Unknown DatabaseScope: {}", value)), + } + } +} + +impl Serialize for DatabaseScope { + fn serialize(&self, serializer: S) -> Result { + let value = match self { + DatabaseScope::Public => 1, + DatabaseScope::Private => 2, + DatabaseScope::Shared => 3, + }; + serializer.serialize_i32(value) + } +} + +impl<'de> Deserialize<'de> for DatabaseScope { + fn deserialize>(deserializer: D) -> Result { + let value = i32::deserialize(deserializer)?; + DatabaseScope::try_from(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct APNsCloudKitPayload { + #[serde(skip_serializing_if = "Option::is_none")] + pub aps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ck: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CKNotificationPayload { + #[serde(rename = "cid", skip_serializing_if = "Option::is_none")] + pub container_id: Option, + #[serde(rename = "nid", skip_serializing_if = "Option::is_none")] + pub notification_id: Option, + #[serde(rename = "p", skip_serializing_if = "Option::is_none")] + pub is_pruned: Option, + // Query notification fields + #[serde(rename = "rid", skip_serializing_if = "Option::is_none")] + pub record_id: Option, + #[serde(rename = "zid", skip_serializing_if = "Option::is_none")] + pub zone_id: Option, + #[serde(rename = "zoid", skip_serializing_if = "Option::is_none")] + pub zone_owner: Option, + #[serde(rename = "fo", skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(rename = "af", skip_serializing_if = "Option::is_none")] + pub record_fields: Option, + #[serde(rename = "rt", skip_serializing_if = "Option::is_none")] + pub record_type: Option, + #[serde(rename = "dbs", skip_serializing_if = "Option::is_none")] + pub database_scope: Option, +} + +#[derive(Debug, Clone)] +pub enum CKNotification { + Query(CKQueryNotification), + RecordZone(CKRecordZoneNotification), + Database(CKDatabaseNotification), +} + +#[derive(Debug, Clone)] +pub struct CKQueryNotification { + pub container_id: Option, + pub notification_id: Option, + pub is_pruned: Option, + pub record_id: Option, + pub zone_id: Option, + pub zone_owner: Option, + pub reason: Option, + pub record_fields: Option, + pub record_type: Option, + pub database_scope: Option, +} + +#[derive(Debug, Clone)] +pub struct CKRecordZoneNotification { + pub container_id: Option, + pub notification_id: Option, + pub is_pruned: Option, + pub zone_id: Option, + pub zone_owner: Option, + pub database_scope: Option, +} + +#[derive(Debug, Clone)] +pub struct CKDatabaseNotification { + pub container_id: Option, + pub notification_id: Option, + pub is_pruned: Option, + pub database_scope: Option, +} + +pub fn parse_notification(json: &str) -> Result { + let payload: APNsCloudKitPayload = + serde_json::from_str(json).map_err(|e| AppleError::JsonError(e.to_string()))?; + + let ck = payload + .ck + .ok_or_else(|| AppleError::JsonError("Missing 'ck' field in payload".to_string()))?; + + let db_scope = ck + .database_scope + .and_then(|v| DatabaseScope::try_from(v).ok()); + + // Determine notification type based on fields present + if ck.record_id.is_some() || ck.record_type.is_some() || ck.reason.is_some() { + // Query notification + let reason = ck + .reason + .and_then(|v| QueryNotificationReason::try_from(v).ok()); + + Ok(CKNotification::Query(CKQueryNotification { + container_id: ck.container_id, + notification_id: ck.notification_id, + is_pruned: ck.is_pruned, + record_id: ck.record_id, + zone_id: ck.zone_id, + zone_owner: ck.zone_owner, + reason, + record_fields: ck.record_fields, + record_type: ck.record_type, + database_scope: db_scope, + })) + } else if ck.zone_id.is_some() { + // Record zone notification + Ok(CKNotification::RecordZone(CKRecordZoneNotification { + container_id: ck.container_id, + notification_id: ck.notification_id, + is_pruned: ck.is_pruned, + zone_id: ck.zone_id, + zone_owner: ck.zone_owner, + database_scope: db_scope, + })) + } else { + // Database notification + Ok(CKNotification::Database(CKDatabaseNotification { + container_id: ck.container_id, + notification_id: ck.notification_id, + is_pruned: ck.is_pruned, + database_scope: db_scope, + })) + } +} + +impl super::client::CloudKitClient { + pub async fn poll_notifications( + &self, + webcourier_url: &str, + ) -> Result, AppleError> { + let long_poll_client = Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let res = long_poll_client + .get(webcourier_url) + .send() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|e| AppleError::HttpError(e.to_string()))?; + + if !status.is_success() { + return Err(AppleError::HttpError(format!( + "WebCourier polling failed with status: {}", + status + ))); + } + + // Parse the response - the webcourier returns a JSON object + let response: serde_json::Value = + serde_json::from_str(&body).map_err(|e| AppleError::JsonError(e.to_string()))?; + + let mut notifications = Vec::new(); + + if let Some(items) = response.get("notifications").and_then(|v| v.as_array()) { + for item in items { + let item_str = serde_json::to_string(item) + .map_err(|e| AppleError::JsonError(e.to_string()))?; + if let Ok(notification) = parse_notification(&item_str) { + notifications.push(notification); + } + } + } + + Ok(notifications) + } +} diff --git a/src/cloudkit/types.rs b/src/cloudkit/types.rs index d101e10..4d33c13 100644 --- a/src/cloudkit/types.rs +++ b/src/cloudkit/types.rs @@ -309,4 +309,30 @@ pub struct NotificationInfo { skip_serializing_if = "Option::is_none" )] pub should_send_mutable_content: Option, + #[serde(rename = "collapseIdKey", skip_serializing_if = "Option::is_none")] + pub collapse_id_key: Option, + #[serde(rename = "desiredKeys", skip_serializing_if = "Option::is_none")] + pub desired_keys: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde( + rename = "titleLocalizationKey", + skip_serializing_if = "Option::is_none" + )] + pub title_localization_key: Option, + #[serde( + rename = "titleLocalizationArgs", + skip_serializing_if = "Option::is_none" + )] + pub title_localization_args: Option>, + #[serde( + rename = "subtitleLocalizationKey", + skip_serializing_if = "Option::is_none" + )] + pub subtitle_localization_key: Option, + #[serde( + rename = "subtitleLocalizationArgs", + skip_serializing_if = "Option::is_none" + )] + pub subtitle_localization_args: Option>, } diff --git a/src/error.rs b/src/error.rs index 7ac0d94..0f800e6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -16,6 +16,10 @@ pub enum AppleError { CloudKitError(CloudKitErrorResponse), #[cfg(feature = "cloudkit")] SignatureError(String), + #[cfg(feature = "appstore")] + AppStoreError(AppStoreErrorResponse), + #[cfg(feature = "appstore")] + CertificateError(String), } #[derive(Debug, Clone)] @@ -146,6 +150,24 @@ impl fmt::Display for CloudKitErrorResponse { } } +#[cfg(feature = "appstore")] +#[derive(Debug, Clone)] +pub struct AppStoreErrorResponse { + pub error_code: i64, + pub error_message: String, +} + +#[cfg(feature = "appstore")] +impl fmt::Display for AppStoreErrorResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "App Store error {}: {}", + self.error_code, self.error_message + ) + } +} + impl fmt::Display for ErrorResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}: {}", self.error_type, self.message) @@ -182,6 +204,10 @@ impl fmt::Display for AppleError { AppleError::CloudKitError(err) => write!(f, "{}", err), #[cfg(feature = "cloudkit")] AppleError::SignatureError(msg) => write!(f, "Signature error: {}", msg), + #[cfg(feature = "appstore")] + AppleError::AppStoreError(err) => write!(f, "{}", err), + #[cfg(feature = "appstore")] + AppleError::CertificateError(msg) => write!(f, "Certificate error: {}", msg), } } } diff --git a/src/lib.rs b/src/lib.rs index 6504da9..df69051 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,9 @@ pub mod user; #[cfg(feature = "cloudkit")] pub mod cloudkit; +#[cfg(feature = "appstore")] +pub mod appstore; + #[derive(Serialize, Deserialize)] pub struct TokenResponse { pub access_token: String, diff --git a/tests/cloudkit_types_tests.rs b/tests/cloudkit_types_tests.rs index b8b5deb..73ac1e7 100644 --- a/tests/cloudkit_types_tests.rs +++ b/tests/cloudkit_types_tests.rs @@ -243,6 +243,13 @@ mod cloudkit_types_tests { should_badge: Some(true), should_send_content_available: None, should_send_mutable_content: None, + collapse_id_key: None, + desired_keys: None, + category: None, + title_localization_key: None, + title_localization_args: None, + subtitle_localization_key: None, + subtitle_localization_args: None, }; let json = serde_json::to_string(&info).unwrap(); assert!(json.contains("\"alertBody\":\"hello\""));