From e799cae5fea1ed9850d602da363ff1d97c6b8f19 Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Mon, 19 Aug 2024 18:49:57 -0700 Subject: [PATCH] Adding Async client backed by httpx --- NOTICE.txt | 16 + README.md | 32 ++ appstoreserverlibrary/api_client.py | 254 ++++++++++++- requirements.txt | 3 +- setup.py | 3 + tests/test_api_client_async.py | 528 ++++++++++++++++++++++++++++ 6 files changed, 818 insertions(+), 18 deletions(-) create mode 100644 tests/test_api_client_async.py diff --git a/NOTICE.txt b/NOTICE.txt index 3e3bb75..1a41504 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -666,3 +666,19 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +_____________________ + +Encode OSS Ltd. (httpx) + +Copyright © 2019, Encode OSS Ltd. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.md b/README.md index d960b43..f84bc8f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,38 @@ timestamp = round(time.time()*1000) base64_encoded_signature = promotion_code_signature_generator.create_signature(product_id, subscription_offer_id, application_username, nonce, timestamp) ``` +### Async HTTPX Support + +#### Pip +Include the optional async dependency +```sh +pip install app-store-server-library[async] +``` + +#### API Usage +```python +from appstoreserverlibrary.api_client import AsyncAppStoreServerAPIClient, APIException +from appstoreserverlibrary.models.Environment import Environment + +private_key = read_private_key("/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") # Implementation will vary + +key_id = "ABCDEFGHIJ" +issuer_id = "99b16628-15e4-4668-972b-eeff55eeff55" +bundle_id = "com.example" +environment = Environment.SANDBOX + +client = AsyncAppStoreServerAPIClient(private_key, key_id, issuer_id, bundle_id, environment) + +try: + response = await client.request_test_notification() + print(response) +except APIException as e: + print(e) + +# Once client use is finished +await client.async_close() +``` + ## Support Only the latest major version of the library will receive updates, including security updates. Therefore, it is recommended to update to new major versions. diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index a6ff166..01360bc 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -3,7 +3,7 @@ import calendar import datetime from enum import IntEnum, Enum -from typing import Any, Dict, List, Optional, Type, TypeVar, Union +from typing import Any, Dict, List, MutableMapping, Optional, Type, TypeVar, Union from attr import define import requests @@ -458,7 +458,7 @@ class GetTransactionHistoryVersion(str, Enum): V2 = "v2" -class AppStoreServerAPIClient: +class BaseAppStoreServerAPIClient: def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): if environment == Environment.XCODE: raise ValueError("Xcode is not a supported environment for an AppStoreServerAPIClient") @@ -486,35 +486,52 @@ def _generate_token(self) -> str: algorithm="ES256", headers={"kid": self._key_id}, ) - - def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T]) -> T: - url = self._base_url + path - c = _get_cattrs_converter(type(body)) if body is not None else None - json = c.unstructure(body) if body is not None else None - headers = { + + def _get_full_url(self, path) -> str: + return self._base_url + path + + def _get_headers(self) -> Dict[str, str]: + return { 'User-Agent': "app-store-server-library/python/1.4.0", 'Authorization': 'Bearer ' + self._generate_token(), 'Accept': 'application/json' } - - response = self._execute_request(method, url, queryParameters, headers, json) - if 200 <= response.status_code < 300: + + def _get_request_json(self, body) -> Dict[str, Any]: + c = _get_cattrs_converter(type(body)) if body is not None else None + return c.unstructure(body) if body is not None else None + + def _parse_response(self, status_code: int, headers: MutableMapping, json_supplier, destination_class: Type[T]) -> T: + if 200 <= status_code < 300: if destination_class is None: return c = _get_cattrs_converter(destination_class) - response_body = response.json() + response_body = json_supplier() return c.structure(response_body, destination_class) else: # Best effort parsing of the response body - if not 'content-type' in response.headers or response.headers['content-type'] != 'application/json': - raise APIException(response.status_code) + if not 'content-type' in headers or headers['content-type'] != 'application/json': + raise APIException(status_code) try: - response_body = response.json() - raise APIException(response.status_code, response_body['errorCode'], response_body['errorMessage']) + response_body = json_supplier() + raise APIException(status_code, response_body['errorCode'], response_body['errorMessage']) except APIException as e: raise e except Exception as e: - raise APIException(response.status_code) from e + raise APIException(status_code) from e + + +class AppStoreServerAPIClient(BaseAppStoreServerAPIClient): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): + super().__init__(signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id, environment=environment) + + def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T]) -> T: + url = self._get_full_url(path) + json = self._get_request_json(body) + headers = self._get_headers() + + response = self._execute_request(method, url, queryParameters, headers, json) + return self._parse_response(response.status_code, response.headers, lambda: response.json(), destination_class) def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]) -> requests.Response: return requests.request(method, url, params=params, headers=headers, json=json) @@ -697,3 +714,206 @@ def send_consumption_data(self, transaction_id: str, consumption_request: Consum :raises APIException: If a response was returned indicating the request could not be processed """ self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None) + + +class AsyncAppStoreServerAPIClient(BaseAppStoreServerAPIClient): + def __init__(self, signing_key: bytes, key_id: str, issuer_id: str, bundle_id: str, environment: Environment): + super().__init__(signing_key=signing_key, key_id=key_id, issuer_id=issuer_id, bundle_id=bundle_id, environment=environment) + try: + import httpx + self.http_client = httpx.AsyncClient() + except: + raise ModuleNotFoundError("httpx not found but attempting to instantiate an async client") + + async def async_close(self): + await self.http_client.aclose() + + async def _make_request(self, path: str, method: str, queryParameters: Dict[str, Union[str, List[str]]], body, destination_class: Type[T]) -> T: + url = self._get_full_url(path) + json = self._get_request_json(body) + headers = self._get_headers() + + response = await self._execute_request(method, url, queryParameters, headers, json) + return self._parse_response(response.status_code, response.headers, lambda: response.json(), destination_class) + + async def _execute_request(self, method: str, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]): + return await self.http_client.request(method, url, params=params, headers=headers, json=json) + + async def extend_renewal_date_for_all_active_subscribers(self, mass_extend_renewal_date_request: MassExtendRenewalDateRequest) -> MassExtendRenewalDateResponse: + """ + Uses a subscription's product identifier to extend the renewal date for all of its eligible active subscribers. + https://developer.apple.com/documentation/appstoreserverapi/extend_subscription_renewal_dates_for_all_active_subscribers + + :param mass_extend_renewal_date_request: The request body for extending a subscription renewal date for all of its active subscribers. + :return: A response that indicates the server successfully received the subscription-renewal-date extension request. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/subscriptions/extend/mass", "POST", {}, mass_extend_renewal_date_request, MassExtendRenewalDateResponse) + + async def extend_subscription_renewal_date(self, original_transaction_id: str, extend_renewal_date_request: ExtendRenewalDateRequest) -> ExtendRenewalDateResponse: + """ + Extends the renewal date of a customer's active subscription using the original transaction identifier. + https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date + + :param original_transaction_id: The original transaction identifier of the subscription receiving a renewal date extension. + :param extend_renewal_date_request: The request body containing subscription-renewal-extension data. + :return: A response that indicates whether an individual renewal-date extension succeeded, and related details. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/subscriptions/extend/" + original_transaction_id, "PUT", {}, extend_renewal_date_request, ExtendRenewalDateResponse) + + async def get_all_subscription_statuses(self, transaction_id: str, status: Optional[List[Status]] = None) -> StatusResponse: + """ + Get the statuses for all of a customer's auto-renewable subscriptions in your app. + https://developer.apple.com/documentation/appstoreserverapi/get_all_subscription_statuses + + :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + :param status: An optional filter that indicates the status of subscriptions to include in the response. Your query may specify more than one status query parameter. + :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if status is not None: + queryParameters["status"] = [s.value for s in status] + + return await self._make_request("/inApps/v1/subscriptions/" + transaction_id, "GET", queryParameters, None, StatusResponse) + + async def get_refund_history(self, transaction_id: str, revision: Optional[str]) -> RefundHistoryResponse: + """ + Get a paginated list of all of a customer's refunded in-app purchases for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_refund_history + + :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + :param 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. + :return: A response that contains status information for all of a customer's auto-renewable subscriptions in your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + + queryParameters: Dict[str, List[str]] = dict() + if revision is not None: + queryParameters["revision"] = [revision] + + return await self._make_request("/inApps/v2/refund/lookup/" + transaction_id, "GET", queryParameters, None, RefundHistoryResponse) + + async def get_status_of_subscription_renewal_date_extensions(self, request_identifier: str, product_id: str) -> MassExtendRenewalDateStatusResponse: + """ + Checks whether a renewal date extension request completed, and provides the final count of successful or failed extensions. + https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions + + :param request_identifier: The UUID that represents your request to the Extend Subscription Renewal Dates for All Active Subscribers endpoint. + :param product_id: The product identifier of the auto-renewable subscription that you request a renewal-date extension for. + :return: A response that indicates the current status of a request to extend the subscription renewal date to all eligible subscribers. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/subscriptions/extend/mass/" + product_id + "/" + request_identifier, "GET", {}, None, MassExtendRenewalDateStatusResponse) + + async def get_test_notification_status(self, test_notification_token: str) -> CheckTestNotificationResponse: + """ + Check the status of the test App Store server notification sent to your server. + https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status + + :param test_notification_token: The test notification token received from the Request a Test Notification endpoint + :return: A response that contains the contents of the test notification sent by the App Store server and the result from your server. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/notifications/test/" + test_notification_token, "GET", {}, None, CheckTestNotificationResponse) + + async def get_notification_history(self, pagination_token: Optional[str], notification_history_request: NotificationHistoryRequest) -> NotificationHistoryResponse: + """ + Get a list of notifications that the App Store server attempted to send to your server. + https://developer.apple.com/documentation/appstoreserverapi/get_notification_history + + :param 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. + :param notification_history_request: The request body that includes the start and end dates, and optional query constraints. + :return: A response that contains the App Store Server Notifications history for your app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if pagination_token is not None: + queryParameters["paginationToken"] = [pagination_token] + + return await self._make_request("/inApps/v1/notifications/history", "POST", queryParameters, notification_history_request, NotificationHistoryResponse) + + async def get_transaction_history(self, transaction_id: str, revision: Optional[str], transaction_history_request: TransactionHistoryRequest, version: GetTransactionHistoryVersion = GetTransactionHistoryVersion.V1) -> HistoryResponse: + """ + Get a customer's in-app purchase transaction history for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_transaction_history + + :param transaction_id: The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + :param 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. + :param transaction_history_request: The request parameters that includes the startDate,endDate,productIds,productTypes and optional query constraints. + :param version: The version of the Get Transaction History endpoint to use. V2 is recommended. + :return: A response that contains the customer's transaction history for an app. + :throws APIException: If a response was returned indicating the request could not be processed + """ + queryParameters: Dict[str, List[str]] = dict() + if revision is not None: + queryParameters["revision"] = [revision] + + if transaction_history_request.startDate is not None: + queryParameters["startDate"] = [str(transaction_history_request.startDate)] + + if transaction_history_request.endDate is not None: + queryParameters["endDate"] = [str(transaction_history_request.endDate)] + + if transaction_history_request.productIds is not None: + queryParameters["productId"] = transaction_history_request.productIds + + if transaction_history_request.productTypes is not None: + queryParameters["productType"] = [product_type.value for product_type in transaction_history_request.productTypes] + + if transaction_history_request.sort is not None: + queryParameters["sort"] = [transaction_history_request.sort.value] + + if transaction_history_request.subscriptionGroupIdentifiers is not None: + queryParameters["subscriptionGroupIdentifier"] = transaction_history_request.subscriptionGroupIdentifiers + + if transaction_history_request.inAppOwnershipType is not None: + queryParameters["inAppOwnershipType"] = [transaction_history_request.inAppOwnershipType.value] + + if transaction_history_request.revoked is not None: + queryParameters["revoked"] = [str(transaction_history_request.revoked)] + + return await self._make_request("/inApps/" + version + "/history/" + transaction_id, "GET", queryParameters, None, HistoryResponse) + + async def get_transaction_info(self, transaction_id: str) -> TransactionInfoResponse: + """ + Get information about a single transaction for your app. + https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info + + :param transaction_id The identifier of a transaction that belongs to the customer, and which may be an original transaction identifier. + :return: A response that contains signed transaction information for a single transaction. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/transactions/" + transaction_id, "GET", {}, None, TransactionInfoResponse) + + async def look_up_order_id(self, order_id: str) -> OrderLookupResponse: + """ + Get a customer's in-app purchases from a receipt using the order ID. + https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id + + :param order_id: The order ID for in-app purchases that belong to the customer. + :return: A response that includes the order lookup status and an array of signed transactions for the in-app purchases in the order. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/lookup/" + order_id, "GET", {}, None, OrderLookupResponse) + async def request_test_notification(self) -> SendTestNotificationResponse: + """ + Ask App Store Server Notifications to send a test notification to your server. + https://developer.apple.com/documentation/appstoreserverapi/request_a_test_notification + + :return: A response that contains the test notification token. + :throws APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/notifications/test", "POST", {}, None, SendTestNotificationResponse) + + async def send_consumption_data(self, transaction_id: str, consumption_request: ConsumptionRequest): + """ + Send consumption information about a consumable in-app purchase to the App Store after your server receives a consumption request notification. + https://developer.apple.com/documentation/appstoreserverapi/send_consumption_information + + :param transaction_id: The transaction identifier for which you're providing consumption information. You receive this identifier in the CONSUMPTION_REQUEST notification the App Store sends to your server. + :param consumption_request: The request body containing consumption information. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/transactions/consumption/" + transaction_id, "PUT", {}, consumption_request, None) diff --git a/requirements.txt b/requirements.txt index b044e44..c66e9f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ requests >= 2.28.0, < 3 cryptography >= 40.0.0 pyOpenSSL >= 23.1.1 asn1==2.7.0 -cattrs >= 23.1.2 \ No newline at end of file +cattrs >= 23.1.2 +httpx==0.27.0 diff --git a/setup.py b/setup.py index ec57fff..e12bd15 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,9 @@ packages=find_packages(exclude=["tests"]), python_requires=">=3.7, <4", install_requires=["attrs >= 21.3.0", 'PyJWT >= 2.6.0, < 3', 'requests >= 2.28.0, < 3', 'cryptography >= 40.0.0', 'pyOpenSSL >= 23.1.1', 'asn1==2.7.0', 'cattrs >= 23.1.2'], + extras_require={ + "async": ["httpx"], + }, package_data={"appstoreserverlibrary": ["py.typed"]}, license="MIT", classifiers=["License :: OSI Approved :: MIT License"], diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py new file mode 100644 index 0000000..21eea0a --- /dev/null +++ b/tests/test_api_client_async.py @@ -0,0 +1,528 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Any, Dict, List, Union +import unittest + +from httpx import Response + +from appstoreserverlibrary.api_client import APIError, APIException, AsyncAppStoreServerAPIClient, GetTransactionHistoryVersion +from appstoreserverlibrary.models.AccountTenure import AccountTenure +from appstoreserverlibrary.models.AutoRenewStatus import AutoRenewStatus +from appstoreserverlibrary.models.ConsumptionRequest import ConsumptionRequest +from appstoreserverlibrary.models.ConsumptionStatus import ConsumptionStatus +from appstoreserverlibrary.models.DeliveryStatus import DeliveryStatus +from appstoreserverlibrary.models.Environment import Environment +from appstoreserverlibrary.models.ExpirationIntent import ExpirationIntent +from appstoreserverlibrary.models.ExtendReasonCode import ExtendReasonCode +from appstoreserverlibrary.models.ExtendRenewalDateRequest import ExtendRenewalDateRequest +from appstoreserverlibrary.models.InAppOwnershipType import InAppOwnershipType +from appstoreserverlibrary.models.LastTransactionsItem import LastTransactionsItem +from appstoreserverlibrary.models.LifetimeDollarsPurchased import LifetimeDollarsPurchased +from appstoreserverlibrary.models.LifetimeDollarsRefunded import LifetimeDollarsRefunded +from appstoreserverlibrary.models.MassExtendRenewalDateRequest import MassExtendRenewalDateRequest +from appstoreserverlibrary.models.NotificationHistoryRequest import NotificationHistoryRequest +from appstoreserverlibrary.models.NotificationHistoryResponseItem import NotificationHistoryResponseItem +from appstoreserverlibrary.models.NotificationTypeV2 import NotificationTypeV2 +from appstoreserverlibrary.models.OfferType import OfferType +from appstoreserverlibrary.models.OrderLookupStatus import OrderLookupStatus +from appstoreserverlibrary.models.Platform import Platform +from appstoreserverlibrary.models.PlayTime import PlayTime +from appstoreserverlibrary.models.PriceIncreaseStatus import PriceIncreaseStatus +from appstoreserverlibrary.models.RefundPreference import RefundPreference +from appstoreserverlibrary.models.RevocationReason import RevocationReason +from appstoreserverlibrary.models.SendAttemptItem import SendAttemptItem +from appstoreserverlibrary.models.SendAttemptResult import SendAttemptResult +from appstoreserverlibrary.models.Status import Status +from appstoreserverlibrary.models.SubscriptionGroupIdentifierItem import SubscriptionGroupIdentifierItem +from appstoreserverlibrary.models.Subtype import Subtype +from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest +from appstoreserverlibrary.models.TransactionReason import TransactionReason +from appstoreserverlibrary.models.Type import Type +from appstoreserverlibrary.models.UserStatus import UserStatus + +from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file + +from io import BytesIO + +class DecodedPayloads(unittest.IsolatedAsyncioTestCase): + async def test_extend_renewal_date_for_all_active_subscribers(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendRenewalDateForAllActiveSubscribersResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac', 'storefrontCountryCodes': ['USA', 'MEX'], 'productId': 'com.example.productId'}) + + extend_renewal_date_request = MassExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac', + storefrontCountryCodes=['USA', 'MEX'], + productId='com.example.productId') + + mass_extend_renewal_date_response = await client.extend_renewal_date_for_all_active_subscribers(extend_renewal_date_request) + + self.assertIsNotNone(mass_extend_renewal_date_response) + self.assertEqual('758883e8-151b-47b7-abd0-60c4d804c2f5', mass_extend_renewal_date_response.requestIdentifier) + + async def test_extend_subscription_renewal_date(self): + client = self.get_client_with_body_from_file('tests/resources/models/extendSubscriptionRenewalDateResponse.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/4124214', + {}, + {'extendByDays': 45, 'extendReasonCode': 1, 'requestIdentifier': 'fdf964a4-233b-486c-aac1-97d8d52688ac'}) + + extend_renewal_date_request = ExtendRenewalDateRequest( + extendByDays=45, + extendReasonCode=ExtendReasonCode.CUSTOMER_SATISFACTION, + requestIdentifier='fdf964a4-233b-486c-aac1-97d8d52688ac' + ) + + extend_renewal_date_response = await client.extend_subscription_renewal_date('4124214', extend_renewal_date_request) + + self.assertIsNotNone(extend_renewal_date_response) + self.assertEqual('2312412', extend_renewal_date_response.originalTransactionId) + self.assertEqual('9993', extend_renewal_date_response.webOrderLineItemId) + self.assertTrue(extend_renewal_date_response.success) + self.assertEqual(1698148900000, extend_renewal_date_response.effectiveDate) + + async def test_get_all_subscription_statuses(self): + client = self.get_client_with_body_from_file('tests/resources/models/getAllSubscriptionStatusesResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/subscriptions/4321', + {'status': [2, 1]}, + None) + + status_response = await client.get_all_subscription_statuses('4321', [Status.EXPIRED, Status.ACTIVE]) + + self.assertIsNotNone(status_response) + self.assertEqual(Environment.LOCAL_TESTING, status_response.environment) + self.assertEqual('LocalTesting', status_response.rawEnvironment) + self.assertEqual('com.example', status_response.bundleId) + self.assertEqual(5454545, status_response.appAppleId) + + + expected_body = [ + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_one', + lastTransactions=[ + LastTransactionsItem( + status=Status.ACTIVE, + originalTransactionId='3749183', + signedTransactionInfo='signed_transaction_one', + signedRenewalInfo='signed_renewal_one' + ), + LastTransactionsItem( + status=Status.REVOKED, + originalTransactionId='5314314134', + signedTransactionInfo='signed_transaction_two', + signedRenewalInfo='signed_renewal_two' + ) + ] + ), + SubscriptionGroupIdentifierItem( + subscriptionGroupIdentifier='sub_group_two', + lastTransactions=[ + LastTransactionsItem( + status=Status.EXPIRED, + originalTransactionId='3413453', + signedTransactionInfo='signed_transaction_three', + signedRenewalInfo='signed_renewal_three' + ) + ] + ) + ] + self.assertEqual(expected_body, status_response.data) + + async def test_get_refund_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRefundHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/refund/lookup/555555', + {'revision': ['revision_input']}, + None) + + refund_history_response = await client.get_refund_history('555555', 'revision_input') + + self.assertIsNotNone(refund_history_response) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], refund_history_response.signedTransactions) + self.assertEqual('revision_output', refund_history_response.revision) + self.assertTrue(refund_history_response.hasMore) + + async def test_get_status_of_subscription_renewal_date_extensions(self): + client = self.get_client_with_body_from_file('tests/resources/models/getStatusOfSubscriptionRenewalDateExtensionsResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/subscriptions/extend/mass/20fba8a0-2b80-4a7d-a17f-85c1854727f8/com.example.product', + {}, + None) + + mass_extend_renewal_date_status_response = await client.get_status_of_subscription_renewal_date_extensions('com.example.product', '20fba8a0-2b80-4a7d-a17f-85c1854727f8') + + self.assertIsNotNone(mass_extend_renewal_date_status_response) + self.assertEqual('20fba8a0-2b80-4a7d-a17f-85c1854727f8', mass_extend_renewal_date_status_response.requestIdentifier) + self.assertTrue(mass_extend_renewal_date_status_response.complete) + self.assertEqual(1698148900000, mass_extend_renewal_date_status_response.completeDate) + self.assertEqual(30, mass_extend_renewal_date_status_response.succeededCount) + self.assertEqual(2, mass_extend_renewal_date_status_response.failedCount) + + async def test_get_test_notification_status(self): + client = self.get_client_with_body_from_file('tests/resources/models/getTestNotificationStatusResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/notifications/test/8cd2974c-f905-492a-bf9a-b2f47c791d19', + {}, + None) + + check_test_notification_response = await client.get_test_notification_status('8cd2974c-f905-492a-bf9a-b2f47c791d19') + + self.assertIsNotNone(check_test_notification_response) + self.assertEqual('signed_payload', check_test_notification_response.signedPayload) + sendAttemptItems = [ + SendAttemptItem(attemptDate=1698148900000,sendAttemptResult=SendAttemptResult.NO_RESPONSE), + SendAttemptItem(attemptDate=1698148950000,sendAttemptResult=SendAttemptResult.SUCCESS) + ] + self.assertEqual(sendAttemptItems, check_test_notification_response.sendAttempts) + + async def test_get_notification_history(self): + client = self.get_client_with_body_from_file('tests/resources/models/getNotificationHistoryResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/history', + {'paginationToken': ['a036bc0e-52b8-4bee-82fc-8c24cb6715d6']}, + {'startDate': 1698148900000, 'endDate': 1698148950000, 'notificationType': 'SUBSCRIBED', 'notificationSubtype': 'INITIAL_BUY', 'transactionId': '999733843', 'onlyFailures': True}) + + notification_history_request = NotificationHistoryRequest( + startDate=1698148900000, + endDate=1698148950000, + notificationType=NotificationTypeV2.SUBSCRIBED, + notificationSubtype=Subtype.INITIAL_BUY, + transactionId='999733843', + onlyFailures=True + ) + + notification_history_response = await client.get_notification_history('a036bc0e-52b8-4bee-82fc-8c24cb6715d6', notification_history_request) + + self.assertIsNotNone(notification_history_response) + self.assertEqual('57715481-805a-4283-8499-1c19b5d6b20a', notification_history_response.paginationToken) + self.assertTrue(notification_history_response.hasMore) + expected_notification_history = [ + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148900000, + sendAttemptResult=SendAttemptResult.NO_RESPONSE + ), + SendAttemptItem( + attemptDate=1698148950000, + rawSendAttemptResult='SUCCESS' + ) + ], signedPayload='signed_payload_one'), + NotificationHistoryResponseItem(sendAttempts=[ + SendAttemptItem( + attemptDate=1698148800000, + sendAttemptResult=SendAttemptResult.CIRCULAR_REDIRECT + ) + ], signedPayload='signed_payload_two') + ] + self.assertEqual(expected_notification_history, notification_history_response.notificationHistory) + + async def test_get_transaction_history_v1(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V1) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + async def test_get_transaction_history_v2(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) + + self.assertIsNotNone(history_response) + self.assertEqual('revision_output', history_response.revision) + self.assertTrue(history_response.hasMore) + self.assertEqual('com.example', history_response.bundleId) + self.assertEqual(323232, history_response.appAppleId) + self.assertEqual(Environment.LOCAL_TESTING, history_response.environment) + self.assertEqual('LocalTesting', history_response.rawEnvironment) + self.assertEqual(['signed_transaction_value', 'signed_transaction_value2'], history_response.signedTransactions) + + async def test_get_transaction_info(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionInfoResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/transactions/1234', + {}, + None) + + transaction_info_response = await client.get_transaction_info('1234') + + self.assertIsNotNone(transaction_info_response) + self.assertEqual('signed_transaction_info_value', transaction_info_response.signedTransactionInfo) + + async def test_look_up_order_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/lookupOrderIdResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/lookup/W002182', + {}, + None) + + order_lookup_response = await client.look_up_order_id('W002182') + + self.assertIsNotNone(order_lookup_response) + self.assertEqual(OrderLookupStatus.INVALID, order_lookup_response.status) + self.assertEqual(1, order_lookup_response.rawStatus) + self.assertEqual(['signed_transaction_one', 'signed_transaction_two'], order_lookup_response.signedTransactions) + + async def test_request_test_notification(self): + client = self.get_client_with_body_from_file('tests/resources/models/requestTestNotificationResponse.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None) + + send_test_notification_response = await client.request_test_notification() + + self.assertIsNotNone(send_test_notification_response) + self.assertEqual('ce3af791-365e-4c60-841b-1674b43c1609', send_test_notification_response.testNotificationToken) + + async def test_send_consumption_data(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/transactions/consumption/49571273', + {}, + {'customerConsented': True, + 'consumptionStatus': 1, + 'platform': 2, + 'sampleContentProvided': False, + 'deliveryStatus': 3, + 'appAccountToken': '7389a31a-fb6d-4569-a2a6-db7d85d84813', + 'accountTenure': 4, + 'playTime': 5, + 'lifetimeDollarsRefunded': 6, + 'lifetimeDollarsPurchased': 7, + 'userStatus': 4, + 'refundPreference': 3}) + + consumptionRequest = ConsumptionRequest( + customerConsented=True, + consumptionStatus=ConsumptionStatus.NOT_CONSUMED, + platform=Platform.NON_APPLE, + sampleContentProvided=False, + deliveryStatus=DeliveryStatus.DID_NOT_DELIVER_DUE_TO_SERVER_OUTAGE, + appAccountToken='7389a31a-fb6d-4569-a2a6-db7d85d84813', + accountTenure=AccountTenure.THIRTY_DAYS_TO_NINETY_DAYS, + playTime=PlayTime.ONE_DAY_TO_FOUR_DAYS, + lifetimeDollarsRefunded=LifetimeDollarsRefunded.ONE_THOUSAND_DOLLARS_TO_ONE_THOUSAND_NINE_HUNDRED_NINETY_NINE_DOLLARS_AND_NINETY_NINE_CENTS, + lifetimeDollarsPurchased=LifetimeDollarsPurchased.TWO_THOUSAND_DOLLARS_OR_GREATER, + userStatus=UserStatus.LIMITED_ACCESS, + refundPreference=RefundPreference.NO_PREFERENCE + ) + + await client.send_consumption_data('49571273', consumptionRequest) + + async def test_api_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 500) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(500, e.http_status_code) + self.assertEqual(5000000, e.raw_api_error) + self.assertEqual(APIError.GENERAL_INTERNAL, e.api_error) + self.assertEqual("An unknown error occurred.", e.error_message) + return + + self.assertFalse(True) + + async def test_xcode_not_supported_error(self): + try: + signing_key = self.get_signing_key() + AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.XCODE) + except ValueError as e: + self.assertEqual("Xcode is not a supported environment for an AppStoreServerAPIClient", e.args[0]) + return + + self.assertFalse(True) + + async def test_api_too_many_requests(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiTooManyRequestsException.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 429) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(429, e.http_status_code) + self.assertEqual(4290000, e.raw_api_error) + self.assertEqual(APIError.RATE_LIMIT_EXCEEDED, e.api_error) + self.assertEqual("Rate limit exceeded.", e.error_message) + return + + self.assertFalse(True) + + async def test_unknown_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/apiUnknownError.json', + 'POST', + 'https://local-testing-base-url/inApps/v1/notifications/test', + {}, + None, + 400) + try: + await client.request_test_notification() + except APIException as e: + self.assertEqual(400, e.http_status_code) + self.assertEqual(9990000, e.raw_api_error) + self.assertIsNone(e.api_error) + self.assertEqual("Testing error.", e.error_message) + return + + self.assertFalse(True) + + async def test_get_transaction_history_with_unknown_environment(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedEnvironment.json', + 'GET', + 'https://local-testing-base-url/inApps/v2/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + history_response = await client.get_transaction_history('1234', 'revision_input', request, GetTransactionHistoryVersion.V2) + + self.assertIsNone(history_response.environment) + self.assertEqual("LocalTestingxxx", history_response.rawEnvironment) + + async def test_get_transaction_history_with_malformed_app_apple_id(self): + client = self.get_client_with_body_from_file('tests/resources/models/transactionHistoryResponseWithMalformedAppAppleId.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/history/1234', + {'revision': ['revision_input'], + 'startDate': ['123455'], + 'endDate': ['123456'], + 'productId': ['com.example.1', 'com.example.2'], + 'productType': ['CONSUMABLE', 'AUTO_RENEWABLE'], + 'sort': ['ASCENDING'], + 'subscriptionGroupIdentifier': ['sub_group_id', 'sub_group_id_2'], + 'inAppOwnershipType': ['FAMILY_SHARED'], + 'revoked': ['False']}, + None) + + request = TransactionHistoryRequest( + sort=Order.ASCENDING, + productTypes=[ProductType.CONSUMABLE, ProductType.AUTO_RENEWABLE], + endDate=123456, + startDate=123455, + revoked=False, + inAppOwnershipType=InAppOwnershipType.FAMILY_SHARED, + productIds=['com.example.1', 'com.example.2'], + subscriptionGroupIdentifiers=['sub_group_id', 'sub_group_id_2'] + ) + + try: + await client.get_transaction_history('1234', 'revision_input', request) + except Exception: + return + + self.assertFalse(True) + + def get_signing_key(self): + return read_data_from_binary_file('tests/resources/certs/testSigningKey.p8') + + def get_client_with_body(self, body: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): + signing_key = self.get_signing_key() + client = AsyncAppStoreServerAPIClient(signing_key, 'keyId', 'issuerId', 'com.example', Environment.LOCAL_TESTING) + async def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, Union[str, List[str]]], headers: Dict[str, str], json: Dict[str, Any]): + self.assertEqual(expected_method, method) + self.assertEqual(expected_url, url) + self.assertEqual(expected_params, params) + self.assertEqual(['User-Agent', 'Authorization', 'Accept'], list(headers.keys())) + self.assertEqual('application/json', headers['Accept']) + self.assertTrue(headers['User-Agent'].startswith('app-store-server-library/python')) + self.assertTrue(headers['Authorization'].startswith('Bearer ')) + decoded_jwt = decode_json_from_signed_date(headers['Authorization'][7:]) + self.assertEqual('appstoreconnect-v1', decoded_jwt['payload']['aud']) + self.assertEqual('issuerId', decoded_jwt['payload']['iss']) + self.assertEqual('keyId', decoded_jwt['header']['kid']) + self.assertEqual('com.example', decoded_jwt['payload']['bid']) + self.assertEqual(expected_json, json) + response = Response(status_code, headers={'Content-Type': 'application/json'}, content=body) + return response + + client._execute_request = fake_execute_and_validate_inputs + return client + + def get_client_with_body_from_file(self, path: str, expected_method: str, expected_url: str, expected_params: Dict[str, Union[str, List[str]]], expected_json: Dict[str, Any], status_code: int = 200): + body = read_data_from_binary_file(path) + return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) +