From 222a6014cc2c7def9dbfdb3e61c73b8932a4667d Mon Sep 17 00:00:00 2001 From: tikhop Date: Fri, 26 Jan 2024 00:28:58 +0100 Subject: [PATCH 1/2] feat: AppStore server API client --- Cargo.toml | 14 +- README.md | 31 +- assets/models/apiException.json | 4 + .../models/apiTooManyRequestsException.json | 4 + assets/models/apiUnknownError.json | 4 + ...alDateForAllActiveSubscribersResponse.json | 3 + ...extendSubscriptionRenewalDateResponse.json | 6 + .../getAllSubscriptionStatusesResponse.json | 35 + .../getNotificationHistoryResponse.json | 27 + assets/models/getRefundHistoryResponse.json | 8 + ...criptionRenewalDateExtensionsResponse.json | 7 + .../getTestNotificationStatusResponse.json | 12 + assets/models/lookupOrderIdResponse.json | 7 + .../requestTestNotificationResponse.json | 3 + assets/models/transactionHistoryResponse.json | 11 + ...istoryResponseWithMalformedAppAppleId.json | 11 + ...storyResponseWithMalformedEnvironment.json | 11 + assets/models/transactionInfoResponse.json | 3 + assets/testSigningKey.p8 | 5 + src/api_client.rs | 1089 +++++++++++++++++ src/lib.rs | 10 +- .../check_test_notification_response.rs | 2 +- src/primitives/environment.rs | 12 + src/primitives/error_payload.rs | 256 +++- .../extend_renewal_date_response.rs | 4 + src/primitives/in_app_ownership_type.rs | 10 + src/primitives/last_transactions_item.rs | 2 +- ...ass_extend_renewal_date_status_response.rs | 15 +- .../notification_history_request.rs | 11 +- .../notification_history_response_item.rs | 2 +- src/primitives/send_attempt_item.rs | 2 +- src/primitives/status.rs | 12 + .../subscription_group_identifier_item.rs | 2 +- src/primitives/transaction_history_request.rs | 20 + src/receipt_utility.rs | 2 +- 35 files changed, 1637 insertions(+), 20 deletions(-) create mode 100644 assets/models/apiException.json create mode 100644 assets/models/apiTooManyRequestsException.json create mode 100644 assets/models/apiUnknownError.json create mode 100644 assets/models/extendRenewalDateForAllActiveSubscribersResponse.json create mode 100644 assets/models/extendSubscriptionRenewalDateResponse.json create mode 100644 assets/models/getAllSubscriptionStatusesResponse.json create mode 100644 assets/models/getNotificationHistoryResponse.json create mode 100644 assets/models/getRefundHistoryResponse.json create mode 100644 assets/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json create mode 100644 assets/models/getTestNotificationStatusResponse.json create mode 100644 assets/models/lookupOrderIdResponse.json create mode 100644 assets/models/requestTestNotificationResponse.json create mode 100644 assets/models/transactionHistoryResponse.json create mode 100644 assets/models/transactionHistoryResponseWithMalformedAppAppleId.json create mode 100644 assets/models/transactionHistoryResponseWithMalformedEnvironment.json create mode 100644 assets/models/transactionInfoResponse.json create mode 100644 assets/testSigningKey.p8 create mode 100644 src/api_client.rs diff --git a/Cargo.toml b/Cargo.toml index 6afdad7..93768c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,11 +29,23 @@ chrono = { version = "0.4.32", features = ["serde"] } base64 = "0.21.7" asn1-rs = { version = "0.5.2", optional = true } +# Networking +reqwest = { version = "0.11.23", features = ["json"], optional = true } + # Utils thiserror = "1.0.56" # Tools regex = { version = "1.10.3", optional = true } +url = "2.5.0" + + +[dev-dependencies] +http = "1.0.0" +tokio = { version = "1.35.1", features = ["test-util", "macros"] } +jsonwebtoken = { version = "9.2.0", features = ["use_pem"] } [features] -receipt_utility = ["dep:asn1-rs", "dep:regex"] +api-client = ["dep:reqwest"] +receipt-utility = ["dep:asn1-rs", "dep:regex"] + diff --git a/README.md b/README.md index cc28efa..0cd6d2b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,35 @@ Check ## Usage +### API Usage + +```rust +use app_store_server_library::{AppStoreServerApiClient, Environment, AppStoreApiResponse, APIError}; + +#[tokio::main] +async fn main() { + let issuer_id = "99b16628-15e4-4668-972b-eeff55eeff55"; + let key_id = "ABCDEFGHIJ"; + let bundle_id = "com.example"; + let encoded_key = std::fs::read_to_string("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8").unwrap(); // Adjust the path accordingly + let environment = Environment::Sandbox; + + let client = AppStoreServerApiClient::new(encoded_key, key_id, issuer_id, bundle_id, environment); + match client.request_test_notification().await { + Ok(res) => { + println!("{}", response.test_notification_token); + } + Err(err) => { + println!("{}", err.http_status_code); + println!("{:?}", err.raw_api_error); + println!("{:?}", err.api_error); + println!("{}", err.error_message); + } + } +} +``` +> Note: To extract transaction id from app/tx receipt, `api-client` feature must be enabled. + ### Verification Usage ```rust @@ -37,7 +66,7 @@ let decoded_payload = verifier.verify_and_decode_notification(payload).unwrap(); let receipt = "MI.."; let transaction_id = extract_transaction_id_from_app_receipt(receipt); ``` -> Note: To extract transaction id from app/tx receipt, `receipt_utility` feature must be enabled. +> Note: To extract transaction id from app/tx receipt, `receipt-utility` feature must be enabled. ### Promotional Offer Signature Creation ```rust diff --git a/assets/models/apiException.json b/assets/models/apiException.json new file mode 100644 index 0000000..a270c53 --- /dev/null +++ b/assets/models/apiException.json @@ -0,0 +1,4 @@ +{ + "errorCode": 5000000, + "errorMessage": "An unknown error occurred." +} \ No newline at end of file diff --git a/assets/models/apiTooManyRequestsException.json b/assets/models/apiTooManyRequestsException.json new file mode 100644 index 0000000..3b3cb9c --- /dev/null +++ b/assets/models/apiTooManyRequestsException.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4290000, + "errorMessage": "Rate limit exceeded." +} \ No newline at end of file diff --git a/assets/models/apiUnknownError.json b/assets/models/apiUnknownError.json new file mode 100644 index 0000000..9377dd7 --- /dev/null +++ b/assets/models/apiUnknownError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 9990000, + "errorMessage": "Testing error." +} \ No newline at end of file diff --git a/assets/models/extendRenewalDateForAllActiveSubscribersResponse.json b/assets/models/extendRenewalDateForAllActiveSubscribersResponse.json new file mode 100644 index 0000000..21fd582 --- /dev/null +++ b/assets/models/extendRenewalDateForAllActiveSubscribersResponse.json @@ -0,0 +1,3 @@ +{ + "requestIdentifier": "758883e8-151b-47b7-abd0-60c4d804c2f5" +} \ No newline at end of file diff --git a/assets/models/extendSubscriptionRenewalDateResponse.json b/assets/models/extendSubscriptionRenewalDateResponse.json new file mode 100644 index 0000000..6c5f89f --- /dev/null +++ b/assets/models/extendSubscriptionRenewalDateResponse.json @@ -0,0 +1,6 @@ +{ + "originalTransactionId": "2312412", + "webOrderLineItemId": "9993", + "success": true, + "effectiveDate": 1698148900000 +} \ No newline at end of file diff --git a/assets/models/getAllSubscriptionStatusesResponse.json b/assets/models/getAllSubscriptionStatusesResponse.json new file mode 100644 index 0000000..b5d139c --- /dev/null +++ b/assets/models/getAllSubscriptionStatusesResponse.json @@ -0,0 +1,35 @@ +{ + "environment": "LocalTesting", + "bundleId": "com.example", + "appAppleId": 5454545, + "data": [ + { + "subscriptionGroupIdentifier": "sub_group_one", + "lastTransactions": [ + { + "status": 1, + "originalTransactionId": "3749183", + "signedTransactionInfo": "signed_transaction_one", + "signedRenewalInfo": "signed_renewal_one" + }, + { + "status": 5, + "originalTransactionId": "5314314134", + "signedTransactionInfo": "signed_transaction_two", + "signedRenewalInfo": "signed_renewal_two" + } + ] + }, + { + "subscriptionGroupIdentifier": "sub_group_two", + "lastTransactions": [ + { + "status": 2, + "originalTransactionId": "3413453", + "signedTransactionInfo": "signed_transaction_three", + "signedRenewalInfo": "signed_renewal_three" + } + ] + } + ] +} \ No newline at end of file diff --git a/assets/models/getNotificationHistoryResponse.json b/assets/models/getNotificationHistoryResponse.json new file mode 100644 index 0000000..75c27b7 --- /dev/null +++ b/assets/models/getNotificationHistoryResponse.json @@ -0,0 +1,27 @@ +{ + "paginationToken": "57715481-805a-4283-8499-1c19b5d6b20a", + "hasMore": true, + "notificationHistory": [ + { + "sendAttempts": [ + { + "attemptDate": 1698148900000, + "sendAttemptResult": "NO_RESPONSE" + }, { + "attemptDate": 1698148950000, + "sendAttemptResult": "SUCCESS" + } + ], + "signedPayload": "signed_payload_one" + }, + { + "sendAttempts": [ + { + "attemptDate": 1698148800000, + "sendAttemptResult": "CIRCULAR_REDIRECT" + } + ], + "signedPayload": "signed_payload_two" + } + ] +} \ No newline at end of file diff --git a/assets/models/getRefundHistoryResponse.json b/assets/models/getRefundHistoryResponse.json new file mode 100644 index 0000000..d1ff65a --- /dev/null +++ b/assets/models/getRefundHistoryResponse.json @@ -0,0 +1,8 @@ +{ + "signedTransactions": [ + "signed_transaction_one", + "signed_transaction_two" + ], + "revision": "revision_output", + "hasMore": true +} \ No newline at end of file diff --git a/assets/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json b/assets/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json new file mode 100644 index 0000000..9bd7ddc --- /dev/null +++ b/assets/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json @@ -0,0 +1,7 @@ +{ + "requestIdentifier": "20fba8a0-2b80-4a7d-a17f-85c1854727f8", + "complete": true, + "completeDate": 1698148900000, + "succeededCount": 30, + "failedCount": 2 +} \ No newline at end of file diff --git a/assets/models/getTestNotificationStatusResponse.json b/assets/models/getTestNotificationStatusResponse.json new file mode 100644 index 0000000..9f83b90 --- /dev/null +++ b/assets/models/getTestNotificationStatusResponse.json @@ -0,0 +1,12 @@ +{ + "signedPayload": "signed_payload", + "sendAttempts": [ + { + "attemptDate": 1698148900000, + "sendAttemptResult": "NO_RESPONSE" + }, { + "attemptDate": 1698148950000, + "sendAttemptResult": "SUCCESS" + } + ] +} \ No newline at end of file diff --git a/assets/models/lookupOrderIdResponse.json b/assets/models/lookupOrderIdResponse.json new file mode 100644 index 0000000..09a43d5 --- /dev/null +++ b/assets/models/lookupOrderIdResponse.json @@ -0,0 +1,7 @@ +{ + "status": 1, + "signedTransactions": [ + "signed_transaction_one", + "signed_transaction_two" + ] +} \ No newline at end of file diff --git a/assets/models/requestTestNotificationResponse.json b/assets/models/requestTestNotificationResponse.json new file mode 100644 index 0000000..3ecc9e2 --- /dev/null +++ b/assets/models/requestTestNotificationResponse.json @@ -0,0 +1,3 @@ +{ + "testNotificationToken": "ce3af791-365e-4c60-841b-1674b43c1609" +} \ No newline at end of file diff --git a/assets/models/transactionHistoryResponse.json b/assets/models/transactionHistoryResponse.json new file mode 100644 index 0000000..c5cc638 --- /dev/null +++ b/assets/models/transactionHistoryResponse.json @@ -0,0 +1,11 @@ +{ + "revision": "revision_output", + "hasMore": true, + "bundleId": "com.example", + "appAppleId": 323232, + "environment": "LocalTesting", + "signedTransactions": [ + "signed_transaction_value", + "signed_transaction_value2" + ] +} \ No newline at end of file diff --git a/assets/models/transactionHistoryResponseWithMalformedAppAppleId.json b/assets/models/transactionHistoryResponseWithMalformedAppAppleId.json new file mode 100644 index 0000000..29cd990 --- /dev/null +++ b/assets/models/transactionHistoryResponseWithMalformedAppAppleId.json @@ -0,0 +1,11 @@ +{ + "revision": "revision_output", + "hasMore": 1, + "bundleId": "com.example", + "appAppleId": "hi", + "environment": "LocalTesting", + "signedTransactions": [ + "signed_transaction_value", + "signed_transaction_value2" + ] + } \ No newline at end of file diff --git a/assets/models/transactionHistoryResponseWithMalformedEnvironment.json b/assets/models/transactionHistoryResponseWithMalformedEnvironment.json new file mode 100644 index 0000000..9d5d56d --- /dev/null +++ b/assets/models/transactionHistoryResponseWithMalformedEnvironment.json @@ -0,0 +1,11 @@ +{ + "revision": "revision_output", + "hasMore": true, + "bundleId": "com.example", + "appAppleId": 323232, + "environment": "LocalTestingxxx", + "signedTransactions": [ + "signed_transaction_value", + "signed_transaction_value2" + ] + } \ No newline at end of file diff --git a/assets/models/transactionInfoResponse.json b/assets/models/transactionInfoResponse.json new file mode 100644 index 0000000..57d84e2 --- /dev/null +++ b/assets/models/transactionInfoResponse.json @@ -0,0 +1,3 @@ +{ + "signedTransactionInfo": "signed_transaction_info_value" +} \ No newline at end of file diff --git a/assets/testSigningKey.p8 b/assets/testSigningKey.p8 new file mode 100644 index 0000000..0622800 --- /dev/null +++ b/assets/testSigningKey.p8 @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgSpP55ELdXswj9JRZ +APRwtTfS4CNRqpKIs+28rNHiPAqhRANCAASs8nLES7b+goKslppNVOurf0MonZdw +3pb6TxS8Z/5j+UNY1sWK1ChxpuwNS9I3R50cfdQo/lA9PPhw6XIg8ytd +-----END PRIVATE KEY----- diff --git a/src/api_client.rs b/src/api_client.rs new file mode 100644 index 0000000..c656c42 --- /dev/null +++ b/src/api_client.rs @@ -0,0 +1,1089 @@ +use std::collections::HashMap; +use std::fmt; +use chrono::{Utc}; +use serde::{Serialize, Deserialize}; +use reqwest::{Client, RequestBuilder, Method}; +use jsonwebtoken::{Header, Algorithm, encode, EncodingKey}; +use reqwest::header::HeaderMap; +use crate::primitives::check_test_notification_response::CheckTestNotificationResponse; +use crate::primitives::consumption_request::ConsumptionRequest; +use crate::primitives::environment::Environment; +use crate::primitives::extend_renewal_date_request::ExtendRenewalDateRequest; +use crate::primitives::extend_renewal_date_response::ExtendRenewalDateResponse; +use crate::primitives::history_response::HistoryResponse; +use crate::primitives::mass_extend_renewal_date_request::MassExtendRenewalDateRequest; +use crate::primitives::mass_extend_renewal_date_status_response::MassExtendRenewalDateStatusResponse; +use crate::primitives::notification_history_request::NotificationHistoryRequest; +use crate::primitives::notification_history_response::NotificationHistoryResponse; +use crate::primitives::order_lookup_response::OrderLookupResponse; +use crate::primitives::refund_history_response::RefundHistoryResponse; +use crate::primitives::send_test_notification_response::SendTestNotificationResponse; +use crate::primitives::status::Status; +use crate::primitives::status_response::StatusResponse; +use crate::primitives::transaction_history_request::TransactionHistoryRequest; +use crate::primitives::transaction_info_response::TransactionInfoResponse; + +#[derive(Debug, Serialize, Deserialize)] +pub struct APIException { + http_status_code: u16, + api_error: Option, + raw_api_error: Option, + error_message: Option, +} + +impl fmt::Display for APIException { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "APIException: HTTP Status Code {}", self.http_status_code)?; + if let Some(api_error) = &self.api_error { + write!(f, ", API Error: {:?}", api_error)?; + } + if let Some(raw_api_error) = &self.raw_api_error { + write!(f, ", Raw API Error: {}", raw_api_error)?; + } + if let Some(error_message) = &self.error_message { + write!(f, ", Error Message: {}", error_message)?; + } + Ok(()) + } +} + +#[cfg(test)] +use http::Response; +use serde_json::Value; +use crate::primitives::error_payload::{APIError, ErrorPayload}; + +impl std::error::Error for APIException {} + +#[cfg(test)] +type RequestVerifier = fn(&reqwest::Request, Option<&[u8]>) -> (); +#[cfg(test)] +type RequestOverride = dyn Fn(&reqwest::Request, Option<&[u8]>) -> http::Response>; + +pub struct AppStoreServerAPIClient { + base_url: String, + signing_key: Vec, + key_id: String, + issuer_id: String, + bundle_id: String, + client: Client, + #[cfg(test)] + request_override: Box, +} + +impl AppStoreServerAPIClient { + #[cfg(not(test))] + pub fn new(signing_key: Vec, key_id: &str, issuer_id: &str, bundle_id: &str, environment: Environment) -> Self { + let base_url = environment.base_url(); + let client = Client::new(); + Self { base_url, signing_key, key_id: key_id.to_string(), issuer_id: issuer_id.to_string(), bundle_id: bundle_id.to_string(), client } + } + + #[cfg(test)] + pub fn new(signing_key: Vec, key_id: &str, issuer_id: &str, bundle_id: &str, environment: Environment, request_override: Box) -> Self { + let base_url = environment.base_url(); + let client = Client::new(); + Self { base_url, signing_key, key_id: key_id.to_string(), issuer_id: issuer_id.to_string(), bundle_id: bundle_id.to_string(), client, request_override} + } + + fn generate_token(&self) -> String { + let future_time = Utc::now() + chrono::Duration::minutes(5); + let key_id = (&self.key_id).to_string(); + + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(key_id); + + let claims = Claims { + bid: &self.bundle_id, + iss: &self.issuer_id, + aud: "appstoreconnect-v1", + exp: future_time.timestamp(), + }; + + encode(&header, &claims, &EncodingKey::from_ec_pem(self.signing_key.as_slice()).unwrap()).unwrap() + } + + fn build_request(&self, path: &str, method: Method) -> RequestBuilder { + let url = format!("{}{}", self.base_url, path); + + let mut headers = HeaderMap::new(); + headers.append("User-Agent", "app-store-server-library/rust/1.0.0".parse().unwrap()); + headers.append("Authorization", format!("Bearer {}", self.generate_token()).parse().unwrap()); + headers.append("Accept", "application/json".parse().unwrap()); + + self.client + .request(method, url) + .headers(headers) + } + + async fn make_request_with_response_body(&self, request: RequestBuilder) -> Result + where + Res: for<'de> Deserialize<'de> + { + let response = self.make_request(request).await?; + let json_result = response.json::().await.map_err(|_| APIException { + http_status_code: 500, + api_error: None, + raw_api_error: None, + error_message: Some("Failed to deserialize response JSON".to_string()), + })?; + Ok(json_result) + } + + async fn make_request_without_response_body(&self, request: RequestBuilder) -> Result<(), APIException> { + let _ = self.make_request(request).await?; + Ok(()) + } + + #[cfg(not(test))] + async fn make_request(&self, request: RequestBuilder) -> Result { + let response = request.send().await; + + match response { + Ok(response) => { + let status_code = response.status().as_u16(); + + if status_code >= 200 && status_code < 300 { + Ok(response) + } else if let Ok(json_error) = response.json::().await { + let error_code = json_error.error_code.clone(); + let error_message = json_error.error_message.clone(); + Err(APIException { + http_status_code: status_code, + api_error: error_code, + raw_api_error: (&json_error).raw_error_code(), + error_message: error_message, + }) + } else { + Err(APIException { + http_status_code: 500, + api_error: None, + raw_api_error: None, + error_message: Some("Failed to send HTTP request".to_string()), + }) + } + } + Err(_) => Err(APIException { + http_status_code: 500, + api_error: None, + raw_api_error: None, + error_message: Some("Failed to send HTTP request".to_string()), + }), + } + } + + #[cfg(test)] + async fn make_request(&self, request: RequestBuilder) -> Result>, APIException> + { + let request = request.build().unwrap(); + let body_encoded = match request.body() { + None => None, + Some(body) => body.as_bytes() + }; + let response = (self.request_override)(&request, body_encoded); + + let status_code = response.status().as_u16(); + + if status_code >= 200 && status_code < 300 { + Ok(response) + } else if let Ok(json_error) = response.json::().await { + let error_code = json_error.error_code.clone(); + let error_message = json_error.error_message.clone(); + + Err(APIException { + http_status_code: status_code, + api_error: error_code, + raw_api_error: (&json_error).raw_error_code(), + error_message: error_message, + }) + } else { + Err(APIException { + http_status_code: 500, + api_error: None, + raw_api_error: None, + error_message: Some("Failed to send HTTP request".to_string()), + }) + } + } + + /// Uses a subscription's product identifier to extend the renewal date for all of its eligible active subscribers. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers) + /// + /// # Arguments + /// + /// * `mass_extend_renewal_date_request` - The request body for extending a subscription renewal date for all of its active subscribers. + /// + /// # Returns + /// + /// A response that indicates the server successfully received the subscription-renewal-date extension request. + /// + /// # Errors + /// + /// Throws an `APIException` if a response was returned indicating the request could not be processed. + pub async fn extend_renewal_date_for_all_active_subscribers(&self, mass_extend_renewal_date_request: &MassExtendRenewalDateRequest) -> Result { + let req = self.build_request("/inApps/v1/subscriptions/extend/mass", Method::POST) + .json(&mass_extend_renewal_date_request); + self.make_request_with_response_body(req).await + } + + /// Extends the renewal date of a customer's active subscription using the original transaction identifier. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date) + /// + /// # Arguments + /// + /// * `original_transaction_id` - The original transaction identifier of the subscription receiving a renewal date extension. + /// * `extend_renewal_date_request` - The request body containing subscription-renewal-extension data. + /// + /// # Returns + /// + /// A response that indicates whether an individual renewal-date extension succeeded, and related details. + /// + /// # Errors + /// + /// Returns an `APIError` if the request could not be processed. + pub async fn extend_subscription_renewal_date(&self, original_transaction_id: &str, extend_renewal_date_request: &ExtendRenewalDateRequest) -> Result { + let path = format!("/inApps/v1/subscriptions/extend/{}", original_transaction_id); + let req = self.build_request(path.as_str(), Method::PUT) + .json(&extend_renewal_date_request); + self.make_request_with_response_body(req).await + } + + /// Get the statuses for all of a customer's auto-renewable subscriptions in your app. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses) + /// + /// # Arguments + /// + /// * `transaction_id` - The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + /// * `status` - An optional filter that indicates the status of subscriptions to include in the response. + /// + /// # Returns + /// + /// A response that contains status information for all of a customer's auto-renewable subscriptions in your app. + /// + /// # Errors + /// + /// Returns an `APIError` if the request could not be processed. + pub async fn get_all_subscription_statuses(&self, transaction_id: &str, status: Option<&Vec>) -> Result { + let mut query_parameters: Vec<(&str, String)> = vec![]; + if let Some(status) = status { + for item in status { + let value = ("status", item.raw_value().to_string()); + query_parameters.push(value); + } + } + + let path = format!("/inApps/v1/subscriptions/{}", transaction_id); + let req = self.build_request(path.as_str(), Method::GET) + .query(&query_parameters); + self.make_request_with_response_body(req).await + } + + /// Get a paginated list of all of a customer's refunded in-app purchases for your app. + /// + /// [Apple Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_refund_history) + /// + /// # Arguments + /// + /// * `transaction_id` - The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + /// * `revision` - A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Use the revision token from the previous `RefundHistoryResponse`. + /// + /// # Returns + /// + /// A result containing either the response that contains status information for all of a customer's auto-renewable subscriptions in your app, or an `APIError` if the request could not be processed. + /// + /// # Errors + /// + /// * `RefundHistoryNotFoundError` (Status Code: 4040008) - An error that indicates that the test notification token is expired or the test notification status isn’t available. + /// * `RefundHistoryRequestNotFoundError` (Status Code: 4040009) - An error that indicates the server didn't find a subscription-renewal-date extension request for the request identifier and product identifier you provided. + /// * `RefundHistoryServerError` (Status Code: 5000000) - An error that indicates a server error occurred during the request processing. + /// + pub async fn get_refund_history(&self, transaction_id: &str, revision: &str) -> Result { + let mut query_parameters: HashMap<&str, &str> = HashMap::new(); + if !revision.is_empty() { + query_parameters.insert("revision", revision); + } + let path = format!("/inApps/v2/refund/lookup/{}", transaction_id); + let req = self.build_request(path.as_str(), Method::GET) + .query(&query_parameters); + self.make_request_with_response_body(req).await + } + + /// Checks whether a renewal date extension request completed, and provides the final count of successful or failed extensions. + /// + /// [Apple Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions) + /// + /// # Arguments + /// + /// * `request_identifier` - The UUID that represents your request to the Extend Subscription Renewal Dates for All Active Subscribers endpoint. + /// * `product_id` - The product identifier of the auto-renewable subscription that you request a renewal-date extension for. + /// + /// # Returns + /// + /// A result containing either the response that indicates the current status of a request to extend the subscription renewal date to all eligible subscribers, or an `APIError` if the request could not be processed. + /// + /// # Errors + /// + /// * `SubscriptionRenewalDateStatusNotFoundError` (Status Code: 4040009) - An error that indicates the server didn't find a subscription-renewal-date extension request for the request identifier and product identifier you provided. + /// * `SubscriptionRenewalDateStatusServerError` (Status Code: 5000000) - An error that indicates a server error occurred during the request processing. + /// + pub async fn get_status_of_subscription_renewal_date_extensions(&self, request_identifier: &str, product_id: &str) -> Result { + let path = format!("/inApps/v1/subscriptions/extend/mass/{}/{}", product_id, request_identifier); + let req = self.build_request(path.as_str(), Method::GET); + self.make_request_with_response_body(req).await + } + + /// Check the status of the test App Store server notification sent to your server. + /// + /// [Apple Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status) + /// + /// # Arguments + /// + /// * `test_notification_token` - The test notification token received from the Request a Test Notification endpoint. + /// + /// # Returns + /// + /// A result containing either the response that contains the contents of the test notification sent by the App Store server and the result from your server, or an `APIError` if the request could not be processed. + /// + /// # Errors + /// + /// * `TestNotificationNotFoundError` (Status Code: 4040008) - An error that indicates that the test notification token is expired or the test notification status isn’t available. + /// * `TestNotificationServerError` (Status Code: 5000000) - An error that indicates a server error occurred during the request processing. + /// + pub async fn get_test_notification_status( + &self, + test_notification_token: &str, + ) -> Result { + let path = format!("/inApps/v1/notifications/test/{}", test_notification_token); + let req = self.build_request(path.as_str(), Method::GET); + self.make_request_with_response_body(req).await + } + + /// Get a list of notifications that the App Store server attempted to send to your server. + /// + /// [Apple Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_notification_history) + /// + /// # Arguments + /// + /// * `pagination_token` - An optional token you use to get the next set of up to 20 notification history records. All responses that have more records available include a paginationToken. Omit this parameter the first time you call this endpoint. + /// * `notification_history_request` - The request body that includes the start and end dates, and optional query constraints. + /// + /// # Returns + /// + /// A response that contains the App Store Server Notifications history for your app. + /// + /// # Errors + /// + /// * `NotificationHistoryNotFoundError` (Status Code: 4040008) - An error that indicates that the notification history is not found. + /// * `NotificationHistoryServerError` (Status Code: 5000000) - An error that indicates a server error occurred during the request processing. + /// + pub async fn get_notification_history( + &self, + pagination_token: &str, + notification_history_request: &NotificationHistoryRequest, + ) -> Result { + let mut query_parameters: HashMap<&str, &str> = HashMap::new(); + if !pagination_token.is_empty() { + query_parameters.insert("paginationToken", pagination_token); + } + + let req = self.build_request("/inApps/v1/notifications/history", Method::POST) + .query(&query_parameters) + .json(¬ification_history_request); + self.make_request_with_response_body(req).await + } + + /// Get a customer's in-app purchase transaction history for your app. + /// + /// [Apple Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history) + /// + /// # Arguments + /// + /// * `transaction_id` - The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + /// * `revision` - A token you provide to get the next set of up to 20 transactions. All responses include a revision token. Note: For requests that use the revision token, include the same query parameters from the initial request. Use the revision token from the previous HistoryResponse. + /// * `transaction_history_request` - The request body that includes the start and end dates, and optional query constraints. + /// + /// # Returns + /// + /// A response that contains the customer's transaction history for an app. + /// + /// # Errors + /// + /// * `TransactionHistoryNotFoundError` (Status Code: 4040010) - An error that indicates a transaction identifier wasn't found. + /// * `TransactionHistoryServerError` (Status Code: 5000000) - An error that indicates a server error occurred during the request processing. + /// + pub async fn get_transaction_history( + &self, + transaction_id: &str, + revision: Option<&str>, + transaction_history_request: &TransactionHistoryRequest, + ) -> Result { + let mut query_parameters: Vec<(&str, Value)> = vec![]; + + if let Some(rev) = revision { + query_parameters.push(("revision", rev.into())); + } + + if let Some(start_date) = transaction_history_request.start_date { + let start_date = start_date.timestamp_millis().to_string(); + query_parameters.push(("startDate", start_date.into())); + } + + if let Some(end_date) = transaction_history_request.end_date { + let end_date = end_date.timestamp_millis().to_string(); + query_parameters.push(("endDate", end_date.into())); + } + + if let Some(product_ids) = &transaction_history_request.product_ids { + for item in product_ids { + query_parameters.push(("productId", item.as_str().into())); + } + } + + if let Some(product_types) = &transaction_history_request.product_types { + for item in product_types { + query_parameters.push(("productType", item.raw_value().to_string().into())); + } + } + + if let Some(sort) = &transaction_history_request.sort { + query_parameters.push(("sort", sort.raw_value().to_string().into())); + } + + if let Some(subscription_group_ids) = &transaction_history_request.subscription_group_identifiers { + for item in subscription_group_ids { + query_parameters.push(("subscriptionGroupIdentifier", item.as_str().into())); + } + } + + if let Some(ownership_type) = &transaction_history_request.in_app_ownership_type { + query_parameters.push(("inAppOwnershipType", ownership_type.raw_value().to_string().into())); + } + + if let Some(revoked) = &transaction_history_request.revoked { + query_parameters.push(("revoked", revoked.to_string().into())); + } + + let path = format!("/inApps/v1/history/{}", transaction_id); + let req = self.build_request(path.as_str(), Method::GET) + .query(&query_parameters); + self.make_request_with_response_body(req).await + } + + /// Get information about a single transaction for your app. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info) + /// + /// # Arguments + /// + /// * `transaction_id` - The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + /// + /// # Returns + /// + /// A response that contains signed transaction information for a single transaction. + /// + /// # Errors + /// + /// Returns an `APIException` if the request could not be processed. + pub async fn get_transaction_info(&self, transaction_id: &str) -> Result { + let path = format!("/inApps/v1/transactions/{}", transaction_id); + let req = self.build_request(path.as_str(), Method::GET); + self.make_request_with_response_body(req).await + } + + /// Get a customer's in-app purchases from a receipt using the order ID. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id) + /// + /// # Arguments + /// + /// * `order_id` - The order ID for in-app purchases that belong to the customer. + /// + /// # Returns + /// + /// A response that includes the order lookup status and an array of signed transactions for the in-app purchases in the order. + /// + /// # Errors + /// + /// Returns an `APIException` if the request could not be processed. + pub async fn look_up_order_id(&self, order_id: &str) -> Result { + let path = format!("/inApps/v1/lookup/{}", order_id); + let req = self.build_request(path.as_str(), Method::GET); + self.make_request_with_response_body(req).await + } + + /// Ask App Store Server Notifications to send a test notification to your server. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification) + /// + /// # Returns + /// + /// A response that contains the test notification token. + /// + /// # Errors + /// + /// Returns an `APIException` if the request could not be processed. + pub async fn request_test_notification(&self) -> Result { + let path = "/inApps/v1/notifications/test"; + let req = self.build_request(path, Method::POST); + self.make_request_with_response_body(req).await + } + + /// Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. + /// + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information) + /// + /// # Arguments + /// + /// * `transaction_id` - The transaction identifier for which you're providing consumption information. + /// * `consumption_request` - The request body containing consumption information. + /// + /// # Errors + /// + /// Returns an `APIException` if the request could not be processed. + pub async fn send_consumption_data(&self, transaction_id: &str, consumption_request: &ConsumptionRequest) -> Result<(), APIException> { + let path = format!("/inApps/v1/transactions/consumption/{}", transaction_id); + let req = self.build_request(path.as_str(), Method::PUT) + .json(consumption_request); + self.make_request_without_response_body(req).await + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims<'a> { + bid: &'a str, + iss: &'a str, + aud: &'a str, + exp: i64, +} + +#[cfg(test)] +use serde::de::DeserializeOwned; + +#[cfg(test)] +trait ResponseExt { + async fn json(self) -> serde_json::Result; +} + +#[cfg(test)] +impl ResponseExt for Response> { + async fn json(self) -> serde_json::Result { + let body = std::str::from_utf8(self.body().as_slice()).unwrap(); + serde_json::from_str(body) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use base64::Engine; + use http::StatusCode; + use serde_json::Value; + use chrono::DateTime; + use chrono::NaiveDateTime; + use url::Url; + use uuid::Uuid; + use base64::engine::general_purpose::STANDARD; + use base64::prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD}; + use crate::primitives::account_tenure::AccountTenure; + use crate::primitives::consumption_status::ConsumptionStatus; + use crate::primitives::delivery_status::DeliveryStatus; + use crate::primitives::extend_reason_code::ExtendReasonCode; + use crate::primitives::in_app_ownership_type::InAppOwnershipType; + use crate::primitives::last_transactions_item::LastTransactionsItem; + use crate::primitives::lifetime_dollars_purchased::LifetimeDollarsPurchased; + use crate::primitives::lifetime_dollars_refunded::LifetimeDollarsRefunded; + use crate::primitives::notification_history_response_item::NotificationHistoryResponseItem; + use crate::primitives::notification_type_v2::NotificationTypeV2; + use crate::primitives::order_lookup_status::OrderLookupStatus; + use crate::primitives::platform::Platform; + use crate::primitives::play_time::PlayTime; + use crate::primitives::send_attempt_item::SendAttemptItem; + use crate::primitives::send_attempt_result::SendAttemptResult; + use crate::primitives::subscription_group_identifier_item::SubscriptionGroupIdentifierItem; + use crate::primitives::subtype::Subtype; + use crate::primitives::transaction_history_request::{Order, ProductType}; + use crate::primitives::user_status::UserStatus; + use super::*; + + #[tokio::test] + async fn test_extend_renewal_date_for_all_active_subscribers() { + let client = app_store_server_api_client_with_body_from_file("assets/models/extendRenewalDateForAllActiveSubscribersResponse.json", StatusCode::OK, Some(|req, body| { + assert_eq!(Method::POST, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass", req.url().as_str()); + + let decoded_json: HashMap<&str, Value> = serde_json::from_slice(body.unwrap()).unwrap(); + assert_eq!(45, decoded_json.get("extendByDays").unwrap().as_u64().unwrap()); + assert_eq!(1, decoded_json.get("extendReasonCode").unwrap().as_u64().unwrap()); + assert_eq!("fdf964a4-233b-486c-aac1-97d8d52688ac", decoded_json.get("requestIdentifier").unwrap().as_str().unwrap()); + assert_eq!(vec!["USA", "MEX"], decoded_json.get("storefrontCountryCodes").unwrap().as_array().unwrap().to_vec()); + assert_eq!("com.example.productId", decoded_json.get("productId").unwrap().as_str().unwrap()); + })); + + let dto = MassExtendRenewalDateRequest { + extend_by_days: 45, + extend_reason_code: ExtendReasonCode::CustomerSatisfaction, + request_identifier: "fdf964a4-233b-486c-aac1-97d8d52688ac".to_string(), + storefront_country_codes: vec!["USA".to_string(), "MEX".to_string()], + product_id: "com.example.productId".to_string(), + }; + + let response = client.extend_renewal_date_for_all_active_subscribers(&dto).await.unwrap(); + assert_eq!("758883e8-151b-47b7-abd0-60c4d804c2f5", response.request_identifier.unwrap().as_str()); + } + + #[tokio::test] + async fn test_extend_subscription_renewal_date() { + let client = app_store_server_api_client_with_body_from_file("assets/models/extendSubscriptionRenewalDateResponse.json", StatusCode::OK, Some(|req, body| { + assert_eq!(Method::PUT, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/4124214", req.url().as_str()); + + let decoded_json: HashMap<&str, Value> = serde_json::from_slice(body.unwrap()).unwrap(); + assert_eq!(45, decoded_json.get("extendByDays").unwrap().as_u64().unwrap()); + assert_eq!(1, decoded_json.get("extendReasonCode").unwrap().as_u64().unwrap()); + assert_eq!("fdf964a4-233b-486c-aac1-97d8d52688ac", decoded_json.get("requestIdentifier").unwrap().as_str().unwrap()); + })); + + let extend_renewal_date_request = ExtendRenewalDateRequest { + extend_by_days: Some(45), + extend_reason_code: Some(ExtendReasonCode::CustomerSatisfaction), + request_identifier: Some("fdf964a4-233b-486c-aac1-97d8d52688ac".to_string()), + }; + + let response = client.extend_subscription_renewal_date("4124214", &extend_renewal_date_request).await.unwrap(); + assert_eq!("2312412", response.original_transaction_id.unwrap().as_str()); + assert_eq!("9993", response.web_order_line_item_id.unwrap().as_str()); + assert_eq!(true, response.success.unwrap()); + assert_eq!(1698148900, response.effective_date.unwrap().timestamp()); + } + + #[tokio::test] + async fn test_get_all_subscription_statuses() { + let client = app_store_server_api_client_with_body_from_file("assets/models/getAllSubscriptionStatusesResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/4321?status=2&status=1", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let statuses = vec![Status::Expired, Status::Active]; + let response = client.get_all_subscription_statuses("4321", Some(&statuses)).await.unwrap(); + + assert_eq!(Environment::LocalTesting, response.environment.unwrap()); + assert_eq!("com.example", response.bundle_id.as_str()); + assert_eq!(5454545, response.app_apple_id); + + let item = SubscriptionGroupIdentifierItem { + subscription_group_identifier: Some("sub_group_one".to_string()), + last_transactions: Some(vec![ + LastTransactionsItem { + status: Status::Active.into(), + original_transaction_id: "3749183".to_string().into(), + signed_transaction_info: "signed_transaction_one".to_string().into(), + signed_renewal_info: "signed_renewal_one".to_string().into(), + }, + LastTransactionsItem { + status: Status::Revoked.into(), + original_transaction_id: "5314314134".to_string().into(), + signed_transaction_info: "signed_transaction_two".to_string().into(), + signed_renewal_info: "signed_renewal_two".to_string().into(), + }, + ]), + }; + + let second_item = SubscriptionGroupIdentifierItem { + subscription_group_identifier: "sub_group_two".to_string().into(), + last_transactions: vec![ + LastTransactionsItem { + status: Status::Expired.into(), + original_transaction_id: "3413453".to_string().into(), + signed_transaction_info: "signed_transaction_three".to_string().into(), + signed_renewal_info: "signed_renewal_three".to_string().into(), + }, + ].into(), + }; + + assert_eq!(vec![item, second_item], response.data); + } + + #[tokio::test] + async fn test_get_refund_history() { + let client = app_store_server_api_client_with_body_from_file("assets/models/getRefundHistoryResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v2/refund/lookup/555555?revision=revision_input", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.get_refund_history("555555", "revision_input").await.unwrap(); + + assert_eq!(vec!["signed_transaction_one", "signed_transaction_two"], response.signed_transactions); + assert_eq!("revision_output", response.revision); + assert_eq!(true, response.has_more); + } + + #[tokio::test] + async fn test_get_status_of_subscription_renewal_date_extensions() { + let client = app_store_server_api_client_with_body_from_file("assets/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.get_status_of_subscription_renewal_date_extensions("com.example.product", "20fba8a0-2b80-4a7d-a17f-85c1854727f8").await.unwrap(); + + assert_eq!("20fba8a0-2b80-4a7d-a17f-85c1854727f8", response.request_identifier.unwrap().as_str()); + assert_eq!(true, response.complete.unwrap()); + assert_eq!(1698148900, response.complete_date.unwrap().timestamp()); + assert_eq!(30, response.succeeded_count.unwrap()); + assert_eq!(2, response.failed_count.unwrap()); + } + + #[tokio::test] + async fn test_get_test_notification_status() { + let client = app_store_server_api_client_with_body_from_file("assets/models/getTestNotificationStatusResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.get_test_notification_status("8cd2974c-f905-492a-bf9a-b2f47c791d19").await.unwrap(); + assert_eq!("signed_payload", response.signed_payload.unwrap()); + + let send_attempt_items = vec![ + SendAttemptItem { + attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), + send_attempt_result: SendAttemptResult::NoResponse.into(), + }, + SendAttemptItem { + attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + send_attempt_result: SendAttemptResult::Success.into(), + }, + ]; + assert_eq!(send_attempt_items, response.send_attempts.unwrap()); + } + + #[tokio::test] + async fn test_get_notification_history() { + let client = app_store_server_api_client_with_body_from_file("assets/models/getNotificationHistoryResponse.json", StatusCode::OK, Some(|req, body| { + assert_eq!(Method::POST, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/history?paginationToken=a036bc0e-52b8-4bee-82fc-8c24cb6715d6", req.url().as_str()); + + let decoded_json: HashMap<&str, Value> = serde_json::from_slice(body.unwrap()).unwrap(); + assert_eq!(1698148900000, decoded_json["startDate"].as_i64().unwrap()); + assert_eq!(1698148950000, decoded_json["endDate"].as_i64().unwrap()); + assert_eq!("SUBSCRIBED", decoded_json["notificationType"].as_str().unwrap()); + assert_eq!("INITIAL_BUY", decoded_json["notificationSubtype"].as_str().unwrap()); + assert_eq!("999733843", decoded_json["transactionId"].as_str().unwrap()); + assert_eq!(true, decoded_json["onlyFailures"].as_bool().unwrap()); + })); + + let notification_history_request = NotificationHistoryRequest { + start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), + end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + notification_type: NotificationTypeV2::Subscribed.into(), + notification_subtype: Subtype::InitialBuy.into(), + transaction_id: "999733843".to_string().into(), + only_failures: true.into(), + }; + + let response = client.get_notification_history("a036bc0e-52b8-4bee-82fc-8c24cb6715d6", ¬ification_history_request).await.unwrap(); + assert_eq!("57715481-805a-4283-8499-1c19b5d6b20a", response.pagination_token.unwrap()); + assert_eq!(true, response.has_more.unwrap()); + + let expected_notification_history = vec![ + NotificationHistoryResponseItem { + signed_payload: "signed_payload_one".to_string().into(), + send_attempts: vec![ + SendAttemptItem { + attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148900, 0).unwrap(), Utc).into(), + send_attempt_result: SendAttemptResult::NoResponse.into(), + }, + SendAttemptItem { + attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148950, 0).unwrap(), Utc).into(), + send_attempt_result: SendAttemptResult::Success.into(), + }, + ].into(), + }, + NotificationHistoryResponseItem { + signed_payload: "signed_payload_two".to_string().into(), + send_attempts: vec![ + SendAttemptItem { + attempt_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(1698148800, 0).unwrap(), Utc).into(), + send_attempt_result: SendAttemptResult::CircularRedirect.into(), + }, + ].into(), + }, + ]; + assert_eq!(expected_notification_history, response.notification_history.unwrap()); + } + + #[tokio::test] + async fn test_get_transaction_history() { + let client = app_store_server_api_client_with_body_from_file("assets/models/transactionHistoryResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + let url = req.url(); + let url_components = Url::parse(url.as_str()).unwrap(); + assert_eq!("/inApps/v1/history/1234", url_components.path()); + let params = url_components.query_pairs().into_owned().fold(HashMap::new(), |mut acc, (k, v)| { + acc.entry(k.to_string()).or_insert_with(Vec::new).push(v); + acc + }); + assert_eq!(&vec!["revision_input"], params.get("revision").unwrap()); + assert_eq!(&vec!["123455"], params.get("startDate").unwrap()); + assert_eq!(&vec!["123456"], params.get("endDate").unwrap()); + assert_eq!(&vec!["com.example.1", "com.example.2"], params.get("productId").unwrap()); + assert_eq!(&vec!["CONSUMABLE", "AUTO_RENEWABLE"], params.get("productType").unwrap()); + assert_eq!(&vec!["ASCENDING"], params.get("sort").unwrap()); + assert_eq!(&vec!["sub_group_id", "sub_group_id_2"], params.get("subscriptionGroupIdentifier").unwrap()); + assert_eq!(&vec!["FAMILY_SHARED"], params.get("inAppOwnershipType").unwrap()); + assert_eq!(&vec!["false"], params.get("revoked").unwrap()); + })); + + let request = TransactionHistoryRequest { + start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), + end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + product_ids: vec!["com.example.1", "com.example.2"].into_iter().map(String::from).collect::>().into(), + product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), + sort: Order::Ascending.into(), + subscription_group_identifiers: vec!["sub_group_id".to_string(), "sub_group_id_2".to_string()].into(), + in_app_ownership_type: InAppOwnershipType::FamilyShared.into(), + revoked: false.into(), + }; + + let response = client.get_transaction_history("1234", Some("revision_input"), &request).await.unwrap(); + assert_eq!("revision_output", response.revision.unwrap()); + assert_eq!(true, response.has_more.unwrap()); + assert_eq!("com.example", response.bundle_id.unwrap().as_str()); + assert_eq!(323232, response.app_apple_id.unwrap()); + assert_eq!(Environment::LocalTesting, response.environment.unwrap()); + assert_eq!(vec!["signed_transaction_value", "signed_transaction_value2"], response.signed_transactions.unwrap()); + } + + #[tokio::test] + async fn test_get_transaction_info() { + let client = app_store_server_api_client_with_body_from_file("assets/models/transactionInfoResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/1234", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.get_transaction_info("1234").await.unwrap(); + assert_eq!("signed_transaction_info_value", response.signed_transaction_info.unwrap()); + } + + #[tokio::test] + async fn test_look_up_order_id() { + let client = app_store_server_api_client_with_body_from_file("assets/models/lookupOrderIdResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::GET, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/lookup/W002182", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.look_up_order_id("W002182").await.unwrap(); + assert_eq!(OrderLookupStatus::Invalid, response.status); + assert_eq!(vec!["signed_transaction_one", "signed_transaction_two"], response.signed_transactions); + } + + #[tokio::test] + async fn test_request_test_notification() { + let client = app_store_server_api_client_with_body_from_file("assets/models/requestTestNotificationResponse.json", StatusCode::OK, Some(|req, _body| { + assert_eq!(Method::POST, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/notifications/test", req.url().as_str()); + assert!(req.body().is_none()); + })); + + let response = client.request_test_notification().await.unwrap(); + assert_eq!("ce3af791-365e-4c60-841b-1674b43c1609", response.test_notification_token.unwrap()); + } + + #[tokio::test] + async fn test_send_consumption_data() { + let client = app_store_server_api_client("".into(), StatusCode::OK, Some(|req, body| { + assert_eq!(Method::PUT, req.method()); + assert_eq!("https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/consumption/49571273", req.url().as_str()); + assert_eq!("application/json", req.headers().get("Content-Type").unwrap().to_str().unwrap()); + let decoded_json: HashMap = serde_json::from_slice(body.unwrap()).unwrap(); + assert_eq!(true, decoded_json["customerConsented"].as_bool().unwrap()); + assert_eq!(1, decoded_json["consumptionStatus"].as_i64().unwrap()); + assert_eq!(2, decoded_json["platform"].as_i64().unwrap()); + assert_eq!(false, decoded_json["sampleContentProvided"].as_bool().unwrap()); + assert_eq!(3, decoded_json["deliveryStatus"].as_i64().unwrap()); + assert_eq!("7389A31A-FB6D-4569-A2A6-DB7D85D84813".to_lowercase().as_str(), decoded_json["appAccountToken"].as_str().unwrap()); + assert_eq!(4, decoded_json["accountTenure"].as_i64().unwrap()); + assert_eq!(5, decoded_json["playTime"].as_i64().unwrap()); + assert_eq!(6, decoded_json["lifetimeDollarsRefunded"].as_i64().unwrap()); + assert_eq!(7, decoded_json["lifetimeDollarsPurchased"].as_i64().unwrap()); + assert_eq!(4, decoded_json["userStatus"].as_i64().unwrap()); + })); + + let consumption_request = ConsumptionRequest { + customer_consented: true.into(), + consumption_status: ConsumptionStatus::NotConsumed.into(), + platform: Platform::NonApple.into(), + sample_content_provided: false.into(), + delivery_status: DeliveryStatus::DidNotDeliverDueToServerOutage.into(), + app_account_token: Some(Uuid::parse_str("7389a31a-fb6d-4569-a2a6-db7d85d84813").unwrap()), + account_tenure: AccountTenure::ThirtyDaysToNinetyDays.into(), + play_time: PlayTime::OneDayToFourDays.into(), + lifetime_dollars_refunded: LifetimeDollarsRefunded::OneThousandDollarsToOneThousandNineHundredNinetyNineDollarsAndNinetyNineCents.into(), + lifetime_dollars_purchased: LifetimeDollarsPurchased::TwoThousandDollarsOrGreater.into(), + user_status: UserStatus::LimitedAccess.into(), + }; + + let _ = client.send_consumption_data("49571273", &consumption_request).await.unwrap(); + } + + #[tokio::test] + async fn test_headers() { + let client = app_store_server_api_client_with_body_from_file("assets/models/transactionInfoResponse.json", StatusCode::OK, Some(|req, _body| { + let headers = req.headers(); + assert!(headers.get("User-Agent").unwrap().to_str().unwrap().starts_with("app-store-server-library/rust")); + assert_eq!("application/json", headers.get("Accept").unwrap()); + let authorization = headers.get("Authorization").unwrap().to_str().unwrap(); + assert!(authorization.starts_with("Bearer ")); + let token_components: Vec<&str> = authorization[7..].split('.').collect(); + let header_data = BASE64_STANDARD_NO_PAD.decode(token_components[0]).unwrap(); + let payload_data = BASE64_STANDARD_NO_PAD.decode(token_components[1]).unwrap(); + let header: HashMap = serde_json::from_slice(&header_data).unwrap(); + let payload: HashMap = serde_json::from_slice(&payload_data).unwrap(); + + assert_eq!("appstoreconnect-v1", payload["aud"].as_str().unwrap()); + assert_eq!("issuerId", payload["iss"].as_str().unwrap()); + assert_eq!("keyId", header["kid"].as_str().unwrap()); + assert_eq!("com.example", payload["bid"].as_str().unwrap()); + assert_eq!("ES256", header["alg"].as_str().unwrap()); + })); + + let _ = client.get_transaction_info("1234").await; + } + + #[tokio::test] + async fn test_api_error() { + let client = app_store_server_api_client_with_body_from_file("assets/models/apiException.json", StatusCode::INTERNAL_SERVER_ERROR, None); + let result = client.get_transaction_info("1234").await; + + match result { + Ok(_) => { + assert!(false, "Unexpected response type"); + } + Err(error) => { + assert_eq!(500, error.http_status_code); + assert_eq!(APIError::GeneralInternal, error.api_error.unwrap()); + assert_eq!(5000000, error.raw_api_error.unwrap()); + assert_eq!("An unknown error occurred.", error.error_message.unwrap()); + } + } + } + + #[tokio::test] + async fn test_api_too_many_requests() { + let client = app_store_server_api_client_with_body_from_file("assets/models/apiTooManyRequestsException.json", StatusCode::TOO_MANY_REQUESTS, None); + let result = client.get_transaction_info("1234").await; + + match result { + Ok(_) => { + assert!(false, "Unexpected response type"); + } + Err(error) => { + assert_eq!(429, error.http_status_code); + assert_eq!(APIError::RateLimitExceeded, error.api_error.unwrap()); + assert_eq!("Rate limit exceeded.", error.error_message.unwrap()); + } + } + } + + #[tokio::test] + async fn test_api_unknown_error() { + let client = app_store_server_api_client_with_body_from_file("assets/models/apiUnknownError.json", StatusCode::BAD_REQUEST, None); + let result = client.get_transaction_info("1234").await; + + match result { + Ok(_) => { + assert!(false, "Unexpected response type"); + } + Err(error) => { + assert_eq!(400, error.http_status_code); + assert_eq!(None, error.api_error); + //todo! assert_eq!(9990000, error.raw_api_error.unwrap()); + assert_eq!("Testing error.", error.error_message.unwrap()); + } + } + } + + #[tokio::test] + async fn test_decoding_with_unknown_enum_value() { + let client = app_store_server_api_client_with_body_from_file("assets/models/transactionHistoryResponseWithMalformedEnvironment.json", StatusCode::OK, None); + + let request = TransactionHistoryRequest { + start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), + end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + product_ids: vec!["com.example.1".to_string(), "com.example.2".to_string()].into(), + product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), + sort: Some(Order::Ascending), + subscription_group_identifiers: vec!["sub_group_id".to_string(), "sub_group_id_2".to_string()].into(), + in_app_ownership_type: Some(InAppOwnershipType::FamilyShared), + revoked: Some(false), + }; + + let result = client.get_transaction_history("1234", Some("revision_input"), &request).await.unwrap(); + assert_eq!(Environment::Unknown, result.environment.unwrap()); + } + + #[tokio::test] + async fn test_decoding_with_malformed_json() { + let client = app_store_server_api_client_with_body_from_file("assets/models/transactionHistoryResponseWithMalformedAppAppleId.json", StatusCode::OK, None); + + let request = TransactionHistoryRequest { + start_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 455000000).unwrap(), Utc).into(), + end_date: DateTime::from_naive_utc_and_offset(NaiveDateTime::from_timestamp_opt(123, 456000000).unwrap(), Utc).into(), + product_ids: vec!["com.example.1".to_string(), "com.example.2".to_string()].into(), + product_types: vec![ProductType::Consumable, ProductType::AutoRenewable].into(), + sort: Some(Order::Ascending), + subscription_group_identifiers: vec!["sub_group_id".to_string(), "sub_group_id_2".to_string()].into(), + in_app_ownership_type: Some(InAppOwnershipType::FamilyShared), + revoked: Some(false), + }; + + let result = client.get_transaction_history("1234", Some("revision_input"), &request).await; + match result { + Ok(_) => { + assert!(false, "Unexpected response type"); + } + Err(error) => { + assert_eq!(500, error.http_status_code); + assert_eq!(None, error.api_error); + assert_eq!(None, error.raw_api_error); + assert_eq!("Failed to deserialize response JSON", error.error_message.unwrap()); + } + } + } + + fn app_store_server_api_client_with_body_from_file(path: &str, status: http::StatusCode, request_verifier: Option) -> AppStoreServerAPIClient { + let body = fs::read_to_string(path) + .expect("Failed to read file"); + app_store_server_api_client(body, status, request_verifier) + } + + fn app_store_server_api_client(body: String, status: http::StatusCode, request_verifier: Option) -> AppStoreServerAPIClient { + let key = fs::read("assets/testSigningKey.p8") + .expect("Failed to read file"); + + let request_overrider = move |req: &reqwest::Request, request_body: Option<&[u8]>| { + if let Some(request_verifier) = request_verifier { + (request_verifier)(req, request_body) + } + + let buffered_body = body.as_bytes().to_vec(); + + let response = http::response::Builder::new() + .header("Content-Type", "application/json") + .status(status) + .body(buffered_body) + .unwrap(); + + response + }; + + AppStoreServerAPIClient::new(key, "keyId", "issuerId", "com.example", Environment::LocalTesting, Box::new(request_overrider)) + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index c31709d..7444b77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,13 @@ pub mod chain_verifier; pub mod primitives; pub mod promotional_offer_signature_creator; -#[cfg(feature="receipt_utility")] -pub mod receipt_utility; pub mod signed_data_verifier; mod utils; + +#[cfg(feature = "receipt-utility")] +pub mod receipt_utility; + +#[cfg(feature = "api-client")] +pub mod api_client; + + diff --git a/src/primitives/check_test_notification_response.rs b/src/primitives/check_test_notification_response.rs index 6589588..3847537 100644 --- a/src/primitives/check_test_notification_response.rs +++ b/src/primitives/check_test_notification_response.rs @@ -15,6 +15,6 @@ pub struct CheckTestNotificationResponse { /// An array of information the App Store server records for its attempts to send the TEST notification to your server. The array may contain a maximum of six sendAttemptItem objects. /// /// [sendAttemptItem](https://developer.apple.com/documentation/appstoreserverapi/sendattemptitem) - #[serde(rename = "sendAttemptItem")] + #[serde(rename = "sendAttempts")] pub send_attempts: Option>, } diff --git a/src/primitives/environment.rs b/src/primitives/environment.rs index be73fe7..6eb5722 100644 --- a/src/primitives/environment.rs +++ b/src/primitives/environment.rs @@ -10,4 +10,16 @@ pub enum Environment { Xcode, #[serde(rename = "LocalTesting")] LocalTesting, + #[serde(other)] + Unknown } + +impl Environment { + pub fn base_url(&self) -> String { + match self { + Environment::Production => "https://api.storekit.itunes.apple.com".to_string(), + Environment::Sandbox => "https://api.storekit-sandbox.itunes.apple.com".to_string(), + _ => "https://api.storekit-sandbox.itunes.apple.com".to_string(), + } + } +} \ No newline at end of file diff --git a/src/primitives/error_payload.rs b/src/primitives/error_payload.rs index 36f3dcb..c4b6092 100644 --- a/src/primitives/error_payload.rs +++ b/src/primitives/error_payload.rs @@ -1,10 +1,262 @@ -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_repr::{Deserialize_repr, Serialize_repr}; + +/// Enum representing different API errors with associated status codes. +#[derive(Debug, Clone, Deserialize_repr, Serialize_repr, PartialEq, Hash)] +#[repr(i64)] +pub enum APIError { + /// An error that indicates an invalid request. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/generalbadrequesterror) + GeneralBadRequest = 4000000, + + /// An error that indicates an invalid app identifier. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidappidentifiererror) + InvalidAppIdentifier = 4000002, + + /// An error that indicates an invalid request revision. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidrequestrevisionerror) + InvalidRequestRevision = 4000005, + + /// An error that indicates an invalid transaction identifier. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidtransactioniderror) + InvalidTransactionId = 4000006, + + /// An error that indicates an invalid original transaction identifier. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidoriginaltransactioniderror) + InvalidOriginalTransactionId = 4000008, + + /// An error that indicates an invalid extend-by-days value. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidextendbydayserror) + InvalidExtendByDays = 4000009, + + /// An error that indicates an invalid reason code. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidextendreasoncodeerror) + InvalidExtendReasonCode = 4000010, + + /// An error that indicates an invalid request identifier. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidrequestidentifiererror) + InvalidRequestIdentifier = 4000011, + + /// An error that indicates that the start date is earlier than the earliest allowed date. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/startdatetoofarinpasterror) + StartDateTooFarInPast = 4000012, + + /// An error that indicates that the end date precedes the start date, or the two dates are equal. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/startdateafterenddateerror) + StartDateAfterEndDate = 4000013, + + /// An error that indicates the pagination token is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidpaginationtokenerror) + InvalidPaginationToken = 4000014, + + /// An error that indicates the start date is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidstartdateerror) + InvalidStartDate = 4000015, + + /// An error that indicates the end date is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidenddateerror) + InvalidEndDate = 4000016, + + /// An error that indicates the pagination token expired. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/paginationtokenexpirederror) + PaginationTokenExpired = 4000017, + + /// An error that indicates the notification type or subtype is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidnotificationtypeerror) + InvalidNotificationType = 4000018, + + /// An error that indicates the request is invalid because it has too many constraints applied. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/multiplefilterssuppliederror) + MultipleFiltersSupplied = 4000019, + + /// An error that indicates the test notification token is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidtestnotificationtokenerror) + InvalidTestNotificationToken = 4000020, + + /// An error that indicates an invalid sort parameter. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidsorterror) + InvalidSort = 4000021, + + /// An error that indicates an invalid product type parameter. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidproducttypeerror) + InvalidProductType = 4000022, + + /// An error that indicates the product ID parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidproductiderror) + InvalidProductId = 4000023, + + /// An error that indicates an invalid subscription group identifier. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidsubscriptiongroupidentifiererror) + InvalidSubscriptionGroupIdentifier = 4000024, + + /// An error that indicates the query parameter exclude-revoked is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidexcluderevokederror) + InvalidExcludeRevoked = 4000025, + + /// An error that indicates an invalid in-app ownership type parameter. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidinappownershiptypeerror) + InvalidInAppOwnershipType = 4000026, + + /// An error that indicates a required storefront country code is empty. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidemptystorefrontcountrycodelisterror) + InvalidEmptyStorefrontCountryCodeList = 4000027, + + /// An error that indicates a storefront code is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidstorefrontcountrycodeerror) + InvalidStorefrontCountryCode = 4000028, + + /// An error that indicates the revoked parameter contains an invalid value. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidrevokederror) + InvalidRevoked = 4000030, + + /// An error that indicates the status parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidstatuserror) + InvalidStatus = 4000031, + + /// An error that indicates the value of the account tenure field is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidaccounttenureerror) + InvalidAccountTenure = 4000032, + + /// An error that indicates the value of the app account token is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidappaccounttokenerror) + InvalidAppAccountToken = 4000033, + + /// An error that indicates the consumption status is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidconsumptionstatuserror) + InvalidConsumptionStatus = 4000034, + + /// An error that indicates the customer consented status is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidcustomerconsentederror) + InvalidCustomerConsented = 4000035, + + /// An error that indicates the delivery status is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invaliddeliverystatuserror) + InvalidDeliveryStatus = 4000036, + + /// An error that indicates the lifetime dollars purchased field is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidlifetimedollarspurchasederror) + InvalidLifetimeDollarsPurchased = 4000037, + + /// An error that indicates the lifetime dollars refunded field is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidlifetimedollarsrefundederror) + InvalidLifetimeDollarsRefunded = 4000038, + + /// An error that indicates the platform parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidplatformerror) + InvalidPlatform = 4000039, + + /// An error that indicates the play time parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidplaytimeerror) + InvalidPlayTime = 4000040, + + /// An error that indicates the sample content provided parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invalidsamplecontentprovidederror) + InvalidSampleContentProvided = 4000041, + + /// An error that indicates the user status parameter is invalid. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/invaliduserstatuserror) + InvalidUserStatus = 4000042, + + /// An error that indicates the transaction is not consumable. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/transactionnotconsumableerror) + InvalidTransactionNotConsumable = 4000043, + + /// An error that indicates the subscription doesn't qualify for a renewal-date extension due to its subscription state. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/subscriptionextensionineligibleerror) + SubscriptionExtensionIneligible = 4030004, + + /// An error that indicates the subscription doesn’t qualify for a renewal-date extension because it has already received the maximum extensions. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/subscriptionmaxextensionerror) + SubscriptionMaxExtension = 4030005, + + /// An error that indicates a subscription isn't directly eligible for a renewal date extension because the user obtained it through Family Sharing. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/familysharedsubscriptionextensionineligibleerror) + FamilySharedSubscriptionExtensionIneligible = 4030007, + + /// An error that indicates the App Store account wasn’t found. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/accountnotfounderror) + AccountNotFound = 4040001, + + /// An error response that indicates the App Store account wasn’t found, but you can try again. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/accountnotfoundretryableerror) + AccountNotFoundRetryable = 4040002, + + /// An error that indicates the app wasn’t found. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/appnotfounderror) + AppNotFound = 4040003, + + /// An error response that indicates the app wasn’t found, but you can try again. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/appnotfoundretryableerror) + AppNotFoundRetryable = 4040004, + + /// An error that indicates an original transaction identifier wasn't found. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/originaltransactionidnotfounderror) + OriginalTransactionIdNotFound = 4040005, + + /// An error response that indicates the original transaction identifier wasn’t found, but you can try again. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/originaltransactionidnotfoundretryableerror) + OriginalTransactionIdNotFoundRetryable = 4040006, + + /// An error that indicates that the App Store server couldn’t find a notifications URL for your app in this environment. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/servernotificationurlnotfounderror) + ServerNotificationUrlNotFound = 4040007, + + /// An error that indicates that the test notification token is expired or the test notification status isn’t available. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/testnotificationnotfounderror) + TestNotificationNotFound = 4040008, + + /// An error that indicates the server didn't find a subscription-renewal-date extension request for the request identifier and product identifier you provided. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/statusrequestnotfounderror) + StatusRequestNotFound = 4040009, + + /// An error that indicates a transaction identifier wasn't found. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/transactionidnotfounderror) + TransactionIdNotFound = 4040010, + + /// An error that indicates that the request exceeded the rate limit. + /// [Documentation](https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror) + RateLimitExceeded = 4290000, + + /// An error that indicates a general internal error. + /// + /// [GeneralInternalError](https://developer.apple.com/documentation/appstoreserverapi/generalinternalerror) + GeneralInternal = 5000000, + + /// An error response that indicates an unknown error occurred, but you can try again. + /// + /// [GeneralInternalRetryableError](https://developer.apple.com/documentation/appstoreserverapi/generalinternalretryableerror) + GeneralInternalRetryable = 5000001 +} #[derive(Debug, Clone, Deserialize, Serialize, Hash)] pub struct ErrorPayload { #[serde(rename = "errorCode")] - pub error_code: Option, + #[serde(default, deserialize_with = "deserialize_maybe_none")] + pub error_code: Option, #[serde(rename = "errorMessage")] pub error_message: Option, } + +impl ErrorPayload { + pub fn raw_error_code(&self) -> Option { + match &self.error_code { + None => return None, + Some(code) => return Some(code.clone() as i64) + } + } +} +// custom deserializer function +fn deserialize_maybe_none<'de, D, T: Deserialize<'de>>( + deserializer: D, +) -> Result, D::Error> + where + D: Deserializer<'de>, +{ + // deserialize into local enum + if let Ok(value) = Deserialize::deserialize(deserializer) { + Ok(value) + } else { + Ok(None) + } +} \ No newline at end of file diff --git a/src/primitives/extend_renewal_date_response.rs b/src/primitives/extend_renewal_date_response.rs index f46a416..39bd19e 100644 --- a/src/primitives/extend_renewal_date_response.rs +++ b/src/primitives/extend_renewal_date_response.rs @@ -1,9 +1,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_with::formats::Flexible; +use serde_with::TimestampMilliSeconds; /// A response that indicates whether an individual renewal-date extension succeeded, and related details. /// /// [ExtendRenewalDateResponse](https://developer.apple.com/documentation/appstoreserverapi/extendrenewaldateresponse) +#[serde_with::serde_as] #[derive(Debug, Clone, Deserialize, Serialize, Hash)] pub struct ExtendRenewalDateResponse { /// The original transaction identifier of a purchase. @@ -28,5 +31,6 @@ pub struct ExtendRenewalDateResponse { /// /// [effectiveDate](https://developer.apple.com/documentation/appstoreserverapi/effectivedate) #[serde(rename = "effectiveDate")] + #[serde_as(as = "Option>")] pub effective_date: Option>, } diff --git a/src/primitives/in_app_ownership_type.rs b/src/primitives/in_app_ownership_type.rs index 55ea197..73da415 100644 --- a/src/primitives/in_app_ownership_type.rs +++ b/src/primitives/in_app_ownership_type.rs @@ -10,3 +10,13 @@ pub enum InAppOwnershipType { #[serde(rename = "PURCHASED")] Purchased, } + + +impl InAppOwnershipType { + pub fn raw_value(&self) -> &str { + match self { + InAppOwnershipType::FamilyShared => "FAMILY_SHARED", + InAppOwnershipType::Purchased => "PURCHASED", + } + } +} diff --git a/src/primitives/last_transactions_item.rs b/src/primitives/last_transactions_item.rs index 95df523..70051ed 100644 --- a/src/primitives/last_transactions_item.rs +++ b/src/primitives/last_transactions_item.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// The most recent App Store-signed transaction information and App Store-signed renewal information for an auto-renewable subscription. /// /// [lastTransactionsItem](https://developer.apple.com/documentation/appstoreserverapi/lasttransactionsitem) -#[derive(Debug, Clone, Deserialize, Serialize, Hash)] +#[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq)] pub struct LastTransactionsItem { /// The status of the auto-renewable subscription. /// diff --git a/src/primitives/mass_extend_renewal_date_status_response.rs b/src/primitives/mass_extend_renewal_date_status_response.rs index 28f6be2..af37c43 100644 --- a/src/primitives/mass_extend_renewal_date_status_response.rs +++ b/src/primitives/mass_extend_renewal_date_status_response.rs @@ -1,36 +1,41 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_with::formats::Flexible; +use serde_with::TimestampMilliSeconds; /// A response that indicates the current status of a request to extend the subscription renewal date to all eligible subscribers. /// /// [MassExtendRenewalDateStatusResponse](https://developer.apple.com/documentation/appstoreserverapi/massextendrenewaldatestatusresponse) +#[serde_with::serde_as] #[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq, Eq)] pub struct MassExtendRenewalDateStatusResponse { /// A string that contains a unique identifier you provide to track each subscription-renewal-date extension request. /// /// [requestIdentifier](https://developer.apple.com/documentation/appstoreserverapi/requestidentifier) #[serde(rename = "requestIdentifier")] - pub request_identifier: String, + pub request_identifier: Option, /// A Boolean value that indicates whether the App Store completed the request to extend a subscription renewal date to active subscribers. /// /// [complete](https://developer.apple.com/documentation/appstoreserverapi/complete) - pub complete: bool, + pub complete: Option, /// The UNIX time, in milliseconds, that the App Store completes a request to extend a subscription renewal date for eligible subscribers. /// /// [completeDate](https://developer.apple.com/documentation/appstoreserverapi/completedate) #[serde(rename = "completeDate")] - pub complete_date: chrono::NaiveDateTime, + #[serde_as(as = "Option>")] + pub complete_date: Option>, /// The count of subscriptions that successfully receive a subscription-renewal-date extension. /// /// [succeededCount](https://developer.apple.com/documentation/appstoreserverapi/succeededcount) #[serde(rename = "succeededCount")] - pub succeeded_count: i64, + pub succeeded_count: Option, /// The count of subscriptions that fail to receive a subscription-renewal-date extension. /// /// [failedCount](https://developer.apple.com/documentation/appstoreserverapi/failedcount) #[serde(rename = "failedCount")] - pub failed_count: i64, + pub failed_count: Option, } diff --git a/src/primitives/notification_history_request.rs b/src/primitives/notification_history_request.rs index da6886b..7ae8a18 100644 --- a/src/primitives/notification_history_request.rs +++ b/src/primitives/notification_history_request.rs @@ -1,11 +1,14 @@ use crate::primitives::notification_type_v2::NotificationTypeV2; use crate::primitives::subtype::Subtype; -use chrono::NaiveDateTime; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_with::formats::Flexible; +use serde_with::TimestampMilliSeconds; /// The request body for notification history. /// /// [NotificationHistoryRequest](https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryrequest) +#[serde_with::serde_as] #[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq, Eq)] pub struct NotificationHistoryRequest { /// The start date of the timespan for the requested App Store Server Notification history records. @@ -13,14 +16,16 @@ pub struct NotificationHistoryRequest { /// /// [startDate](https://developer.apple.com/documentation/appstoreserverapi/startdate) #[serde(rename = "startDate")] - pub start_date: Option, + #[serde_as(as = "Option>")] + pub start_date: Option>, /// The end date of the timespan for the requested App Store Server Notification history records. /// Choose an endDate that’s later than the startDate. If you choose an endDate in the future, the endpoint automatically uses the current date as the endDate. /// /// [endDate](https://developer.apple.com/documentation/appstoreserverapi/enddate) #[serde(rename = "endDate")] - pub end_date: Option, + #[serde_as(as = "Option>")] + pub end_date: Option>, /// A notification type. Provide this field to limit the notification history records to those with this one notification type. /// For a list of notifications types, see notificationType. diff --git a/src/primitives/notification_history_response_item.rs b/src/primitives/notification_history_response_item.rs index 4194b39..92bd6a3 100644 --- a/src/primitives/notification_history_response_item.rs +++ b/src/primitives/notification_history_response_item.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// The App Store server notification history record, including the signed notification payload and the result of the server’s first send attempt. /// /// [notificationHistoryResponseItem](https://developer.apple.com/documentation/appstoreserverapi/notificationhistoryresponseitem) -#[derive(Debug, Clone, Deserialize, Serialize, Hash)] +#[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq)] pub struct NotificationHistoryResponseItem { /// A cryptographically signed payload, in JSON Web Signature (JWS) format, containing the response body for a version 2 notification. /// diff --git a/src/primitives/send_attempt_item.rs b/src/primitives/send_attempt_item.rs index 91af81a..9c7e944 100644 --- a/src/primitives/send_attempt_item.rs +++ b/src/primitives/send_attempt_item.rs @@ -7,7 +7,7 @@ use serde_with::TimestampMilliSeconds; /// /// [sendAttemptItem](https://developer.apple.com/documentation/appstoreserverapi/sendattemptitem) #[serde_with::serde_as] -#[derive(Debug, Clone, Deserialize, Serialize, Hash)] +#[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq)] pub struct SendAttemptItem { /// The date the App Store server attempts to send a notification. /// diff --git a/src/primitives/status.rs b/src/primitives/status.rs index a614fe4..31ab1a3 100644 --- a/src/primitives/status.rs +++ b/src/primitives/status.rs @@ -12,3 +12,15 @@ pub enum Status { BillingGracePeriod = 4, Revoked = 5, } + +impl Status { + pub fn raw_value(&self) -> u8 { + match &self { + Status::Active => 1, + Status::Expired => 2, + Status::BillingRetry => 3, + Status::BillingGracePeriod => 4, + Status::Revoked => 5, + } + } +} \ No newline at end of file diff --git a/src/primitives/subscription_group_identifier_item.rs b/src/primitives/subscription_group_identifier_item.rs index ebfbf4b..7536be1 100644 --- a/src/primitives/subscription_group_identifier_item.rs +++ b/src/primitives/subscription_group_identifier_item.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; /// Information for auto-renewable subscriptions, including signed transaction information and signed renewal information, for one subscription group. /// /// [SubscriptionGroupIdentifierItem](https://developer.apple.com/documentation/appstoreserverapi/subscriptiongroupidentifieritem) -#[derive(Debug, Clone, Deserialize, Serialize, Hash)] +#[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq)] pub struct SubscriptionGroupIdentifierItem { /// The identifier of the subscription group that the subscription belongs to. /// diff --git a/src/primitives/transaction_history_request.rs b/src/primitives/transaction_history_request.rs index 4f6d4b4..9192615 100644 --- a/src/primitives/transaction_history_request.rs +++ b/src/primitives/transaction_history_request.rs @@ -52,6 +52,17 @@ pub enum ProductType { NonConsumable, } +impl ProductType { + pub fn raw_value(&self) -> &str { + match self { + ProductType::AutoRenewable => "AUTO_RENEWABLE", + ProductType::NonRenewable => "NON_RENEWABLE", + ProductType::Consumable => "CONSUMABLE", + ProductType::NonConsumable => "NON_CONSUMABLE", + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, Hash, PartialEq, Eq)] pub enum Order { #[serde(rename = "ASCENDING")] @@ -59,3 +70,12 @@ pub enum Order { #[serde(rename = "DESCENDING")] Descending, } + +impl Order { + pub fn raw_value(&self) -> &str { + match self { + Order::Ascending => "ASCENDING", + Order::Descending => "DESCENDING", + } + } +} \ No newline at end of file diff --git a/src/receipt_utility.rs b/src/receipt_utility.rs index bc207c2..3e0462a 100644 --- a/src/receipt_utility.rs +++ b/src/receipt_utility.rs @@ -146,7 +146,7 @@ pub fn extract_transaction_id_from_transaction_receipt( if let Some(encoded_transaction_id) = transaction_id_match.get(1) { return Ok(Some(encoded_transaction_id.as_str().to_string())); } - } + }; } } } From fb8055eed1c67cecb27598522660f1acc623d8ac Mon Sep 17 00:00:00 2001 From: tikhop Date: Fri, 2 Feb 2024 14:01:09 +0100 Subject: [PATCH 2/2] chore: Bump up version --- Cargo.toml | 2 +- README.md | 2 +- src/api_client.rs | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1ceac9e..2908a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "app-store-server-library" description = "The Rust server library for the App Store Server API and App Store Server Notifications" -version = "0.9.1" +version = "1.0.0" repository = "https://github.com/namecare/app-store-server-library-rust" homepage = "https://github.com/namecare/app-store-server-library-rust" authors = ["tkhp", "namecare"] diff --git a/README.md b/README.md index 27ae87c..0a5b733 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Specify `app-store-server-library` in your project's `Cargo.toml` file, under th ```rust [dependencies] -app-store-server-library = { version = "0.9.1", features = ["receipt_utility"] } +app-store-server-library = { version = "1.0.0", features = ["receipt-utility", "api-client"] } ``` Check [crates.io](https://crates.io/crates/app-store-server-library) for the latest version number. diff --git a/src/api_client.rs b/src/api_client.rs index c656c42..6af1d6a 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -584,8 +584,7 @@ mod tests { use chrono::NaiveDateTime; use url::Url; use uuid::Uuid; - use base64::engine::general_purpose::STANDARD; - use base64::prelude::{BASE64_STANDARD, BASE64_STANDARD_NO_PAD}; + use base64::prelude::BASE64_STANDARD_NO_PAD; use crate::primitives::account_tenure::AccountTenure; use crate::primitives::consumption_status::ConsumptionStatus; use crate::primitives::delivery_status::DeliveryStatus; @@ -671,7 +670,7 @@ mod tests { assert_eq!(Environment::LocalTesting, response.environment.unwrap()); assert_eq!("com.example", response.bundle_id.as_str()); - assert_eq!(5454545, response.app_apple_id); + assert_eq!(5454545, response.app_apple_id.unwrap()); let item = SubscriptionGroupIdentifierItem { subscription_group_identifier: Some("sub_group_one".to_string()),