diff --git a/appstoreserverlibrary/api_client.py b/appstoreserverlibrary/api_client.py index 630ceb3e..d368bdbf 100644 --- a/appstoreserverlibrary/api_client.py +++ b/appstoreserverlibrary/api_client.py @@ -32,6 +32,8 @@ from .models.TransactionHistoryRequest import TransactionHistoryRequest from .models.TransactionInfoResponse import TransactionInfoResponse from .models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest +from .models.UploadMessageRequestBody import UploadMessageRequestBody +from .models.GetMessageListResponse import GetMessageListResponse T = TypeVar('T') @@ -457,10 +459,53 @@ class APIError(IntEnum): GENERAL_INTERNAL_RETRYABLE = 5000001 """ An error response that indicates an unknown error occurred, but you can try again. - + https://developer.apple.com/documentation/appstoreserverapi/generalinternalretryableerror """ + # Retention Messaging specific errors + HEADER_TOO_LONG_ERROR = 4000101 + """ + An error that indicates the message header exceeds 66 characters. + + https://developer.apple.com/documentation/retentionmessaging/headertoolongerror + """ + + BODY_TOO_LONG_ERROR = 4000102 + """ + An error that indicates the message body exceeds 144 characters. + + https://developer.apple.com/documentation/retentionmessaging/bodytoolongerror + """ + + ALT_TEXT_TOO_LONG_ERROR = 4000103 + """ + An error that indicates the alt text exceeds 150 characters. + + https://developer.apple.com/documentation/retentionmessaging/alttexttoolongerror + """ + + MAXIMUM_NUMBER_OF_MESSAGES_REACHED_ERROR = 4030001 + """ + An error that indicates the maximum number of retention messages (2000) has been reached. + + https://developer.apple.com/documentation/retentionmessaging/maximumnumberofmessagesreachederror + """ + + MESSAGE_NOT_FOUND_ERROR = 4040001 + """ + An error that indicates the specified message was not found. + + https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror + """ + + MESSAGE_ALREADY_EXISTS_ERROR = 4090001 + """ + An error that indicates the message identifier already exists. + + https://developer.apple.com/documentation/retentionmessaging/messagealreadyexistserror + """ + @define class APIException(Exception): @@ -758,6 +803,37 @@ def set_app_account_token(self, original_transaction_id: str, update_app_account """ self._make_request("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + def upload_retention_message(self, message_identifier: str, retention_message_request: UploadMessageRequestBody) -> None: + """ + Upload a message to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-message + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param retention_message_request: The request body containing the message text and optional image reference. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request("/inApps/v1/messaging/message/" + message_identifier, "PUT", {}, retention_message_request, None) + + def get_retention_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + https://developer.apple.com/documentation/retentionmessaging/get-message-list + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse) + + def delete_retention_message(self, message_identifier: str) -> None: + """ + Delete a previously uploaded message. + https://developer.apple.com/documentation/retentionmessaging/delete-message + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + self._make_request("/inApps/v1/messaging/message/" + message_identifier, "DELETE", {}, None, 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) @@ -970,3 +1046,34 @@ async def set_app_account_token(self, original_transaction_id: str, update_app_a :raises APIException: If a response was returned indicating the request could not be processed """ await self._make_request("/inApps/v1/transactions/" + original_transaction_id + "/appAccountToken", "PUT", {}, update_app_account_token_request, None) + + async def upload_retention_message(self, message_identifier: str, retention_message_request: UploadMessageRequestBody) -> None: + """ + Upload a message to use for retention messaging. + https://developer.apple.com/documentation/retentionmessaging/upload-message + + :param message_identifier: A UUID you provide to uniquely identify the message you upload. + :param retention_message_request: The request body containing the message text and optional image reference. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/messaging/message/" + message_identifier, "PUT", {}, retention_message_request, None) + + async def get_retention_message_list(self) -> GetMessageListResponse: + """ + Get the message identifier and state of all uploaded messages. + https://developer.apple.com/documentation/retentionmessaging/get-message-list + + :return: A response that contains status information for all messages. + :raises APIException: If a response was returned indicating the request could not be processed + """ + return await self._make_request("/inApps/v1/messaging/message/list", "GET", {}, None, GetMessageListResponse) + + async def delete_retention_message(self, message_identifier: str) -> None: + """ + Delete a previously uploaded message. + https://developer.apple.com/documentation/retentionmessaging/delete-message + + :param message_identifier: The identifier of the message to delete. + :raises APIException: If a response was returned indicating the request could not be processed + """ + await self._make_request("/inApps/v1/messaging/message/" + message_identifier, "DELETE", {}, None, None) diff --git a/appstoreserverlibrary/models/GetMessageListResponse.py b/appstoreserverlibrary/models/GetMessageListResponse.py new file mode 100644 index 00000000..ad959a71 --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponse.py @@ -0,0 +1,21 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional, List +from attr import define +import attr +from .GetMessageListResponseItem import GetMessageListResponseItem + +@define +class GetMessageListResponse: + """ + A response that contains status information for all messages. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponse + """ + + messageIdentifiers: Optional[List[GetMessageListResponseItem]] = attr.ib(default=None) + """ + An array of all message identifiers and their message states. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/GetMessageListResponseItem.py b/appstoreserverlibrary/models/GetMessageListResponseItem.py new file mode 100644 index 00000000..92125d5e --- /dev/null +++ b/appstoreserverlibrary/models/GetMessageListResponseItem.py @@ -0,0 +1,34 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr +from .RetentionMessageState import RetentionMessageState +from .LibraryUtility import AttrsRawValueAware + +@define +class GetMessageListResponseItem(AttrsRawValueAware): + """ + A message identifier and status information for a message. + + https://developer.apple.com/documentation/retentionmessaging/getmessagelistresponseitem + """ + + messageIdentifier: Optional[str] = attr.ib(default=None) + """ + The identifier of the message. + + https://developer.apple.com/documentation/retentionmessaging/messageidentifier + """ + + messageState: Optional[RetentionMessageState] = RetentionMessageState.create_main_attr('rawMessageState') + """ + The current state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messagestate + """ + + rawMessageState: Optional[str] = RetentionMessageState.create_raw_attr('messageState') + """ + See messageState + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/RetentionMessageState.py b/appstoreserverlibrary/models/RetentionMessageState.py new file mode 100644 index 00000000..9ecaab0a --- /dev/null +++ b/appstoreserverlibrary/models/RetentionMessageState.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from enum import Enum +from .LibraryUtility import AppStoreServerLibraryEnumMeta + +class RetentionMessageState(Enum, metaclass=AppStoreServerLibraryEnumMeta): + """ + The approval state of the message. + + https://developer.apple.com/documentation/retentionmessaging/messagestate + """ + + PENDING = "PENDING" + """ + The message is awaiting approval. + """ + + APPROVED = "APPROVED" + """ + The message is approved. + """ + + REJECTED = "REJECTED" + """ + The message is rejected. + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/UploadMessageImage.py b/appstoreserverlibrary/models/UploadMessageImage.py new file mode 100644 index 00000000..4f271b92 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageImage.py @@ -0,0 +1,28 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr + +@define +class UploadMessageImage: + """ + The definition of an image with its alternative text. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ + + imageIdentifier: Optional[str] = attr.ib(default=None) + """ + The unique identifier of an image. + + https://developer.apple.com/documentation/retentionmessaging/imageidentifier + """ + + altText: Optional[str] = attr.ib(default=None) + """ + The alternative text you provide for the corresponding image. + Maximum length: 150 + + https://developer.apple.com/documentation/retentionmessaging/alttext + """ \ No newline at end of file diff --git a/appstoreserverlibrary/models/UploadMessageRequestBody.py b/appstoreserverlibrary/models/UploadMessageRequestBody.py new file mode 100644 index 00000000..e0975789 --- /dev/null +++ b/appstoreserverlibrary/models/UploadMessageRequestBody.py @@ -0,0 +1,37 @@ +# Copyright (c) 2023 Apple Inc. Licensed under MIT License. + +from typing import Optional +from attr import define +import attr +from .UploadMessageImage import UploadMessageImage + +@define +class UploadMessageRequestBody: + """ + The request body for uploading a message, which includes the message text and an optional image reference. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessagerequestbody + """ + + header: Optional[str] = attr.ib(default=None) + """ + The header text of the retention message that the system displays to customers. + Maximum length: 66 + + https://developer.apple.com/documentation/retentionmessaging/header + """ + + body: Optional[str] = attr.ib(default=None) + """ + The body text of the retention message that the system displays to customers. + Maximum length: 144 + + https://developer.apple.com/documentation/retentionmessaging/body + """ + + image: Optional[UploadMessageImage] = attr.ib(default=None) + """ + The optional image identifier and its alternative text to appear as part of a text-based message with an image. + + https://developer.apple.com/documentation/retentionmessaging/uploadmessageimage + """ \ No newline at end of file diff --git a/tests/resources/models/getRetentionMessageListResponse.json b/tests/resources/models/getRetentionMessageListResponse.json new file mode 100644 index 00000000..cbf260bd --- /dev/null +++ b/tests/resources/models/getRetentionMessageListResponse.json @@ -0,0 +1,16 @@ +{ + "messageIdentifiers": [ + { + "messageIdentifier": "test-message-1", + "messageState": "APPROVED" + }, + { + "messageIdentifier": "test-message-2", + "messageState": "PENDING" + }, + { + "messageIdentifier": "test-message-3", + "messageState": "REJECTED" + } + ] +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageAlreadyExistsError.json b/tests/resources/models/retentionMessageAlreadyExistsError.json new file mode 100644 index 00000000..2ed3e089 --- /dev/null +++ b/tests/resources/models/retentionMessageAlreadyExistsError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4090001, + "errorMessage": "An error that indicates the message identifier already exists." +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageHeaderTooLongError.json b/tests/resources/models/retentionMessageHeaderTooLongError.json new file mode 100644 index 00000000..46f70643 --- /dev/null +++ b/tests/resources/models/retentionMessageHeaderTooLongError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4000101, + "errorMessage": "An error that indicates the message header exceeds 66 characters." +} \ No newline at end of file diff --git a/tests/resources/models/retentionMessageNotFoundError.json b/tests/resources/models/retentionMessageNotFoundError.json new file mode 100644 index 00000000..93eff5f6 --- /dev/null +++ b/tests/resources/models/retentionMessageNotFoundError.json @@ -0,0 +1,4 @@ +{ + "errorCode": 4040001, + "errorMessage": "An error that indicates the specified message was not found." +} \ No newline at end of file diff --git a/tests/test_api_client.py b/tests/test_api_client.py index a3151fe9..f7976d8a 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -35,6 +35,9 @@ from appstoreserverlibrary.models.TransactionHistoryRequest import Order, ProductType, TransactionHistoryRequest from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -593,3 +596,125 @@ def fake_execute_and_validate_inputs(method: bytes, url: str, params: Dict[str, 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) + + def test_upload_retention_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + # Should not raise exception for successful upload + client.upload_retention_message('test-message-id', retention_message_request) + + def test_upload_retention_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + { + 'header': 'Test Header', + 'body': 'Test message body', + 'image': { + 'imageIdentifier': 'test-image-id', + 'altText': 'Test image' + } + }) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body', + image=UploadMessageImage( + imageIdentifier='test-image-id', + altText='Test image' + ) + ) + + # Should not raise exception for successful upload + client.upload_retention_message('test-message-id', retention_message_request) + + def test_get_retention_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRetentionMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + + response = client.get_retention_message_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.messageIdentifiers) + self.assertEqual(3, len(response.messageIdentifiers)) + + # Check first message + message1 = response.messageIdentifiers[0] + self.assertEqual('test-message-1', message1.messageIdentifier) + self.assertEqual(RetentionMessageState.APPROVED, message1.messageState) + + # Check second message + message2 = response.messageIdentifiers[1] + self.assertEqual('test-message-2', message2.messageIdentifier) + self.assertEqual(RetentionMessageState.PENDING, message2.messageState) + + # Check third message + message3 = response.messageIdentifiers[2] + self.assertEqual('test-message-3', message3.messageIdentifier) + self.assertEqual(RetentionMessageState.REJECTED, message3.messageState) + + def test_delete_retention_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + None) + + # Should not raise exception for successful deletion + client.delete_retention_message('test-message-id') + + def test_upload_retention_message_already_exists_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageAlreadyExistsError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}, + 409) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + try: + client.upload_retention_message('test-message-id', retention_message_request) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("An error that indicates the message identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + def test_delete_retention_message_not_found_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/nonexistent-message-id', + {}, + None, + 404) + + try: + client.delete_retention_message('nonexistent-message-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("An error that indicates the specified message was not found.", e.error_message) + return + + self.assertFalse(True) diff --git a/tests/test_api_client_async.py b/tests/test_api_client_async.py index 1114001f..74ded647 100644 --- a/tests/test_api_client_async.py +++ b/tests/test_api_client_async.py @@ -40,6 +40,9 @@ from appstoreserverlibrary.models.Type import Type from appstoreserverlibrary.models.UpdateAppAccountTokenRequest import UpdateAppAccountTokenRequest from appstoreserverlibrary.models.UserStatus import UserStatus +from appstoreserverlibrary.models.UploadMessageRequestBody import UploadMessageRequestBody +from appstoreserverlibrary.models.UploadMessageImage import UploadMessageImage +from appstoreserverlibrary.models.RetentionMessageState import RetentionMessageState from tests.util import decode_json_from_signed_date, read_data_from_binary_file, read_data_from_file @@ -595,3 +598,125 @@ def get_client_with_body_from_file(self, path: str, expected_method: str, expect body = read_data_from_binary_file(path) return self.get_client_with_body(body, expected_method, expected_url, expected_params, expected_json, status_code) + async def test_upload_retention_message(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + # Should not raise exception for successful upload + await client.upload_retention_message('test-message-id', retention_message_request) + + async def test_upload_retention_message_with_image(self): + client = self.get_client_with_body(b'', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + { + 'header': 'Test Header', + 'body': 'Test message body', + 'image': { + 'imageIdentifier': 'test-image-id', + 'altText': 'Test image' + } + }) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body', + image=UploadMessageImage( + imageIdentifier='test-image-id', + altText='Test image' + ) + ) + + # Should not raise exception for successful upload + await client.upload_retention_message('test-message-id', retention_message_request) + + async def test_get_retention_message_list(self): + client = self.get_client_with_body_from_file('tests/resources/models/getRetentionMessageListResponse.json', + 'GET', + 'https://local-testing-base-url/inApps/v1/messaging/message/list', + {}, + None) + + response = await client.get_retention_message_list() + + self.assertIsNotNone(response) + self.assertIsNotNone(response.messageIdentifiers) + self.assertEqual(3, len(response.messageIdentifiers)) + + # Check first message + message1 = response.messageIdentifiers[0] + self.assertEqual('test-message-1', message1.messageIdentifier) + self.assertEqual(RetentionMessageState.APPROVED, message1.messageState) + + # Check second message + message2 = response.messageIdentifiers[1] + self.assertEqual('test-message-2', message2.messageIdentifier) + self.assertEqual(RetentionMessageState.PENDING, message2.messageState) + + # Check third message + message3 = response.messageIdentifiers[2] + self.assertEqual('test-message-3', message3.messageIdentifier) + self.assertEqual(RetentionMessageState.REJECTED, message3.messageState) + + async def test_delete_retention_message(self): + client = self.get_client_with_body(b'', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + None) + + # Should not raise exception for successful deletion + await client.delete_retention_message('test-message-id') + + async def test_upload_retention_message_already_exists_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageAlreadyExistsError.json', + 'PUT', + 'https://local-testing-base-url/inApps/v1/messaging/message/test-message-id', + {}, + {'header': 'Test Header', 'body': 'Test message body', 'image': None}, + 409) + + retention_message_request = UploadMessageRequestBody( + header='Test Header', + body='Test message body' + ) + + try: + await client.upload_retention_message('test-message-id', retention_message_request) + except APIException as e: + self.assertEqual(409, e.http_status_code) + self.assertEqual(4090001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_ALREADY_EXISTS_ERROR, e.api_error) + self.assertEqual("An error that indicates the message identifier already exists.", e.error_message) + return + + self.assertFalse(True) + + async def test_delete_retention_message_not_found_error(self): + client = self.get_client_with_body_from_file('tests/resources/models/retentionMessageNotFoundError.json', + 'DELETE', + 'https://local-testing-base-url/inApps/v1/messaging/message/nonexistent-message-id', + {}, + None, + 404) + + try: + await client.delete_retention_message('nonexistent-message-id') + except APIException as e: + self.assertEqual(404, e.http_status_code) + self.assertEqual(4040001, e.raw_api_error) + self.assertEqual(APIError.MESSAGE_NOT_FOUND_ERROR, e.api_error) + self.assertEqual("An error that indicates the specified message was not found.", e.error_message) + return + + self.assertFalse(True) +