From 5e561a8cebac9a985b94bb557ced263aa5d494e7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 30 Jan 2025 16:12:31 +0100 Subject: [PATCH 001/106] DEVEXP-710: Implement Models and Apis for "Available Numbers" Development: - Implemented Pydantic models for API responses. - Added Pydantic models for query parameters. Enhancements to Input and Output Handling: - Added functionality to convert unknown fields from snake_case to camelCase and vice versa when handling extra fields in requests and responses. - Improved handling for related request and response models, including: - Optional parameters support. - Management of unexpected parameters in server responses. - Processing of nested data structures with Pydantic to ensure proper validation - Handling of datetime fields. - Used TypedDict for dictionary-like parameter handling to improve DX Testing: - Developed unit tests for all models. - Created unit tests for API endpoints. Signed-off-by: Jessica Matsuoka --- .gitignore | 5 +- pyproject.toml | 1 + requirements-dev.txt | 6 +- sinch/core/models/base_model.py | 79 +++++++++ sinch/domains/numbers/__init__.py | 106 +----------- sinch/domains/numbers/available_numbers.py | 158 ++++++++++++++++++ .../endpoints/available/activate_number.py | 28 ++-- .../available/list_available_numbers.py | 69 +++----- .../endpoints/available/rent_any_number.py | 67 -------- .../endpoints/available/search_for_number.py | 32 ++-- .../numbers/endpoints/numbers_endpoint.py | 56 ++++++- sinch/domains/numbers/models/__init__.py | 41 ----- .../available/activate_number_request.py | 34 ++++ .../available/activate_number_response.py | 21 +++ .../check_number_availability_request.py | 6 + .../check_number_availability_response.py | 16 ++ .../list_available_numbers_request.py | 12 ++ .../list_available_numbers_response.py | 11 ++ .../numbers/models/available/requests.py | 36 ---- .../numbers/models/available/responses.py | 40 ----- sinch/domains/numbers/models/numbers.py | 53 ++++++ .../test_activate_number_endpoint.py | 57 +++++++ .../test_list_available_numbers_endpoint.py | 112 +++++++++++++ .../test_search_for_number_endpoint.py | 104 ++++++++++++ .../test_activate_number_request_model.py | 95 +++++++++++ ...st_list_available_numbers_request_model.py | 128 ++++++++++++++ .../test_search_for_number_request_model.py | 47 ++++++ .../test_activate_number_response_model.py | 152 +++++++++++++++++ ...t_list_available_numbers_response_model.py | 44 +++++ .../test_search_for_number_response_model.py | 116 +++++++++++++ .../domains/numbers/test_available_numbers.py | 102 +++++++++++ 31 files changed, 1472 insertions(+), 362 deletions(-) create mode 100644 sinch/domains/numbers/available_numbers.py delete mode 100644 sinch/domains/numbers/endpoints/available/rent_any_number.py create mode 100644 sinch/domains/numbers/models/available/activate_number_request.py create mode 100644 sinch/domains/numbers/models/available/activate_number_response.py create mode 100644 sinch/domains/numbers/models/available/check_number_availability_request.py create mode 100644 sinch/domains/numbers/models/available/check_number_availability_response.py create mode 100644 sinch/domains/numbers/models/available/list_available_numbers_request.py create mode 100644 sinch/domains/numbers/models/available/list_available_numbers_response.py delete mode 100644 sinch/domains/numbers/models/available/requests.py delete mode 100644 sinch/domains/numbers/models/available/responses.py create mode 100644 sinch/domains/numbers/models/numbers.py create mode 100644 tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py create mode 100644 tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py create mode 100644 tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py create mode 100644 tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py create mode 100644 tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py create mode 100644 tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py create mode 100644 tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py create mode 100644 tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py create mode 100644 tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py create mode 100644 tests/unit/domains/numbers/test_available_numbers.py diff --git a/.gitignore b/.gitignore index e79cdc6f..bdcc825c 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,7 @@ cython_debug/ .idea/ # Poetry -poetry.lock \ No newline at end of file +poetry.lock + +# .DS_Store files +.DS_Store \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 665599b0..2c9529c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ keywords = ["sinch", "sdk"] python = ">=3.9" requests = "*" httpx = "*" +pydantic = ">=2.0.0" [build-system] requires = ["poetry-core"] diff --git a/requirements-dev.txt b/requirements-dev.txt index 56637da2..d4617425 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,7 @@ # Testing pytest pytest-asyncio +pytest-mock coverage # Code Quality @@ -8,4 +9,7 @@ flake8 # HTTP Libraries httpx -requests \ No newline at end of file +requests + +# Data Validation +pydantic >= 2.0.0 \ No newline at end of file diff --git a/sinch/core/models/base_model.py b/sinch/core/models/base_model.py index da2472e9..d76fc10e 100644 --- a/sinch/core/models/base_model.py +++ b/sinch/core/models/base_model.py @@ -1,5 +1,9 @@ import json +import re +from datetime import datetime from dataclasses import asdict, dataclass +from typing import Any +from pydantic import BaseModel, ConfigDict @dataclass @@ -15,3 +19,78 @@ def as_json(self): class SinchRequestBaseModel(SinchBaseModel): def as_dict(self): return {k: v for k, v in asdict(self).items() if v is not None} + + +class BaseModelConfigRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Converts snake_case to camelCase.""" + components = snake_str.split('_') + return components[0] + ''.join(x.title() for x in components[1:]) + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow" + ) + + def model_dump(self, **kwargs) -> dict: + """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" + # Get the standard model dump + data = super().model_dump(**kwargs) + + # Get extra fields + extra_data = self.__pydantic_extra__ or {} + + # Convert extra fields to camelCase and collect the original snake_case keys + converted_extra = {} + for key, value in extra_data.items(): + camel_case_key = self._to_camel_case(key) + converted_extra[camel_case_key] = value + + # Remove snake_case keys from `data` before merging converted extras + for key in extra_data.keys(): + data.pop(key, None) # Ensure snake_case fields are removed from final output + + # Merge the cleaned base data with the converted extra fields + return {**data, **converted_extra} + + +class BaseModelConfigResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case, + and serializes datetime fields to ISO format. + """ + + @staticmethod + def datetime_encoder(v: datetime) -> str: + """"Converts a datetime object to a string in ISO 8601 format """ + return v.strftime("%Y-%m-%dT%H:%M:%S.%fZ")[:-3] + "Z" + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r'(? None: + """ Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 9dc430d5..db72959f 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,11 +1,7 @@ from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint +from sinch.domains.numbers.available_numbers import AvailableNumbers from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint - from sinch.domains.numbers.endpoints.active.list_active_numbers_for_project import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint @@ -17,15 +13,7 @@ ListActiveNumbersRequest, GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest ) -from sinch.domains.numbers.models.available.requests import ( - ListAvailableNumbersRequest, ActivateNumberRequest, - CheckNumberAvailabilityRequest, RentAnyNumberRequest -) from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.available.responses import ( - ListAvailableNumbersResponse, ActivateNumberResponse, - CheckNumberAvailabilityResponse -) from sinch.domains.numbers.models.active.responses import ( ListActiveNumbersResponse, UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse @@ -39,98 +27,6 @@ ) -class AvailableNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None - ) -> ListAvailableNumbersResponse: - """ - Search for available virtual numbers using a variety of parameters to filter results. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - AvailableNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListAvailableNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_search_pattern=number_search_pattern, - number_pattern=number_pattern - ) - ) - ) - - def activate( - self, - phone_number: str, - sms_configuration: dict = None, - voice_configuration: dict = None - ) -> ActivateNumberResponse: - """ - Activate a virtual number to use with SMS products, Voice products, or both. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ActivateNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ActivateNumberRequest( - phone_number=phone_number, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration - ) - ) - ) - - def rent_any( - self, - region_code: str, - type_: str, - number_pattern: str = None, - capabilities: list = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - callback_url: str = None - ) -> RentAnyNumberRequest: - return self._sinch.configuration.transport.request( - RentAnyNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RentAnyNumberRequest( - region_code=region_code, - type_=type_, - number_pattern=number_pattern, - capabilities=capabilities, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - callback_url=callback_url - ) - ) - ) - - def check_availability(self, phone_number: str) -> CheckNumberAvailabilityResponse: - """ - Enter a specific phone number to check availability. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - SearchForNumberEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CheckNumberAvailabilityRequest( - phone_number=phone_number - ) - ) - ) - - class ActiveNumbers: def __init__(self, sinch): self._sinch = sinch diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py new file mode 100644 index 00000000..54912ef2 --- /dev/null +++ b/sinch/domains/numbers/available_numbers.py @@ -0,0 +1,158 @@ +from typing import Optional, TypedDict, overload +from typing_extensions import NotRequired +from pydantic import conlist, StrictInt, StrictStr +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest + +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse + + +class SmsConfigurationDict(TypedDict): + service_plan_id: str + campaign_id: NotRequired[str] + + +class VoiceConfigurationDict(TypedDict): + type: str + app_id: NotRequired[str] + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[str] + + +class AvailableNumbers: + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) + + def list( + self, + region_code: StrictStr, + number_type: StrictStr, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[StrictStr] = None, + capabilities: Optional[conlist] = None, + page_size: Optional[StrictInt] = None, + **kwargs + ) -> ListAvailableNumbersResponse: + """ + Search for available virtual numbers for you to activate using a variety of parameters to filter results. + + Args: + region_code (str): ISO 3166-1 alpha-2 country code of the phone number. + number_type (str): Type of number (MOBILE, LOCAL, TOLL_FREE). + number_pattern (str): Specific sequence of digits to search for. + number_search_pattern (str): Pattern to apply (START, CONTAIN, END). + capabilities (list): Capabilities (SMS, VOICE) required for the number. + page_size (int): Maximum number of items to return. + **kwargs: Additional filters for the request. + + Returns: + ListAvailableNumbersResponse: A response object with available numbers and their details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = ListAvailableNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_search_pattern=number_search_pattern, + number_pattern=number_pattern, + **kwargs + ) + + return self._request(AvailableNumbersEndpoint, request_data) + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: None = None, + voice_configuration: None = None, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDict, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + def activate( + self, + phone_number: StrictStr, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> ActivateNumberResponse: + """ + Activate a virtual number to use with SMS products, Voice products, or both. + + Args: + phone_number (StrictStr): The phone number in E.164 format with leading +. + sms_configuration (SmsConfigurationDict): Configuration for SMS activation. + voice_configuration (VoiceConfigurationDict): Configuration for Voice activation. + callback_url (StrictStr): The callback URL to be called. + **kwargs: Additional parameters for the request. + + Returns: + ActivateNumberResponse: A response object with the activated number and its details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = ActivateNumberRequest( + phone_number=phone_number, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + return self._request(ActivateNumberEndpoint, request_data) + + def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + """ + Enter a specific phone number to check availability. + + Args: + phone_number (str): The phone number in E.164 format with leading +. + **kwargs: Additional parameters for the request. + + Returns: + CheckNumberAvailabilityResponse: A response object with the availability status of the number. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = CheckNumberAvailabilityRequest(phone_number=phone_number, **kwargs) + return self._request(SearchForNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/endpoints/available/activate_number.py b/sinch/domains/numbers/endpoints/available/activate_number.py index 1155e89e..83179104 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number.py +++ b/sinch/domains/numbers/endpoints/available/activate_number.py @@ -1,21 +1,31 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import ActivateNumberRequest -from sinch.domains.numbers.models.available.responses import ActivateNumberResponse +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse class ActivateNumberEndpoint(NumbersEndpoint): + """ + Endpoint to activate a virtual number for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data def build_url(self, sinch) -> str: + """ + Constructs the full URL for the endpoint by formatting the placeholders with actual values. + + Args: + sinch (Sinch): The Sinch client instance containing configuration details like the API origin. + + Returns: + str: The fully constructed URL for this API call. + """ return self.ENDPOINT_URL.format( origin=sinch.configuration.numbers_origin, project_id=self.project_id, @@ -23,10 +33,4 @@ def build_url(self, sinch) -> str: ) def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: - super(ActivateNumberEndpoint, self).handle_response(response) - return ActivateNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"] - ) + return self.process_response_model(response.body, ActivateNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers.py b/sinch/domains/numbers/endpoints/available/list_available_numbers.py index 10036e71..40c12e13 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers.py @@ -1,61 +1,40 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models import Number - -from sinch.domains.numbers.models.available.requests import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.responses import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse class AvailableNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list available virtual numbers for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) def build_query_params(self) -> dict: - query_params = { - "regionCode": self.request_data.region_code, - "type": self.request_data.number_type - } - - if self.request_data.page_size: - query_params["size"] = self.request_data.page_size - - if self.request_data.capabilities: - query_params["capabilities"] = self.request_data.capabilities - - if self.request_data.number_pattern: - query_params["numberPattern.pattern"] = self.request_data.number_pattern - - if self.request_data.number_search_pattern: - query_params["numberPattern.searchPattern"] = self.request_data.number_search_pattern - + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + # Serialize fields + query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) return query_params def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: - super(AvailableNumbersEndpoint, self).handle_response(response) - return ListAvailableNumbersResponse( - [ - Number( - phone_number=number["phoneNumber"], - region_code=number["regionCode"], - type=number["type"], - capability=number["capability"], - setup_price=number["setupPrice"], - monthly_price=number["monthlyPrice"], - payment_interval_months=number["paymentIntervalMonths"], - supporting_documentation_required=number["supportingDocumentationRequired"] - ) for number in response.body["availableNumbers"] - ] - ) + """ + Processes the API response and maps it to a response model. + + Args: + response (HTTPResponse): The raw HTTP response object received from the API. + + Returns: + ListAvailableNumbersResponse: The response model containing the parsed response data. + """ + return self.process_response_model(response.body, ListAvailableNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number.py deleted file mode 100644 index 692a17d0..00000000 --- a/sinch/domains/numbers/endpoints/available/rent_any_number.py +++ /dev/null @@ -1,67 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.requests import RentAnyNumberRequest -from sinch.domains.numbers.models.available.responses import RentAnyNumberResponse - - -class RentAnyNumberEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RentAnyNumberRequest): - super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def request_body(self): - request_data = self.request_data.as_dict() - request_body = {} - - if request_data.get("region_code"): - request_body["regionCode"] = request_data["region_code"] - - if request_data.get("type_"): - request_body["type"] = request_data["type_"] - - if request_data.get("number_pattern"): - request_body["numberPattern"] = request_data["number_pattern"] - - if request_data.get("capabilities"): - request_body["capabilities"] = request_data["capabilities"] - - if request_data.get("sms_configuration"): - request_body["smsConfiguration"] = request_data["sms_configuration"] - - if request_data.get("voice_configuration"): - request_body["voiceConfiguration"] = request_data["voice_configuration"] - - if request_data.get("callback_url"): - request_body["callbackUrl"] = request_data["callback_url"] - - return json.dumps(request_body) - - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: - super(RentAnyNumberEndpoint, self).handle_response(response) - return RentAnyNumberResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - project_id=response.body["projectId"], - callback_url=response.body["callbackUrl"], - expire_at=response.body["expireAt"], - money=response.body["money"], - next_charge_date=response.body["nextChargeDate"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"], - payment_interval_months=response.body["paymentIntervalMonths"] - ) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number.py b/sinch/domains/numbers/endpoints/available/search_for_number.py index 1d896247..d1f6d85a 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number.py @@ -1,11 +1,14 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.responses import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.requests import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest class SearchForNumberEndpoint(NumbersEndpoint): + """ + Endpoint to check the availability of a virtual number for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -16,6 +19,9 @@ def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest self.request_data = request_data def build_url(self, sinch) -> str: + """ + Constructs the full URL for the endpoint by formatting the placeholders with actual values. + """ return self.ENDPOINT_URL.format( origin=sinch.configuration.numbers_origin, project_id=self.project_id, @@ -23,14 +29,14 @@ def build_url(self, sinch) -> str: ) def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: - super(SearchForNumberEndpoint, self).handle_response(response) - return CheckNumberAvailabilityResponse( - phone_number=response.body["phoneNumber"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - setup_price=response.body["setupPrice"], - monthly_price=response.body["monthlyPrice"], - payment_interval_months=response.body["paymentIntervalMonths"], - supporting_documentation_required=response.body["supportingDocumentationRequired"] - ) + """ + Processes the API response and maps it to a response + + Args: + response (HTTPResponse): The raw HTTP response object received from the API. + + Returns: + CheckNumberAvailabilityResponse: The response model containing the parsed response data + of the requested phone number. + """ + return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 1d8a9346..2fe2f1d4 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,13 +1,67 @@ +from pydantic import BaseModel from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.domains.numbers.exceptions import NumbersException class NumbersEndpoint(HTTPEndpoint): + """ + A base class for all endpoints, providing reusable logic for URL building + and response parsing. + """ + ENDPOINT_URL: str = "" + HTTP_METHOD: str = "" + HTTP_AUTHENTICATION: str = "" + + def __init__(self, project_id: str, request_data: object): + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + """ + Constructs the URL for the endpoint. + + Args: + sinch: The Sinch client instance. + + Returns: + str: Fully constructed endpoint URL. + """ + if not self.ENDPOINT_URL: + raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") + + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id + ) + + def process_response_model(self, response_body: dict, response_model: type[BaseModel]) -> BaseModel: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class to map the response. + + Returns: + Parsed response object. + """ + try: + model_instance = response_model.model_validate(response_body) + # Remove None values while preserving nested objects + cleaned_data = model_instance.model_dump(exclude_none=True) + # Remove attributes that are not in cleaned data + for key in model_instance.model_fields: + if key not in cleaned_data: + delattr(model_instance, key) + return model_instance + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + def handle_response(self, response: HTTPResponse): if response.status_code >= 400: raise NumbersException( - message=response.body["error"].get("message"), + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", response=response, is_from_server=True ) diff --git a/sinch/domains/numbers/models/__init__.py b/sinch/domains/numbers/models/__init__.py index 986eb93e..e69de29b 100644 --- a/sinch/domains/numbers/models/__init__.py +++ b/sinch/domains/numbers/models/__init__.py @@ -1,41 +0,0 @@ -from dataclasses import dataclass -from decimal import Decimal -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.enums import NumberType, NumberCapability - - -@dataclass -class Number(SinchBaseModel): - phone_number: str - region_code: str - type: NumberType - capability: NumberCapability - setup_price: dict - monthly_price: dict - payment_interval_months: int - supporting_documentation_required: bool - - -@dataclass -class ScheduledVoiceProvisioning(SinchBaseModel): - app_id: str - status: str - last_updated_time: str - - -@dataclass -class VoiceConfiguration(SinchBaseModel): - app_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class SmsConfiguration(SinchBaseModel): - service_plan_id: str - scheduled_provisioning: ScheduledVoiceProvisioning - - -@dataclass -class Money(SinchBaseModel): - currency_code: str - amount: Decimal diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/available/activate_number_request.py new file mode 100644 index 00000000..88f8c473 --- /dev/null +++ b/sinch/domains/numbers/models/available/activate_number_request.py @@ -0,0 +1,34 @@ +from typing import Optional, Dict, Literal +from pydantic import Field, StrictStr +from sinch.core.models.base_model import BaseModelConfigRequest + + +class SmsConfiguration(BaseModelConfigRequest): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + + +class VoiceConfiguration(BaseModelConfigRequest): + type: Literal["RTC", "EST", "FAX"] + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class ActivateNumberRequest(BaseModelConfigRequest): + phone_number: StrictStr = Field(alias="phoneNumber") + # Accepts only dictionary input, not Pydantic models + sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + if "smsConfiguration" in data: + # Validate dictionary and ensure correct structure + SmsConfiguration(**data["smsConfiguration"]) + + if "voiceConfiguration" in data: + VoiceConfiguration(**data["voiceConfiguration"]) + + super().__init__(**data) diff --git a/sinch/domains/numbers/models/available/activate_number_response.py b/sinch/domains/numbers/models/available/activate_number_response.py new file mode 100644 index 00000000..87995c85 --- /dev/null +++ b/sinch/domains/numbers/models/available/activate_number_response.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.numbers.models.numbers import Money, SmsConfiguration, VoiceConfiguration +from sinch.core.models.base_model import BaseModelConfigResponse + + +class ActivateNumberResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[StrictStr] = None + capability: Optional[conlist(StrictStr, min_length=1)] = None + money: Optional[Money] = None + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfiguration] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfiguration] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/available/check_number_availability_request.py b/sinch/domains/numbers/models/available/check_number_availability_request.py new file mode 100644 index 00000000..842674d9 --- /dev/null +++ b/sinch/domains/numbers/models/available/check_number_availability_request.py @@ -0,0 +1,6 @@ +from pydantic import Field, StrictStr +from sinch.core.models.base_model import BaseModelConfigRequest + + +class CheckNumberAvailabilityRequest(BaseModelConfigRequest): + phone_number: StrictStr = Field(alias="phoneNumber") diff --git a/sinch/domains/numbers/models/available/check_number_availability_response.py b/sinch/domains/numbers/models/available/check_number_availability_response.py new file mode 100644 index 00000000..d5f5859a --- /dev/null +++ b/sinch/domains/numbers/models/available/check_number_availability_response.py @@ -0,0 +1,16 @@ +from typing import List, Optional, Literal +from pydantic import Field, StrictInt, StrictStr, StrictBool +from sinch.core.models.base_model import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import Money + + +class CheckNumberAvailabilityResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[Literal["MOBILE", "LOCAL", "TOLL_FREE"]] = None + capability: Optional[List[Literal["SMS", "VOICE"]]] = None + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = \ + (Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/models/available/list_available_numbers_request.py b/sinch/domains/numbers/models/available/list_available_numbers_request.py new file mode 100644 index 00000000..b9eddb2b --- /dev/null +++ b/sinch/domains/numbers/models/available/list_available_numbers_request.py @@ -0,0 +1,12 @@ +from typing import Optional, Literal +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.core.models.base_model import BaseModelConfigRequest + + +class ListAvailableNumbersRequest(BaseModelConfigRequest): + region_code: StrictStr = Field(alias="regionCode") + number_type: Literal["MOBILE", "LOCAL", "TOLL_FREE"] = Field(alias="type") + page_size: Optional[StrictInt] = Field(default=None, alias="size") + capabilities: Optional[conlist(StrictStr, min_length=1)] = None + number_search_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.searchPattern") + number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/available/list_available_numbers_response.py b/sinch/domains/numbers/models/available/list_available_numbers_response.py new file mode 100644 index 00000000..b8b77b06 --- /dev/null +++ b/sinch/domains/numbers/models/available/list_available_numbers_response.py @@ -0,0 +1,11 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field +from sinch.domains.numbers.models.numbers import Number + + +class ListAvailableNumbersResponse(BaseModel): + available_numbers: Optional[List[Number]] = Field(default=None, alias="availableNumbers") + + model_config = ConfigDict( + populate_by_name=True + ) diff --git a/sinch/domains/numbers/models/available/requests.py b/sinch/domains/numbers/models/available/requests.py deleted file mode 100644 index 063cedfc..00000000 --- a/sinch/domains/numbers/models/available/requests.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListAvailableNumbersRequest(SinchRequestBaseModel): - region_code: str - number_type: str - page_size: int - capabilities: list - number_search_pattern: str - number_pattern: str - - -@dataclass -class ActivateNumberRequest(SinchRequestBaseModel): - phone_number: str - sms_configuration: dict - voice_configuration: dict - - -@dataclass -class RentAnyNumberRequest(SinchRequestBaseModel): - region_code: str - type_: str - number_pattern: str - capabilities: list - sms_configuration: dict - voice_configuration: dict - callback_url: str - - -@dataclass -class CheckNumberAvailabilityRequest(SinchRequestBaseModel): - phone_number: str diff --git a/sinch/domains/numbers/models/available/responses.py b/sinch/domains/numbers/models/available/responses.py deleted file mode 100644 index 2e1d1501..00000000 --- a/sinch/domains/numbers/models/available/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models import Number - - -@dataclass -class ListAvailableNumbersResponse(SinchBaseModel): - available_numbers: List[Number] - - -@dataclass -class ActivateNumberResponse(SinchBaseModel): - phone_number: str - region_code: str - type: str - capability: list - - -@dataclass -class RentAnyNumberResponse(SinchBaseModel): - phone_number: str - project_id: str - region_code: str - type: str - capability: list - money: dict - payment_interval_months: int - next_charge_date: str - expire_at: str - sms_configuration: object - voice_configuration: object - callback_url: str - capability: tuple - - -@dataclass -class CheckNumberAvailabilityResponse(Number): - pass diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py new file mode 100644 index 00000000..7c31b312 --- /dev/null +++ b/sinch/domains/numbers/models/numbers.py @@ -0,0 +1,53 @@ +from datetime import datetime +from typing import List, Optional, Literal +from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist +from decimal import Decimal +from sinch.core.models.base_model import BaseModelConfigResponse + + +class ScheduledProvisioningSmsConfiguration(BaseModelConfigResponse): + service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + status: Optional[StrictStr] = None + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + error_codes: Optional[conlist(StrictStr, min_length=1)] = Field(default=None, alias="errorCodes") + + +class SmsConfiguration(BaseModelConfigResponse): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + scheduled_provisioning: Optional[ScheduledProvisioningSmsConfiguration] = ( + Field(default=None, alias="scheduledProvisioning")) + + +class ScheduledVoiceProvisioningVoiceConfiguration(BaseModelConfigResponse): + type: Optional[StrictStr] = None + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StrictStr] = None + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") + + +class VoiceConfiguration(BaseModelConfigResponse): + type: StrictStr + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + scheduled_voice_provisioning: Optional[ScheduledVoiceProvisioningVoiceConfiguration] = \ + (Field(default=None, alias="scheduledVoiceProvisioning")) + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class Money(BaseModelConfigResponse): + currency_code: StrictStr = Field(alias="currencyCode") + amount: Decimal + + +class Number(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[Literal["MOBILE", "LOCAL", "TOLL_FREE"]] = Field(default=None, alias="type") + capability: Optional[List[Literal["SMS", "VOICE"]]] = Field(default=None, alias="capability") + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py new file mode 100644 index 00000000..0689b306 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -0,0 +1,57 @@ +import pytest +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def mock_request_data(): + return ActivateNumberRequest(phone_number="+1234567890") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "mobile", + "capability": ["SMS", "Voice"] + }, + headers={"Content-Type": "application/json"} + ) + + +def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" + assert endpoint.build_url(mock_sinch_client) == expected_url + + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + # Verify each field is mapped as expected + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "mobile" + assert response.capability == ["SMS", "Voice"] diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py new file mode 100644 index 00000000..7e68f74e --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py @@ -0,0 +1,112 @@ +import pytest +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.core.models.http_response import HTTPResponse + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + +@pytest.fixture +def request_data(): + return ListAvailableNumbersRequest( + region_code="US", + number_type="MOBILE", + page_size=10, + capabilities=["SMS"], + number_pattern="123", + number_search_pattern="STARTS_WITH", + extra_field="extra value" + ) + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "availableNumbers": [ + { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + }, + { + "phoneNumber": "+2345678901", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "monthlyPrice": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + } + ], + }, + headers={"Content-Type": "application/json"} + ) + +@pytest.fixture +def endpoint(request_data): + return AvailableNumbersEndpoint("test_project_id", request_data) + +def test_build_url(endpoint, mock_sinch_client): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + expected_url = "https://api.sinch.com/v1/projects/test_project_id/availableNumbers" + assert endpoint.build_url(mock_sinch_client) == expected_url + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS"], + "numberPattern.pattern": "123", + "numberPattern.searchPattern": "STARTS_WITH", + "extraField": "extra value" + } + assert endpoint.build_query_params() == expected_params + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListAvailableNumbersResponse) + assert len(parsed_response.available_numbers) == 2 + assert parsed_response.available_numbers[0].phone_number == "+1234567890" + assert parsed_response.available_numbers[1].phone_number == "+2345678901" \ No newline at end of file diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py new file mode 100644 index 00000000..557884b5 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py @@ -0,0 +1,104 @@ +import pytest +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def mock_sinch_client(): + """ + Mock the Sinch client with configuration. + """ + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def mock_request_data(): + """ + Mock the request data for the endpoint. + """ + return CheckNumberAvailabilityRequest(phone_number="+1234567890") + + +@pytest.fixture +def mock_response(): + """ + Mock the HTTP response object returned by the API. + """ + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + }, + headers={"Content-Type": "application/json"} + ) + + +def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890" + assert endpoint.build_url(mock_sinch_client) == expected_url + + +def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) + response = endpoint.handle_response(mock_response) + + assert isinstance(response, CheckNumberAvailabilityResponse) + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.currency_code == "USD" + assert response.setup_price.amount == 2.00 + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.payment_interval_months == 0 + assert response.supporting_documentation_required is True + + +def test_handle_response_expects_missing_fields(mock_response): + """ + Check if response handles missing fields by excluding them without failure. + """ + mock_response.body.pop("paymentIntervalMonths") + endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.monthly_price.currency_code == "USD" + assert response.monthly_price.amount == 2.00 + assert response.supporting_documentation_required is True + assert "payment_interval_months" not in response.model_dump() diff --git a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py new file mode 100644 index 00000000..d590d066 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py @@ -0,0 +1,95 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest + +def test_activate_number_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "app_id": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = ActivateNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "app_id": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + +def test_activate_number_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "phoneNumber": "+1234567890", + "smsConfiguration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + request = ActivateNumberRequest(**data) + + # Assert fields are populated correctly + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"servicePlanId": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + +def test_activate_number_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "phone_number": "+1234567890", + "smsConfiguration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + request = ActivateNumberRequest(**data) + + # Assert fields are populated correctly + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"servicePlanId": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration == { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + assert request.callback_url == "https://example.com/callback" + +def test_activate_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "sms_configuration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + }, + "callback_url": "https://example.com/callback" + } + with pytest.raises(ValidationError) as exc_info: + ActivateNumberRequest(**data) + + # Assert the error mentions the missing phone_number field + assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) diff --git a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py new file mode 100644 index 00000000..827b3bf7 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py @@ -0,0 +1,128 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest + + +def test_list_available_numbers_request_expects_snake_case_input(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "region_code": "US", + "number_type": "MOBILE", + "page_size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "number_pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "regionCode": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "numberPattern.searchPattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_mixed_case_input(): + """ + Test that the model correctly handles mixed camelCase and snake_case input. + """ + data = { + "region_code": "US", + "type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "prefix", + "numberPattern.pattern": "12345" + } + + # Instantiate the model + request = ListAvailableNumbersRequest(**data) + + # Assert the field values + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.page_size == 10 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "prefix" + assert request.number_pattern == "12345" + + +def test_list_available_numbers_request_expects_validation_error_for_missing_required_field(): + """ + Test that the model raises a validation error for missing required fields. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "capabilities": ["SMS", "VOICE"] + } + + with pytest.raises(ValidationError) as exc_info: + ListAvailableNumbersRequest(**data) + + # Assert the error mentions the missing region_code field + assert "region_code" in str(exc_info.value) or "regionCode" in str(exc_info.value) + + +def test_list_available_numbers_expects_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extraField": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.extraField == "Extra Value" + +def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.extra_field == "Extra Value" \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py new file mode 100644 index 00000000..14ed584a --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py @@ -0,0 +1,47 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest + + +def test_check_number_availability_request_expects_accepts_snake_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = CheckNumberAvailabilityRequest(phone_number="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_accepts_camel_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + + assert request.phone_number == "+1234567890" + + +def test_check_number_availability_request_expects_alias_mapping_correct(): + """ + Test that the model correctly handles alias mappings for phoneNumber. + """ + request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + + assert request.model_dump(by_alias=True)["phoneNumber"] == "+1234567890" + assert request.model_dump(by_alias=False)["phone_number"] == "+1234567890" + + +def test_search_number_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a ValidationError when a required field is missing. + """ + data = {} + + with pytest.raises(ValidationError) as excinfo: + CheckNumberAvailabilityRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "phoneNumber" in error_message \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py new file mode 100644 index 00000000..2747b13e --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py @@ -0,0 +1,152 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse + +@pytest.fixture +def test_data(): + return { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-01-22T13:19:31.095Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + +def assert_sms_configuration(sms_config): + """ + Assert sms_configuration fields. + """ + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + scheduled_provisioning = sms_config.scheduled_provisioning + assert scheduled_provisioning.service_plan_id == "string" + assert scheduled_provisioning.campaign_id == "string" + assert scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + +def assert_voice_configuration(voice_config): + """ + Assert voice_configuration fields. + """ + assert voice_config.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.app_id == "string" + scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning + assert scheduled_voice_provisioning.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert scheduled_voice_provisioning.trunk_id == "string" + +def test_activate_number_response_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + converts nested keys to snake_case, and handles dynamic fields + """ + data = { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-01-29T13:19:31.095Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + response = ActivateNumberResponse(**data) + + assert response.phone_number == "+12025550134" + assert response.display_name == "string" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.payment_interval_months == 0 + expected_next_charge_data = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_data + expected_expire_at = ( + datetime(2025, 1, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + assert response.callback_url == "https://www.your-callback-server.com/callback" + # Assert sms_configuration and voice_configuration using helper functions + assert_sms_configuration(response.sms_configuration) + assert_voice_configuration(response.voice_configuration) + + +def test_activate_number_response_expects_unrecognized_fields_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "phoneNumber": "+12025550134", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "unexpectedField": "unexpectedValue", + "anotherExtraField": 42, + } + response = ActivateNumberResponse(**data) + + # Assert known fields + assert response.phone_number == "+12025550134" + + # Assert unrecognized fields are dynamically added + assert response.unexpected_field == "unexpectedValue" + assert response.another_extra_field == 42 diff --git a/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py new file mode 100644 index 00000000..79ace32b --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py @@ -0,0 +1,44 @@ +import pytest +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse + +@pytest.fixture +def test_data(): + return { + "availableNumbers": [ + { + "phoneNumber": "+12025550134", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + } + ] + } + +def test_list_available_numbers_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListAvailableNumbersResponse(**test_data) + assert response.available_numbers[0].phone_number == "+12025550134" + assert response.available_numbers[0].region_code == "US" + assert response.available_numbers[0].type == "MOBILE" + assert response.available_numbers[0].capability == ["SMS", "VOICE"] + assert response.available_numbers[0].setup_price.currency_code == "USD" + assert response.available_numbers[0].setup_price.amount == 2.00 + assert response.available_numbers[0].monthly_price.currency_code == "USD" + assert response.available_numbers[0].monthly_price.amount == 2.00 + assert response.available_numbers[0].payment_interval_months == 0 + assert response.available_numbers[0].supporting_documentation_required == True diff --git a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py new file mode 100644 index 00000000..41fa1c2b --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py @@ -0,0 +1,116 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse + +def test_check_number_availability_response_expects_valid_data(): + """ + Expects CheckNumberAvailabilityResponse to be created with valid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"}, + "paymentIntervalMonths": 1, + "supportingDocumentationRequired": True + } + + response = CheckNumberAvailabilityResponse(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.payment_interval_months == 1 + assert response.supporting_documentation_required is True + +def test_check_number_availability_response_missing_optional_fields_expects_valid_data(): + """ + Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, + and doesn't include them in the response. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + response = CheckNumberAvailabilityResponse(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.payment_interval_months is None + assert response.supporting_documentation_required is None + +def test_check_number_availability_response_expects_validation_error_for_invalid_data(): + """ + Test CheckNumberAvailabilityResponse with invalid data. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "INVALID_TYPE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + with pytest.raises(ValidationError): + CheckNumberAvailabilityResponse(**data) + +def test_check_number_availability_response_expects_validation_error_for_missing_required_fields(): + """ + Check if validation fails when required fields are missing. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} + } + + with pytest.raises(ValidationError): + CheckNumberAvailabilityResponse.model_validate(data, strict=True) + +def test_check_number_availability_response_extra_field_expects_parsed_data_snake_case(): + """ + Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, + and doesn't include them in the response. + """ + data = { + "phoneNumber": "+1234567890", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS", "VOICE"], + "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, + "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"}, + "extraValue": 5, + } + + response = CheckNumberAvailabilityResponse(**data) + + assert response.phone_number == "+1234567890" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS", "VOICE"] + assert response.setup_price.amount == 10.00 + assert response.setup_price.currency_code == "USD" + assert response.monthly_price.amount == 5.00 + assert response.monthly_price.currency_code == "USD" + assert response.extra_value == 5 diff --git a/tests/unit/domains/numbers/test_available_numbers.py b/tests/unit/domains/numbers/test_available_numbers.py new file mode 100644 index 00000000..869403d1 --- /dev/null +++ b/tests/unit/domains/numbers/test_available_numbers.py @@ -0,0 +1,102 @@ +import pytest +from unittest.mock import MagicMock +from sinch.domains.numbers.available_numbers import AvailableNumbers +from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint + +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest + +from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse + +@pytest.fixture +def mock_sinch(): + """Creates a mocked Sinch client.""" + mock_sinch = MagicMock() + mock_sinch.configuration.project_id = "test_project_id" + mock_sinch.configuration.transport.request = MagicMock() + return mock_sinch + + +def test_list_available_numbers_expects_valid_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.list method sends the correct request + and handles the response properly. + """ + # Use construct to create a mock response without Pydantic validation + mock_response = ListAvailableNumbersResponse(availableNumbers=[]) + mock_sinch.configuration.transport.request.return_value = mock_response + + # Spy on the AvailableNumbersEndpoint to capture calls + spy_endpoint = mocker.spy(AvailableNumbersEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.list( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + page_size=10, + number_search_pattern="START" + ) + + # Verify the endpoint's constructor was called with the correct arguments + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + # Validate the kwargs + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListAvailableNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=10, + capabilities=["SMS", "VOICE"], + number_search_pattern="START", + ) + + assert response == mock_response + mock_sinch.configuration.transport.request.assert_called_once() + + +def test_activate_number_expects_correct_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.activate method sends the correct request + and handles the response properly. + """ + mock_response = ActivateNumberResponse.model_construct() + mock_sinch.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ActivateNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.activate(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ActivateNumberRequest(phone_number="+1234567890") + + assert response == mock_response + +def test_check_availability_expects_correct_request(mock_sinch, mocker): + """ + Test that the AvailableNumbers.check_availability method sends the correct request + and handles the response properly. + """ + mock_response = CheckNumberAvailabilityResponse.model_construct() + mock_sinch.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(SearchForNumberEndpoint, "__init__") + + available_numbers = AvailableNumbers(mock_sinch) + response = available_numbers.check_availability(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == CheckNumberAvailabilityRequest(phone_number="+1234567890") + + assert response == mock_response From d3a515cf969af6cb8a9c7ff2f55d49d332070552 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 3 Feb 2025 13:05:35 +0100 Subject: [PATCH 002/106] chore: refactor models - Refactor models to remove redundancy - Mapping approach for VoiceConfiguration to dynamically select the correct model Signed-off-by: Jessica Matsuoka --- .gitignore | 5 +- sinch/core/models/base_model.py | 79 ------- sinch/domains/numbers/available_numbers.py | 203 ++++++++++++++++-- .../endpoints/available/rent_any_number.py | 41 ++++ .../available/activate_number_request.py | 38 ++-- .../available/activate_number_response.py | 17 +- .../check_number_availability_request.py | 2 +- .../check_number_availability_response.py | 14 +- .../list_available_numbers_request.py | 14 +- .../available/rent_any_number_request.py | 21 ++ .../available/rent_any_number_response.py | 21 ++ .../numbers/models/base_model_numbers.py | 71 ++++++ sinch/domains/numbers/models/numbers.py | 100 +++++++-- .../test_rent_any_number_endpoint.py | 154 +++++++++++++ .../requests/test_base_model_requests.py | 30 +++ ...st_list_available_numbers_request_model.py | 18 +- .../test_rent_any_number_request_model.py | 75 +++++++ .../test_activate_number_response_model.py | 4 +- .../test_rent_any_number_response_model.py | 163 ++++++++++++++ .../test_search_for_number_response_model.py | 8 +- .../domains/numbers/models/test_numbers.py | 110 ++++++++++ 21 files changed, 1025 insertions(+), 163 deletions(-) create mode 100644 sinch/domains/numbers/endpoints/available/rent_any_number.py create mode 100644 sinch/domains/numbers/models/available/rent_any_number_request.py create mode 100644 sinch/domains/numbers/models/available/rent_any_number_response.py create mode 100644 sinch/domains/numbers/models/base_model_numbers.py create mode 100644 tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py create mode 100644 tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py create mode 100644 tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py create mode 100644 tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py create mode 100644 tests/unit/domains/numbers/models/test_numbers.py diff --git a/.gitignore b/.gitignore index bdcc825c..8e3c77ce 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +tox.ini .nox/ .coverage .coverage.* @@ -132,4 +133,6 @@ cython_debug/ poetry.lock # .DS_Store files -.DS_Store \ No newline at end of file +.DS_Store + +qodana.yaml \ No newline at end of file diff --git a/sinch/core/models/base_model.py b/sinch/core/models/base_model.py index d76fc10e..da2472e9 100644 --- a/sinch/core/models/base_model.py +++ b/sinch/core/models/base_model.py @@ -1,9 +1,5 @@ import json -import re -from datetime import datetime from dataclasses import asdict, dataclass -from typing import Any -from pydantic import BaseModel, ConfigDict @dataclass @@ -19,78 +15,3 @@ def as_json(self): class SinchRequestBaseModel(SinchBaseModel): def as_dict(self): return {k: v for k, v in asdict(self).items() if v is not None} - - -class BaseModelConfigRequest(BaseModel): - """ - A base model that allows extra fields and converts snake_case to camelCase. - """ - - @staticmethod - def _to_camel_case(snake_str: str) -> str: - """Converts snake_case to camelCase.""" - components = snake_str.split('_') - return components[0] + ''.join(x.title() for x in components[1:]) - - model_config = ConfigDict( - # Allows using both alias (camelCase) and field name (snake_case) - populate_by_name=True, - # Allows extra values in input - extra="allow" - ) - - def model_dump(self, **kwargs) -> dict: - """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" - # Get the standard model dump - data = super().model_dump(**kwargs) - - # Get extra fields - extra_data = self.__pydantic_extra__ or {} - - # Convert extra fields to camelCase and collect the original snake_case keys - converted_extra = {} - for key, value in extra_data.items(): - camel_case_key = self._to_camel_case(key) - converted_extra[camel_case_key] = value - - # Remove snake_case keys from `data` before merging converted extras - for key in extra_data.keys(): - data.pop(key, None) # Ensure snake_case fields are removed from final output - - # Merge the cleaned base data with the converted extra fields - return {**data, **converted_extra} - - -class BaseModelConfigResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case, - and serializes datetime fields to ISO format. - """ - - @staticmethod - def datetime_encoder(v: datetime) -> str: - """"Converts a datetime object to a string in ISO 8601 format """ - return v.strftime("%Y-%m-%dT%H:%M:%S.%fZ")[:-3] + "Z" - - @staticmethod - def _to_snake_case(camel_str: str) -> str: - """Helper to convert camelCase string to snake_case.""" - return re.sub(r'(? None: - """ Converts unknown fields from camelCase to snake_case.""" - if self.__pydantic_extra__: - converted_extra = { - self._to_snake_case(key): value for key, value in self.__pydantic_extra__.items() - } - self.__pydantic_extra__.clear() - self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index 54912ef2..51c9c2a9 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -1,16 +1,25 @@ -from typing import Optional, TypedDict, overload +from typing import Optional, TypedDict, overload, Literal, Union, Annotated from typing_extensions import NotRequired -from pydantic import conlist, StrictInt, StrictStr +from pydantic import conlist, StrictInt, StrictStr, Field from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint + from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + +# Define type aliases +NumberType = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] +CapabilityType = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) +NumberSearchPatternType = Union[Literal["START", "CONTAINS", "END"], StrictStr] class SmsConfigurationDict(TypedDict): @@ -18,14 +27,35 @@ class SmsConfigurationDict(TypedDict): campaign_id: NotRequired[str] -class VoiceConfigurationDict(TypedDict): - type: str +class VoiceConfigurationDictRTC(TypedDict): + type: Literal["RTC"] app_id: NotRequired[str] +class VoiceConfigurationDictEST(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] + + +class VoiceConfigurationDictFAX(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] + + +class VoiceConfigurationDictCustom(TypedDict): + type: str + + class NumberPatternDict(TypedDict): pattern: NotRequired[str] - search_pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternType] + + +VoiceConfigurationDictType = Annotated[ + Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST, VoiceConfigurationDictCustom], + Field(discriminator="type") +] class AvailableNumbers: @@ -53,10 +83,10 @@ def _request(self, endpoint_class, request_data): def list( self, region_code: StrictStr, - number_type: StrictStr, + number_type: NumberType, number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[StrictStr] = None, - capabilities: Optional[conlist] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[CapabilityType] = None, page_size: Optional[StrictInt] = None, **kwargs ) -> ListAvailableNumbersResponse: @@ -64,12 +94,13 @@ def list( Search for available virtual numbers for you to activate using a variety of parameters to filter results. Args: - region_code (str): ISO 3166-1 alpha-2 country code of the phone number. - number_type (str): Type of number (MOBILE, LOCAL, TOLL_FREE). - number_pattern (str): Specific sequence of digits to search for. - number_search_pattern (str): Pattern to apply (START, CONTAIN, END). - capabilities (list): Capabilities (SMS, VOICE) required for the number. - page_size (int): Maximum number of items to return. + region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. + number_type (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. + number_search_pattern (Optional[NumberSearchPatternType]): + Pattern to apply (e.g., "START", "CONTAINS", "END"). + capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + page_size (StrictInt): Maximum number of items to return. **kwargs: Additional filters for the request. Returns: @@ -93,8 +124,8 @@ def list( def activate( self, phone_number: StrictStr, - sms_configuration: None = None, - voice_configuration: None = None, + sms_configuration: None, + voice_configuration: None, callback_url: Optional[StrictStr] = None ) -> ActivateNumberResponse: pass @@ -104,7 +135,27 @@ def activate( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + callback_url: Optional[StrictStr] = None + ) -> ActivateNumberResponse: + pass + + @overload + def activate( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, callback_url: Optional[StrictStr] = None ) -> ActivateNumberResponse: pass @@ -113,18 +164,25 @@ def activate( self, phone_number: StrictStr, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActivateNumberResponse: """ - Activate a virtual number to use with SMS products, Voice products, or both. + Activate a virtual number to use with SMS, Voice, or both products. Args: phone_number (StrictStr): The phone number in E.164 format with leading +. - sms_configuration (SmsConfigurationDict): Configuration for SMS activation. - voice_configuration (VoiceConfigurationDict): Configuration for Voice activation. - callback_url (StrictStr): The callback URL to be called. + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (Optional[StrictStr]): The callback URL to be called. **kwargs: Additional parameters for the request. Returns: @@ -141,6 +199,107 @@ def activate( ) return self._request(ActivateNumberEndpoint, request_data) + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberType, + sms_configuration: None, + voice_configuration: None, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityType] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityType] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityType] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberType, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityType] = None, + callback_url: Optional[str] = None, + ) -> RentAnyNumberResponse: + pass + + def rent_any( + self, + region_code: StrictStr, + type_: NumberType, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityType] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[str] = None, + **kwargs + ) -> RentAnyNumberResponse: + """ + Search for and activate an available Sinch virtual number all in one API call. + Currently, the rentAny operation works only for US 10DLC numbers + + Args: + region_code (str): ISO 3166-1 alpha-2 country code of the phone number. + type_ (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_pattern (Optional[NumberPatternDict]): Specific sequence of digits to search for. + capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (str): The callback URL to receive notifications. + **kwargs: Additional parameters for the request. + + Returns: + RentAnyNumberRequest: A response object with the activated number and its details. + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = RentAnyNumberRequest( + region_code=region_code, + type_=type_, + number_pattern=number_pattern, + capabilities=capabilities, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + return self._request(RentAnyNumberEndpoint, request_data) + def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: """ Enter a specific phone number to check availability. diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number.py new file mode 100644 index 00000000..e71deac0 --- /dev/null +++ b/sinch/domains/numbers/endpoints/available/rent_any_number.py @@ -0,0 +1,41 @@ +import json +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + + +class RentAnyNumberEndpoint(NumbersEndpoint): + """ + Endpoint to rent an available virtual number for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: RentAnyNumberRequest): + super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) + + def request_body(self): + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: + """ + Handles the response from the API call. + + Args: + response (HTTPResponse): The response object from the API call. + + Returns: + RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. + """ + return self.process_response_model(response.body, RentAnyNumberResponse) diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/available/activate_number_request.py index 88f8c473..100825a2 100644 --- a/sinch/domains/numbers/models/available/activate_number_request.py +++ b/sinch/domains/numbers/models/available/activate_number_request.py @@ -1,16 +1,9 @@ -from typing import Optional, Dict, Literal +from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.core.models.base_model import BaseModelConfigRequest - - -class SmsConfiguration(BaseModelConfigRequest): - service_plan_id: StrictStr = Field(alias="servicePlanId") - campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - - -class VoiceConfiguration(BaseModelConfigRequest): - type: Literal["RTC", "EST", "FAX"] - app_id: Optional[StrictStr] = Field(default=None, alias="appId") +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import (SmsConfigurationRequest, VoiceConfigurationFAX, + VoiceConfigurationEST, VoiceConfigurationRTC, + VoiceConfigurationCustom) class ActivateNumberRequest(BaseModelConfigRequest): @@ -24,11 +17,20 @@ def __init__(self, **data): """ Custom initializer to validate nested dictionaries. """ - if "smsConfiguration" in data: - # Validate dictionary and ensure correct structure - SmsConfiguration(**data["smsConfiguration"]) - - if "voiceConfiguration" in data: - VoiceConfiguration(**data["voiceConfiguration"]) + for key in ("smsConfiguration", "sms_configuration"): + if key in data and data[key] is not None: + SmsConfigurationRequest(**data[key]) + + voice_config_map = { + "RTC": VoiceConfigurationRTC, + "EST": VoiceConfigurationEST, + "FAX": VoiceConfigurationFAX, + } + + for key in ("voiceConfiguration", "voice_configuration"): + if key in data and data[key] is not None: + voice_type = data[key].get("type") + voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) + voice_config_class(**data[key]) super().__init__(**data) diff --git a/sinch/domains/numbers/models/available/activate_number_response.py b/sinch/domains/numbers/models/available/activate_number_response.py index 87995c85..4e48f6e0 100644 --- a/sinch/domains/numbers/models/available/activate_number_response.py +++ b/sinch/domains/numbers/models/available/activate_number_response.py @@ -1,8 +1,9 @@ from datetime import datetime from typing import Optional -from pydantic import Field, StrictInt, StrictStr, conlist -from sinch.domains.numbers.models.numbers import Money, SmsConfiguration, VoiceConfiguration -from sinch.core.models.base_model import BaseModelConfigResponse +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.numbers.models.numbers import Money, SmsConfigurationResponse, VoiceConfigurationResponse +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import CapabilityType, NumberType class ActivateNumberResponse(BaseModelConfigResponse): @@ -10,12 +11,12 @@ class ActivateNumberResponse(BaseModelConfigResponse): project_id: Optional[StrictStr] = Field(default=None, alias="projectId") display_name: Optional[StrictStr] = Field(default=None, alias="displayName") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[StrictStr] = None - capability: Optional[conlist(StrictStr, min_length=1)] = None - money: Optional[Money] = None + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfiguration] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfiguration] = Field(default=None, alias="voiceConfiguration") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/available/check_number_availability_request.py b/sinch/domains/numbers/models/available/check_number_availability_request.py index 842674d9..12fb87df 100644 --- a/sinch/domains/numbers/models/available/check_number_availability_request.py +++ b/sinch/domains/numbers/models/available/check_number_availability_request.py @@ -1,5 +1,5 @@ from pydantic import Field, StrictStr -from sinch.core.models.base_model import BaseModelConfigRequest +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest class CheckNumberAvailabilityRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/available/check_number_availability_response.py b/sinch/domains/numbers/models/available/check_number_availability_response.py index d5f5859a..d613e443 100644 --- a/sinch/domains/numbers/models/available/check_number_availability_response.py +++ b/sinch/domains/numbers/models/available/check_number_availability_response.py @@ -1,16 +1,16 @@ -from typing import List, Optional, Literal +from typing import Optional from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.core.models.base_model import BaseModelConfigResponse -from sinch.domains.numbers.models.numbers import Money +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import CapabilityType, Money, NumberType class CheckNumberAvailabilityResponse(BaseModelConfigResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[Literal["MOBILE", "LOCAL", "TOLL_FREE"]] = None - capability: Optional[List[Literal["SMS", "VOICE"]]] = None + type: Optional[NumberType] = None + capability: Optional[CapabilityType] = None setup_price: Optional[Money] = Field(default=None, alias="setupPrice") monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - supporting_documentation_required: Optional[StrictBool] = \ - (Field(default=None, alias="supportingDocumentationRequired")) + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/models/available/list_available_numbers_request.py b/sinch/domains/numbers/models/available/list_available_numbers_request.py index b9eddb2b..e24675a1 100644 --- a/sinch/domains/numbers/models/available/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/available/list_available_numbers_request.py @@ -1,12 +1,14 @@ -from typing import Optional, Literal -from pydantic import Field, StrictInt, StrictStr, conlist -from sinch.core.models.base_model import BaseModelConfigRequest +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import CapabilityType, NumberType, NumberSearchPatternType class ListAvailableNumbersRequest(BaseModelConfigRequest): region_code: StrictStr = Field(alias="regionCode") - number_type: Literal["MOBILE", "LOCAL", "TOLL_FREE"] = Field(alias="type") + number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") - capabilities: Optional[conlist(StrictStr, min_length=1)] = None - number_search_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.searchPattern") + capabilities: Optional[CapabilityType] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = ( + Field(default=None, alias="numberPattern.searchPattern")) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/available/rent_any_number_request.py b/sinch/domains/numbers/models/available/rent_any_number_request.py new file mode 100644 index 00000000..780658ab --- /dev/null +++ b/sinch/domains/numbers/models/available/rent_any_number_request.py @@ -0,0 +1,21 @@ +from typing import Optional, Union, Dict, Any +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import (NumberSearchPatternType, CapabilityType, + SmsConfigurationRequest, VoiceConfigurationType) + + +class NumberPattern(BaseModelConfigRequest): + pattern: Optional[StrictStr] + search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") + + +class RentAnyNumberRequest(BaseModelConfigRequest): + region_code: StrictStr = Field(default=None, alias="regionCode") + type_: StrictStr = Field(default=None, alias="type") + number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") + capabilities: Optional[CapabilityType] = Field(default=None) + sms_configuration: Optional[SmsConfigurationRequest] = Field(default=None, alias="smsConfiguration") + voice_configuration: Union[VoiceConfigurationType, Dict[str, Any], None] = ( + Field(default=None, alias="voiceConfiguration")) + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/available/rent_any_number_response.py b/sinch/domains/numbers/models/available/rent_any_number_response.py new file mode 100644 index 00000000..e80020b3 --- /dev/null +++ b/sinch/domains/numbers/models/available/rent_any_number_response.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.numbers import (CapabilityType, Money, NumberType, + SmsConfigurationResponse, VoiceConfigurationResponse) + + +class RentAnyNumberResponse(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/base_model_numbers.py new file mode 100644 index 00000000..641b4d2c --- /dev/null +++ b/sinch/domains/numbers/models/base_model_numbers.py @@ -0,0 +1,71 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + @staticmethod + def _to_camel_case(snake_str: str) -> str: + """Converts snake_case to camelCase while preserving multiple underscores.""" + components = snake_str.split('_') + return components[0] + ''.join(x.capitalize() if x else '_' for x in components[1:]) + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow" + ) + + def model_dump(self, **kwargs) -> dict: + """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" + # Get the standard model dump + data = super().model_dump(**kwargs) + + # Get extra fields + extra_data = self.__pydantic_extra__ or {} + + # Convert extra fields to camelCase and collect the original snake_case keys + converted_extra = {} + for key, value in extra_data.items(): + camel_case_key = self._to_camel_case(key) + converted_extra[camel_case_key] = value + + # Remove snake_case keys from `data` before merging converted extras + for key in extra_data.keys(): + data.pop(key, None) # Ensure snake_case fields are removed from final output + + # Merge the cleaned base data with the converted extra fields + return {**data, **converted_extra} + + +class BaseModelConfigResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case, + and serializes datetime fields to ISO format. + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r'(? None: + """ Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index 7c31b312..89b480ca 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -1,37 +1,110 @@ from datetime import datetime -from typing import List, Optional, Literal +from typing import Optional, Literal, Union, Annotated from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist from decimal import Decimal -from sinch.core.models.base_model import BaseModelConfigResponse +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse + +CapabilityType = Annotated[ + conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1), + Field(default=None) +] + +NumberSearchPatternType = Annotated[ + Union[Literal["START", "CONTAINS", "END"], StrictStr], + Field(default=None) +] + +NumberType = Annotated[ + Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr], + Field(default=None) +] + +StatusScheduledProvisioning = Annotated[ + Union[Literal["WAITING", "IN_PROGRESS", "FAILED"], StrictStr], + Field(default=None) +] + + +class SmsConfigurationRequest(BaseModelConfigRequest): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + + +class VoiceConfigurationFAX(BaseModelConfigRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class VoiceConfigurationEST(BaseModelConfigRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="truckId") + + +class VoiceConfigurationRTC(BaseModelConfigRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationCustom(BaseModelConfigRequest): + type: StrictStr + + +VoiceConfigurationType = Annotated[ + Union[VoiceConfigurationFAX, VoiceConfigurationEST, VoiceConfigurationRTC], + Field(discriminator="type") +] class ScheduledProvisioningSmsConfiguration(BaseModelConfigResponse): service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - status: Optional[StrictStr] = None + status: Optional[StatusScheduledProvisioning] = None last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") error_codes: Optional[conlist(StrictStr, min_length=1)] = Field(default=None, alias="errorCodes") -class SmsConfiguration(BaseModelConfigResponse): +class SmsConfigurationResponse(BaseModelConfigResponse): service_plan_id: StrictStr = Field(alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") scheduled_provisioning: Optional[ScheduledProvisioningSmsConfiguration] = ( Field(default=None, alias="scheduledProvisioning")) -class ScheduledVoiceProvisioningVoiceConfiguration(BaseModelConfigResponse): - type: Optional[StrictStr] = None +class ScheduledVoiceProvisioningVoiceConfigurationCustom(BaseModelConfigResponse): + type: StrictStr + + +class ScheduledVoiceProvisioningVoiceConfigurationFAX(BaseModelConfigResponse): + type: Literal["FAX"] = "FAX" last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - status: Optional[StrictStr] = None + status: Optional[StatusScheduledProvisioning] = None + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class ScheduledVoiceProvisioningVoiceConfigurationEST(BaseModelConfigResponse): + type: Literal["EST"] = "EST" + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StatusScheduledProvisioning] = None trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") -class VoiceConfiguration(BaseModelConfigResponse): - type: StrictStr +class ScheduledVoiceProvisioningVoiceConfigurationRTC(BaseModelConfigResponse): + type: Literal["RTC"] = "RTC" + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StatusScheduledProvisioning] = None + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationResponse(BaseModelConfigResponse): + type: Union[Literal["RTC", "EST", "FAX"], StrictStr] last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - scheduled_voice_provisioning: Optional[ScheduledVoiceProvisioningVoiceConfiguration] = \ - (Field(default=None, alias="scheduledVoiceProvisioning")) + scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningVoiceConfigurationRTC, + ScheduledVoiceProvisioningVoiceConfigurationEST, + ScheduledVoiceProvisioningVoiceConfigurationFAX, + ScheduledVoiceProvisioningVoiceConfigurationCustom, + None] = Field( + default=None, alias="scheduledVoiceProvisioning" + ) app_id: Optional[StrictStr] = Field(default=None, alias="appId") @@ -43,11 +116,10 @@ class Money(BaseModelConfigResponse): class Number(BaseModelConfigResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[Literal["MOBILE", "LOCAL", "TOLL_FREE"]] = Field(default=None, alias="type") - capability: Optional[List[Literal["SMS", "VOICE"]]] = Field(default=None, alias="capability") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) setup_price: Optional[Money] = Field(default=None, alias="setupPrice") monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") supporting_documentation_required: Optional[StrictBool] = ( Field(default=None, alias="supportingDocumentationRequired")) - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py new file mode 100644 index 00000000..0c7a7472 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py @@ -0,0 +1,154 @@ +import pytest +import json +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.available_numbers import RentAnyNumberEndpoint +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + +@pytest.fixture +def valid_request_data(): + """ + Provides valid mock request data for RentAnyNumberRequest. + """ + return RentAnyNumberRequest( + region_code="US", + type_="MOBILE", + number_pattern={"pattern": "string", "searchPattern": "START"}, + capabilities=["SMS"], + sms_configuration={"servicePlanId": "string", "campaignId": "string"}, + voice_configuration={"appId": "string"}, + callback_url="https://www.your-callback-server.com/callback", + ) + + +@pytest.fixture +def valid_response_data(): + """ + Provides valid mock response data for RentAnyNumberResponse. + """ + return { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-24T09:32:27.437Z", + "expireAt": "2025-01-25T09:32:27.437Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "trunkId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + +def test_build_url_expects_correct_format(mock_sinch_client, valid_request_data): + """ + Test that the build_url method constructs the URL correctly. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers:rentAny" + assert endpoint.build_url(mock_sinch_client) == expected_url + + +def test_request_body_expects_correct_json(valid_request_data): + """ + Test that the request_body method returns the correct JSON structure. + """ + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) + request_body = endpoint.request_body() + + expected_body = { + "numberPattern": {"pattern": "string", "searchPattern": "START"}, + "regionCode": "US", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": {"servicePlanId": "string", "campaignId": "string"}, + "voiceConfiguration": {"appId": "string"}, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + + assert json.loads(request_body) == expected_body + + +def test_handle_response_expects_valid_mapping(valid_response_data): + """ + Test that the handle_response method correctly maps the response data. + """ + mock_response = HTTPResponse(status_code=200, body=valid_response_data, + headers="Content-Type:application/json") + + endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=None) + response = endpoint.handle_response(mock_response) + + # Validate response fields + assert isinstance(response, RentAnyNumberResponse) + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + expected_next_charge_date = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + + sms_config = response.sms_configuration + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + assert sms_config.scheduled_provisioning.service_plan_id == "string" + assert sms_config.scheduled_provisioning.campaign_id == "string" + assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time + assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + voice_config = response.voice_configuration + assert voice_config.type == "RTC" + assert voice_config.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.type == "RTC" + assert voice_config.scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert voice_config.scheduled_voice_provisioning.trunk_id == "string" + assert voice_config.app_id == "string" + assert response.callback_url == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py new file mode 100644 index 00000000..768837ad --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py @@ -0,0 +1,30 @@ +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest + +def test_to_camel_case_expects_parsed_standard_cases(): + """ + Test standard snake_case to camelCase conversion. + """ + assert BaseModelConfigRequest._to_camel_case("foo_bar") == "fooBar" + assert BaseModelConfigRequest._to_camel_case("hello_world") == "helloWorld" + assert BaseModelConfigRequest._to_camel_case("this_is_a_test") == "thisIsATest" + +def test_to_camel_case_expects_parsed_edge_cases(): + """ + Test edge cases like leading/trailing underscores and multiple underscores. + """ + assert BaseModelConfigRequest._to_camel_case("foo__bar") == "foo_Bar" + assert BaseModelConfigRequest._to_camel_case("foo___bar") == "foo__Bar" + assert BaseModelConfigRequest._to_camel_case("trailing_") == "trailing_" + +def test_to_camel_case_expects_empty_string(): + """ + Test empty string case. + """ + assert BaseModelConfigRequest._to_camel_case("") == "" + +def test_to_camel_case_expects_single_word(): + """ + Test single-word cases. + """ + assert BaseModelConfigRequest._to_camel_case("word") == "word" + assert BaseModelConfigRequest._to_camel_case("single") == "single" diff --git a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py index 827b3bf7..285a256d 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py +++ b/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py @@ -125,4 +125,20 @@ def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_c response = ListAvailableNumbersRequest(**data) # Assert known fields - assert response.extra_field == "Extra Value" \ No newline at end of file + assert response.extra_field == "Extra Value" + +def test_list_available_numbers_expects_extra_capability(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "number_type": "MOBILE", + "size": 10, + "region_code": "US", + "capabilities": ["SMS", "VOICE", "EXTRA"], + "extra_field": "Extra Value" + } + response = ListAvailableNumbersRequest(**data) + + # Assert known fields + assert response.capabilities == ["SMS", "VOICE", "EXTRA"] \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py new file mode 100644 index 00000000..8c70e112 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py @@ -0,0 +1,75 @@ +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest + + +def test_rent_any_number_request_expects_valid_data(): + """ + Test that RentAnyNumberRequest correctly parses valid data. + """ + data = { + "numberPattern": { + "pattern": "string", + "searchPattern": "START" + }, + "regionCode": "string", + "type": "MOBILE", + "capabilities": ["SMS"], + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "string" + }, + "callbackUrl": "https://www.your-callback-server.com/callback" + } + + request = RentAnyNumberRequest(**data) + + assert request.number_pattern.pattern == "string" + assert request.number_pattern.search_pattern =="START" + assert request.region_code == "string" + assert request.type_ == "MOBILE" + assert request.capabilities == ["SMS"] + assert request.sms_configuration.service_plan_id == "string" + assert request.sms_configuration.campaign_id == "string" + assert request.voice_configuration.app_id == "string" + assert request.callback_url == "https://www.your-callback-server.com/callback" + + +def test_rent_any_number_request_expects_missing_optional_fields(): + """ + Test that RentAnyNumberRequest handles missing optional fields correctly. + """ + data = { + "regionCode": "string", + "type": "MOBILE" + } + + request = RentAnyNumberRequest(**data) + + assert request.region_code == "string" + assert request.type_ == "MOBILE" + + assert request.number_pattern is None + assert request.capabilities is None + assert request.sms_configuration is None + assert request.voice_configuration is None + assert request.callback_url is None + + +def test_rent_any_number_request_expects_extra_fields(): + """ + Test that RentAnyNumberRequest accepts extra fields. + """ + data = { + "regionCode": "string", + "type": "MOBILE", + "extraField": "Extra field" + } + + request = RentAnyNumberRequest(**data) + + assert request.region_code == "string" + assert request.type_ == "MOBILE" + assert request.extraField == "Extra field" diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py index 2747b13e..6a70479e 100644 --- a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py +++ b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py @@ -68,7 +68,7 @@ def assert_voice_configuration(voice_config): datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" - assert scheduled_voice_provisioning.trunk_id == "string" + assert scheduled_voice_provisioning.app_id == "string" def test_activate_number_response_expects_all_fields_mapped_correctly(test_data): """ @@ -103,7 +103,7 @@ def test_activate_number_response_expects_all_fields_mapped_correctly(test_data) "type": "RTC", "lastUpdatedTime": "2025-01-22T13:19:31.095Z", "status": "PROVISIONING_STATUS_UNSPECIFIED", - "trunkId": "string", + "appId": "string", }, "appId": "string", }, diff --git a/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py new file mode 100644 index 00000000..c0358e95 --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py @@ -0,0 +1,163 @@ +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + +@pytest.fixture +def valid_data(): + """ + Provides valid test data for RentAnyNumberResponse. + """ + return { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-24T09:32:27.437Z", + "expireAt": "2025-01-25T09:32:27.437Z", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "string", + "scheduledProvisioning": { + "servicePlanId": "string", + "campaignId": "string", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_UNSPECIFIED"], + }, + }, + "voiceConfiguration": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "PROVISIONING_STATUS_UNSPECIFIED", + "appId": "string", + }, + "appId": "string", + }, + "callbackUrl": "https://www.your-callback-server.com/callback", + } + +def test_rent_any_number_response_expects_valid_data(valid_data): + """ + Test that RentAnyNumberResponse correctly parses valid data. + """ + + response = RentAnyNumberResponse(**valid_data) + + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + expected_next_charge_date = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + + sms_config = response.sms_configuration + assert sms_config.service_plan_id == "string" + assert sms_config.campaign_id == "string" + assert sms_config.scheduled_provisioning.service_plan_id == "string" + assert sms_config.scheduled_provisioning.campaign_id == "string" + assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time + assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + + voice_config = response.voice_configuration + assert voice_config.type == "RTC" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert voice_config.last_updated_time == expected_last_updated_time + scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning + assert scheduled_voice_provisioning.type == "RTC" + assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" + assert scheduled_voice_provisioning.app_id == "string" + assert voice_config.app_id == "string" + assert response.callback_url == "https://www.your-callback-server.com/callback" + +def test_rent_any_number_response_expects_missing_optional_fields(): + """ + Test that RentAnyNumberResponse handles missing optional fields correctly. + """ + data = { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + } + + response = RentAnyNumberResponse(**data) + + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.money.amount == 2.00 + assert response.payment_interval_months == 0 + + assert response.next_charge_date is None + assert response.expire_at is None + assert response.sms_configuration is None + assert response.voice_configuration is None + assert response.callback_url is None + +def test_rent_any_number_response_expects_validation_error_for_missing_required_fields(): + """ + Test that RentAnyNumberResponse raises a validation error for missing required fields. + """ + data = { + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "smsConfiguration": { + # Missing required field "service_plan_id" + "campaignId": "string" + } + } + + with pytest.raises(ValidationError) as exc_info: + RentAnyNumberResponse(**data) + # Assert the validation error mentions missing fields + assert "smsConfiguration.servicePlanId" in str(exc_info.value) + +def test_rent_any_number_response_expects_ignore_extra_fields(): + """ + Test that RentAnyNumberResponse ignores extra fields. + """ + data = { + "phoneNumber": "+12025550134", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currency_code": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "extraField": "unexpected", + } + + response = RentAnyNumberResponse(**data) + + # Assert valid fields are parsed correctly + assert response.phone_number == "+12025550134" + assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert response.region_code == "US" + assert response.extra_field == "unexpected" \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py index 41fa1c2b..8a518173 100644 --- a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py @@ -57,21 +57,21 @@ def test_check_number_availability_response_missing_optional_fields_expects_vali assert response.payment_interval_months is None assert response.supporting_documentation_required is None -def test_check_number_availability_response_expects_validation_error_for_invalid_data(): +def test_check_number_availability_response_expects_parsed_new_type(): """ Test CheckNumberAvailabilityResponse with invalid data. """ data = { "phoneNumber": "+1234567890", "regionCode": "US", - "type": "INVALID_TYPE", + "type": "NEW_TYPE", "capability": ["SMS", "VOICE"], "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} } - with pytest.raises(ValidationError): - CheckNumberAvailabilityResponse(**data) + response = CheckNumberAvailabilityResponse(**data) + assert response.type == "NEW_TYPE" def test_check_number_availability_response_expects_validation_error_for_missing_required_fields(): """ diff --git a/tests/unit/domains/numbers/models/test_numbers.py b/tests/unit/domains/numbers/models/test_numbers.py new file mode 100644 index 00000000..bbbe5718 --- /dev/null +++ b/tests/unit/domains/numbers/models/test_numbers.py @@ -0,0 +1,110 @@ +from datetime import datetime, timezone +from sinch.domains.numbers.models.numbers import ( + ScheduledProvisioningSmsConfiguration, + SmsConfigurationResponse, + VoiceConfigurationResponse, +) + +def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "status": "ACTIVE", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "errorCodes": ["ERROR_CODE_1"] + } + config = ScheduledProvisioningSmsConfiguration.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.status == "ACTIVE" + expected_last_updated_time = ( + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + assert config.last_updated_time == expected_last_updated_time + assert config.error_codes == ["ERROR_CODE_1"] + +def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed_data(): + """ + Test missing optional fields in ScheduledProvisioningSmsConfiguration + """ + data = { + "servicePlanId": "test_plan" + } + config = ScheduledProvisioningSmsConfiguration.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id is None + assert config.status is None + assert config.last_updated_time is None + assert config.error_codes is None + +def test_sms_configuration_valid_expects_parsed_data(): + """ + Test a valid instance of SmsConfiguration + """ + data = { + "servicePlanId": "test_plan", + "campaignId": "test_campaign", + "scheduledProvisioning": { + "servicePlanId": "test_plan", + "status": "ACTIVE" + } + } + config = SmsConfigurationResponse.model_validate(data) + + assert config.service_plan_id == "test_plan" + assert config.campaign_id == "test_campaign" + assert config.scheduled_provisioning is not None + assert config.scheduled_provisioning.service_plan_id == "test_plan" + assert config.scheduled_provisioning.status == "ACTIVE" + +def test_voice_configuration_rtc_valid_expects_parsed_data(): + """ + Test a valid RTC voice configuration + """ + data = { + "type": "RTC", + "appId": "test_app", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "RTC", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "ACTIVE", + "appId": "test_app" + } + } + config = VoiceConfigurationResponse.model_validate(data) + + assert config.type == "RTC" + assert config.app_id == "test_app" + assert (config.last_updated_time == + datetime(2025, 1, 24, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "RTC" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + +def test_voice_configuration_fax_valid_expects_parsed_data(): + """ + Test a valid FAX voice configuration + """ + data = { + "type": "FAX", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "FAX", + "lastUpdatedTime": "2025-01-24T09:32:27.437Z", + "status": "ACTIVE", + "serviceId": "test_service" + } + } + config = VoiceConfigurationResponse.model_validate(data) + + assert config.type == "FAX" + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "FAX" + assert config.scheduled_voice_provisioning.status == "ACTIVE" + assert config.scheduled_voice_provisioning.service_id == "test_service" From 05ace15f6e21de832e71f63e7ab6beb3c73b07d7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 3 Feb 2025 15:07:48 +0100 Subject: [PATCH 003/106] chore: remove code redundancy --- sinch/domains/numbers/available_numbers.py | 35 ++++++++++------------ sinch/domains/numbers/models/numbers.py | 10 +++++-- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index 51c9c2a9..3f5a25b5 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -1,6 +1,6 @@ from typing import Optional, TypedDict, overload, Literal, Union, Annotated from typing_extensions import NotRequired -from pydantic import conlist, StrictInt, StrictStr, Field +from pydantic import StrictInt, StrictStr, Field from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint @@ -16,10 +16,7 @@ from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse -# Define type aliases -NumberType = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] -CapabilityType = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) -NumberSearchPatternType = Union[Literal["START", "CONTAINS", "END"], StrictStr] +from sinch.domains.numbers.models.numbers import NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues class SmsConfigurationDict(TypedDict): @@ -48,7 +45,7 @@ class VoiceConfigurationDictCustom(TypedDict): class NumberPatternDict(TypedDict): pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternType] + search_pattern: NotRequired[NumberSearchPatternTypeValues] VoiceConfigurationDictType = Annotated[ @@ -83,10 +80,10 @@ def _request(self, endpoint_class, request_data): def list( self, region_code: StrictStr, - number_type: NumberType, + number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternType] = None, - capabilities: Optional[CapabilityType] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capabilities: Optional[CapabilityTypeValues] = None, page_size: Optional[StrictInt] = None, **kwargs ) -> ListAvailableNumbersResponse: @@ -203,11 +200,11 @@ def activate( def rent_any( self, region_code: StrictStr, - type_: NumberType, + type_: NumberTypeValues, sms_configuration: None, voice_configuration: None, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityType] = None, + capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -216,11 +213,11 @@ def rent_any( def rent_any( self, region_code: StrictStr, - type_: NumberType, + type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictRTC, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityType] = None, + capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -229,11 +226,11 @@ def rent_any( def rent_any( self, region_code: StrictStr, - type_: NumberType, + type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictFAX, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityType] = None, + capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -242,11 +239,11 @@ def rent_any( def rent_any( self, region_code: StrictStr, - type_: NumberType, + type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictEST, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityType] = None, + capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -254,9 +251,9 @@ def rent_any( def rent_any( self, region_code: StrictStr, - type_: NumberType, + type_: NumberTypeValues, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityType] = None, + capabilities: Optional[CapabilityTypeValues] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[str] = None, diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index 89b480ca..cc1d89f5 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -4,18 +4,22 @@ from decimal import Decimal from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse +NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] +CapabilityTypeValues = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) +NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] + CapabilityType = Annotated[ - conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1), + CapabilityTypeValues, Field(default=None) ] NumberSearchPatternType = Annotated[ - Union[Literal["START", "CONTAINS", "END"], StrictStr], + NumberSearchPatternTypeValues, Field(default=None) ] NumberType = Annotated[ - Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr], + NumberTypeValues, Field(default=None) ] From 5b3d225b9d4fe66771b970d9858383bbc0f79a11 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 4 Feb 2025 11:31:37 +0100 Subject: [PATCH 004/106] feat: include None values in response, omit in requests --- .../endpoints/available/activate_number.py | 17 +------ .../available/list_available_numbers.py | 4 ++ .../endpoints/available/rent_any_number.py | 13 +---- .../endpoints/available/search_for_number.py | 14 ++---- .../numbers/endpoints/numbers_endpoint.py | 34 ++++++++----- .../numbers/models/base_model_numbers.py | 50 ++++++++++++++----- sinch/domains/numbers/models/numbers.py | 23 ++++----- .../test_activate_number_endpoint.py | 44 +++++++++++++++- .../test_search_for_number_endpoint.py | 2 +- .../test_activate_number_request_model.py | 19 +++++++ 10 files changed, 141 insertions(+), 79 deletions(-) diff --git a/sinch/domains/numbers/endpoints/available/activate_number.py b/sinch/domains/numbers/endpoints/available/activate_number.py index 83179104..33c92617 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number.py +++ b/sinch/domains/numbers/endpoints/available/activate_number.py @@ -16,21 +16,6 @@ class ActivateNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - def build_url(self, sinch) -> str: - """ - Constructs the full URL for the endpoint by formatting the placeholders with actual values. - - Args: - sinch (Sinch): The Sinch client instance containing configuration details like the API origin. - - Returns: - str: The fully constructed URL for this API call. - """ - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: + super(ActivateNumberEndpoint, self).handle_response(response) return self.process_response_model(response.body, ActivateNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers.py b/sinch/domains/numbers/endpoints/available/list_available_numbers.py index 40c12e13..8e87417c 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers.py @@ -27,6 +27,9 @@ def build_query_params(self) -> dict: query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) return query_params + def request_body(self): + pass + def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: """ Processes the API response and maps it to a response model. @@ -37,4 +40,5 @@ def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersRespons Returns: ListAvailableNumbersResponse: The response model containing the parsed response data. """ + super(AvailableNumbersEndpoint, self).handle_response(response) return self.process_response_model(response.body, ListAvailableNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number.py index e71deac0..31478435 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number.py @@ -1,4 +1,3 @@ -import json from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods @@ -17,17 +16,6 @@ class RentAnyNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) - def request_body(self): - """ - Returns the request body as a JSON string. - - Returns: - str: The request body as a JSON string. - """ - # Convert the request data to a dictionary and remove None values - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) - return json.dumps(request_data) - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: """ Handles the response from the API call. @@ -38,4 +26,5 @@ def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: Returns: RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. """ + super(RentAnyNumberEndpoint, self).handle_response(response) return self.process_response_model(response.body, RentAnyNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number.py b/sinch/domains/numbers/endpoints/available/search_for_number.py index d1f6d85a..9e48c37e 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number.py @@ -15,18 +15,9 @@ class SearchForNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - def build_url(self, sinch) -> str: - """ - Constructs the full URL for the endpoint by formatting the placeholders with actual values. - """ - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) + def request_body(self): + pass def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: """ @@ -39,4 +30,5 @@ def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResp CheckNumberAvailabilityResponse: The response model containing the parsed response data of the requested phone number. """ + super(SearchForNumberEndpoint, self).handle_response(response) return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 2fe2f1d4..97d2196f 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,3 +1,4 @@ +import json from pydantic import BaseModel from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint @@ -30,10 +31,26 @@ def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) + placeholders = { + "origin": sinch.configuration.numbers_origin, + "project_id": self.project_id, + } + + if "phone_number" in self.ENDPOINT_URL and hasattr(self.request_data, "phone_number"): + placeholders["phone_number"] = self.request_data.phone_number + + return self.ENDPOINT_URL.format(**placeholders) + + def request_body(self): + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) def process_response_model(self, response_body: dict, response_model: type[BaseModel]) -> BaseModel: """ @@ -47,14 +64,7 @@ def process_response_model(self, response_body: dict, response_model: type[BaseM Parsed response object. """ try: - model_instance = response_model.model_validate(response_body) - # Remove None values while preserving nested objects - cleaned_data = model_instance.model_dump(exclude_none=True) - # Remove attributes that are not in cleaned data - for key in model_instance.model_fields: - if key not in cleaned_data: - delattr(model_instance, key) - return model_instance + return response_model.model_validate(response_body) except Exception as e: raise ValueError(f"Invalid response structure: {e}") from e diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/base_model_numbers.py index 641b4d2c..69aa822d 100644 --- a/sinch/domains/numbers/models/base_model_numbers.py +++ b/sinch/domains/numbers/models/base_model_numbers.py @@ -14,6 +14,23 @@ def _to_camel_case(snake_str: str) -> str: components = snake_str.split('_') return components[0] + ''.join(x.capitalize() if x else '_' for x in components[1:]) + @classmethod + def _convert_dict_keys(cls, obj): + """Recursively convert dictionary keys to camelCase.""" + if isinstance(obj, dict): + new_dict = {} + for key, value in obj.items(): + # Convert dict key to camelCase + camel_key = cls._to_camel_case(key) + # Recurse on the value + new_dict[camel_key] = cls._convert_dict_keys(value) + return new_dict + elif isinstance(obj, list): + # Recurse through any list elements (they might be dicts too) + return [cls._convert_dict_keys(item) for item in obj] + else: + return obj + model_config = ConfigDict( # Allows using both alias (camelCase) and field name (snake_case) populate_by_name=True, @@ -23,30 +40,37 @@ def _to_camel_case(snake_str: str) -> str: def model_dump(self, **kwargs) -> dict: """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" - # Get the standard model dump + # Get the standard model dump. data = super().model_dump(**kwargs) # Get extra fields extra_data = self.__pydantic_extra__ or {} - # Convert extra fields to camelCase and collect the original snake_case keys - converted_extra = {} - for key, value in extra_data.items(): - camel_case_key = self._to_camel_case(key) - converted_extra[camel_case_key] = value + # Merge known + unknown into one dictionary first + combined = {**data, **extra_data} + + final_dict = {} + + for key, value in combined.items(): + if key in extra_data: + # This is an unknown field to be converted + new_key = self._to_camel_case(key) + else: + # Known field - keep the top-level key as given + new_key = key + + # Recursively convert any nested dict keys + converted_value = self._convert_dict_keys(value) - # Remove snake_case keys from `data` before merging converted extras - for key in extra_data.keys(): - data.pop(key, None) # Ensure snake_case fields are removed from final output + # Add to final dictionary + final_dict[new_key] = converted_value - # Merge the cleaned base data with the converted extra fields - return {**data, **converted_extra} + return final_dict class BaseModelConfigResponse(BaseModel): """ - A base model that allows extra fields and converts camelCase to snake_case, - and serializes datetime fields to ISO format. + A base model that allows extra fields and converts camelCase to snake_case """ @staticmethod diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index cc1d89f5..d37b35c9 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -41,7 +41,7 @@ class VoiceConfigurationFAX(BaseModelConfigRequest): class VoiceConfigurationEST(BaseModelConfigRequest): type: Literal["EST"] = "EST" - trunk_id: Optional[StrictStr] = Field(default=None, alias="truckId") + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") class VoiceConfigurationRTC(BaseModelConfigRequest): @@ -74,28 +74,25 @@ class SmsConfigurationResponse(BaseModelConfigResponse): Field(default=None, alias="scheduledProvisioning")) +class ScheduledVoiceProvisioningVoiceConfigurationBase(BaseModelConfigResponse): + type: Literal["FAX", "EST", "RTC"] + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StatusScheduledProvisioning] = None + + class ScheduledVoiceProvisioningVoiceConfigurationCustom(BaseModelConfigResponse): type: StrictStr -class ScheduledVoiceProvisioningVoiceConfigurationFAX(BaseModelConfigResponse): - type: Literal["FAX"] = "FAX" - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - status: Optional[StatusScheduledProvisioning] = None +class ScheduledVoiceProvisioningVoiceConfigurationFAX(ScheduledVoiceProvisioningVoiceConfigurationBase): service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") -class ScheduledVoiceProvisioningVoiceConfigurationEST(BaseModelConfigResponse): - type: Literal["EST"] = "EST" - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - status: Optional[StatusScheduledProvisioning] = None +class ScheduledVoiceProvisioningVoiceConfigurationEST(ScheduledVoiceProvisioningVoiceConfigurationBase): trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") -class ScheduledVoiceProvisioningVoiceConfigurationRTC(BaseModelConfigResponse): - type: Literal["RTC"] = "RTC" - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - status: Optional[StatusScheduledProvisioning] = None +class ScheduledVoiceProvisioningVoiceConfigurationRTC(ScheduledVoiceProvisioningVoiceConfigurationBase): app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py index 0689b306..71a57c95 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -1,4 +1,5 @@ import pytest +import json from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse @@ -17,8 +18,19 @@ class MockSinchClient: @pytest.fixture def mock_request_data(): - return ActivateNumberRequest(phone_number="+1234567890") + return ActivateNumberRequest( + phone_number="+1234567890", + sms_configuration={"servicePlanId": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) +@pytest.fixture +def mock_request_data_snake_case(): + return ActivateNumberRequest( + phone_number="+1234567890", + sms_configuration={"service_plan_id": "YOUR_SMS_servicePlanId"}, + voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} + ) @pytest.fixture def mock_response(): @@ -33,6 +45,20 @@ def mock_response(): headers={"Content-Type": "application/json"} ) +@pytest.fixture +def mock_response_body(): + expected_body = { + "phoneNumber": "+1234567890", + "smsConfiguration": { + "servicePlanId": "YOUR_SMS_servicePlanId" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "YOUR_Voice_appId" + } + } + return json.dumps(expected_body) + def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): """ @@ -42,6 +68,22 @@ def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" assert endpoint.build_url(mock_sinch_client) == expected_url +def test_request_body_expects_correct_json(mock_request_data, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + request_body = endpoint.request_body() + assert request_body == mock_response_body + +def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_snake_case, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data_snake_case) + request_body = endpoint.request_body() + + assert request_body == mock_response_body def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): """ diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py index 557884b5..cc92c2d0 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py @@ -101,4 +101,4 @@ def test_handle_response_expects_missing_fields(mock_response): assert response.monthly_price.currency_code == "USD" assert response.monthly_price.amount == 2.00 assert response.supporting_documentation_required is True - assert "payment_interval_months" not in response.model_dump() + assert response.payment_interval_months is None diff --git a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py index d590d066..4cc8b6e2 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py +++ b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py @@ -93,3 +93,22 @@ def test_activate_number_request_expects_validation_error_for_missing_field(): # Assert the error mentions the missing phone_number field assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) + +def test_activate_number_request_expects_optional_param_none(): + """ + Test that the model correctly handles snake_case input. + """ + data = { + "phone_number": "+1234567890", + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "callback_url": "https://example.com/callback" + } + + # Instantiate the model + request = ActivateNumberRequest(**data) + + # Assert the field values + assert request.phone_number == "+1234567890" + assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} + assert request.voice_configuration is None + assert request.callback_url == "https://example.com/callback" \ No newline at end of file From c616641eff4b8061bab9fb048e1a6774525c63e1 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 4 Feb 2025 15:46:30 +0100 Subject: [PATCH 005/106] chore: refactor unit tests --- .../test_activate_number_request_model.py | 24 ---------- .../requests/test_base_model_requests.py | 19 ++++++++ .../test_activate_number_response_model.py | 46 +++++-------------- .../response/test_base_model_response.py | 15 ++++++ .../test_search_for_number_response_model.py | 27 ----------- 5 files changed, 46 insertions(+), 85 deletions(-) create mode 100644 tests/unit/domains/numbers/models/available/response/test_base_model_response.py diff --git a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py index 4cc8b6e2..db5369fa 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py +++ b/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py @@ -28,30 +28,6 @@ def test_activate_number_request_expects_snake_case_input(): } assert request.callback_url == "https://example.com/callback" -def test_activate_number_request_expects_camel_case_input(): - """ - Test that the model correctly handles camelCase input. - """ - data = { - "phoneNumber": "+1234567890", - "smsConfiguration": {"servicePlanId": "YOUR_SMS_servicePlanId"}, - "voice_configuration": { - "appId": "YOUR_voice_appID", - "type": "RTC" - }, - "callback_url": "https://example.com/callback" - } - request = ActivateNumberRequest(**data) - - # Assert fields are populated correctly - assert request.phone_number == "+1234567890" - assert request.sms_configuration == {"servicePlanId": "YOUR_SMS_servicePlanId"} - assert request.voice_configuration == { - "appId": "YOUR_voice_appID", - "type": "RTC" - } - assert request.callback_url == "https://example.com/callback" - def test_activate_number_request_expects_mixed_case_input(): """ Test that the model correctly handles mixed camelCase and snake_case input. diff --git a/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py index 768837ad..6dc7771e 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py +++ b/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py @@ -28,3 +28,22 @@ def test_to_camel_case_expects_single_word(): """ assert BaseModelConfigRequest._to_camel_case("word") == "word" assert BaseModelConfigRequest._to_camel_case("single") == "single" + +def test_dict_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"}, + "voice_configuration": { + "appId": "YOUR_voice_appID", + "type": "RTC" + } + } + request = BaseModelConfigRequest(**data) + response = request.model_dump() + + assert response == { + 'smsConfiguration': {'servicePlanId': 'YOUR_SMS_servicePlanId'}, + 'voiceConfiguration': {'appId': 'YOUR_voice_appID', 'type': 'RTC'} + } diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py index 6a70479e..0d89410b 100644 --- a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py +++ b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py @@ -13,7 +13,7 @@ def test_data(): "money": {"currencyCode": "USD", "amount": "2.00"}, "paymentIntervalMonths": 0, "nextChargeDate": "2025-01-22T13:19:31.095Z", - "expireAt": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-02-04T13:15:31.095Z", "smsConfiguration": { "servicePlanId": "string", "campaignId": "string", @@ -21,15 +21,15 @@ def test_data(): "servicePlanId": "string", "campaignId": "string", "status": "PROVISIONING_STATUS_UNSPECIFIED", - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-01-24T13:19:31.095Z", "errorCodes": ["ERROR_CODE_UNSPECIFIED"], }, }, "voiceConfiguration": { - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-01-25T18:19:31.095Z", "scheduledVoiceProvisioning": { "type": "RTC", - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-01-26T18:19:31.095Z", "status": "PROVISIONING_STATUS_UNSPECIFIED", "trunkId": "string", }, @@ -49,7 +49,7 @@ def assert_sms_configuration(sms_config): assert scheduled_provisioning.campaign_id == "string" assert scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" expected_last_updated_time = ( - datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 2, 21, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert scheduled_provisioning.last_updated_time == expected_last_updated_time assert scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] @@ -59,13 +59,13 @@ def assert_voice_configuration(voice_config): """ assert voice_config.type == "RTC" expected_last_updated_time = ( - datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 1, 25, 13, 49, 31, 95000, tzinfo=timezone.utc)) assert voice_config.last_updated_time == expected_last_updated_time assert voice_config.app_id == "string" scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning assert scheduled_voice_provisioning.type == "RTC" expected_last_updated_time = ( - datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 2, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" assert scheduled_voice_provisioning.app_id == "string" @@ -84,7 +84,7 @@ def test_activate_number_response_expects_all_fields_mapped_correctly(test_data) "money": {"currencyCode": "USD", "amount": "2.00"}, "paymentIntervalMonths": 0, "nextChargeDate": "2025-01-22T13:19:31.095Z", - "expireAt": "2025-01-29T13:19:31.095Z", + "expireAt": "2025-03-29T13:19:31.095Z", "smsConfiguration": { "servicePlanId": "string", "campaignId": "string", @@ -92,16 +92,16 @@ def test_activate_number_response_expects_all_fields_mapped_correctly(test_data) "servicePlanId": "string", "campaignId": "string", "status": "PROVISIONING_STATUS_UNSPECIFIED", - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-02-21T13:19:31.095Z", "errorCodes": ["ERROR_CODE_UNSPECIFIED"], }, }, "voiceConfiguration": { "type": "RTC", - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-01-25T13:49:31.095Z", "scheduledVoiceProvisioning": { "type": "RTC", - "lastUpdatedTime": "2025-01-22T13:19:31.095Z", + "lastUpdatedTime": "2025-02-22T13:19:31.095Z", "status": "PROVISIONING_STATUS_UNSPECIFIED", "appId": "string", }, @@ -122,31 +122,9 @@ def test_activate_number_response_expects_all_fields_mapped_correctly(test_data) datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert response.next_charge_date == expected_next_charge_data expected_expire_at = ( - datetime(2025, 1, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 3, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert response.expire_at == expected_expire_at assert response.callback_url == "https://www.your-callback-server.com/callback" # Assert sms_configuration and voice_configuration using helper functions assert_sms_configuration(response.sms_configuration) assert_voice_configuration(response.voice_configuration) - - -def test_activate_number_response_expects_unrecognized_fields_snake_case(): - """ - Expects unrecognized fields to be dynamically added as snake_case attributes. - """ - data = { - "phoneNumber": "+12025550134", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "unexpectedField": "unexpectedValue", - "anotherExtraField": 42, - } - response = ActivateNumberResponse(**data) - - # Assert known fields - assert response.phone_number == "+12025550134" - - # Assert unrecognized fields are dynamically added - assert response.unexpected_field == "unexpectedValue" - assert response.another_extra_field == 42 diff --git a/tests/unit/domains/numbers/models/available/response/test_base_model_response.py b/tests/unit/domains/numbers/models/available/response/test_base_model_response.py new file mode 100644 index 00000000..cd609f5c --- /dev/null +++ b/tests/unit/domains/numbers/models/available/response/test_base_model_response.py @@ -0,0 +1,15 @@ +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse + +def test_base_model_response_expects_unrecognized_fields_snake_case(): + """ + Expects unrecognized fields to be dynamically added as snake_case attributes. + """ + data = { + "unexpectedField": "unexpectedValue", + "anotherExtraField": 42, + } + response = BaseModelConfigResponse(**data) + + # Assert unrecognized fields are dynamically added + assert response.unexpected_field == "unexpectedValue" + assert response.another_extra_field == 42 diff --git a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py index 8a518173..8c8d41cf 100644 --- a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py @@ -87,30 +87,3 @@ def test_check_number_availability_response_expects_validation_error_for_missing with pytest.raises(ValidationError): CheckNumberAvailabilityResponse.model_validate(data, strict=True) - -def test_check_number_availability_response_extra_field_expects_parsed_data_snake_case(): - """ - Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, - and doesn't include them in the response. - """ - data = { - "phoneNumber": "+1234567890", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS", "VOICE"], - "setupPrice": {"amount": "10.00", "currencyCode": "USD"}, - "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"}, - "extraValue": 5, - } - - response = CheckNumberAvailabilityResponse(**data) - - assert response.phone_number == "+1234567890" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS", "VOICE"] - assert response.setup_price.amount == 10.00 - assert response.setup_price.currency_code == "USD" - assert response.monthly_price.amount == 5.00 - assert response.monthly_price.currency_code == "USD" - assert response.extra_value == 5 From 8281d46209d61dd922a0f0e54af3074baa7ea6dc Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 4 Feb 2025 16:47:27 +0100 Subject: [PATCH 006/106] chore: refactor numbers endpoint --- .../domains/numbers/endpoints/numbers_endpoint.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 97d2196f..5ba2bf8f 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -31,15 +31,11 @@ def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") - placeholders = { - "origin": sinch.configuration.numbers_origin, - "project_id": self.project_id, - } - - if "phone_number" in self.ENDPOINT_URL and hasattr(self.request_data, "phone_number"): - placeholders["phone_number"] = self.request_data.phone_number - - return self.ENDPOINT_URL.format(**placeholders) + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id, + **vars(self.request_data) + ) def request_body(self): """ From 1c681edbb3061f07e6c4d57acf3154d2b0866c9d Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 5 Feb 2025 12:55:32 +0100 Subject: [PATCH 007/106] feat: address legacy requests --- .../numbers/models/available/activate_number_request.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/available/activate_number_request.py index 100825a2..b792f2b4 100644 --- a/sinch/domains/numbers/models/available/activate_number_request.py +++ b/sinch/domains/numbers/models/available/activate_number_request.py @@ -29,7 +29,8 @@ def __init__(self, **data): for key in ("voiceConfiguration", "voice_configuration"): if key in data and data[key] is not None: - voice_type = data[key].get("type") + # Address legacy requests + voice_type = data[key].get("type") or "RTC" voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) voice_config_class(**data[key]) From 57bcc5052e6a1cea38779de4752d190d158e3f79 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 5 Feb 2025 14:27:05 +0100 Subject: [PATCH 008/106] feat: address legacy requests for rentAnyNumber --- .../available/activate_number_request.py | 22 ++---------- .../available/rent_any_number_request.py | 18 ++++++---- sinch/domains/numbers/validators.py | 36 +++++++++++++++++++ .../test_rent_any_number_request_model.py | 27 +++++--------- 4 files changed, 58 insertions(+), 45 deletions(-) create mode 100644 sinch/domains/numbers/validators.py diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/available/activate_number_request.py index b792f2b4..fc21fb0b 100644 --- a/sinch/domains/numbers/models/available/activate_number_request.py +++ b/sinch/domains/numbers/models/available/activate_number_request.py @@ -1,9 +1,7 @@ from typing import Optional, Dict from pydantic import Field, StrictStr +from sinch.domains.numbers.validators import validate_sms_voice_configuration from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import (SmsConfigurationRequest, VoiceConfigurationFAX, - VoiceConfigurationEST, VoiceConfigurationRTC, - VoiceConfigurationCustom) class ActivateNumberRequest(BaseModelConfigRequest): @@ -17,21 +15,5 @@ def __init__(self, **data): """ Custom initializer to validate nested dictionaries. """ - for key in ("smsConfiguration", "sms_configuration"): - if key in data and data[key] is not None: - SmsConfigurationRequest(**data[key]) - - voice_config_map = { - "RTC": VoiceConfigurationRTC, - "EST": VoiceConfigurationEST, - "FAX": VoiceConfigurationFAX, - } - - for key in ("voiceConfiguration", "voice_configuration"): - if key in data and data[key] is not None: - # Address legacy requests - voice_type = data[key].get("type") or "RTC" - voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) - voice_config_class(**data[key]) - + validate_sms_voice_configuration(data) super().__init__(**data) diff --git a/sinch/domains/numbers/models/available/rent_any_number_request.py b/sinch/domains/numbers/models/available/rent_any_number_request.py index 780658ab..090fed5e 100644 --- a/sinch/domains/numbers/models/available/rent_any_number_request.py +++ b/sinch/domains/numbers/models/available/rent_any_number_request.py @@ -1,8 +1,8 @@ -from typing import Optional, Union, Dict, Any +from typing import Optional, Dict from pydantic import Field, StrictStr +from sinch.domains.numbers.validators import validate_sms_voice_configuration from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import (NumberSearchPatternType, CapabilityType, - SmsConfigurationRequest, VoiceConfigurationType) +from sinch.domains.numbers.models.numbers import NumberSearchPatternType, CapabilityType class NumberPattern(BaseModelConfigRequest): @@ -15,7 +15,13 @@ class RentAnyNumberRequest(BaseModelConfigRequest): type_: StrictStr = Field(default=None, alias="type") number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") capabilities: Optional[CapabilityType] = Field(default=None) - sms_configuration: Optional[SmsConfigurationRequest] = Field(default=None, alias="smsConfiguration") - voice_configuration: Union[VoiceConfigurationType, Dict[str, Any], None] = ( - Field(default=None, alias="voiceConfiguration")) + sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/sinch/domains/numbers/validators.py b/sinch/domains/numbers/validators.py new file mode 100644 index 00000000..4dd74621 --- /dev/null +++ b/sinch/domains/numbers/validators.py @@ -0,0 +1,36 @@ +from typing import Dict, Any +from sinch.domains.numbers.models.numbers import ( + SmsConfigurationRequest, VoiceConfigurationRTC, + VoiceConfigurationEST, VoiceConfigurationFAX, + VoiceConfigurationCustom +) + + +def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: + """ + Validates `sms_configuration` and `voice_configuration` fields in request data. + + Args: + data (dict): The request payload. + + Raises: + ValidationError: If validation fails for the configurations. + """ + # Validate SMS Configuration + for key in ("smsConfiguration", "sms_configuration"): + if key in data and data[key] is not None: + SmsConfigurationRequest(**data[key]) + + # Validate Voice Configuration + voice_config_map = { + "RTC": VoiceConfigurationRTC, + "EST": VoiceConfigurationEST, + "FAX": VoiceConfigurationFAX, + } + + for key in ("voiceConfiguration", "voice_configuration"): + if key in data and data[key] is not None: + # Handle legacy requests + voice_type = data[key].get("type") or "RTC" + voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) + voice_config_class(**data[key]) diff --git a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py index 8c70e112..da72c394 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py @@ -31,9 +31,14 @@ def test_rent_any_number_request_expects_valid_data(): assert request.region_code == "string" assert request.type_ == "MOBILE" assert request.capabilities == ["SMS"] - assert request.sms_configuration.service_plan_id == "string" - assert request.sms_configuration.campaign_id == "string" - assert request.voice_configuration.app_id == "string" + assert request.sms_configuration == { + "servicePlanId": "string", + "campaignId": "string" + } + assert request.voice_configuration == { + "type": "RTC", + "appId": "string" + } assert request.callback_url == "https://www.your-callback-server.com/callback" @@ -57,19 +62,3 @@ def test_rent_any_number_request_expects_missing_optional_fields(): assert request.voice_configuration is None assert request.callback_url is None - -def test_rent_any_number_request_expects_extra_fields(): - """ - Test that RentAnyNumberRequest accepts extra fields. - """ - data = { - "regionCode": "string", - "type": "MOBILE", - "extraField": "Extra field" - } - - request = RentAnyNumberRequest(**data) - - assert request.region_code == "string" - assert request.type_ == "MOBILE" - assert request.extraField == "Extra field" From f535801c62558fbdf627eb2f60b94ce7d4b6e51e Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Fri, 7 Feb 2025 10:15:12 +0100 Subject: [PATCH 009/106] DEVEXP-729: E2E Tests - Available Numbers (#45) --- .github/scripts/wait-for-mockserver.sh | 28 +++ .github/workflows/run-tests.yml | 30 +++ .gitignore | 3 + requirements-dev.txt | 1 + .../clients/sinch_client_configuration.py | 2 - sinch/core/endpoint.py | 21 +- sinch/core/models/http_request.py | 1 - sinch/core/ports/http_transport.py | 4 +- sinch/domains/numbers/available_numbers.py | 18 +- ..._number.py => activate_number_endpoint.py} | 7 +- ....py => list_available_numbers_endpoint.py} | 15 +- ..._number.py => rent_any_number_endpoint.py} | 5 +- ...umber.py => search_for_number_endpoint.py} | 6 +- .../numbers/endpoints/numbers_endpoint.py | 32 +-- sinch/domains/numbers/exceptions.py | 4 + .../numbers/models/base_model_numbers.py | 9 + sinch/domains/numbers/models/numbers.py | 21 +- tests/conftest.py | 14 +- .../numbers/features/steps/numbers.steps.py | 206 ++++++++++++++++++ .../test_activate_number_endpoint.py | 2 +- .../test_list_available_numbers_endpoint.py | 10 +- .../test_search_for_number_endpoint.py | 2 +- .../domains/numbers/models/test_numbers.py | 29 ++- .../domains/numbers/test_available_numbers.py | 6 +- 24 files changed, 402 insertions(+), 74 deletions(-) create mode 100755 .github/scripts/wait-for-mockserver.sh rename sinch/domains/numbers/endpoints/available/{activate_number.py => activate_number_endpoint.py} (73%) rename sinch/domains/numbers/endpoints/available/{list_available_numbers.py => list_available_numbers_endpoint.py} (75%) rename sinch/domains/numbers/endpoints/available/{rent_any_number.py => rent_any_number_endpoint.py} (88%) rename sinch/domains/numbers/endpoints/available/{search_for_number.py => search_for_number_endpoint.py} (81%) create mode 100644 tests/e2e/numbers/features/steps/numbers.steps.py diff --git a/.github/scripts/wait-for-mockserver.sh b/.github/scripts/wait-for-mockserver.sh new file mode 100755 index 00000000..1f80c6cc --- /dev/null +++ b/.github/scripts/wait-for-mockserver.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +wait_for_server() { + local url=$1 + echo "Waiting for $url to be ready..." + + MAX_RETRIES="${MAX_RETRIES:-30}" + SLEEP_SECONDS="${SLEEP_SECONDS:-2}" + + for ((i = 1; i <= MAX_RETRIES; i++)); do + if curl -sSf "$url" > /dev/null; then + echo "$url is ready!" + return 0 + fi + echo "Attempt $i/$MAX_RETRIES: Still waiting for $url..." + sleep "$SLEEP_SECONDS" + done + + echo "Error: $url was not available after $((MAX_RETRIES * SLEEP_SECONDS)) seconds" + exit 1 +} + +# Wait for auth mock servers +wait_for_server "http://localhost:3011/health" +# Wait for numbers mock servers +wait_for_server "http://localhost:3013/health" + +echo "All mock servers are ready!" \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 92ab75e3..40feb587 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -45,3 +45,33 @@ jobs: - name: Coverage Test Report run: | python -m coverage report --skip-empty + + - name: Checkout sinch-sdk-mockserver repository + uses: actions/checkout@v3 + with: + repository: sinch/sinch-sdk-mockserver + token: ${{ secrets.PAT_CI }} + fetch-depth: 0 + path: sinch-sdk-mockserver + + - name: Install Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose + + - name: Start mock servers with Docker Compose + run: | + cd sinch-sdk-mockserver + docker-compose up -d + + - name: Copy feature files + run: | + cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ + + - name: Wait for mock server + run: .github/scripts/wait-for-mockserver.sh + shell: bash + + - name: Run e2e tests + run: behave tests/e2e/**/features + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8e3c77ce..3de3edbd 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ coverage.xml .pytest_cache/ cover/ +# E2E features +*.feature + # Translations *.mo *.pot diff --git a/requirements-dev.txt b/requirements-dev.txt index d4617425..04a0f33f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,6 +3,7 @@ pytest pytest-asyncio pytest-mock coverage +behave # Code Quality flake8 diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 1af0f756..97ec7f53 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -20,7 +20,6 @@ def __init__( token_manager: Union[TokenManager, TokenManagerAsync], logger: Logger = None, logger_name: str = None, - disable_https=False, connection_timeout=10, application_key: str = None, application_secret: str = None, @@ -51,7 +50,6 @@ def __init__( self._templates_region = "eu" self._templates_domain = ".template.api.sinch.com" self.token_manager = token_manager - self.disable_https = disable_https self.transport: HTTPTransport = transport self._set_conversation_origin() diff --git a/sinch/core/endpoint.py b/sinch/core/endpoint.py index d11608b2..30b4b6f7 100644 --- a/sinch/core/endpoint.py +++ b/sinch/core/endpoint.py @@ -4,12 +4,21 @@ class HTTPEndpoint(ABC): ENDPOINT_URL = None - HTTP_METHOD = None - HTTP_AUTHENTICATION = None - def __init__(self, project_id, request_data): + @property + @abstractmethod + def HTTP_METHOD(self) -> str: + pass + + @property + @abstractmethod + def HTTP_AUTHENTICATION(self) -> str: pass + def __init__(self, project_id, request_data): + self.project_id = project_id + self.request_data = request_data + def get_url_without_origin(self, sinch): return '/' + '/'.join(self.build_url(sinch).split('/')[1:]) @@ -17,6 +26,12 @@ def build_url(self, sinch): return def build_query_params(self): + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ pass def request_body(self): diff --git a/sinch/core/models/http_request.py b/sinch/core/models/http_request.py index d824e42a..9daa0c33 100644 --- a/sinch/core/models/http_request.py +++ b/sinch/core/models/http_request.py @@ -4,7 +4,6 @@ @dataclass class HttpRequest: headers: dict - protocol: str url: str http_method: str request_body: dict diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index de172311..18afd47f 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -81,7 +81,6 @@ def authenticate(self, endpoint, request_data): return request_data def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: - protocol = "http://" if self.sinch.configuration.disable_https else "https://" url_query_params = endpoint.build_query_params() return HttpRequest( @@ -89,8 +88,7 @@ def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest: "User-Agent": f"sinch-sdk/{sdk_version} (Python/{python_version()};" f" {self.__class__.__name__};)" }, - protocol=protocol, - url=protocol + endpoint.build_url(self.sinch), + url=endpoint.build_url(self.sinch), http_method=endpoint.HTTP_METHOD, request_body=endpoint.request_body(), query_params=url_query_params, diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index 3f5a25b5..b5147237 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -1,20 +1,20 @@ from typing import Optional, TypedDict, overload, Literal, Union, Annotated from typing_extensions import NotRequired from pydantic import StrictInt, StrictStr, Field -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number import RentAnyNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.rent_any_number_endpoint import RentAnyNumberEndpoint from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest -from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse +from sinch.domains.numbers.models.numbers import Number from sinch.domains.numbers.models.numbers import NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues @@ -86,7 +86,7 @@ def list( capabilities: Optional[CapabilityTypeValues] = None, page_size: Optional[StrictInt] = None, **kwargs - ) -> ListAvailableNumbersResponse: + ) -> list[Number]: """ Search for available virtual numbers for you to activate using a variety of parameters to filter results. @@ -101,7 +101,7 @@ def list( **kwargs: Additional filters for the request. Returns: - ListAvailableNumbersResponse: A response object with available numbers and their details. + list[Number]: A response array with available numbers and their details. For detailed documentation, visit https://developers.sinch.com """ @@ -121,8 +121,8 @@ def list( def activate( self, phone_number: StrictStr, - sms_configuration: None, - voice_configuration: None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None ) -> ActivateNumberResponse: pass diff --git a/sinch/domains/numbers/endpoints/available/activate_number.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py similarity index 73% rename from sinch/domains/numbers/endpoints/available/activate_number.py rename to sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index 33c92617..f918c919 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -1,6 +1,7 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse @@ -17,5 +18,9 @@ def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: - super(ActivateNumberEndpoint, self).handle_response(response) + try: + super(ActivateNumberEndpoint, self).handle_response(response) + except NumbersException as ex: + raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, + is_from_server=ex.is_from_server) return self.process_response_model(response.body, ActivateNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers.py b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py similarity index 75% rename from sinch/domains/numbers/endpoints/available/list_available_numbers.py rename to sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py index 8e87417c..b1e856b1 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py @@ -3,6 +3,7 @@ from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.numbers import Number class AvailableNumbersEndpoint(NumbersEndpoint): @@ -15,14 +16,9 @@ class AvailableNumbersEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) + self.request_data = request_data def build_query_params(self) -> dict: - """ - Constructs the query parameters for the endpoint. - - Returns: - dict: The query parameters to be sent with the API request. - """ # Serialize fields query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) return query_params @@ -30,7 +26,7 @@ def build_query_params(self) -> dict: def request_body(self): pass - def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: + def handle_response(self, response: HTTPResponse) -> list[Number]: """ Processes the API response and maps it to a response model. @@ -38,7 +34,8 @@ def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersRespons response (HTTPResponse): The raw HTTP response object received from the API. Returns: - ListAvailableNumbersResponse: The response model containing the parsed response data. + list[Number]: The response model containing the parsed response data. """ super(AvailableNumbersEndpoint, self).handle_response(response) - return self.process_response_model(response.body, ListAvailableNumbersResponse) + response = self.process_response_model(response.body, ListAvailableNumbersResponse) + return response.available_numbers diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number.py b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py similarity index 88% rename from sinch/domains/numbers/endpoints/available/rent_any_number.py rename to sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py index 31478435..f618e1db 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py @@ -15,6 +15,7 @@ class RentAnyNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) + self.request_data = request_data def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: """ @@ -26,5 +27,7 @@ def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: Returns: RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. """ - super(RentAnyNumberEndpoint, self).handle_response(response) + error = super(RentAnyNumberEndpoint, self).handle_response(response) + if error: + return error return self.process_response_model(response.body, RentAnyNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number.py b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py similarity index 81% rename from sinch/domains/numbers/endpoints/available/search_for_number.py rename to sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py index 9e48c37e..a6eed13c 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py @@ -1,6 +1,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest @@ -30,5 +31,8 @@ def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResp CheckNumberAvailabilityResponse: The response model containing the parsed response data of the requested phone number. """ - super(SearchForNumberEndpoint, self).handle_response(response) + try: + super(SearchForNumberEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 5ba2bf8f..6012959b 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,33 +1,19 @@ import json +from abc import ABC from pydantic import BaseModel +from typing import TypeVar, Type from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.models.numbers import NotFoundError -class NumbersEndpoint(HTTPEndpoint): - """ - A base class for all endpoints, providing reusable logic for URL building - and response parsing. - """ - ENDPOINT_URL: str = "" - HTTP_METHOD: str = "" - HTTP_AUTHENTICATION: str = "" +class NumbersEndpoint(HTTPEndpoint, ABC): def __init__(self, project_id: str, request_data: object): - self.project_id = project_id - self.request_data = request_data + super().__init__(project_id, request_data) def build_url(self, sinch) -> str: - """ - Constructs the URL for the endpoint. - - Args: - sinch: The Sinch client instance. - - Returns: - str: Fully constructed endpoint URL. - """ if not self.ENDPOINT_URL: raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") @@ -48,7 +34,9 @@ def request_body(self): request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) return json.dumps(request_data) - def process_response_model(self, response_body: dict, response_model: type[BaseModel]) -> BaseModel: + BM = TypeVar("BM", bound=BaseModel) + + def process_response_model(self, response_body: dict, response_model: Type[BM]) -> BM: """ Processes the response body and maps it to a response model. @@ -65,6 +53,10 @@ def process_response_model(self, response_body: dict, response_model: type[BaseM raise ValueError(f"Invalid response structure: {e}") from e def handle_response(self, response: HTTPResponse): + if response.status_code == 404: + error = NotFoundError(**response.body['error']) + raise NumbersException(message=error, response=response, is_from_server=True) + if response.status_code >= 400: raise NumbersException( message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", diff --git a/sinch/domains/numbers/exceptions.py b/sinch/domains/numbers/exceptions.py index bb94f383..7a208139 100644 --- a/sinch/domains/numbers/exceptions.py +++ b/sinch/domains/numbers/exceptions.py @@ -3,3 +3,7 @@ class NumbersException(SinchException): pass + + +class NumberNotFoundException(NumbersException): + pass diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/base_model_numbers.py index 69aa822d..f8882973 100644 --- a/sinch/domains/numbers/models/base_model_numbers.py +++ b/sinch/domains/numbers/models/base_model_numbers.py @@ -38,10 +38,19 @@ def _convert_dict_keys(cls, obj): extra="allow" ) + def _convert_dict_to_camel_case(self, data): + if isinstance(data, dict): + return {self._to_camel_case(k): self._convert_dict_to_camel_case(v) for k, v in data.items()} + elif isinstance(data, list): + return [self._convert_dict_to_camel_case(i) for i in data] + return data + def model_dump(self, **kwargs) -> dict: """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" # Get the standard model dump. data = super().model_dump(**kwargs) + if not kwargs or kwargs['by_alias']: + data = self._convert_dict_to_camel_case(data) # Get extra fields extra_data = self.__pydantic_extra__ or {} diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index d37b35c9..8a6967ff 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -1,6 +1,6 @@ from datetime import datetime from typing import Optional, Literal, Union, Annotated -from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist +from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist, ConfigDict from decimal import Decimal from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse @@ -64,7 +64,7 @@ class ScheduledProvisioningSmsConfiguration(BaseModelConfigResponse): campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") status: Optional[StatusScheduledProvisioning] = None last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - error_codes: Optional[conlist(StrictStr, min_length=1)] = Field(default=None, alias="errorCodes") + error_codes: Optional[conlist(StrictStr, min_length=0)] = Field(default=None, alias="errorCodes") class SmsConfigurationResponse(BaseModelConfigResponse): @@ -124,3 +124,20 @@ class Number(BaseModelConfigResponse): payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") supporting_documentation_required: Optional[StrictBool] = ( Field(default=None, alias="supportingDocumentationRequired")) + + +class ErrorDetails(BaseModelConfigResponse): + type: Optional[StrictStr] = Field(default=None, alias="type") + resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") + resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") + owner: Optional[StrictStr] = Field(default=None, alias="owner") + description: Optional[StrictStr] = Field(default=None, alias="description") + + +class NotFoundError(BaseModelConfigResponse): + code: Optional[StrictInt] = Field(default=None, alias="code") + message: Optional[StrictStr] = Field(default=None, alias="message") + status: Optional[StrictStr] = Field(default=None, alias="status") + details: Optional[list[ErrorDetails]] = Field(default=None, alias="details") + + model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigResponse._to_snake_case) diff --git a/tests/conftest.py b/tests/conftest.py index 2f3a4676..175ce5ca 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,8 +44,7 @@ def configure_origin( auth_origin, sms_origin, verification_origin, - voice_origin, - disable_ssl + voice_origin ): if auth_origin: sinch_client.configuration.auth_origin = auth_origin @@ -70,9 +69,6 @@ def configure_origin( sinch_client.configuration.voice_origin = voice_origin sinch_client.configuration.voice_applications_origin = voice_origin - if disable_ssl: - sinch_client.configuration.disable_https = True - return sinch_client @@ -255,7 +251,6 @@ def sinch_client_sync( sms_origin, verification_origin, voice_origin, - disable_ssl, project_id ): return configure_origin( @@ -272,8 +267,7 @@ def sinch_client_sync( auth_origin, sms_origin, verification_origin, - voice_origin, - disable_ssl + voice_origin ) @@ -290,7 +284,6 @@ def sinch_client_async( sms_origin, verification_origin, voice_origin, - disable_ssl, project_id ): return configure_origin( @@ -307,6 +300,5 @@ def sinch_client_async( auth_origin, sms_origin, verification_origin, - voice_origin, - disable_ssl + voice_origin ) \ No newline at end of file diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py new file mode 100644 index 00000000..a59d4718 --- /dev/null +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -0,0 +1,206 @@ +from datetime import timezone, datetime +from behave import given, when, then +from decimal import Decimal +from sinch import SinchClient +from sinch.domains.numbers.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse +from sinch.domains.numbers.models.numbers import NotFoundError + + +@given('the Numbers service is available') +def step_service_is_available(context): + sinch = SinchClient( + project_id='tinyfrog-jump-high-over-lilypadbasin', + key_id='keyId', + key_secret='keySecret', + ) + sinch.configuration.auth_origin = 'http://localhost:3011' + sinch.configuration.numbers_origin = 'http://localhost:3013' + context.sinch = sinch + +@when('I send a request to search for available phone numbers') +def step_search_available_numbers(context): + response = context.sinch.numbers.available.list( + region_code='US', + number_type='LOCAL' + ) + context.response = response + +@then('the response contains "{count}" available phone numbers') +def step_check_available_numbers_count(context, count): + data = context.response + assert len(data) == int(count), f'Expected {count}, got {len(data)}' + +@then('a phone number contains all the expected properties') +def step_check_number_properties(context): + number = context.response[0] + assert number.phone_number == '+12013504948' + assert number.region_code == 'US' + assert number.type == 'LOCAL' + assert number.capability == ['SMS', 'VOICE'] + assert number.setup_price.currency_code == 'EUR' + assert number.setup_price.amount == Decimal('0.80') + assert number.monthly_price.currency_code == 'EUR' + assert number.monthly_price.amount == Decimal('0.80') + assert number.payment_interval_months == 1 + assert number.supporting_documentation_required == True + +@when('I send a request to check the availability of the phone number "{phone_number}"') +def step_check_number_availability(context, phone_number): + try: + response = context.sinch.numbers.available.check_availability(phone_number) + context.response = response + except NumberNotFoundException as e: + context.error = e + + +@then('the response displays the phone number "{phone_number}" details') +def step_validate_number_details(context, phone_number): + data = context.response + assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + +@then('the response contains an error about the number "{phone_number}" not being available') +def step_check_unavailable_number(context, phone_number): + data: NotFoundError = context.error.args[0] + assert data.code == 404 + assert data.status == 'NOT_FOUND' + assert data.details[0].resource_name == phone_number + +@when('I send a request to rent a number with some criteria') +def step_rent_any_number(context): + sinch_client: SinchClient = context.sinch + response = sinch_client.numbers.available.rent_any( + region_code = 'US', + type_ = 'LOCAL', + capabilities = ['SMS', 'VOICE'], + sms_configuration = { + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration = { + 'type': 'RTC', + 'app_id': 'sunshine-rain-drop-very-beautifulday' + }, + number_pattern = { + 'pattern': '7654321', + 'search_pattern': 'END' + }, + ) + context.response = response + +@then('the response contains a rented phone number') +def step_validate_rented_number(context): + data: RentAnyNumberResponse = context.response + assert data.phone_number == '+12017654321' + assert data.project_id == '123c0ffee-dada-beef-cafe-baadc0de5678' + assert data.display_name == '' + assert data.region_code == 'US' + assert data.type == 'LOCAL' + assert data.capability == ['SMS', 'VOICE'] + assert data.money.currency_code == 'EUR' + assert data.money.amount == Decimal('0.80') + assert data.payment_interval_months == 1 + assert data.next_charge_date == datetime.fromisoformat('2024-06-06T14:42:42.022227+00:00').astimezone(tz=timezone.utc) + assert data.expire_at == None + assert data.callback_url == '' + assert data.sms_configuration.service_plan_id == '' + assert data.sms_configuration.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SpaceMonkeySquadron' + assert data.sms_configuration.scheduled_provisioning.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'WAITING' + assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.596223+00:00').astimezone(tz=timezone.utc) + assert data.sms_configuration.scheduled_provisioning.error_codes == [] + assert data.voice_configuration.type == 'RTC' + assert data.voice_configuration.app_id == '' + assert data.voice_configuration.trunk_id == '' + assert data.voice_configuration.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.type == 'RTC' + assert data.voice_configuration.scheduled_voice_provisioning.app_id == 'sunshine-rain-drop-very-beautifulday' + assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING' + assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.604092+00:00').astimezone(tz=timezone.utc) + +@when('I send a request to rent the phone number "{phone_number}"') +def step_rent_specific_number(context, phone_number): + sinch_client: SinchClient = context.sinch + response = sinch_client.numbers.available.activate( + phone_number = phone_number, + sms_configuration= { + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration= { + 'app_id': 'sunshine-rain-drop-very-beautifulday' + } + ) + context.response = response + +@then('the response contains this rented phone number "{phone_number}"') +def step_validate_rented_specific_number(context, phone_number): + data: ActivateNumberResponse = context.response + assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + +@when('I send a request to rent the unavailable phone number "{phone_number}"') +def step_rent_unavailable_number(context, phone_number): + sinch_client: SinchClient = context.sinch + try: + response = sinch_client.numbers.available.activate( + phone_number=phone_number, + sms_configuration={ + 'service_plan_id': 'SpaceMonkeySquadron', + }, + voice_configuration={ + 'app_id': 'sunshine-rain-drop-very-beautifulday' + } + ) + context.response = response + except NumberNotFoundException as e: + context.error = e + +@when("I send a request to list the phone numbers") +def step_when_list_phone_numbers(context): + pass # Placeholder + +@when("I send a request to list all the phone numbers") +def step_when_list_all_phone_numbers(context): + pass # Placeholder + +@then('the response contains "{count}" phone numbers') +def step_then_response_contains_x_phone_numbers(context, count): + pass # Placeholder + +@then('the phone numbers list contains "{count}" phone numbers') +def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): + pass # Placeholder + +@when('I send a request to update the phone number "{phone_number}"') +def step_when_update_phone_number(context, phone_number): + pass # Placeholder + +@then('the response contains a phone number with updated parameters') +def step_then_response_contains_updated_number(context): + pass # Placeholder + +@when('I send a request to retrieve the phone number "{phone_number}"') +def step_when_retrieve_phone_number(context, phone_number): + pass # Placeholder + +@then('the response contains details about the phone number "{phone_number}"') +def step_then_response_contains_phone_details(context, phone_number): + pass # Placeholder + +@then('the response contains details about the phone number "{phone_number}" with an SMS provisioning error') +def step_then_response_contains_sms_provisioning_error(context, phone_number): + pass # Placeholder + +@then('the response contains an error about the number "{phone_number}" not being a rented number') +def step_then_response_contains_error_not_rented(context, phone_number): + pass # Placeholder + +@when('I send a request to release the phone number "{phone_number}"') +def step_when_release_phone_number(context, phone_number): + pass # Placeholder + +@then('the response contains details about the phone number "{phone_number}" to be released') +def step_then_response_contains_released_number(context, phone_number): + pass # Placeholder \ No newline at end of file diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py index 71a57c95..9cc1e554 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -1,6 +1,6 @@ import pytest import json -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py index 7e68f74e..3703dc5c 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse from sinch.core.models.http_response import HTTPResponse @@ -106,7 +106,7 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): Check if response is handled and mapped to the appropriate fields correctly. """ parsed_response = endpoint.handle_response(mock_response) - assert isinstance(parsed_response, ListAvailableNumbersResponse) - assert len(parsed_response.available_numbers) == 2 - assert parsed_response.available_numbers[0].phone_number == "+1234567890" - assert parsed_response.available_numbers[1].phone_number == "+2345678901" \ No newline at end of file + assert isinstance(parsed_response, list) + assert len(parsed_response) == 2 + assert parsed_response[0].phone_number == "+1234567890" + assert parsed_response[1].phone_number == "+2345678901" \ No newline at end of file diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py index cc92c2d0..6343662b 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/models/test_numbers.py b/tests/unit/domains/numbers/models/test_numbers.py index bbbe5718..e006652c 100644 --- a/tests/unit/domains/numbers/models/test_numbers.py +++ b/tests/unit/domains/numbers/models/test_numbers.py @@ -2,7 +2,7 @@ from sinch.domains.numbers.models.numbers import ( ScheduledProvisioningSmsConfiguration, SmsConfigurationResponse, - VoiceConfigurationResponse, + VoiceConfigurationResponse, NotFoundError, ) def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): @@ -108,3 +108,30 @@ def test_voice_configuration_fax_valid_expects_parsed_data(): assert config.scheduled_voice_provisioning.type == "FAX" assert config.scheduled_voice_provisioning.status == "ACTIVE" assert config.scheduled_voice_provisioning.service_id == "test_service" + +def test_not_found_error_deserialize_with_snake_case(): + data = { + 'code': 404, + 'message': '', + 'status': 'NOT_FOUND', + 'details': [ + { + 'type': 'ResourceInfo', + 'resourceType': 'AvailableNumber', + 'resourceName': '+1234567890', + 'owner': '', + 'description': '' + } + ] + } + + not_found_error = NotFoundError.model_validate(data) + + assert not_found_error.code == 404 + assert not_found_error.message == '' + assert not_found_error.status == 'NOT_FOUND' + assert not_found_error.details[0].type == 'ResourceInfo' + assert not_found_error.details[0].resource_type == 'AvailableNumber' + assert not_found_error.details[0].resource_name == '+1234567890' + assert not_found_error.details[0].owner == '' + assert not_found_error.details[0].description == '' diff --git a/tests/unit/domains/numbers/test_available_numbers.py b/tests/unit/domains/numbers/test_available_numbers.py index 869403d1..f3829fc4 100644 --- a/tests/unit/domains/numbers/test_available_numbers.py +++ b/tests/unit/domains/numbers/test_available_numbers.py @@ -1,9 +1,9 @@ import pytest from unittest.mock import MagicMock from sinch.domains.numbers.available_numbers import AvailableNumbers -from sinch.domains.numbers.endpoints.available.list_available_numbers import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.search_for_number import SearchForNumberEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest From 210f58e046c8a4e265dd16cca97873fc60090254 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 13 Feb 2025 09:50:48 +0100 Subject: [PATCH 010/106] DEVEXP-733: [Python SDK] Auto pagination of elements - Implemented the automatic and manual pagination of elements. This iterator allows users to navigate through multiple pages while abstracting away the underlying HTTP requests. Signed-off-by: Jessica Matsuoka --- .../core/adapters/requests_http_transport.py | 1 - .../clients/sinch_client_configuration.py | 14 +- sinch/core/pagination.py | 118 +++++++++++-- sinch/domains/numbers/__init__.py | 131 +-------------- sinch/domains/numbers/active_numbers.py | 156 ++++++++++++++++++ sinch/domains/numbers/available_numbers.py | 98 +++-------- sinch/domains/numbers/base_numbers.py | 65 ++++++++ .../numbers/endpoints/active/__init__.py | 11 ++ .../active/list_active_numbers_endpoint.py | 24 +++ .../active/list_active_numbers_for_project.py | 68 -------- .../numbers/endpoints/available/__init__.py | 11 ++ .../available/activate_number_endpoint.py | 3 + .../list_available_numbers_endpoint.py | 5 - .../available/rent_any_number_endpoint.py | 3 + .../available/search_for_number_endpoint.py | 3 + .../numbers/endpoints/numbers_endpoint.py | 16 +- .../domains/numbers/models/active/__init__.py | 7 + .../active/list_active_numbers_request.py | 23 +++ .../active/list_active_numbers_response.py | 13 ++ .../domains/numbers/models/active/requests.py | 11 -- .../numbers/models/active/responses.py | 9 - .../numbers/models/available/__init__.py | 17 ++ sinch/domains/numbers/models/numbers.py | 16 ++ tests/conftest.py | 20 ++- .../numbers/features/steps/numbers.steps.py | 38 ++++- .../test_list_active_numbers_endpoint.py | 74 +++++++++ .../test_activate_number_endpoint.py | 12 -- .../test_list_available_numbers_endpoint.py | 11 -- .../test_rent_any_number_endpoint.py | 11 -- .../test_list_active_numbers_request_model.py | 75 +++++++++ ...test_list_active_numbers_response_model.py | 92 +++++++++++ .../test_base_model_requests.py | 0 .../test_base_model_response.py | 0 tests/unit/test_configuration.py | 4 +- tests/unit/test_pagination.py | 75 +++++++-- 35 files changed, 857 insertions(+), 378 deletions(-) create mode 100644 sinch/domains/numbers/active_numbers.py create mode 100644 sinch/domains/numbers/base_numbers.py create mode 100644 sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py delete mode 100644 sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py create mode 100644 sinch/domains/numbers/models/active/list_active_numbers_request.py create mode 100644 sinch/domains/numbers/models/active/list_active_numbers_response.py create mode 100644 tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py create mode 100644 tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py create mode 100644 tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py rename tests/unit/domains/numbers/models/{available/requests => base}/test_base_model_requests.py (100%) rename tests/unit/domains/numbers/models/{available/response => base}/test_base_model_response.py (100%) diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 7163bd34..04671812 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -17,7 +17,6 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: f"Sync HTTP {request_data.http_method} call with headers:" f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" ) - response = self.http_session.request( method=request_data.http_method, url=request_data.url, diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 97ec7f53..12385fe9 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -34,18 +34,18 @@ def __init__( self.connection_timeout = connection_timeout self.sms_api_token = sms_api_token self.service_plan_id = service_plan_id - self.auth_origin = "auth.sinch.com" - self.numbers_origin = "numbers.api.sinch.com" - self.verification_origin = "verification.api.sinch.com" - self.voice_applications_origin = "callingapi.sinch.com" - self._voice_domain = "{}.api.sinch.com" + self.auth_origin = "https://auth.sinch.com" + self.numbers_origin = "https://numbers.api.sinch.com" + self.verification_origin = "https://verification.api.sinch.com" + self.voice_applications_origin = "https://callingapi.sinch.com" + self._voice_domain = "https://{}.api.sinch.com" self._voice_region = None self._conversation_region = "eu" self._conversation_domain = ".conversation.api.sinch.com" self._sms_region = "us" self._sms_region_with_service_plan_id = "us" - self._sms_domain = "zt.{}.sms.api.sinch.com" - self._sms_domain_with_service_plan_id = "{}.sms.api.sinch.com" + self._sms_domain = "https://zt.{}.sms.api.sinch.com" + self._sms_domain_with_service_plan_id = "https://{}.sms.api.sinch.com" self._sms_authentication = HTTPAuthentication.OAUTH.value self._templates_region = "eu" self._templates_domain = ".template.api.sinch.com" diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 1b77cb51..28638a43 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,14 +1,22 @@ from abc import ABC, abstractmethod +from collections import namedtuple class PageIterator: - def __init__(self, paginator): + def __init__(self, paginator, yield_first_page=False): self.paginator = paginator + self.yield_first_page = yield_first_page + # If yielding the first page, set started to False + self.started = not yield_first_page def __iter__(self): return self def __next__(self): + if not self.started: + self.started = True + return self.paginator + if self.paginator.has_next_page: return self.paginator.next_page() else: @@ -113,32 +121,118 @@ async def _initialize(cls, sinch, endpoint): return cls(sinch, endpoint, result) -class TokenBasedPaginator(Paginator): - __doc__ = Paginator.__doc__ +class TokenBasedPaginatorBase(Paginator): + """Base paginator for token-based pagination.""" + + def __init__(self, sinch, endpoint, yield_first_page=False, result=None): + self._sinch = sinch + self.endpoint = endpoint + # Determines if the first page should be included + self.yield_first_page = yield_first_page + self.result = result or self._sinch.configuration.transport.request(self.endpoint) + self.has_next_page = bool(self.result.next_page_token) + + def __repr__(self): + pass def _calculate_next_page(self): - if self.result.next_page_token: - self.has_next_page = True - else: - self.has_next_page = False + self.has_next_page = bool(self.result.next_page_token) def next_page(self): + """Fetches the next page and updates pagination state.""" self.endpoint.request_data.page_token = self.result.next_page_token self.result = self._sinch.configuration.transport.request(self.endpoint) self._calculate_next_page() return self def auto_paging_iter(self): - return PageIterator(self) + """Returns an iterator for automatic pagination.""" + return PageIterator(self, yield_first_page=self.yield_first_page) @classmethod def _initialize(cls, sinch, endpoint): + """Creates an instance of the paginator skipping first page.""" result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + return cls(sinch, endpoint, yield_first_page=False, result=result) -class AsyncTokenBasedPaginator(TokenBasedPaginator): - __doc__ = TokenBasedPaginator.__doc__ +class TokenBasedPaginator(TokenBasedPaginatorBase): + """Paginator that skips the first page.""" + pass + + +class TokenBasedPaginatorNumbers(TokenBasedPaginatorBase): + """ + Paginator for handling token-based pagination specifically for phone numbers. + + This paginator is designed to iterate through phone numbers automatically or manually, fetching new pages as needed. + It extends the TokenBasedPaginatorBase class and provides additional methods for number-specific pagination. + """ + + def __init__(self, sinch, endpoint): + super().__init__(sinch, endpoint, yield_first_page=True) + + def numbers_iterator(self): + """Iterates through numbers individually, fetching new pages as needed.""" + while True: + if self.result and self.result.active_numbers: + yield from self.result.active_numbers + + if not self.has_next_page: + break + + self.next_page() + + def list(self): + """Returns the first page's numbers along with pagination metadata.""" + + PagedListResponse = namedtuple( + "PagedResponse", ["result", "has_next_page", "next_page_info", "next_page"] + ) + + next_page_result = self._get_next_page_result() + + return PagedListResponse( + result=self.result.active_numbers, + has_next_page=self.has_next_page, + next_page_info=self._build_next_pagination_info(next_page_result), + next_page=self._next_page_wrapper() + ) + + def _get_next_page_result(self): + """Fetches the next page result.""" + if not self.has_next_page: + return None + + current_state = self.result + self.next_page() + next_page_result = self.result + self.result = current_state + + return next_page_result + + def _build_next_pagination_info(self, next_page_result): + """Constructs and returns structured pagination metadata.""" + return { + "result": self.result.active_numbers, + "result.next": ( + self.result.active_numbers + next_page_result.active_numbers + if next_page_result else self.result.active_numbers + ), + "has_next_page": self.has_next_page, + "has_next_page.next": bool(next_page_result and next_page_result.next_page_token), + } + + def _next_page_wrapper(self): + """Fetches and returns the next page as a formatted PagedListResponse object.""" + def wrapper(): + self.next_page() + return self.list() + return wrapper + + +class AsyncTokenBasedPaginator(TokenBasedPaginatorBase): + """Asynchronous token-based paginator.""" async def next_page(self): self.endpoint.request_data.page_token = self.result.next_page_token @@ -152,4 +246,4 @@ def auto_paging_iter(self): @classmethod async def _initialize(cls, sinch, endpoint): result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + return cls(sinch, endpoint, result=result) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index db72959f..5afb5c76 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,23 +1,11 @@ -from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator from sinch.domains.numbers.available_numbers import AvailableNumbers +from sinch.domains.numbers.active_numbers import ActiveNumbers, ActiveNumbersWithAsyncPagination from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.list_active_numbers_for_project import ListActiveNumbersEndpoint -from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.release_number_from_project import ReleaseNumberFromProjectEndpoint from sinch.domains.numbers.endpoints.regions.list_available_regions import ListAvailableRegionsEndpoint from sinch.domains.numbers.models.regions.requests import ListAvailableRegionsForProjectRequest -from sinch.domains.numbers.models.active.requests import ( - ListActiveNumbersRequest, GetNumberConfigurationRequest, - UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest -) from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.active.responses import ( - ListActiveNumbersResponse, UpdateNumberConfigurationResponse, - GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse -) from sinch.domains.numbers.models.callbacks.responses import ( GetNumbersCallbackConfigurationResponse, UpdateNumbersCallbackConfigurationResponse @@ -27,123 +15,6 @@ ) -class ActiveNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None, - page_token: str = None - ) -> ListActiveNumbersResponse: - """ - Search for all active virtual numbers associated with a certain project. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListActiveNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListActiveNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_pattern=number_pattern, - number_search_pattern=number_search_pattern, - page_token=page_token - ) - ) - ) - - def update( - self, - phone_number: str = None, - display_name: str = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - app_id: str = None - ) -> UpdateNumberConfigurationResponse: - """ - Make updates to the configuration of your virtual number. - Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - UpdateNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateNumberConfigurationRequest( - phone_number=phone_number, - display_name=display_name, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - app_id=app_id - ) - ) - ) - - def get(self, phone_number: str) -> GetNumberConfigurationResponse: - """ - List of configuration settings for your virtual number. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - GetNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetNumberConfigurationRequest( - phone_number=phone_number - ) - ) - ) - - def release(self, phone_number: str) -> ReleaseNumberFromProjectResponse: - """ - Release numbers you no longer need from your project. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ReleaseNumberFromProjectEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ReleaseNumberFromProjectRequest( - phone_number=phone_number - ) - ) - ) - - -class ActiveNumbersWithAsyncPagination(ActiveNumbers): - async def list( - self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None, - page_token: str = None - ) -> ListActiveNumbersResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListActiveNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListActiveNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_pattern=number_pattern, - number_search_pattern=number_search_pattern, - page_token=page_token - ) - ) - ) - - class AvailableRegions: def __init__(self, sinch): self._sinch = sinch diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py new file mode 100644 index 00000000..77e622a5 --- /dev/null +++ b/sinch/domains/numbers/active_numbers.py @@ -0,0 +1,156 @@ +from typing import Optional +from pydantic import StrictStr, StrictInt +from sinch.core.pagination import TokenBasedPaginatorNumbers, AsyncTokenBasedPaginator +from sinch.domains.numbers.base_numbers import BaseNumbers +from sinch.domains.numbers.endpoints.active import ( + GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint +) +from sinch.domains.numbers.models.active import ( + ListActiveNumbersRequest, ListActiveNumbersResponse +) +from sinch.domains.numbers.models.active.requests import ( + GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest +) +from sinch.domains.numbers.models.active.responses import ( + UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse +) +from sinch.domains.numbers.models.numbers import ( + NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues, OrderByValues +) + + +class ActiveNumbers(BaseNumbers): + + def list( + self, + region_code: StrictStr, + number_type: NumberTypeValues, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capabilities: Optional[CapabilityTypeValues] = None, + page_size: Optional[StrictInt] = None, + page_token: Optional[StrictStr] = None, + order_by: Optional[OrderByValues] = None, + **kwargs + ) -> TokenBasedPaginatorNumbers: + """ + Search for all active virtual numbers associated with a certain project. + + Args: + region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. + number_type (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. + number_search_pattern (Optional[NumberSearchPatternType]): + Pattern to apply (e.g., "START", "CONTAINS", "END"). + capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + page_size (StrictInt): Maximum number of items to return. + page_token (Optional[StrictStr]): Token for the next page of results. + order_by (Optional[OrderByValues]): Field to order the results by. (e.g., "phoneNumber", "displayName") + **kwargs: Additional filters for the request. + + Returns: + TokenBasedPaginatorNumbers: A paginator for iterating through the results. + + For detailed documentation, visit https://developers.sinch.com + """ + + return TokenBasedPaginatorNumbers( + sinch=self._sinch, + endpoint=ListActiveNumbersEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListActiveNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + page_token=page_token, + **kwargs + ) + ) + ) + + def update( + self, + phone_number: str = None, + display_name: str = None, + sms_configuration: dict = None, + voice_configuration: dict = None, + app_id: str = None + ) -> UpdateNumberConfigurationResponse: + """ + Make updates to the configuration of your virtual number. + Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + return self._sinch.configuration.transport.request( + UpdateNumberConfigurationEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=UpdateNumberConfigurationRequest( + phone_number=phone_number, + display_name=display_name, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + app_id=app_id + ) + ) + ) + + def get(self, phone_number: str) -> GetNumberConfigurationResponse: + """ + List of configuration settings for your virtual number. + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + return self._sinch.configuration.transport.request( + GetNumberConfigurationEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=GetNumberConfigurationRequest( + phone_number=phone_number + ) + ) + ) + + def release(self, phone_number: str) -> ReleaseNumberFromProjectResponse: + """ + Release numbers you no longer need from your project. + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + return self._sinch.configuration.transport.request( + ReleaseNumberFromProjectEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ReleaseNumberFromProjectRequest( + phone_number=phone_number + ) + ) + ) + + +class ActiveNumbersWithAsyncPagination(ActiveNumbers): + async def list( + self, + region_code: str, + number_type: str, + number_pattern: str = None, + number_search_pattern: str = None, + capabilities: list = None, + page_size: int = None, + page_token: str = None, + **kwargs + ) -> ListActiveNumbersResponse: + return await AsyncTokenBasedPaginator._initialize( + sinch=self._sinch, + endpoint=ListActiveNumbersEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListActiveNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + page_token=page_token + ) + ) + ) diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index b5147237..249d02fe 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -1,81 +1,23 @@ -from typing import Optional, TypedDict, overload, Literal, Union, Annotated -from typing_extensions import NotRequired -from pydantic import StrictInt, StrictStr, Field -from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number_endpoint import RentAnyNumberEndpoint - -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest -from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest - -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse -from sinch.domains.numbers.models.numbers import Number - -from sinch.domains.numbers.models.numbers import NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues - - -class SmsConfigurationDict(TypedDict): - service_plan_id: str - campaign_id: NotRequired[str] - - -class VoiceConfigurationDictRTC(TypedDict): - type: Literal["RTC"] - app_id: NotRequired[str] - - -class VoiceConfigurationDictEST(TypedDict): - type: Literal["EST"] - trunk_id: NotRequired[str] - - -class VoiceConfigurationDictFAX(TypedDict): - type: Literal["FAX"] - service_id: NotRequired[str] - - -class VoiceConfigurationDictCustom(TypedDict): - type: str - - -class NumberPatternDict(TypedDict): - pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternTypeValues] - - -VoiceConfigurationDictType = Annotated[ - Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, - VoiceConfigurationDictEST, VoiceConfigurationDictCustom], - Field(discriminator="type") -] - - -class AvailableNumbers: - def __init__(self, sinch): - self._sinch = sinch - - def _request(self, endpoint_class, request_data): - """ - A helper method to make requests to endpoints. - - Args: - endpoint_class: The endpoint class to call. - request_data: The request data to pass to the endpoint. - - Returns: - The response from the Sinch transport request. - """ - return self._sinch.configuration.transport.request( - endpoint_class( - project_id=self._sinch.configuration.project_id, - request_data=request_data, - ) - ) +from typing import Optional, overload +from pydantic import StrictInt, StrictStr +from sinch.domains.numbers.base_numbers import ( + BaseNumbers, SmsConfigurationDict, VoiceConfigurationDictRTC, VoiceConfigurationDictType, + VoiceConfigurationDictEST, VoiceConfigurationDictFAX, NumberPatternDict) +from sinch.domains.numbers.endpoints.available import ( + ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint +) +from sinch.domains.numbers.models.available import ( + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest +) +from sinch.domains.numbers.models.available import ( + ActivateNumberResponse, CheckNumberAvailabilityResponse, RentAnyNumberResponse +) +from sinch.domains.numbers.models.numbers import ( + CapabilityTypeValues, Number, NumberSearchPatternTypeValues, NumberTypeValues +) + + +class AvailableNumbers(BaseNumbers): def list( self, diff --git a/sinch/domains/numbers/base_numbers.py b/sinch/domains/numbers/base_numbers.py new file mode 100644 index 00000000..14d49d19 --- /dev/null +++ b/sinch/domains/numbers/base_numbers.py @@ -0,0 +1,65 @@ +from typing import TypedDict, Literal, Union, Annotated +from typing_extensions import NotRequired +from pydantic import Field +from sinch.domains.numbers.models.numbers import NumberSearchPatternTypeValues + + +class SmsConfigurationDict(TypedDict): + service_plan_id: str + campaign_id: NotRequired[str] + + +class VoiceConfigurationDictRTC(TypedDict): + type: Literal["RTC"] + app_id: NotRequired[str] + + +class VoiceConfigurationDictEST(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] + + +class VoiceConfigurationDictFAX(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] + + +class VoiceConfigurationDictCustom(TypedDict): + type: str + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternTypeValues] + + +VoiceConfigurationDictType = Annotated[ + Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST, VoiceConfigurationDictCustom], + Field(discriminator="type") +] + + +class BaseNumbers: + """Base class for handling Sinch Number operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data + ) + ) diff --git a/sinch/domains/numbers/endpoints/active/__init__.py b/sinch/domains/numbers/endpoints/active/__init__.py index e69de29b..9be81a11 100644 --- a/sinch/domains/numbers/endpoints/active/__init__.py +++ b/sinch/domains/numbers/endpoints/active/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint +from sinch.domains.numbers.endpoints.active.list_active_numbers_endpoint import ListActiveNumbersEndpoint +from sinch.domains.numbers.endpoints.active.release_number_from_project import ReleaseNumberFromProjectEndpoint +from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint + +__all__ = [ + "GetNumberConfigurationEndpoint", + "ListActiveNumbersEndpoint", + "ReleaseNumberFromProjectEndpoint", + "UpdateNumberConfigurationEndpoint" +] diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py new file mode 100644 index 00000000..fc1ca5e6 --- /dev/null +++ b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py @@ -0,0 +1,24 @@ +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.numbers import ActiveNumber + + +class ListActiveNumbersEndpoint(NumbersEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): + super(ListActiveNumbersEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + pass + + def handle_response(self, response: HTTPResponse) -> list[ActiveNumber]: + super(ListActiveNumbersEndpoint, self).handle_response(response) + return self.process_response_model(response.body, ListActiveNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py b/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py deleted file mode 100644 index 8357ac4a..00000000 --- a/sinch/domains/numbers/endpoints/active/list_active_numbers_for_project.py +++ /dev/null @@ -1,68 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active import ActiveNumber -from sinch.domains.numbers.models.active.requests import ListActiveNumbersRequest -from sinch.domains.numbers.models.active.responses import ListActiveNumbersResponse - - -class ListActiveNumbersEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): - super(ListActiveNumbersEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def build_query_params(self) -> dict: - params = { - "regionCode": self.request_data.region_code, - "type": self.request_data.number_type, - } - - if self.request_data.capabilities: - params["capabilities"] = self.request_data.capabilities - - if self.request_data.number_pattern: - params["numberPattern.pattern"] = self.request_data.number_pattern - - if self.request_data.number_search_pattern: - params["numberPattern.searchPattern"] = self.request_data.capabilities - - if self.request_data.page_size: - params["pageSize"] = self.request_data.page_size - - if self.request_data.page_token: - params["pageToken"] = self.request_data.page_token - - return params - - def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: - super(ListActiveNumbersEndpoint, self).handle_response(response) - return ListActiveNumbersResponse( - [ - ActiveNumber( - phone_number=number["phoneNumber"], - project_id=number["projectId"], - display_name=number["displayName"], - region_code=number["regionCode"], - type=number["type"], - capability=number["capability"], - money=number["money"], - payment_interval_months=number["paymentIntervalMonths"], - next_charge_date=number["nextChargeDate"], - expire_at=number["expireAt"], - sms_configuration=number["smsConfiguration"], - voice_configuration=number["voiceConfiguration"] - ) for number in response.body["activeNumbers"] - ], - next_page_token=response.body["nextPageToken"] - ) diff --git a/sinch/domains/numbers/endpoints/available/__init__.py b/sinch/domains/numbers/endpoints/available/__init__.py index e69de29b..bb1a03fe 100644 --- a/sinch/domains/numbers/endpoints/available/__init__.py +++ b/sinch/domains/numbers/endpoints/available/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint +from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint +from sinch.domains.numbers.endpoints.available.rent_any_number_endpoint import RentAnyNumberEndpoint +from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint + +__all__ = [ + "ActivateNumberEndpoint", + "AvailableNumbersEndpoint", + "RentAnyNumberEndpoint", + "SearchForNumberEndpoint" +] diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index f918c919..ef591a67 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -17,6 +17,9 @@ class ActivateNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) + def build_query_params(self) -> dict: + pass + def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: try: super(ActivateNumberEndpoint, self).handle_response(response) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py index b1e856b1..4ce25de5 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py @@ -18,11 +18,6 @@ def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) self.request_data = request_data - def build_query_params(self) -> dict: - # Serialize fields - query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) - return query_params - def request_body(self): pass diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py index f618e1db..a4ce0be0 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py @@ -17,6 +17,9 @@ def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) self.request_data = request_data + def build_query_params(self) -> dict: + pass + def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: """ Handles the response from the API call. diff --git a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py index a6eed13c..6320ab77 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py @@ -17,6 +17,9 @@ class SearchForNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) + def build_query_params(self) -> dict: + pass + def request_body(self): pass diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 6012959b..03966c2e 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -7,10 +7,12 @@ from sinch.domains.numbers.exceptions import NumbersException from sinch.domains.numbers.models.numbers import NotFoundError +BM = TypeVar("BM", bound=BaseModel) + class NumbersEndpoint(HTTPEndpoint, ABC): - def __init__(self, project_id: str, request_data: object): + def __init__(self, project_id: str, request_data: BM): super().__init__(project_id, request_data) def build_url(self, sinch) -> str: @@ -23,6 +25,16 @@ def build_url(self, sinch) -> str: **vars(self.request_data) ) + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) + return query_params + def request_body(self): """ Returns the request body as a JSON string. @@ -34,8 +46,6 @@ def request_body(self): request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) return json.dumps(request_data) - BM = TypeVar("BM", bound=BaseModel) - def process_response_model(self, response_body: dict, response_model: Type[BM]) -> BM: """ Processes the response body and maps it to a response model. diff --git a/sinch/domains/numbers/models/active/__init__.py b/sinch/domains/numbers/models/active/__init__.py index e2d393d9..436aed45 100644 --- a/sinch/domains/numbers/models/active/__init__.py +++ b/sinch/domains/numbers/models/active/__init__.py @@ -2,6 +2,13 @@ from sinch.core.models.base_model import SinchBaseModel from sinch.domains.numbers.enums import NumberType, NumberCapability +from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse + +__all__ = [ + "ListActiveNumbersRequest", + "ListActiveNumbersResponse" +] @dataclass diff --git a/sinch/domains/numbers/models/active/list_active_numbers_request.py b/sinch/domains/numbers/models/active/list_active_numbers_request.py new file mode 100644 index 00000000..3f0d944c --- /dev/null +++ b/sinch/domains/numbers/models/active/list_active_numbers_request.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, field_validator +from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.numbers import CapabilityType, NumberType, NumberSearchPatternType, OrderByValues + + +class ListActiveNumbersRequest(BaseModelConfigRequest): + region_code: StrictStr = Field(alias="regionCode") + number_type: NumberType = Field(alias="type") + page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") + capabilities: Optional[CapabilityType] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = ( + Field(default=None, alias="numberPattern.searchPattern")) + number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") + page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") + order_by: Optional[OrderByValues] = Field(default=None, alias="orderBy") + + @field_validator("order_by", mode="before") + @classmethod + def convert_order_by(cls, value): + if isinstance(value, str): + return cls._to_camel_case(value) + return value diff --git a/sinch/domains/numbers/models/active/list_active_numbers_response.py b/sinch/domains/numbers/models/active/list_active_numbers_response.py new file mode 100644 index 00000000..37fb41a6 --- /dev/null +++ b/sinch/domains/numbers/models/active/list_active_numbers_response.py @@ -0,0 +1,13 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt +from sinch.domains.numbers.models.numbers import ActiveNumber + + +class ListActiveNumbersResponse(BaseModel): + active_numbers: Optional[List[ActiveNumber]] = Field(default=None, alias="activeNumbers") + next_page_token: Optional[StrictStr] = Field(default=None, alias="nextPageToken") + total_size: Optional[StrictInt] = Field(default=None, alias="totalSize") + + model_config = ConfigDict( + populate_by_name=True + ) diff --git a/sinch/domains/numbers/models/active/requests.py b/sinch/domains/numbers/models/active/requests.py index 6af5b046..1f0d0647 100644 --- a/sinch/domains/numbers/models/active/requests.py +++ b/sinch/domains/numbers/models/active/requests.py @@ -3,17 +3,6 @@ from sinch.core.models.base_model import SinchRequestBaseModel -@dataclass -class ListActiveNumbersRequest(SinchRequestBaseModel): - region_code: str - number_type: str - page_size: int - capabilities: list - number_search_pattern: str - number_pattern: str - page_token: str - - @dataclass class GetNumberConfigurationRequest(SinchRequestBaseModel): phone_number: str diff --git a/sinch/domains/numbers/models/active/responses.py b/sinch/domains/numbers/models/active/responses.py index e47e6e17..442cd20a 100644 --- a/sinch/domains/numbers/models/active/responses.py +++ b/sinch/domains/numbers/models/active/responses.py @@ -1,16 +1,7 @@ from dataclasses import dataclass -from typing import List, Optional - -from sinch.core.models.base_model import SinchBaseModel from sinch.domains.numbers.models.active import ActiveNumber -@dataclass -class ListActiveNumbersResponse(SinchBaseModel): - active_numbers: List[ActiveNumber] - next_page_token: Optional[str] = None - - @dataclass class UpdateNumberConfigurationResponse(ActiveNumber): pass diff --git a/sinch/domains/numbers/models/available/__init__.py b/sinch/domains/numbers/models/available/__init__.py index e69de29b..4cb2a5d6 100644 --- a/sinch/domains/numbers/models/available/__init__.py +++ b/sinch/domains/numbers/models/available/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse + +__all__ = [ + "ActivateNumberRequest", + "ActivateNumberResponse", + "CheckNumberAvailabilityRequest", + "CheckNumberAvailabilityResponse", + "ListAvailableNumbersRequest", + "RentAnyNumberRequest", + "RentAnyNumberResponse" +] diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index 8a6967ff..a566497e 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -7,6 +7,7 @@ NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] CapabilityTypeValues = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] +OrderByValues = Union[Literal["phoneNumber", "displayName"], StrictStr] CapabilityType = Annotated[ CapabilityTypeValues, @@ -114,6 +115,21 @@ class Money(BaseModelConfigResponse): amount: Decimal +class ActiveNumber(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + + class Number(BaseModelConfigResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") diff --git a/tests/conftest.py b/tests/conftest.py index 175ce5ca..db790817 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -196,6 +196,14 @@ def first_token_based_pagination_response(): def second_token_based_pagination_response(): return TokenBasedPaginationResponse( pig_dogs=["Bartosz", "Piotr"], + next_page_token="ka#556" + ) + + +@pytest.fixture +def third_token_based_pagination_response(): + return TokenBasedPaginationResponse( + pig_dogs=["Madrid", "Spain"], next_page_token="" ) @@ -301,4 +309,14 @@ def sinch_client_async( sms_origin, verification_origin, voice_origin - ) \ No newline at end of file + ) + +@pytest.fixture +def mock_sinch_client(): + class MockConfiguration: + numbers_origin = "https://api.sinch.com" + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() \ No newline at end of file diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index a59d4718..e395eae5 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,6 +2,7 @@ from behave import given, when, then from decimal import Decimal from sinch import SinchClient +from sinch.core.pagination import TokenBasedPaginatorNumbers from sinch.domains.numbers.exceptions import NumberNotFoundException from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse @@ -159,19 +160,44 @@ def step_rent_unavailable_number(context, phone_number): @when("I send a request to list the phone numbers") def step_when_list_phone_numbers(context): - pass # Placeholder + sinch_client: SinchClient = context.sinch + response = sinch_client.numbers.active.list( + region_code='US', + number_type='LOCAL' + ) + context.response = response + +@then('the response contains "{count}" phone numbers') +def step_then_response_contains_x_phone_numbers(context, count): + data : TokenBasedPaginatorNumbers = context.response + assert len(data.result.active_numbers) == int(count), \ + f'Expected {count}, got {len(context.response.data)}' @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): - pass # Placeholder + sinch_client: SinchClient = context.sinch + response = sinch_client.numbers.active.list( + region_code='US', + number_type='LOCAL' + ) + active_numbers_list = [] -@then('the response contains "{count}" phone numbers') -def step_then_response_contains_x_phone_numbers(context, count): - pass # Placeholder + for number in response.numbers_iterator(): + active_numbers_list.append(number) + context.active_numbers_list = active_numbers_list @then('the phone numbers list contains "{count}" phone numbers') def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): - pass # Placeholder + assert len(context.active_numbers_list) == int(count), f'Expected {count}, got {len(context.active_numbers_list)}' + phone_number1 = context.active_numbers_list[0] + assert phone_number1.voice_configuration.type == 'FAX' + assert phone_number1.voice_configuration.service_id == '01W4FFL35P4NC4K35FAXSERVICE' + phone_number2 = context.active_numbers_list[1] + assert phone_number2.voice_configuration.type == 'EST' + assert phone_number2.voice_configuration.trunk_id == '01W4FFL35P4NC4K35SIPTRUNK00' + phone_number3 = context.active_numbers_list[2] + assert phone_number3.voice_configuration.type == 'RTC' + assert phone_number3.voice_configuration.app_id == 'sunshine-rain-drop-very-beautifulday' @when('I send a request to update the phone number "{phone_number}"') def step_when_update_phone_number(context, phone_number): diff --git a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py new file mode 100644 index 00000000..267f4e41 --- /dev/null +++ b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py @@ -0,0 +1,74 @@ +import pytest +from sinch.domains.numbers.endpoints.active.list_active_numbers_endpoint import ListActiveNumbersEndpoint +from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse +from sinch.core.models.http_response import HTTPResponse + +@pytest.fixture +def request_data(): + return ListActiveNumbersRequest( + region_code="AR", + number_type="LOCAL", + page_size=15, + capabilities=["SMS"], + number_pattern="123", + number_search_pattern="STARTS_WITH" + ) + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "activeNumbers": [ + { + "phoneNumber": "+1234567890", + "projectId": "37b62a7b-0177-429a-bb0b-e10f848de0b8", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers"}], + "nextPageToken": "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==", + "totalSize": 10 + }, + headers={"Content-Type": "application/json"} + ) + +@pytest.fixture +def endpoint(request_data): + return ListActiveNumbersEndpoint("test_project_id", request_data) + +def test_build_url(endpoint, mock_sinch_client): + assert endpoint.build_url(mock_sinch_client) == "https://api.sinch.com/v1/projects/test_project_id/activeNumbers" + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "regionCode": "AR", + "type": "LOCAL", + "pageSize": 15, + "capabilities": ["SMS"], + "numberPattern.pattern": "123", + "numberPattern.searchPattern": "STARTS_WITH" + } + assert endpoint.build_query_params() == expected_params + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListActiveNumbersResponse) + assert parsed_response.active_numbers[0].phone_number == "+1234567890" + assert parsed_response.active_numbers[0].project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert parsed_response.active_numbers[0].display_name == "" diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py index 9cc1e554..db925a4c 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -4,18 +4,6 @@ from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse - -@pytest.fixture -def mock_sinch_client(): - class MockConfiguration: - numbers_origin = "https://api.sinch.com" - - class MockSinchClient: - configuration = MockConfiguration() - - return MockSinchClient() - - @pytest.fixture def mock_request_data(): return ActivateNumberRequest( diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py index 3703dc5c..176d0749 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py @@ -1,19 +1,8 @@ import pytest from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse from sinch.core.models.http_response import HTTPResponse -@pytest.fixture -def mock_sinch_client(): - class MockConfiguration: - numbers_origin = "https://api.sinch.com" - - class MockSinchClient: - configuration = MockConfiguration() - - return MockSinchClient() - @pytest.fixture def request_data(): return ListAvailableNumbersRequest( diff --git a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py index 0c7a7472..bf126cce 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py @@ -7,17 +7,6 @@ from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse -@pytest.fixture -def mock_sinch_client(): - class MockConfiguration: - numbers_origin = "https://api.sinch.com" - - class MockSinchClient: - configuration = MockConfiguration() - - return MockSinchClient() - - @pytest.fixture def valid_request_data(): """ diff --git a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py new file mode 100644 index 00000000..eed1cd2c --- /dev/null +++ b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py @@ -0,0 +1,75 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest + +@pytest.mark.parametrize( + "order_by_input, expected_order_by", + [ + ("phone_number", "phoneNumber"), + ("display_name", "displayName"), + ("new_field", "newField"), + ("newField", "newField") + ] +) + +def test_list_active_numbers_orderby_field_request_expects_camel_case_input(order_by_input, expected_order_by): + """ + Test that the model correctly parses order_by field. + """ + data = { + "region_code": "US", + "number_type": "MOBILE", + "order_by": order_by_input + } + + request = ListActiveNumbersRequest(**data) + + assert request.region_code == "US" + assert request.number_type == "MOBILE" + assert request.order_by == expected_order_by + +def test_list_active_numbers_request_expects_parsed_input(): + """ + Test that the model correctly parses input. + """ + data = { + "region_code": "GB", + "number_type": "LOCAL", + "page_size": 5, + "capabilities": ["SMS", "VOICE"], + "number_search_pattern": "START", + "number_pattern": "5678", + "page_token": "abc123", + "order_by": "phoneNumber" + } + + request = ListActiveNumbersRequest(**data) + + assert request.region_code == "GB" + assert request.number_type == "LOCAL" + assert request.page_size == 5 + assert request.capabilities == ["SMS", "VOICE"] + assert request.number_search_pattern == "START" + assert request.number_pattern == "5678" + assert request.page_token == "abc123" + assert request.order_by == "phoneNumber" + +def test_list_available_numbers_request_expects_camel_case_input(): + """ + Test that the model correctly handles camelCase input. + """ + data = { + "regionCode": "US", + "number_type": "MOBILE", + } + request = ListActiveNumbersRequest(**data) + assert request.region_code == "US" + assert request.number_type == "MOBILE" + +def test_list_active_numbers_request_expects_validation_error_for_missing_field(): + """ + Test that missing required fields raise a ValidationError. + """ + data = {} + with pytest.raises(ValidationError): + ListActiveNumbersRequest(**data) \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py new file mode 100644 index 00000000..7f833212 --- /dev/null +++ b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py @@ -0,0 +1,92 @@ +from datetime import datetime, timezone +from decimal import Decimal +import pytest +from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse + +@pytest.fixture +def test_data(): + return { + "activeNumbers": [ + { + "phoneNumber": "+12085088605", + "projectId": "37b62a7b-0177-429a-bb0b-e10f848de0b8", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": [ + "SMS", + "VOICE" + ], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-03-04T15:28:16.449951Z", + "expireAt": None, + "smsConfiguration": { + "servicePlanId": "al_2308", + "scheduledProvisioning": None, + "campaignId": "" + }, + "voiceConfiguration": { + "appId": "", + "scheduledVoiceProvisioning": { + "appId": "123456", + "status": "FAILED", + "lastUpdatedTime": "2025-02-04T15:32:06.693027Z", + "type": "RTC", + "trunkId": "", + "serviceId": "" + }, + "lastUpdatedTime": None, + "type": "RTC", + "trunkId": "", + "serviceId": "" + }, + "callbackUrl": "" + } + ], + "nextPageToken": "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==", + "totalSize": 10 + } + +def assert_voice_configuration(voice_config): + assert voice_config.app_id == "" + assert voice_config.scheduled_voice_provisioning.app_id == "123456" + assert voice_config.scheduled_voice_provisioning.status == "FAILED" + expected_last_updated_time = ( + datetime(2025, 2, 4, 15, 32, 6, 693027, tzinfo=timezone.utc)) + assert voice_config.scheduled_voice_provisioning.last_updated_time == expected_last_updated_time + assert voice_config.scheduled_voice_provisioning.type == "RTC" + assert voice_config.scheduled_voice_provisioning.trunk_id == "" + assert voice_config.scheduled_voice_provisioning.service_id == "" + +def assert_sms_configuration(sms_config): + assert sms_config.service_plan_id == "al_2308" + assert sms_config.scheduled_provisioning is None + assert sms_config.campaign_id == "" + +def test_list_active_numbers_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListActiveNumbersResponse(**test_data) + assert response.active_numbers[0].phone_number == "+12085088605" + assert response.active_numbers[0].project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert response.active_numbers[0].display_name == "" + assert response.active_numbers[0].region_code == "US" + assert response.active_numbers[0].type == "LOCAL" + assert response.active_numbers[0].capability == ["SMS", "VOICE"] + assert response.active_numbers[0].money.currency_code == "EUR" + # Floats have precision issues; using Decimal for exact comparison. + assert response.active_numbers[0].money.amount == Decimal("0.80") + assert response.active_numbers[0].payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 3, 4, 15, 28, 16, 449951, tzinfo=timezone.utc)) + assert response.active_numbers[0].next_charge_date == expected_next_charge_date + assert response.active_numbers[0].expire_at is None + assert_sms_configuration(response.active_numbers[0].sms_configuration) + assert_voice_configuration(response.active_numbers[0].voice_configuration) + assert response.next_page_token == "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==" + assert response.total_size == 10 diff --git a/tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py b/tests/unit/domains/numbers/models/base/test_base_model_requests.py similarity index 100% rename from tests/unit/domains/numbers/models/available/requests/test_base_model_requests.py rename to tests/unit/domains/numbers/models/base/test_base_model_requests.py diff --git a/tests/unit/domains/numbers/models/available/response/test_base_model_response.py b/tests/unit/domains/numbers/models/base/test_base_model_response.py similarity index 100% rename from tests/unit/domains/numbers/models/available/response/test_base_model_response.py rename to tests/unit/domains/numbers/models/base/test_base_model_response.py diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index da5815b7..010660b1 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -20,7 +20,7 @@ def test_configuration_initialization_happy_path(sinch_client_sync): def test_set_sms_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): sinch_client_sync.configuration.sms_region = "pl" - assert "zt.pl.sms.api.sinch.com" == sinch_client_sync.configuration.sms_origin + assert "https://zt.pl.sms.api.sinch.com" == sinch_client_sync.configuration.sms_origin def test_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): @@ -30,7 +30,7 @@ def test_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_cli def test_set_sms_region_with_service_plan_id_property_and_check_that_sms_origin_was_updated(sinch_client_sync): sinch_client_sync.configuration.sms_region_with_service_plan_id = "Herring" - assert sinch_client_sync.configuration.sms_origin_with_service_plan_id.startswith("Herring") + assert sinch_client_sync.configuration.sms_origin_with_service_plan_id == "https://Herring.sms.api.sinch.com" def test_set_conversation_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 1b126c5a..242a1a24 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -4,6 +4,7 @@ IntBasedPaginator, AsyncIntBasedPaginator, TokenBasedPaginator, + TokenBasedPaginatorNumbers, AsyncTokenBasedPaginator ) @@ -72,7 +73,6 @@ def test_page_int_iterator_sync_using_auto_pagination( assert page_counter == 2 - async def test_page_int_iterator_async_using_manual_pagination( first_int_based_pagination_response, second_int_based_pagination_response, @@ -142,7 +142,8 @@ async def test_page_int_iterator_async_using_auto_pagination( def test_page_token_iterator_sync_using_manual_pagination( token_based_pagination_request_data, first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ): endpoint = Mock() endpoint.request_data = token_based_pagination_request_data @@ -150,7 +151,8 @@ def test_page_token_iterator_sync_using_manual_pagination( sinch_client.configuration.transport.request.side_effect = [ first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ] token_based_paginator = TokenBasedPaginator._initialize( sinch=sinch_client, @@ -158,13 +160,13 @@ def test_page_token_iterator_sync_using_manual_pagination( ) assert token_based_paginator - page_counter = 0 + page_counter = 1 while token_based_paginator.has_next_page: token_based_paginator = token_based_paginator.next_page() page_counter += 1 assert isinstance(token_based_paginator, TokenBasedPaginator) - assert page_counter == 1 + assert page_counter == 3 def test_page_token_iterator_sync_using_auto_pagination( @@ -193,11 +195,59 @@ def test_page_token_iterator_sync_using_auto_pagination( assert page_counter == 1 +def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(int_based_pagination_request_data): + """ Test that the pagination iterates correctly through multiple pages. """ + + first_token_based_pagination_response = Mock() + first_token_based_pagination_response.active_numbers = [ + Mock(phone_number="+12345678901"), + Mock(phone_number="+12345678902") + ] + first_token_based_pagination_response.next_page_token = "token_1" + + second_token_based_pagination_response = Mock() + second_token_based_pagination_response.active_numbers = [ + Mock(phone_number="+12345678903"), + Mock(phone_number="+12345678904") + ] + second_token_based_pagination_response.next_page_token = "token_2" + + third_token_based_pagination_response = Mock() + third_token_based_pagination_response.active_numbers = [ + Mock(phone_number="+12345678905") + ] + third_token_based_pagination_response.next_page_token = None + + int_based_pagination_request_data = Mock() + endpoint = Mock() + endpoint.request_data = int_based_pagination_request_data + sinch_client = Mock() + + sinch_client.configuration.transport.request.side_effect = [ + first_token_based_pagination_response, + second_token_based_pagination_response, + third_token_based_pagination_response + ] + + token_based_paginator = TokenBasedPaginatorNumbers( + sinch=sinch_client, + endpoint=endpoint + ) + assert token_based_paginator + + number_counter = 0 + + for _ in token_based_paginator.numbers_iterator(): + number_counter += 1 + + assert number_counter == 5 + async def test_page_token_iterator_async_using_manual_pagination( token_based_pagination_request_data, first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ): endpoint = Mock() endpoint.request_data = token_based_pagination_request_data @@ -205,7 +255,8 @@ async def test_page_token_iterator_async_using_manual_pagination( sinch_client.configuration.transport.request.side_effect = [ first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ] token_based_paginator = await AsyncTokenBasedPaginator._initialize( sinch=sinch_client, @@ -219,13 +270,14 @@ async def test_page_token_iterator_async_using_manual_pagination( page_counter += 1 assert isinstance(token_based_paginator, AsyncTokenBasedPaginator) - assert page_counter == 1 + assert page_counter == 2 async def test_page_token_iterator_async_using_auto_pagination( token_based_pagination_request_data, first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ): endpoint = Mock() endpoint.request_data = token_based_pagination_request_data @@ -233,7 +285,8 @@ async def test_page_token_iterator_async_using_auto_pagination( sinch_client.configuration.transport.request.side_effect = [ first_token_based_pagination_response, - second_token_based_pagination_response + second_token_based_pagination_response, + third_token_based_pagination_response ] token_based_paginator = await AsyncTokenBasedPaginator._initialize( sinch=sinch_client, @@ -246,4 +299,4 @@ async def test_page_token_iterator_async_using_auto_pagination( page_counter += 1 assert isinstance(page, AsyncTokenBasedPaginator) - assert page_counter == 1 + assert page_counter == 2 From 40521aeae7ec478a0c30637affe1d4d4af178ad9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 17 Feb 2025 11:04:29 +0100 Subject: [PATCH 011/106] refactor: refactored models, updated unit test, and added parameter in API call --- sinch/core/pagination.py | 14 +++------- sinch/domains/numbers/active_numbers.py | 10 +++---- sinch/domains/numbers/available_numbers.py | 14 +++++----- .../available/activate_number_endpoint.py | 3 +-- .../active/list_active_numbers_request.py | 12 +++++---- .../available/activate_number_response.py | 23 +++------------- .../list_available_numbers_request.py | 10 ++++--- .../available/rent_any_number_response.py | 6 ++--- sinch/domains/numbers/models/numbers.py | 5 ++-- .../test_list_active_numbers_endpoint.py | 26 ++++++++++++++++--- tests/unit/test_pagination.py | 17 ++++++------ 11 files changed, 70 insertions(+), 70 deletions(-) diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 28638a43..c7443a9e 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -121,7 +121,7 @@ async def _initialize(cls, sinch, endpoint): return cls(sinch, endpoint, result) -class TokenBasedPaginatorBase(Paginator): +class TokenBasedPaginator(Paginator): """Base paginator for token-based pagination.""" def __init__(self, sinch, endpoint, yield_first_page=False, result=None): @@ -132,9 +132,6 @@ def __init__(self, sinch, endpoint, yield_first_page=False, result=None): self.result = result or self._sinch.configuration.transport.request(self.endpoint) self.has_next_page = bool(self.result.next_page_token) - def __repr__(self): - pass - def _calculate_next_page(self): self.has_next_page = bool(self.result.next_page_token) @@ -156,12 +153,7 @@ def _initialize(cls, sinch, endpoint): return cls(sinch, endpoint, yield_first_page=False, result=result) -class TokenBasedPaginator(TokenBasedPaginatorBase): - """Paginator that skips the first page.""" - pass - - -class TokenBasedPaginatorNumbers(TokenBasedPaginatorBase): +class TokenBasedPaginatorNumbers(TokenBasedPaginator): """ Paginator for handling token-based pagination specifically for phone numbers. @@ -231,7 +223,7 @@ def wrapper(): return wrapper -class AsyncTokenBasedPaginator(TokenBasedPaginatorBase): +class AsyncTokenBasedPaginator(TokenBasedPaginator): """Asynchronous token-based paginator.""" async def next_page(self): diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py index 77e622a5..dd77078c 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/active_numbers.py @@ -16,7 +16,7 @@ UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) from sinch.domains.numbers.models.numbers import ( - NumberTypeValues, CapabilityTypeValues, NumberSearchPatternTypeValues, OrderByValues + CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues, OrderByValues ) @@ -28,7 +28,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capability: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, @@ -43,7 +43,7 @@ def list( number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. number_search_pattern (Optional[NumberSearchPatternType]): Pattern to apply (e.g., "START", "CONTAINS", "END"). - capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + capability (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) page_size (StrictInt): Maximum number of items to return. page_token (Optional[StrictStr]): Token for the next page of results. order_by (Optional[OrderByValues]): Field to order the results by. (e.g., "phoneNumber", "displayName") @@ -54,7 +54,6 @@ def list( For detailed documentation, visit https://developers.sinch.com """ - return TokenBasedPaginatorNumbers( sinch=self._sinch, endpoint=ListActiveNumbersEndpoint( @@ -63,10 +62,11 @@ def list( region_code=region_code, number_type=number_type, page_size=page_size, - capabilities=capabilities, + capabilities=capability, number_pattern=number_pattern, number_search_pattern=number_search_pattern, page_token=page_token, + order_by=order_by, **kwargs ) ) diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index 249d02fe..582ab60c 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -13,7 +13,7 @@ ActivateNumberResponse, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.models.numbers import ( - CapabilityTypeValues, Number, NumberSearchPatternTypeValues, NumberTypeValues + CapabilityTypeValuesList, Number, NumberSearchPatternTypeValues, NumberTypeValues ) @@ -25,7 +25,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, **kwargs ) -> list[Number]: @@ -146,7 +146,7 @@ def rent_any( sms_configuration: None, voice_configuration: None, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -159,7 +159,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictRTC, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -172,7 +172,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictFAX, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -185,7 +185,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictEST, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[str] = None, ) -> RentAnyNumberResponse: pass @@ -195,7 +195,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[str] = None, diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index ef591a67..9b06dc60 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -2,8 +2,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse +from sinch.domains.numbers.models.available import ActivateNumberRequest, ActivateNumberResponse class ActivateNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/models/active/list_active_numbers_request.py b/sinch/domains/numbers/models/active/list_active_numbers_request.py index 3f0d944c..b2060c74 100644 --- a/sinch/domains/numbers/models/active/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/active/list_active_numbers_request.py @@ -1,16 +1,18 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import CapabilityType, NumberType, NumberSearchPatternType, OrderByValues +from sinch.domains.numbers.models.numbers import (CapabilityTypeValuesList, NumberTypeValues, + NumberSearchPatternTypeValues, OrderByValues) class ListActiveNumbersRequest(BaseModelConfigRequest): region_code: StrictStr = Field(alias="regionCode") - number_type: NumberType = Field(alias="type") + number_type: NumberTypeValues = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") - capabilities: Optional[CapabilityType] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternType] = ( - Field(default=None, alias="numberPattern.searchPattern")) + capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( + Field(default=None, alias="numberPattern.searchPattern") + ) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") order_by: Optional[OrderByValues] = Field(default=None, alias="orderBy") diff --git a/sinch/domains/numbers/models/available/activate_number_response.py b/sinch/domains/numbers/models/available/activate_number_response.py index 4e48f6e0..e32b8ae7 100644 --- a/sinch/domains/numbers/models/available/activate_number_response.py +++ b/sinch/domains/numbers/models/available/activate_number_response.py @@ -1,22 +1,5 @@ -from datetime import datetime -from typing import Optional -from pydantic import Field, StrictInt, StrictStr -from sinch.domains.numbers.models.numbers import Money, SmsConfigurationResponse, VoiceConfigurationResponse -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse -from sinch.domains.numbers.models.numbers import CapabilityType, NumberType +from sinch.domains.numbers.models.numbers import ActiveNumber -class ActivateNumberResponse(BaseModelConfigResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - project_id: Optional[StrictStr] = Field(default=None, alias="projectId") - display_name: Optional[StrictStr] = Field(default=None, alias="displayName") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) - money: Optional[Money] = Field(default=None) - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") - expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") +class ActivateNumberResponse(ActiveNumber): + pass diff --git a/sinch/domains/numbers/models/available/list_available_numbers_request.py b/sinch/domains/numbers/models/available/list_available_numbers_request.py index e24675a1..8f4d5b76 100644 --- a/sinch/domains/numbers/models/available/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/available/list_available_numbers_request.py @@ -1,14 +1,16 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import CapabilityType, NumberType, NumberSearchPatternType +from sinch.domains.numbers.models.numbers import ( + CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues +) class ListAvailableNumbersRequest(BaseModelConfigRequest): region_code: StrictStr = Field(alias="regionCode") - number_type: NumberType = Field(alias="type") + number_type: NumberTypeValues = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") - capabilities: Optional[CapabilityType] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternType] = ( + capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( Field(default=None, alias="numberPattern.searchPattern")) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/available/rent_any_number_response.py b/sinch/domains/numbers/models/available/rent_any_number_response.py index e80020b3..f55061bc 100644 --- a/sinch/domains/numbers/models/available/rent_any_number_response.py +++ b/sinch/domains/numbers/models/available/rent_any_number_response.py @@ -2,7 +2,7 @@ from typing import Optional from pydantic import Field, StrictStr, StrictInt from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse -from sinch.domains.numbers.models.numbers import (CapabilityType, Money, NumberType, +from sinch.domains.numbers.models.numbers import (CapabilityTypeValuesList, Money, NumberTypeValues, SmsConfigurationResponse, VoiceConfigurationResponse) @@ -10,8 +10,8 @@ class RentAnyNumberResponse(BaseModelConfigResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") project_id: Optional[StrictStr] = Field(default=None, alias="projectId") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) + type: Optional[NumberTypeValues] = Field(default=None) + capability: Optional[CapabilityTypeValuesList] = Field(default=None) money: Optional[Money] = Field(default=None) payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index a566497e..52810fc6 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -5,12 +5,12 @@ from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] -CapabilityTypeValues = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) +CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] OrderByValues = Union[Literal["phoneNumber", "displayName"], StrictStr] CapabilityType = Annotated[ - CapabilityTypeValues, + CapabilityTypeValuesList, Field(default=None) ] @@ -128,6 +128,7 @@ class ActiveNumber(BaseModelConfigResponse): expire_at: Optional[datetime] = Field(default=None, alias="expireAt") sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") class Number(BaseModelConfigResponse): diff --git a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py index 267f4e41..622aff69 100644 --- a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py @@ -1,3 +1,6 @@ +from datetime import datetime, timezone +from decimal import Decimal + import pytest from sinch.domains.numbers.endpoints.active.list_active_numbers_endpoint import ListActiveNumbersEndpoint from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest @@ -10,7 +13,7 @@ def request_data(): region_code="AR", number_type="LOCAL", page_size=15, - capabilities=["SMS"], + capabilities=["SMS", "VOICE"], number_pattern="123", number_search_pattern="STARTS_WITH" ) @@ -35,7 +38,9 @@ def mock_response(): "paymentIntervalMonths": 1, "nextChargeDate": "2025-02-28T14:04:26.190127Z", "expireAt": "2025-02-28T14:04:26.190127Z", - "callbackUrl": "https://yourcallback/numbers"}], + "callbackUrl": "https://yourcallback/numbers" + } + ], "nextPageToken": "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==", "totalSize": 10 }, @@ -57,7 +62,7 @@ def test_build_query_params_expects_correct_mapping(endpoint): "regionCode": "AR", "type": "LOCAL", "pageSize": 15, - "capabilities": ["SMS"], + "capabilities": ["SMS", "VOICE"], "numberPattern.pattern": "123", "numberPattern.searchPattern": "STARTS_WITH" } @@ -72,3 +77,18 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): assert parsed_response.active_numbers[0].phone_number == "+1234567890" assert parsed_response.active_numbers[0].project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" assert parsed_response.active_numbers[0].display_name == "" + assert parsed_response.active_numbers[0].region_code == "US" + assert parsed_response.active_numbers[0].type == "LOCAL" + assert parsed_response.active_numbers[0].capability == ["SMS", "VOICE"] + assert parsed_response.active_numbers[0].money.currency_code == "EUR" + assert parsed_response.active_numbers[0].money.amount == Decimal("0.80") + assert parsed_response.active_numbers[0].payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.active_numbers[0].next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.active_numbers[0].expire_at == expected_expire_at + assert parsed_response.active_numbers[0].callback_url == "https://yourcallback/numbers" + assert parsed_response.next_page_token == "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==" + assert parsed_response.total_size == 10 diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 242a1a24..6507a1a7 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -130,12 +130,13 @@ async def test_page_int_iterator_async_using_auto_pagination( page_counter = 0 assert int_based_paginator.result.page == page_counter - page_counter = 0 + # Previous implementation starts from second page + page_counter = 1 async for page in int_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, AsyncIntBasedPaginator) - assert page_counter == 2 + assert page_counter == 3 assert not int_based_paginator.result.pig_dogs @@ -188,12 +189,12 @@ def test_page_token_iterator_sync_using_auto_pagination( ) assert token_based_paginator - page_counter = 0 + page_counter = 1 for page in token_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, TokenBasedPaginator) - assert page_counter == 1 + assert page_counter == 2 def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(int_based_pagination_request_data): """ Test that the pagination iterates correctly through multiple pages. """ @@ -264,13 +265,13 @@ async def test_page_token_iterator_async_using_manual_pagination( ) assert token_based_paginator - page_counter = 0 + page_counter = 1 while token_based_paginator.has_next_page: token_based_paginator = await token_based_paginator.next_page() page_counter += 1 assert isinstance(token_based_paginator, AsyncTokenBasedPaginator) - assert page_counter == 2 + assert page_counter == 3 async def test_page_token_iterator_async_using_auto_pagination( @@ -294,9 +295,9 @@ async def test_page_token_iterator_async_using_auto_pagination( ) assert token_based_paginator - page_counter = 0 + page_counter = 1 async for page in token_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, AsyncTokenBasedPaginator) - assert page_counter == 2 + assert page_counter == 3 From b20c3abb823128ff81bad208f809179d841bc4e5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 20 Feb 2025 14:04:45 +0100 Subject: [PATCH 012/106] refactor: improve pagination logic --- README.md | 4 +- sinch/core/pagination.py | 186 ++++++++++-------- sinch/domains/numbers/active_numbers.py | 43 ++-- .../active/list_active_numbers_endpoint.py | 10 +- .../available/activate_number_endpoint.py | 3 - .../list_available_numbers_endpoint.py | 7 +- .../available/rent_any_number_endpoint.py | 3 - .../available/search_for_number_endpoint.py | 6 +- .../numbers/endpoints/numbers_endpoint.py | 12 +- .../active/list_active_numbers_response.py | 10 +- .../check_number_availability_response.py | 2 +- .../available/rent_any_number_response.py | 2 +- .../numbers/models/base_model_numbers.py | 6 +- sinch/domains/numbers/models/numbers.py | 10 +- tests/conftest.py | 21 +- .../numbers/features/steps/numbers.steps.py | 11 +- .../test_list_active_numbers_endpoint.py | 9 +- .../test_activate_number_endpoint.py | 6 +- .../test_list_available_numbers_endpoint.py | 6 +- .../test_rent_any_number_endpoint.py | 6 +- .../test_search_for_number_endpoint.py | 21 +- .../test_list_active_numbers_request_model.py | 5 +- ...test_list_active_numbers_response_model.py | 4 + .../models/base/test_base_model_requests.py | 2 + tests/unit/test_pagination.py | 134 +++++++++---- 25 files changed, 317 insertions(+), 212 deletions(-) diff --git a/README.md b/README.md index 733c0c0a..750fc3f6 100644 --- a/README.md +++ b/README.md @@ -141,8 +141,8 @@ By default, two HTTP implementations are provided: For creating custom HTTP client code, use either `SinchClient` or `SinchClientAsync` client and inject your transport during initialisation: ```python sinch_client = SinchClientAsync( - key_id="Spanish", - key_secret="Inquisition", + key_id="key_id", + key_secret="key_secret", project_id="some_project", transport=MyHTTPAsyncImplementation ) diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index c7443a9e..93f90586 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,5 +1,7 @@ from abc import ABC, abstractmethod from collections import namedtuple +from typing import Generic +from sinch.domains.numbers.models.numbers import BM class PageIterator: @@ -18,7 +20,8 @@ def __next__(self): return self.paginator if self.paginator.has_next_page: - return self.paginator.next_page() + self.paginator = self.paginator.next_page() + return self.paginator else: raise StopIteration @@ -26,18 +29,26 @@ def __next__(self): class AsyncPageIterator: def __init__(self, paginator): self.paginator = paginator + self.first_yield = True def __aiter__(self): return self async def __anext__(self): + if self.first_yield: + self.first_yield = False + return self.paginator + if self.paginator.has_next_page: - return await self.paginator.next_page() - else: - raise StopAsyncIteration + next_paginator = await self.paginator.next_page() + if next_paginator: + self.paginator = next_paginator + return self.paginator + raise StopAsyncIteration -class Paginator(ABC): + +class Paginator(ABC, Generic[BM]): """ Pagination response object. @@ -51,7 +62,7 @@ class Paginator(ABC): if paginated_response.has_next_page: paginated_response = paginated_response.next_page() """ - def __init__(self, sinch, endpoint, result): + def __init__(self, sinch, endpoint, result: BM): self._sinch = sinch self.result = result self.endpoint = endpoint @@ -61,6 +72,16 @@ def __init__(self, sinch, endpoint, result): def __repr__(self): return "Paginated response content: " + str(self.result) + # TODO: Make content() method abstract in Parent class as we implement in the other domains: + # - Refactor pydantic models in other domains to have a content property. + def content(self): + pass + + # TODO: Make iterator() method abstract in Parent class as we implement in the other domains: + # - Refactor pydantic models in other domains to have a content property. + def iterator(self): + pass + @abstractmethod def auto_paging_iter(self): pass @@ -121,116 +142,100 @@ async def _initialize(cls, sinch, endpoint): return cls(sinch, endpoint, result) -class TokenBasedPaginator(Paginator): - """Base paginator for token-based pagination.""" +class TokenBasedPaginator(Paginator[BM]): + """Base paginator for token-based pagination with explicit page navigation and metadata.""" def __init__(self, sinch, endpoint, yield_first_page=False, result=None): - self._sinch = sinch - self.endpoint = endpoint - # Determines if the first page should be included + super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) self.yield_first_page = yield_first_page - self.result = result or self._sinch.configuration.transport.request(self.endpoint) - self.has_next_page = bool(self.result.next_page_token) - def _calculate_next_page(self): - self.has_next_page = bool(self.result.next_page_token) + def content(self) -> list[BM]: + return getattr(self.result, "content", []) def next_page(self): - """Fetches the next page and updates pagination state.""" + """Returns a new paginator instance for the next page.""" + if not self.has_next_page: + return None + self.endpoint.request_data.page_token = self.result.next_page_token - self.result = self._sinch.configuration.transport.request(self.endpoint) - self._calculate_next_page() - return self + next_result = self._sinch.configuration.transport.request(self.endpoint) + + return TokenBasedPaginator(self._sinch, self.endpoint, result=next_result) def auto_paging_iter(self): """Returns an iterator for automatic pagination.""" - return PageIterator(self, yield_first_page=self.yield_first_page) - - @classmethod - def _initialize(cls, sinch, endpoint): - """Creates an instance of the paginator skipping first page.""" - result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, yield_first_page=False, result=result) - - -class TokenBasedPaginatorNumbers(TokenBasedPaginator): - """ - Paginator for handling token-based pagination specifically for phone numbers. + return PageIterator(self, yield_first_page=True) - This paginator is designed to iterate through phone numbers automatically or manually, fetching new pages as needed. - It extends the TokenBasedPaginatorBase class and provides additional methods for number-specific pagination. - """ - - def __init__(self, sinch, endpoint): - super().__init__(sinch, endpoint, yield_first_page=True) + def iterator(self): + """Iterates over individual items across all pages.""" + paginator = self + while paginator: + yield from paginator.content() - def numbers_iterator(self): - """Iterates through numbers individually, fetching new pages as needed.""" - while True: - if self.result and self.result.active_numbers: - yield from self.result.active_numbers - - if not self.has_next_page: + next_page_instance = paginator.next_page() + if not next_page_instance: break - - self.next_page() + paginator = next_page_instance def list(self): - """Returns the first page's numbers along with pagination metadata.""" + """Returns structured pagination metadata along with the first page's content (sync).""" + next_page_instance = self.next_page() + return self._list(next_page_instance, sync=True) + def _list(self, next_page_instance, sync=True): + """Core logic for `list()`, shared between sync and async versions.""" PagedListResponse = namedtuple( "PagedResponse", ["result", "has_next_page", "next_page_info", "next_page"] ) - next_page_result = self._get_next_page_result() + next_page_info = { + "result": self.content(), + "result.next": ( + self.content() + (next_page_instance.content() if next_page_instance else []) + ), + "has_next_page": self.has_next_page, + "has_next_page.next": bool(next_page_instance and next_page_instance.has_next_page), + } + + next_page_wrapper = self._get_next_page_wrapper(next_page_instance, sync) return PagedListResponse( - result=self.result.active_numbers, + result=self.content(), has_next_page=self.has_next_page, - next_page_info=self._build_next_pagination_info(next_page_result), - next_page=self._next_page_wrapper() + next_page_info=next_page_info, + next_page=next_page_wrapper ) - def _get_next_page_result(self): - """Fetches the next page result.""" - if not self.has_next_page: - return None - - current_state = self.result - self.next_page() - next_page_result = self.result - self.result = current_state - - return next_page_result + def _get_next_page_wrapper(self, next_page_instance, sync): + """Returns a function for fetching the next page.""" + if sync: + return lambda: next_page_instance.list() if next_page_instance else None + else: + async def async_next_page_wrapper(): + return await next_page_instance.list() if next_page_instance else None + return async_next_page_wrapper - def _build_next_pagination_info(self, next_page_result): - """Constructs and returns structured pagination metadata.""" - return { - "result": self.result.active_numbers, - "result.next": ( - self.result.active_numbers + next_page_result.active_numbers - if next_page_result else self.result.active_numbers - ), - "has_next_page": self.has_next_page, - "has_next_page.next": bool(next_page_result and next_page_result.next_page_token), - } + def _calculate_next_page(self): + self.has_next_page = bool(getattr(self.result, "next_page_token", None)) - def _next_page_wrapper(self): - """Fetches and returns the next page as a formatted PagedListResponse object.""" - def wrapper(): - self.next_page() - return self.list() - return wrapper + @classmethod + def _initialize(cls, sinch, endpoint): + """Creates an instance of the paginator skipping first page.""" + result = sinch.configuration.transport.request(endpoint) + return cls(sinch, endpoint, yield_first_page=False, result=result) class AsyncTokenBasedPaginator(TokenBasedPaginator): """Asynchronous token-based paginator.""" async def next_page(self): + if not self.has_next_page: + return None + self.endpoint.request_data.page_token = self.result.next_page_token - self.result = await self._sinch.configuration.transport.request(self.endpoint) - self._calculate_next_page() - return self + next_result = await self._sinch.configuration.transport.request(self.endpoint) + + return AsyncTokenBasedPaginator(self._sinch, self.endpoint, result=next_result) def auto_paging_iter(self): return AsyncPageIterator(self) @@ -239,3 +244,20 @@ def auto_paging_iter(self): async def _initialize(cls, sinch, endpoint): result = await sinch.configuration.transport.request(endpoint) return cls(sinch, endpoint, result=result) + + async def list(self): + """Returns structured pagination metadata""" + next_page_instance = await self.next_page() + return self._list(next_page_instance, sync=False) + + async def iterator(self): + """Iterates asynchronously over individual items across all pages.""" + paginator = self + while paginator: + for item in paginator.content(): + yield item + + next_page_instance = await paginator.next_page() + if not next_page_instance: + break + paginator = next_page_instance diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py index dd77078c..8b9798a6 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/active_numbers.py @@ -1,13 +1,13 @@ from typing import Optional from pydantic import StrictStr, StrictInt -from sinch.core.pagination import TokenBasedPaginatorNumbers, AsyncTokenBasedPaginator +from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator from sinch.domains.numbers.base_numbers import BaseNumbers from sinch.domains.numbers.endpoints.active import ( GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, UpdateNumberConfigurationEndpoint ) from sinch.domains.numbers.models.active import ( - ListActiveNumbersRequest, ListActiveNumbersResponse + ListActiveNumbersRequest ) from sinch.domains.numbers.models.active.requests import ( GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest @@ -16,7 +16,7 @@ UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) from sinch.domains.numbers.models.numbers import ( - CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues, OrderByValues + CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues, OrderByValues, ActiveNumber ) @@ -33,17 +33,18 @@ def list( page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, **kwargs - ) -> TokenBasedPaginatorNumbers: + ) -> Paginator[ActiveNumber]: """ Search for all active virtual numbers associated with a certain project. Args: region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. - number_type (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + number_type (NumberTypeValues): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. - number_search_pattern (Optional[NumberSearchPatternType]): + number_search_pattern (Optional[NumberSearchPatternTypeValues]): Pattern to apply (e.g., "START", "CONTAINS", "END"). - capability (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) + capability (Optional[CapabilityTypeValuesList]): Capabilities required for the number. + (e.g., ["SMS", "VOICE"]) page_size (StrictInt): Maximum number of items to return. page_token (Optional[StrictStr]): Token for the next page of results. order_by (Optional[OrderByValues]): Field to order the results by. (e.g., "phoneNumber", "displayName") @@ -54,7 +55,7 @@ def list( For detailed documentation, visit https://developers.sinch.com """ - return TokenBasedPaginatorNumbers( + return TokenBasedPaginator( sinch=self._sinch, endpoint=ListActiveNumbersEndpoint( project_id=self._sinch.configuration.project_id, @@ -72,6 +73,10 @@ def list( ) ) + # TODO: Refactor the update(), get(), release() functions to use Pydantic models: + # - Replace primitive types with Pydantic for better validation and maintainability. + # - Define Pydantic models for request and response data. + # - Improve readability and maintainability through refactoring. def update( self, phone_number: str = None, @@ -130,15 +135,16 @@ def release(self, phone_number: str) -> ReleaseNumberFromProjectResponse: class ActiveNumbersWithAsyncPagination(ActiveNumbers): async def list( self, - region_code: str, - number_type: str, - number_pattern: str = None, - number_search_pattern: str = None, - capabilities: list = None, - page_size: int = None, - page_token: str = None, + region_code: StrictStr, + number_type: StrictStr, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capability: Optional[CapabilityTypeValuesList] = None, + page_size: Optional[StrictInt] = None, + page_token: Optional[StrictStr] = None, + order_by: Optional[OrderByValues] = None, **kwargs - ) -> ListActiveNumbersResponse: + ) -> AsyncTokenBasedPaginator: return await AsyncTokenBasedPaginator._initialize( sinch=self._sinch, endpoint=ListActiveNumbersEndpoint( @@ -147,10 +153,11 @@ async def list( region_code=region_code, number_type=number_type, page_size=page_size, - capabilities=capabilities, + capabilities=capability, number_pattern=number_pattern, number_search_pattern=number_search_pattern, - page_token=page_token + page_token=page_token, + order_by=order_by, ) ) ) diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py index fc1ca5e6..edb40341 100644 --- a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py @@ -3,7 +3,6 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse -from sinch.domains.numbers.models.numbers import ActiveNumber class ListActiveNumbersEndpoint(NumbersEndpoint): @@ -16,9 +15,12 @@ def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): self.project_id = project_id self.request_data = request_data - def request_body(self): - pass + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) - def handle_response(self, response: HTTPResponse) -> list[ActiveNumber]: + def request_body(self) -> str: + return "" + + def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: super(ListActiveNumbersEndpoint, self).handle_response(response) return self.process_response_model(response.body, ListActiveNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index 9b06dc60..e94424ec 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -16,9 +16,6 @@ class ActivateNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - def build_query_params(self) -> dict: - pass - def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: try: super(ActivateNumberEndpoint, self).handle_response(response) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py index 4ce25de5..d5e50474 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py @@ -18,8 +18,11 @@ def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) self.request_data = request_data - def request_body(self): - pass + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def request_body(self) -> str: + return "" def handle_response(self, response: HTTPResponse) -> list[Number]: """ diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py index a4ce0be0..f618e1db 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py @@ -17,9 +17,6 @@ def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) self.request_data = request_data - def build_query_params(self) -> dict: - pass - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: """ Handles the response from the API call. diff --git a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py index 6320ab77..987cf407 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py @@ -18,10 +18,10 @@ def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest super(SearchForNumberEndpoint, self).__init__(project_id, request_data) def build_query_params(self) -> dict: - pass + return self.request_data.model_dump(exclude_none=True, by_alias=True) - def request_body(self): - pass + def request_body(self) -> str: + return "" def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: """ diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index 03966c2e..cf5cb3fd 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,13 +1,10 @@ import json from abc import ABC -from pydantic import BaseModel -from typing import TypeVar, Type +from typing import Type from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.domains.numbers.exceptions import NumbersException -from sinch.domains.numbers.models.numbers import NotFoundError - -BM = TypeVar("BM", bound=BaseModel) +from sinch.domains.numbers.models.numbers import BM, NotFoundError class NumbersEndpoint(HTTPEndpoint, ABC): @@ -32,10 +29,9 @@ def build_query_params(self) -> dict: Returns: dict: The query parameters to be sent with the API request. """ - query_params = self.request_data.model_dump(exclude_none=True, by_alias=True) - return query_params + return {} - def request_body(self): + def request_body(self) -> str: """ Returns the request body as a JSON string. diff --git a/sinch/domains/numbers/models/active/list_active_numbers_response.py b/sinch/domains/numbers/models/active/list_active_numbers_response.py index 37fb41a6..6b772cac 100644 --- a/sinch/domains/numbers/models/active/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/active/list_active_numbers_response.py @@ -9,5 +9,11 @@ class ListActiveNumbersResponse(BaseModel): total_size: Optional[StrictInt] = Field(default=None, alias="totalSize") model_config = ConfigDict( - populate_by_name=True - ) + populate_by_name=True, + extra="allow", + ) + + @property + def content(self): + """Returns the active numbers as part of the response object to be used in the pagination.""" + return self.active_numbers or [] diff --git a/sinch/domains/numbers/models/available/check_number_availability_response.py b/sinch/domains/numbers/models/available/check_number_availability_response.py index d613e443..f959ae7f 100644 --- a/sinch/domains/numbers/models/available/check_number_availability_response.py +++ b/sinch/domains/numbers/models/available/check_number_availability_response.py @@ -8,7 +8,7 @@ class CheckNumberAvailabilityResponse(BaseModelConfigResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = None - capability: Optional[CapabilityType] = None + capabilities: Optional[CapabilityType] = None setup_price: Optional[Money] = Field(default=None, alias="setupPrice") monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") diff --git a/sinch/domains/numbers/models/available/rent_any_number_response.py b/sinch/domains/numbers/models/available/rent_any_number_response.py index f55061bc..ce6522cb 100644 --- a/sinch/domains/numbers/models/available/rent_any_number_response.py +++ b/sinch/domains/numbers/models/available/rent_any_number_response.py @@ -11,7 +11,7 @@ class RentAnyNumberResponse(BaseModelConfigResponse): project_id: Optional[StrictStr] = Field(default=None, alias="projectId") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberTypeValues] = Field(default=None) - capability: Optional[CapabilityTypeValuesList] = Field(default=None) + capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) money: Optional[Money] = Field(default=None) payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/base_model_numbers.py index f8882973..824d622e 100644 --- a/sinch/domains/numbers/models/base_model_numbers.py +++ b/sinch/domains/numbers/models/base_model_numbers.py @@ -11,8 +11,12 @@ class BaseModelConfigRequest(BaseModel): @staticmethod def _to_camel_case(snake_str: str) -> str: """Converts snake_case to camelCase while preserving multiple underscores.""" + if not snake_str or "_" not in snake_str: + return snake_str components = snake_str.split('_') - return components[0] + ''.join(x.capitalize() if x else '_' for x in components[1:]) + return components[0].lower() + ''.join( + (x.capitalize() if x else '_') for x in components[1:] + ) @classmethod def _convert_dict_keys(cls, obj): diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index 52810fc6..94580088 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -1,13 +1,15 @@ from datetime import datetime -from typing import Optional, Literal, Union, Annotated -from pydantic import Field, StrictStr, StrictInt, StrictBool, conlist, ConfigDict from decimal import Decimal +from typing import Annotated, Literal, Optional, TypeVar, Union +from pydantic import BaseModel, ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse +BM = TypeVar("BM", bound=BaseModel) + NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] -OrderByValues = Union[Literal["phoneNumber", "displayName"], StrictStr] +OrderByValues = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] CapabilityType = Annotated[ CapabilityTypeValuesList, @@ -121,7 +123,7 @@ class ActiveNumber(BaseModelConfigResponse): display_name: Optional[StrictStr] = Field(default=None, alias="displayName") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) + capabilities: Optional[CapabilityType] = Field(default=None) money: Optional[Money] = Field(default=None) payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") diff --git a/tests/conftest.py b/tests/conftest.py index db790817..9abb23d3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ # This file that contains fixtures that are shared across all tests in the tests directory. import os from dataclasses import dataclass +from unittest.mock import Mock import pytest @@ -8,6 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.numbers.models.numbers import ActiveNumber @dataclass @@ -312,11 +314,24 @@ def sinch_client_async( ) @pytest.fixture -def mock_sinch_client(): +def mock_sinch_client_numbers(): class MockConfiguration: - numbers_origin = "https://api.sinch.com" + numbers_origin = "https://mock-numbers-api.sinch.com" class MockSinchClient: configuration = MockConfiguration() - return MockSinchClient() \ No newline at end of file + return MockSinchClient() + +@pytest.fixture +def mock_pagination_active_number_responses(): + return [ + Mock(content=[ActiveNumber(phone_number="+12345678901"), + ActiveNumber(phone_number="+12345678902")], + next_page_token="token_1"), + Mock(content=[ActiveNumber(phone_number="+12345678903"), + ActiveNumber(phone_number="+12345678904")], + next_page_token="token_2"), + Mock(content=[ActiveNumber(phone_number="+12345678905")], + next_page_token=None) + ] \ No newline at end of file diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index e395eae5..821e8ee7 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,7 +2,7 @@ from behave import given, when, then from decimal import Decimal from sinch import SinchClient -from sinch.core.pagination import TokenBasedPaginatorNumbers +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.numbers.exceptions import NumberNotFoundException from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse @@ -165,12 +165,13 @@ def step_when_list_phone_numbers(context): region_code='US', number_type='LOCAL' ) - context.response = response + # Get the first page + context.response = response.list() + @then('the response contains "{count}" phone numbers') def step_then_response_contains_x_phone_numbers(context, count): - data : TokenBasedPaginatorNumbers = context.response - assert len(data.result.active_numbers) == int(count), \ + assert len(context.response.result) == int(count), \ f'Expected {count}, got {len(context.response.data)}' @when("I send a request to list all the phone numbers") @@ -182,7 +183,7 @@ def step_when_list_all_phone_numbers(context): ) active_numbers_list = [] - for number in response.numbers_iterator(): + for number in response.iterator(): active_numbers_list.append(number) context.active_numbers_list = active_numbers_list diff --git a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py index 622aff69..27fe8467 100644 --- a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py @@ -10,7 +10,7 @@ @pytest.fixture def request_data(): return ListActiveNumbersRequest( - region_code="AR", + region_code="US", number_type="LOCAL", page_size=15, capabilities=["SMS", "VOICE"], @@ -51,15 +51,16 @@ def mock_response(): def endpoint(request_data): return ListActiveNumbersEndpoint("test_project_id", request_data) -def test_build_url(endpoint, mock_sinch_client): - assert endpoint.build_url(mock_sinch_client) == "https://api.sinch.com/v1/projects/test_project_id/activeNumbers" +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers") def test_build_query_params_expects_correct_mapping(endpoint): """ Check if Query params is handled and mapped to the appropriate fields correctly. """ expected_params = { - "regionCode": "AR", + "regionCode": "US", "type": "LOCAL", "pageSize": 15, "capabilities": ["SMS", "VOICE"], diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py index db925a4c..31be528d 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py @@ -48,13 +48,13 @@ def mock_response_body(): return json.dumps(expected_body) -def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): +def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_data): """ Check if endpoint URL is constructed correctly based on input data. """ endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) - expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" - assert endpoint.build_url(mock_sinch_client) == expected_url + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url def test_request_body_expects_correct_json(mock_request_data, mock_response_body): """ diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py index 176d0749..e617dc3a 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py @@ -68,12 +68,12 @@ def mock_response(): def endpoint(request_data): return AvailableNumbersEndpoint("test_project_id", request_data) -def test_build_url(endpoint, mock_sinch_client): +def test_build_url(endpoint, mock_sinch_client_numbers): """ Check if endpoint URL is constructed correctly based on input data. """ - expected_url = "https://api.sinch.com/v1/projects/test_project_id/availableNumbers" - assert endpoint.build_url(mock_sinch_client) == expected_url + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/availableNumbers" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url def test_build_query_params_expects_correct_mapping(endpoint): """ diff --git a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py index bf126cce..97da1476 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py @@ -65,13 +65,13 @@ def valid_response_data(): } -def test_build_url_expects_correct_format(mock_sinch_client, valid_request_data): +def test_build_url_expects_correct_format(mock_sinch_client_numbers, valid_request_data): """ Test that the build_url method constructs the URL correctly. """ endpoint = RentAnyNumberEndpoint(project_id="test_project", request_data=valid_request_data) - expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers:rentAny" - assert endpoint.build_url(mock_sinch_client) == expected_url + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers:rentAny" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url def test_request_body_expects_correct_json(valid_request_data): diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py index 6343662b..b5da6d41 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py @@ -4,21 +4,6 @@ from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse - -@pytest.fixture -def mock_sinch_client(): - """ - Mock the Sinch client with configuration. - """ - class MockConfiguration: - numbers_origin = "https://api.sinch.com" - - class MockSinchClient: - configuration = MockConfiguration() - - return MockSinchClient() - - @pytest.fixture def mock_request_data(): """ @@ -57,13 +42,13 @@ def mock_response(): ) -def test_build_url_expects_correct_url(mock_sinch_client, mock_request_data): +def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_data): """ Check if endpoint URL is constructed correctly based on input data. """ endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) - expected_url = "https://api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890" - assert endpoint.build_url(mock_sinch_client) == expected_url + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): diff --git a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py index eed1cd2c..4013f153 100644 --- a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py @@ -6,9 +6,12 @@ "order_by_input, expected_order_by", [ ("phone_number", "phoneNumber"), + ("PHONE_NUMBER", "phoneNumber"), ("display_name", "displayName"), + ("DISPLAY_NAME", "displayName"), ("new_field", "newField"), - ("newField", "newField") + ("newField", "newField"), + (None, None) ] ) diff --git a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py index 7f833212..02015fc3 100644 --- a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py @@ -90,3 +90,7 @@ def test_list_active_numbers_response_expects_correct_mapping(test_data): assert_voice_configuration(response.active_numbers[0].voice_configuration) assert response.next_page_token == "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==" assert response.total_size == 10 + +def test_list_active_numbers_response_expects_content_mapping(test_data): + response = ListActiveNumbersResponse(**test_data) + assert response.content == response.active_numbers \ No newline at end of file diff --git a/tests/unit/domains/numbers/models/base/test_base_model_requests.py b/tests/unit/domains/numbers/models/base/test_base_model_requests.py index 6dc7771e..858bb657 100644 --- a/tests/unit/domains/numbers/models/base/test_base_model_requests.py +++ b/tests/unit/domains/numbers/models/base/test_base_model_requests.py @@ -7,6 +7,8 @@ def test_to_camel_case_expects_parsed_standard_cases(): assert BaseModelConfigRequest._to_camel_case("foo_bar") == "fooBar" assert BaseModelConfigRequest._to_camel_case("hello_world") == "helloWorld" assert BaseModelConfigRequest._to_camel_case("this_is_a_test") == "thisIsATest" + assert BaseModelConfigRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" + assert BaseModelConfigRequest._to_camel_case("appId") == "appId" def test_to_camel_case_expects_parsed_edge_cases(): """ diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 6507a1a7..183a8081 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,14 +1,12 @@ +import pytest from unittest.mock import Mock, AsyncMock - from sinch.core.pagination import ( IntBasedPaginator, AsyncIntBasedPaginator, TokenBasedPaginator, - TokenBasedPaginatorNumbers, AsyncTokenBasedPaginator ) - def test_page_int_iterator_sync_using_manual_pagination( first_int_based_pagination_response, second_int_based_pagination_response, @@ -130,8 +128,6 @@ async def test_page_int_iterator_async_using_auto_pagination( page_counter = 0 assert int_based_paginator.result.page == page_counter - # Previous implementation starts from second page - page_counter = 1 async for page in int_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, AsyncIntBasedPaginator) @@ -189,59 +185,64 @@ def test_page_token_iterator_sync_using_auto_pagination( ) assert token_based_paginator - page_counter = 1 + page_counter = 0 for page in token_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, TokenBasedPaginator) assert page_counter == 2 -def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(int_based_pagination_request_data): - """ Test that the pagination iterates correctly through multiple pages. """ - first_token_based_pagination_response = Mock() - first_token_based_pagination_response.active_numbers = [ - Mock(phone_number="+12345678901"), - Mock(phone_number="+12345678902") - ] - first_token_based_pagination_response.next_page_token = "token_1" +def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(token_based_pagination_request_data, + mock_pagination_active_number_responses): + """ Test that the pagination iterates correctly through multiple items. """ - second_token_based_pagination_response = Mock() - second_token_based_pagination_response.active_numbers = [ - Mock(phone_number="+12345678903"), - Mock(phone_number="+12345678904") - ] - second_token_based_pagination_response.next_page_token = "token_2" + sinch_client = Mock() + sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses + endpoint = Mock() + endpoint.request_data = token_based_pagination_request_data - third_token_based_pagination_response = Mock() - third_token_based_pagination_response.active_numbers = [ - Mock(phone_number="+12345678905") - ] - third_token_based_pagination_response.next_page_token = None + token_based_paginator = TokenBasedPaginator( + sinch=sinch_client, + endpoint=endpoint + ) + assert token_based_paginator + + number_counter = 0 + for _ in token_based_paginator.iterator(): + number_counter += 1 + assert number_counter == 5 + + +def test_page_token_iterator_sync_using_list_expects_correct_metadata(token_based_pagination_request_data, + mock_pagination_active_number_responses): + """Test `list()` correctly structures pagination metadata with proper `.content` handling.""" - int_based_pagination_request_data = Mock() endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data + endpoint.request_data = token_based_pagination_request_data sinch_client = Mock() - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response - ] + sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses - token_based_paginator = TokenBasedPaginatorNumbers( + token_based_paginator = TokenBasedPaginator( sinch=sinch_client, endpoint=endpoint ) assert token_based_paginator - number_counter = 0 + list_response = token_based_paginator.list() - for _ in token_based_paginator.numbers_iterator(): - number_counter += 1 + page_counter = 0 + reached_last_page = False - assert number_counter == 5 + while not reached_last_page: + page_counter += 1 + if list_response.has_next_page: + list_response = list_response.next_page() + else: + reached_last_page = True + + assert page_counter == 3 async def test_page_token_iterator_async_using_manual_pagination( @@ -295,9 +296,66 @@ async def test_page_token_iterator_async_using_auto_pagination( ) assert token_based_paginator - page_counter = 1 + page_counter = 0 async for page in token_based_paginator.auto_paging_iter(): page_counter += 1 assert isinstance(page, AsyncTokenBasedPaginator) assert page_counter == 3 + + +async def test_page_token_iterator_async_using_list_expects_correct_metadata( + token_based_pagination_request_data, + mock_pagination_active_number_responses +): + """Test async`list()` correctly structures pagination metadata with proper `.content` handling.""" + + endpoint = Mock() + endpoint.request_data = token_based_pagination_request_data + sinch_client = AsyncMock() + + sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses + + async_token_based_paginator = await AsyncTokenBasedPaginator._initialize( + sinch=sinch_client, + endpoint=endpoint + ) + assert async_token_based_paginator + + list_response = await async_token_based_paginator.list() + + page_counter = 0 + reached_last_page = False + + while not reached_last_page: + page_counter += 1 + if list_response.has_next_page: + list_response = await list_response.next_page() + else: + reached_last_page = True + + assert page_counter == 3 + + +async def test_page_token_iterator_numbers_async_using_auto_pagination_expects_iter( + token_based_pagination_request_data, + mock_pagination_active_number_responses +): + """Test that the async pagination iterates correctly through multiple items.""" + + sinch_client = AsyncMock() + sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses + endpoint = Mock() + endpoint.request_data = token_based_pagination_request_data + + async_token_based_paginator = await AsyncTokenBasedPaginator._initialize( + sinch=sinch_client, + endpoint=endpoint + ) + assert async_token_based_paginator + + number_counter = 0 + async for _ in async_token_based_paginator.iterator(): + number_counter += 1 + + assert number_counter == 5 From 9aad89a1af8ab43e0c909f7926f11d2a4a61fcd5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 20 Feb 2025 14:53:22 +0100 Subject: [PATCH 013/106] refactor: preserve subclass type --- sinch/core/pagination.py | 2 +- sinch/domains/numbers/active_numbers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 93f90586..5b439a14 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -160,7 +160,7 @@ def next_page(self): self.endpoint.request_data.page_token = self.result.next_page_token next_result = self._sinch.configuration.transport.request(self.endpoint) - return TokenBasedPaginator(self._sinch, self.endpoint, result=next_result) + return self.__class__(self._sinch, self.endpoint, result=next_result) def auto_paging_iter(self): """Returns an iterator for automatic pagination.""" diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py index 8b9798a6..fb789813 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/active_numbers.py @@ -144,7 +144,7 @@ async def list( page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, **kwargs - ) -> AsyncTokenBasedPaginator: + ) -> Paginator[ActiveNumber]: return await AsyncTokenBasedPaginator._initialize( sinch=self._sinch, endpoint=ListActiveNumbersEndpoint( From 482b1f8b7a4f88ac7b6177aaf65a296d7a0f0143 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 21 Feb 2025 15:07:39 +0100 Subject: [PATCH 014/106] refactor: improve async/sync pagination and rename pagination methods --- sinch/core/pagination.py | 64 ++++++++----------- sinch/core/types.py | 4 ++ sinch/domains/numbers/active_numbers.py | 10 +-- .../active/list_active_numbers_endpoint.py | 6 +- .../available/activate_number_endpoint.py | 6 ++ .../list_available_numbers_endpoint.py | 3 - .../available/rent_any_number_endpoint.py | 5 ++ .../available/search_for_number_endpoint.py | 6 -- .../numbers/endpoints/numbers_endpoint.py | 8 +-- sinch/domains/numbers/models/numbers.py | 5 +- .../numbers/features/steps/numbers.steps.py | 3 +- ...test_list_active_numbers_response_model.py | 2 +- tests/unit/test_pagination.py | 64 ++----------------- 13 files changed, 62 insertions(+), 124 deletions(-) create mode 100644 sinch/core/types.py diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 5b439a14..48448879 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,13 +1,12 @@ from abc import ABC, abstractmethod from collections import namedtuple from typing import Generic -from sinch.domains.numbers.models.numbers import BM +from sinch.core.types import BM class PageIterator: def __init__(self, paginator, yield_first_page=False): self.paginator = paginator - self.yield_first_page = yield_first_page # If yielding the first page, set started to False self.started = not yield_first_page @@ -27,16 +26,16 @@ def __next__(self): class AsyncPageIterator: - def __init__(self, paginator): + def __init__(self, paginator, yield_first_page=False): self.paginator = paginator - self.first_yield = True + self.started = not yield_first_page def __aiter__(self): return self async def __anext__(self): - if self.first_yield: - self.first_yield = False + if not self.started: + self.started = True return self.paginator if self.paginator.has_next_page: @@ -44,8 +43,8 @@ async def __anext__(self): if next_paginator: self.paginator = next_paginator return self.paginator - - raise StopAsyncIteration + else: + raise StopAsyncIteration class Paginator(ABC, Generic[BM]): @@ -82,8 +81,9 @@ def content(self): def iterator(self): pass - @abstractmethod - def auto_paging_iter(self): + # TODO: Make get_content() method abstract in Parent class as we implement in the other domains: + # - Refactor pydantic models in other domains to have a content property. + def get_content(self): pass @abstractmethod @@ -134,7 +134,7 @@ async def next_page(self): return self def auto_paging_iter(self): - return AsyncPageIterator(self) + return AsyncPageIterator(self, yield_first_page=True) @classmethod async def _initialize(cls, sinch, endpoint): @@ -147,7 +147,6 @@ class TokenBasedPaginator(Paginator[BM]): def __init__(self, sinch, endpoint, yield_first_page=False, result=None): super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) - self.yield_first_page = yield_first_page def content(self) -> list[BM]: return getattr(self.result, "content", []) @@ -162,10 +161,6 @@ def next_page(self): return self.__class__(self._sinch, self.endpoint, result=next_result) - def auto_paging_iter(self): - """Returns an iterator for automatic pagination.""" - return PageIterator(self, yield_first_page=True) - def iterator(self): """Iterates over individual items across all pages.""" paginator = self @@ -177,13 +172,13 @@ def iterator(self): break paginator = next_page_instance - def list(self): + def get_content(self): """Returns structured pagination metadata along with the first page's content (sync).""" next_page_instance = self.next_page() - return self._list(next_page_instance, sync=True) + return self._get_content(next_page_instance, sync=True) - def _list(self, next_page_instance, sync=True): - """Core logic for `list()`, shared between sync and async versions.""" + def _get_content(self, next_page_instance, sync=True): + """Core logic for `get_content()`, shared between sync and async versions.""" PagedListResponse = namedtuple( "PagedResponse", ["result", "has_next_page", "next_page_info", "next_page"] ) @@ -209,10 +204,10 @@ def _list(self, next_page_instance, sync=True): def _get_next_page_wrapper(self, next_page_instance, sync): """Returns a function for fetching the next page.""" if sync: - return lambda: next_page_instance.list() if next_page_instance else None + return lambda: next_page_instance.get_content() if next_page_instance else None else: async def async_next_page_wrapper(): - return await next_page_instance.list() if next_page_instance else None + return await next_page_instance.get_content() if next_page_instance else None return async_next_page_wrapper def _calculate_next_page(self): @@ -235,20 +230,7 @@ async def next_page(self): self.endpoint.request_data.page_token = self.result.next_page_token next_result = await self._sinch.configuration.transport.request(self.endpoint) - return AsyncTokenBasedPaginator(self._sinch, self.endpoint, result=next_result) - - def auto_paging_iter(self): - return AsyncPageIterator(self) - - @classmethod - async def _initialize(cls, sinch, endpoint): - result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result=result) - - async def list(self): - """Returns structured pagination metadata""" - next_page_instance = await self.next_page() - return self._list(next_page_instance, sync=False) + return self.__class__(self._sinch, self.endpoint, result=next_result) async def iterator(self): """Iterates asynchronously over individual items across all pages.""" @@ -261,3 +243,13 @@ async def iterator(self): if not next_page_instance: break paginator = next_page_instance + + async def get_content(self): + """Returns structured pagination metadata""" + next_page_instance = await self.next_page() + return self._get_content(next_page_instance, sync=False) + + @classmethod + async def _initialize(cls, sinch, endpoint): + result = await sinch.configuration.transport.request(endpoint) + return cls(sinch, endpoint, yield_first_page=False, result=result) diff --git a/sinch/core/types.py b/sinch/core/types.py new file mode 100644 index 00000000..592a9201 --- /dev/null +++ b/sinch/core/types.py @@ -0,0 +1,4 @@ +from typing import TypeVar +from pydantic import BaseModel + +BM = TypeVar("BM", bound=BaseModel) diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py index fb789813..84e70ffa 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/active_numbers.py @@ -28,7 +28,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capability: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, @@ -43,7 +43,7 @@ def list( number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. number_search_pattern (Optional[NumberSearchPatternTypeValues]): Pattern to apply (e.g., "START", "CONTAINS", "END"). - capability (Optional[CapabilityTypeValuesList]): Capabilities required for the number. + capabilities (Optional[CapabilityTypeValuesList]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) page_size (StrictInt): Maximum number of items to return. page_token (Optional[StrictStr]): Token for the next page of results. @@ -63,7 +63,7 @@ def list( region_code=region_code, number_type=number_type, page_size=page_size, - capabilities=capability, + capabilities=capabilities, number_pattern=number_pattern, number_search_pattern=number_search_pattern, page_token=page_token, @@ -139,7 +139,7 @@ async def list( number_type: StrictStr, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capability: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, @@ -153,7 +153,7 @@ async def list( region_code=region_code, number_type=number_type, page_size=page_size, - capabilities=capability, + capabilities=capabilities, number_pattern=number_pattern, number_search_pattern=number_search_pattern, page_token=page_token, diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py index edb40341..570c1525 100644 --- a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py @@ -6,6 +6,9 @@ class ListActiveNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list all active numbers for a project. + """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -18,9 +21,6 @@ def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def request_body(self) -> str: - return "" - def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: super(ListActiveNumbersEndpoint, self).handle_response(response) return self.process_response_model(response.body, ListActiveNumbersResponse) diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index e94424ec..183b3f2c 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -1,3 +1,4 @@ +import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint @@ -16,6 +17,11 @@ class ActivateNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: ActivateNumberRequest): super(ActivateNumberEndpoint, self).__init__(project_id, request_data) + def request_body(self) -> str: + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: try: super(ActivateNumberEndpoint, self).handle_response(response) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py index d5e50474..a5c9af5b 100644 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py @@ -21,9 +21,6 @@ def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def request_body(self) -> str: - return "" - def handle_response(self, response: HTTPResponse) -> list[Number]: """ Processes the API response and maps it to a response model. diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py index f618e1db..6973b3d1 100644 --- a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py @@ -1,3 +1,4 @@ +import json from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods @@ -17,6 +18,10 @@ def __init__(self, project_id: str, request_data: RentAnyNumberRequest): super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) self.request_data = request_data + def request_body(self) -> str: + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: """ Handles the response from the API call. diff --git a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py index 987cf407..db23fff6 100644 --- a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py @@ -17,12 +17,6 @@ class SearchForNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - def build_query_params(self) -> dict: - return self.request_data.model_dump(exclude_none=True, by_alias=True) - - def request_body(self) -> str: - return "" - def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: """ Processes the API response and maps it to a response diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/endpoints/numbers_endpoint.py index cf5cb3fd..3e207c1c 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/endpoints/numbers_endpoint.py @@ -1,10 +1,10 @@ -import json from abc import ABC from typing import Type from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM from sinch.domains.numbers.exceptions import NumbersException -from sinch.domains.numbers.models.numbers import BM, NotFoundError +from sinch.domains.numbers.models.numbers import NotFoundError class NumbersEndpoint(HTTPEndpoint, ABC): @@ -38,9 +38,7 @@ def request_body(self) -> str: Returns: str: The request body as a JSON string. """ - # Convert the request data to a dictionary and remove None values - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) - return json.dumps(request_data) + return "" def process_response_model(self, response_body: dict, response_model: Type[BM]) -> BM: """ diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py index 94580088..feca46a2 100644 --- a/sinch/domains/numbers/models/numbers.py +++ b/sinch/domains/numbers/models/numbers.py @@ -1,10 +1,9 @@ from datetime import datetime from decimal import Decimal -from typing import Annotated, Literal, Optional, TypeVar, Union -from pydantic import BaseModel, ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr +from typing import Annotated, Literal, Optional, Union +from pydantic import ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse -BM = TypeVar("BM", bound=BaseModel) NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 821e8ee7..e5aa3857 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,7 +2,6 @@ from behave import given, when, then from decimal import Decimal from sinch import SinchClient -from sinch.core.pagination import TokenBasedPaginator from sinch.domains.numbers.exceptions import NumberNotFoundException from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse @@ -230,4 +229,4 @@ def step_when_release_phone_number(context, phone_number): @then('the response contains details about the phone number "{phone_number}" to be released') def step_then_response_contains_released_number(context, phone_number): - pass # Placeholder \ No newline at end of file + pass # Placeholder diff --git a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py index 02015fc3..28c1bab2 100644 --- a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py @@ -93,4 +93,4 @@ def test_list_active_numbers_response_expects_correct_mapping(test_data): def test_list_active_numbers_response_expects_content_mapping(test_data): response = ListActiveNumbersResponse(**test_data) - assert response.content == response.active_numbers \ No newline at end of file + assert response.content == response.active_numbers diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 183a8081..43d55f69 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -166,33 +166,6 @@ def test_page_token_iterator_sync_using_manual_pagination( assert page_counter == 3 -def test_page_token_iterator_sync_using_auto_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response - ] - token_based_paginator = TokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator - - page_counter = 0 - for page in token_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, TokenBasedPaginator) - - assert page_counter == 2 - - def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(token_based_pagination_request_data, mock_pagination_active_number_responses): """ Test that the pagination iterates correctly through multiple items. """ @@ -230,7 +203,7 @@ def test_page_token_iterator_sync_using_list_expects_correct_metadata(token_base ) assert token_based_paginator - list_response = token_based_paginator.list() + list_response = token_based_paginator.get_content() page_counter = 0 reached_last_page = False @@ -275,35 +248,6 @@ async def test_page_token_iterator_async_using_manual_pagination( assert page_counter == 3 -async def test_page_token_iterator_async_using_auto_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response - ] - token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator - - page_counter = 0 - async for page in token_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, AsyncTokenBasedPaginator) - - assert page_counter == 3 - - async def test_page_token_iterator_async_using_list_expects_correct_metadata( token_based_pagination_request_data, mock_pagination_active_number_responses @@ -322,19 +266,19 @@ async def test_page_token_iterator_async_using_list_expects_correct_metadata( ) assert async_token_based_paginator - list_response = await async_token_based_paginator.list() + list_response = await async_token_based_paginator.get_content() page_counter = 0 reached_last_page = False while not reached_last_page: - page_counter += 1 if list_response.has_next_page: list_response = await list_response.next_page() + page_counter += 1 else: reached_last_page = True - assert page_counter == 3 + assert page_counter == 2 async def test_page_token_iterator_numbers_async_using_auto_pagination_expects_iter( From f597eca50e7aa3a9d7b325c05536b8c29f01a486 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 21 Feb 2025 15:41:22 +0100 Subject: [PATCH 015/106] rename method - e2e test --- tests/e2e/numbers/features/steps/numbers.steps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index e5aa3857..7f29e9c4 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -165,7 +165,7 @@ def step_when_list_phone_numbers(context): number_type='LOCAL' ) # Get the first page - context.response = response.list() + context.response = response.get_content() @then('the response contains "{count}" phone numbers') From 5048bc8117568716f55f8c1260c646126d64252b Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 24 Feb 2025 16:02:36 +0100 Subject: [PATCH 016/106] refactor: simplify pagination logic --- sinch/core/pagination.py | 71 +----- sinch/domains/numbers/active_numbers.py | 2 +- tests/conftest.py | 30 +-- .../numbers/features/steps/numbers.steps.py | 6 +- .../test_list_active_numbers_request_model.py | 2 +- tests/unit/test_pagination.py | 209 +++++++----------- 6 files changed, 94 insertions(+), 226 deletions(-) diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 48448879..1fc77ca5 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from collections import namedtuple from typing import Generic from sinch.core.types import BM @@ -50,16 +49,6 @@ async def __anext__(self): class Paginator(ABC, Generic[BM]): """ Pagination response object. - - auto_paging_iter method returns an iterator object that can be used for iterator-based page traversing. - For example: - for page in paginated_response.auto_paging_iter(): - ...process page object - - For manual pagination use has_next_page property with next_page() method. - For example: - if paginated_response.has_next_page: - paginated_response = paginated_response.next_page() """ def __init__(self, sinch, endpoint, result: BM): self._sinch = sinch @@ -81,11 +70,6 @@ def content(self): def iterator(self): pass - # TODO: Make get_content() method abstract in Parent class as we implement in the other domains: - # - Refactor pydantic models in other domains to have a content property. - def get_content(self): - pass - @abstractmethod def next_page(self): pass @@ -145,7 +129,7 @@ async def _initialize(cls, sinch, endpoint): class TokenBasedPaginator(Paginator[BM]): """Base paginator for token-based pagination with explicit page navigation and metadata.""" - def __init__(self, sinch, endpoint, yield_first_page=False, result=None): + def __init__(self, sinch, endpoint, result=None): super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) def content(self) -> list[BM]: @@ -157,9 +141,9 @@ def next_page(self): return None self.endpoint.request_data.page_token = self.result.next_page_token - next_result = self._sinch.configuration.transport.request(self.endpoint) - - return self.__class__(self._sinch, self.endpoint, result=next_result) + self.result = self._sinch.configuration.transport.request(self.endpoint) + self._calculate_next_page() + return self def iterator(self): """Iterates over individual items across all pages.""" @@ -172,44 +156,6 @@ def iterator(self): break paginator = next_page_instance - def get_content(self): - """Returns structured pagination metadata along with the first page's content (sync).""" - next_page_instance = self.next_page() - return self._get_content(next_page_instance, sync=True) - - def _get_content(self, next_page_instance, sync=True): - """Core logic for `get_content()`, shared between sync and async versions.""" - PagedListResponse = namedtuple( - "PagedResponse", ["result", "has_next_page", "next_page_info", "next_page"] - ) - - next_page_info = { - "result": self.content(), - "result.next": ( - self.content() + (next_page_instance.content() if next_page_instance else []) - ), - "has_next_page": self.has_next_page, - "has_next_page.next": bool(next_page_instance and next_page_instance.has_next_page), - } - - next_page_wrapper = self._get_next_page_wrapper(next_page_instance, sync) - - return PagedListResponse( - result=self.content(), - has_next_page=self.has_next_page, - next_page_info=next_page_info, - next_page=next_page_wrapper - ) - - def _get_next_page_wrapper(self, next_page_instance, sync): - """Returns a function for fetching the next page.""" - if sync: - return lambda: next_page_instance.get_content() if next_page_instance else None - else: - async def async_next_page_wrapper(): - return await next_page_instance.get_content() if next_page_instance else None - return async_next_page_wrapper - def _calculate_next_page(self): self.has_next_page = bool(getattr(self.result, "next_page_token", None)) @@ -217,7 +163,7 @@ def _calculate_next_page(self): def _initialize(cls, sinch, endpoint): """Creates an instance of the paginator skipping first page.""" result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, yield_first_page=False, result=result) + return cls(sinch, endpoint, result=result) class AsyncTokenBasedPaginator(TokenBasedPaginator): @@ -244,12 +190,7 @@ async def iterator(self): break paginator = next_page_instance - async def get_content(self): - """Returns structured pagination metadata""" - next_page_instance = await self.next_page() - return self._get_content(next_page_instance, sync=False) - @classmethod async def _initialize(cls, sinch, endpoint): result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, yield_first_page=False, result=result) + return cls(sinch, endpoint, result=result) diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/active_numbers.py index 84e70ffa..254a7fca 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/active_numbers.py @@ -136,7 +136,7 @@ class ActiveNumbersWithAsyncPagination(ActiveNumbers): async def list( self, region_code: StrictStr, - number_type: StrictStr, + number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, capabilities: Optional[CapabilityTypeValuesList] = None, diff --git a/tests/conftest.py b/tests/conftest.py index 9abb23d3..ac974936 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,30 +186,6 @@ def token_based_pagination_request_data(): ) -@pytest.fixture -def first_token_based_pagination_response(): - return TokenBasedPaginationResponse( - pig_dogs=["Walaszek", "Połać"], - next_page_token="za30%wsze" - ) - - -@pytest.fixture -def second_token_based_pagination_response(): - return TokenBasedPaginationResponse( - pig_dogs=["Bartosz", "Piotr"], - next_page_token="ka#556" - ) - - -@pytest.fixture -def third_token_based_pagination_response(): - return TokenBasedPaginationResponse( - pig_dogs=["Madrid", "Spain"], - next_page_token="" - ) - - @pytest.fixture def int_based_pagination_request_data(): return IntBasedPaginationRequest( @@ -334,4 +310,10 @@ def mock_pagination_active_number_responses(): next_page_token="token_2"), Mock(content=[ActiveNumber(phone_number="+12345678905")], next_page_token=None) + ] + +@pytest.fixture +def mock_pagination_expected_phone_numbers_response(): + return [ + "+12345678901", "+12345678902", "+12345678903", "+12345678904", "+12345678905" ] \ No newline at end of file diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 7f29e9c4..2c21a882 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -165,13 +165,13 @@ def step_when_list_phone_numbers(context): number_type='LOCAL' ) # Get the first page - context.response = response.get_content() + context.response = response.content() @then('the response contains "{count}" phone numbers') def step_then_response_contains_x_phone_numbers(context, count): - assert len(context.response.result) == int(count), \ - f'Expected {count}, got {len(context.response.data)}' + assert len(context.response) == int(count), \ + f'Expected {count}, got {len(context.response)}' @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): diff --git a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py index 4013f153..8f34bd97 100644 --- a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py @@ -43,7 +43,7 @@ def test_list_active_numbers_request_expects_parsed_input(): "number_search_pattern": "START", "number_pattern": "5678", "page_token": "abc123", - "order_by": "phoneNumber" + "order_by": "PHONE_NUMBER" } request = ListActiveNumbersRequest(**data) diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 43d55f69..60b3c526 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -136,170 +136,115 @@ async def test_page_int_iterator_async_using_auto_pagination( assert not int_based_paginator.result.pig_dogs -def test_page_token_iterator_sync_using_manual_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = Mock() +# Helper function to initialize token paginator +def initialize_token_paginator(endpoint_mock, request_data, responses, is_async=False): + client = AsyncMock() if is_async else Mock() + client.configuration.transport.request.side_effect = responses - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response - ] - token_based_paginator = TokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator + endpoint_mock.request_data = request_data - page_counter = 1 - while token_based_paginator.has_next_page: - token_based_paginator = token_based_paginator.next_page() - page_counter += 1 - assert isinstance(token_based_paginator, TokenBasedPaginator) + if is_async: + return AsyncTokenBasedPaginator._initialize(sinch=client, endpoint=endpoint_mock) + return TokenBasedPaginator(sinch=client, endpoint=endpoint_mock) - assert page_counter == 3 - -def test_page_token_iterator_numbers_sync_using_auto_pagination_expects_iter(token_based_pagination_request_data, - mock_pagination_active_number_responses): +def test_page_token_iterator_sync_using_manual_pagination( + token_based_pagination_request_data, + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response +): """ Test that the pagination iterates correctly through multiple items. """ - - sinch_client = Mock() - sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - - token_based_paginator = TokenBasedPaginator( - sinch=sinch_client, - endpoint=endpoint + token_based_paginator = initialize_token_paginator( + endpoint_mock=Mock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses ) - assert token_based_paginator - - number_counter = 0 - for _ in token_based_paginator.iterator(): - number_counter += 1 - assert number_counter == 5 - - -def test_page_token_iterator_sync_using_list_expects_correct_metadata(token_based_pagination_request_data, - mock_pagination_active_number_responses): - """Test `list()` correctly structures pagination metadata with proper `.content` handling.""" - - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = Mock() + assert token_based_paginator is not None - sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses - - token_based_paginator = TokenBasedPaginator( - sinch=sinch_client, - endpoint=endpoint - ) - assert token_based_paginator - - list_response = token_based_paginator.get_content() - - page_counter = 0 + page_counter = 1 + active_numbers_list = [] reached_last_page = False - while not reached_last_page: - page_counter += 1 - if list_response.has_next_page: - list_response = list_response.next_page() + active_numbers_list.extend([num.phone_number for num in token_based_paginator.content()]) + if token_based_paginator.has_next_page: + token_based_paginator = token_based_paginator.next_page() + page_counter += 1 + assert isinstance(token_based_paginator, TokenBasedPaginator) else: reached_last_page = True assert page_counter == 3 - - -async def test_page_token_iterator_async_using_manual_pagination( - token_based_pagination_request_data, - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response -): - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_token_based_pagination_response, - second_token_based_pagination_response, - third_token_based_pagination_response - ] - token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + assert active_numbers_list == mock_pagination_expected_phone_numbers_response + +def test_page_token_iterator_sync_using_auto_pagination_expects_iter( + token_based_pagination_request_data, + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response + ): + """Test that the pagination iterates correctly through multiple items.""" + token_based_paginator = initialize_token_paginator( + endpoint_mock=Mock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses ) - assert token_based_paginator + assert token_based_paginator is not None - page_counter = 1 - while token_based_paginator.has_next_page: - token_based_paginator = await token_based_paginator.next_page() - page_counter += 1 - assert isinstance(token_based_paginator, AsyncTokenBasedPaginator) + active_numbers_list = [] + for number in token_based_paginator.iterator(): + active_numbers_list.append(number.phone_number) - assert page_counter == 3 + assert len(active_numbers_list) == len(mock_pagination_expected_phone_numbers_response) + assert active_numbers_list == mock_pagination_expected_phone_numbers_response -async def test_page_token_iterator_async_using_list_expects_correct_metadata( +@pytest.mark.asyncio +async def test_page_token_iterator_async_using_manual_pagination_expects_iter( token_based_pagination_request_data, - mock_pagination_active_number_responses + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response ): - """Test async`list()` correctly structures pagination metadata with proper `.content` handling.""" - - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses - - async_token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the async pagination iterates correctly through multiple items.""" + async_token_based_paginator = await initialize_token_paginator( + endpoint_mock=AsyncMock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses, + is_async=True ) - assert async_token_based_paginator + assert async_token_based_paginator is not None - list_response = await async_token_based_paginator.get_content() - - page_counter = 0 + active_numbers_list = [] + page_counter = 1 reached_last_page = False - while not reached_last_page: - if list_response.has_next_page: - list_response = await list_response.next_page() + active_numbers_list.extend([num.phone_number for num in async_token_based_paginator.content()]) + if async_token_based_paginator.has_next_page: + async_token_based_paginator = await async_token_based_paginator.next_page() page_counter += 1 + assert isinstance(async_token_based_paginator, AsyncTokenBasedPaginator) else: reached_last_page = True - assert page_counter == 2 - + assert page_counter == 3 + assert active_numbers_list == mock_pagination_expected_phone_numbers_response -async def test_page_token_iterator_numbers_async_using_auto_pagination_expects_iter( +@pytest.mark.asyncio +async def test_page_token_iterator_async_using_auto_pagination_expects_iter( token_based_pagination_request_data, - mock_pagination_active_number_responses + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response ): """Test that the async pagination iterates correctly through multiple items.""" - - sinch_client = AsyncMock() - sinch_client.configuration.transport.request.side_effect = mock_pagination_active_number_responses - endpoint = Mock() - endpoint.request_data = token_based_pagination_request_data - - async_token_based_paginator = await AsyncTokenBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + async_token_based_paginator = await initialize_token_paginator( + endpoint_mock=AsyncMock(), + request_data=token_based_pagination_request_data, + responses=mock_pagination_active_number_responses, + is_async=True ) - assert async_token_based_paginator + assert async_token_based_paginator is not None - number_counter = 0 - async for _ in async_token_based_paginator.iterator(): - number_counter += 1 + active_numbers_list = [] + async for number in async_token_based_paginator.iterator(): + active_numbers_list.append(number.phone_number) - assert number_counter == 5 + assert len(active_numbers_list) == len(mock_pagination_expected_phone_numbers_response) + assert active_numbers_list == mock_pagination_expected_phone_numbers_response From bd0c5afc3960d7262b8485075428e07230876370 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 4 Mar 2025 08:44:00 +0100 Subject: [PATCH 017/106] DEVEXP 757: [Python E2E] Run tests with Sync and Async clients For the moment, the E2E tests steps have been implemented using the Sync SinchClient. As the Async SinchClient uses another HTTP library and the pagination uses a different implementation, it's necessary to run the E2E tests suite in both ways. Signed-off-by: Jessica Matsuoka --- .github/workflows/run-tests.yml | 15 +++- tests/e2e/numbers/features/environment.py | 51 ++++++++++++ .../numbers/features/steps/numbers.steps.py | 78 ++++++++++++------- 3 files changed, 111 insertions(+), 33 deletions(-) create mode 100644 tests/e2e/numbers/features/environment.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 40feb587..f9f549c8 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -36,12 +36,15 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt + - name: Lint with flake8 run: | flake8 sinch --count --max-complexity=10 --max-line-length=120 --statistics + - name: Test with Pytest run: | coverage run --source=. -m pytest + - name: Coverage Test Report run: | python -m coverage report --skip-empty @@ -72,6 +75,12 @@ jobs: run: .github/scripts/wait-for-mockserver.sh shell: bash - - name: Run e2e tests - run: behave tests/e2e/**/features - \ No newline at end of file + - name: Run e2e tests sync + run: | + export SINCH_CLIENT_MODE=sync + behave tests/e2e/**/features + + - name: Run e2e tests async + run: | + export SINCH_CLIENT_MODE=async + behave tests/e2e/**/features \ No newline at end of file diff --git a/tests/e2e/numbers/features/environment.py b/tests/e2e/numbers/features/environment.py new file mode 100644 index 00000000..f959d2fa --- /dev/null +++ b/tests/e2e/numbers/features/environment.py @@ -0,0 +1,51 @@ +import os +import logging +import asyncio +from sinch import SinchClient, SinchClientAsync + +def get_logger(): + """Creates and returns a logger instance for this module only.""" + log = logging.getLogger(__name__) + log.setLevel(logging.INFO) + if not log.hasHandlers(): + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + log.addHandler(handler) + log.propagate = False + + return log + +logger = get_logger() + +def before_all(context): + """ + Initializes the appropriate Sinch client based on the environment variable SINCH_CLIENT_MODE. + If it's set to 'async', a single event loop is created for all tests. + Otherwise, we use the synchronous client. + """ + client_mode = os.getenv('SINCH_CLIENT_MODE', 'sync') + + logger.info(f" Running E2E tests in **{client_mode.upper()}** mode") + + client_params = { + 'project_id': 'tinyfrog-jump-high-over-lilypadbasin', + 'key_id': 'keyId', + 'key_secret': 'keySecret', + } + if client_mode == 'async': + # Create and set a single event loop for the entire test run + context.loop = asyncio.new_event_loop() + asyncio.set_event_loop(context.loop) + context.sinch = SinchClientAsync(**client_params) + else: + # Sync client does not need an event loop + context.sinch = SinchClient(**client_params) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.numbers_origin = 'http://localhost:3013' + +def after_all(context): + """ + Closes the Async event loop if it was created during the test + """ + if hasattr(context, 'loop'): + context.loop.close() \ No newline at end of file diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 2c21a882..0ee89316 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -1,23 +1,31 @@ +import inspect from datetime import timezone, datetime from behave import given, when, then from decimal import Decimal -from sinch import SinchClient from sinch.domains.numbers.exceptions import NumberNotFoundException from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse from sinch.domains.numbers.models.numbers import NotFoundError +def execute_sync_or_async(context,call): + """ + Ensures proper execution of both synchronous and asynchronous calls. + - If the call is synchronous, it executes directly. + - If the call is a coroutine (async), it runs using asyncio + This abstracts away execution differences, allowing test steps to be written uniformly. + """ + if call is None: + return None + if inspect.iscoroutine(call): + # Reuse the single loop created in before_all + return context.loop.run_until_complete(call) + else: + return call @given('the Numbers service is available') def step_service_is_available(context): - sinch = SinchClient( - project_id='tinyfrog-jump-high-over-lilypadbasin', - key_id='keyId', - key_secret='keySecret', - ) - sinch.configuration.auth_origin = 'http://localhost:3011' - sinch.configuration.numbers_origin = 'http://localhost:3013' - context.sinch = sinch + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' @when('I send a request to search for available phone numbers') def step_search_available_numbers(context): @@ -25,7 +33,7 @@ def step_search_available_numbers(context): region_code='US', number_type='LOCAL' ) - context.response = response + context.response = execute_sync_or_async(context, response) @then('the response contains "{count}" available phone numbers') def step_check_available_numbers_count(context, count): @@ -50,11 +58,10 @@ def step_check_number_properties(context): def step_check_number_availability(context, phone_number): try: response = context.sinch.numbers.available.check_availability(phone_number) - context.response = response + context.response = execute_sync_or_async(context, response) except NumberNotFoundException as e: context.error = e - @then('the response displays the phone number "{phone_number}" details') def step_validate_number_details(context, phone_number): data = context.response @@ -69,8 +76,7 @@ def step_check_unavailable_number(context, phone_number): @when('I send a request to rent a number with some criteria') def step_rent_any_number(context): - sinch_client: SinchClient = context.sinch - response = sinch_client.numbers.available.rent_any( + response = context.sinch.numbers.available.rent_any( region_code = 'US', type_ = 'LOCAL', capabilities = ['SMS', 'VOICE'], @@ -86,7 +92,7 @@ def step_rent_any_number(context): 'search_pattern': 'END' }, ) - context.response = response + context.response = execute_sync_or_async(context, response) @then('the response contains a rented phone number') def step_validate_rented_number(context): @@ -100,7 +106,9 @@ def step_validate_rented_number(context): assert data.money.currency_code == 'EUR' assert data.money.amount == Decimal('0.80') assert data.payment_interval_months == 1 - assert data.next_charge_date == datetime.fromisoformat('2024-06-06T14:42:42.022227+00:00').astimezone(tz=timezone.utc) + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.022227+00:00' + ).astimezone(tz=timezone.utc) assert data.expire_at == None assert data.callback_url == '' assert data.sms_configuration.service_plan_id == '' @@ -108,7 +116,9 @@ def step_validate_rented_number(context): assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SpaceMonkeySquadron' assert data.sms_configuration.scheduled_provisioning.campaign_id == '' assert data.sms_configuration.scheduled_provisioning.status == 'WAITING' - assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.596223+00:00').astimezone(tz=timezone.utc) + assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T14:42:42.596223+00:00' + ).astimezone(tz=timezone.utc) assert data.sms_configuration.scheduled_provisioning.error_codes == [] assert data.voice_configuration.type == 'RTC' assert data.voice_configuration.app_id == '' @@ -119,12 +129,13 @@ def step_validate_rented_number(context): assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == '' assert data.voice_configuration.scheduled_voice_provisioning.service_id == '' assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING' - assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat('2024-06-06T14:42:42.604092+00:00').astimezone(tz=timezone.utc) + assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T14:42:42.604092+00:00' + ).astimezone(tz=timezone.utc) @when('I send a request to rent the phone number "{phone_number}"') def step_rent_specific_number(context, phone_number): - sinch_client: SinchClient = context.sinch - response = sinch_client.numbers.available.activate( + response = context.sinch.numbers.available.activate( phone_number = phone_number, sms_configuration= { 'service_plan_id': 'SpaceMonkeySquadron', @@ -133,7 +144,7 @@ def step_rent_specific_number(context, phone_number): 'app_id': 'sunshine-rain-drop-very-beautifulday' } ) - context.response = response + context.response = execute_sync_or_async(context, response) @then('the response contains this rented phone number "{phone_number}"') def step_validate_rented_specific_number(context, phone_number): @@ -142,9 +153,8 @@ def step_validate_rented_specific_number(context, phone_number): @when('I send a request to rent the unavailable phone number "{phone_number}"') def step_rent_unavailable_number(context, phone_number): - sinch_client: SinchClient = context.sinch try: - response = sinch_client.numbers.available.activate( + response = context.sinch.numbers.available.activate( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -153,18 +163,18 @@ def step_rent_unavailable_number(context, phone_number): 'app_id': 'sunshine-rain-drop-very-beautifulday' } ) - context.response = response + context.response = execute_sync_or_async(context, response) except NumberNotFoundException as e: context.error = e @when("I send a request to list the phone numbers") def step_when_list_phone_numbers(context): - sinch_client: SinchClient = context.sinch - response = sinch_client.numbers.active.list( + response = context.sinch.numbers.active.list( region_code='US', number_type='LOCAL' ) # Get the first page + response = execute_sync_or_async(context, response) context.response = response.content() @@ -175,15 +185,23 @@ def step_then_response_contains_x_phone_numbers(context, count): @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): - sinch_client: SinchClient = context.sinch - response = sinch_client.numbers.active.list( + response = context.sinch.numbers.active.list( region_code='US', number_type='LOCAL' ) active_numbers_list = [] - for number in response.iterator(): - active_numbers_list.append(number) + response = execute_sync_or_async(context, response) + if inspect.isasyncgen(response.iterator()): + async def collect_async_numbers(): + async for number in response.iterator(): + active_numbers_list.append(number) + + execute_sync_or_async(context, collect_async_numbers()) + else: + for number in response.iterator(): + active_numbers_list.append(number) + context.active_numbers_list = active_numbers_list @then('the phone numbers list contains "{count}" phone numbers') From 23cfc25f4c47516230584050a7e7541f8d38ebb9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 6 Mar 2025 17:32:03 +0100 Subject: [PATCH 018/106] DEVEXP-758: SinchClient Configuration - Update unit tests to validate all credentials - Update README to explain which credentials must be set for each API Signed-off-by: Jessica Matsuoka --- README.md | 53 +++++++++++++++++++++----------- tests/unit/test_client.py | 18 +++++++++++ tests/unit/test_configuration.py | 53 ++++++++++++++++++++++++-------- 3 files changed, 93 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 750fc3f6..7381f6e3 100644 --- a/README.md +++ b/README.md @@ -33,47 +33,64 @@ For more information on the Sinch APIs on which this SDK is based, refer to the You can install this package by typing: `pip install sinch` +## Products + +Sinch client provides access to the following Sinch products: +- Numbers +- SMS +- Verification +- Voice API +- Conversation API (beta release) + + ## Getting started + ### Client initialization -To initialize communication with Sinch backed, credentials obtained from Sinch portal have to be provided to the main client class of this SDK. -It's highly advised to not hardcode those credentials, but to fetch them from environment variables: +To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API +you intend to use. For security best practices, avoid hardcoding credentials. +Instead, retrieve them from environment variables. + +#### Verification and Voice APIs + +To initialize the client for the **Verification** and **Voice** APIs, use the following credentials: ```python from sinch import SinchClient sinch_client = SinchClient( - key_id="key_id", - key_secret="key_secret", - project_id="some_project", application_key="application_key", application_secret="application_secret" ) ``` +#### SMS API +For the SMS API in **Australia (AU)**, **Brazil (BR)**, **Canada (CA)**, **the United States (US)**, +and **the European Union (EU)**, provide the following parameters: + ```python -import os from sinch import SinchClient sinch_client = SinchClient( - key_id=os.getenv("KEY_ID"), - key_secret=os.getenv("KEY_SECRET"), - project_id=os.getenv("PROJECT_ID"), - application_key=os.getenv("APPLICATION_KEY"), - application_secret=os.getenv("APPLICATION_SECRET") + service_plan_id="service_plan_id", + sms_api_token="api_token" ) ``` -## Products +#### All Other Sinch APIs +For all other Sinch APIs, including SMS in US and EU regions, use the following parameters: -Sinch client provides access to the following Sinch products: -- Numbers -- SMS -- Verification -- Voice API -- Conversation API (beta release) +```python +from sinch import SinchClient + +sinch_client = SinchClient( + key_id="key_id", + key_secret="key_secret", + project_id="project_id", +) +``` ## Logging diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 24907c27..0ae159d0 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -20,18 +20,36 @@ def test_sinch_client_async_initialization(): assert sinch_client_async +def test_sinch_client_empty_expects_initialization(): + """ Test that SinchClient can be initialized with no parameters """ + sinch_client_sync = SinchClient() + assert sinch_client_sync + + +def test_sinch_client_async_empty_expects_initialization(): + """ Test that SinchClientAsync can be initialized with no parameters """ + sinch_client_async = SinchClientAsync() + assert sinch_client_async + + def test_sinch_client_has_all_business_domains(sinch_client_sync): + """ Test that SinchClient has all domains """ assert hasattr(sinch_client_sync, "authentication") assert hasattr(sinch_client_sync, "sms") assert hasattr(sinch_client_sync, "conversation") assert hasattr(sinch_client_sync, "numbers") + assert hasattr(sinch_client_sync, "verification") + assert hasattr(sinch_client_sync, "voice") def test_sinch_client_async_has_all_business_domains(sinch_client_async): + """ Test that SinchClientAsync has all domains """ assert hasattr(sinch_client_async, "authentication") assert hasattr(sinch_client_async, "sms") assert hasattr(sinch_client_async, "conversation") assert hasattr(sinch_client_async, "numbers") + assert hasattr(sinch_client_async, "verification") + assert hasattr(sinch_client_async, "voice") def test_sinch_client_has_configuration_object(sinch_client_sync): diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 010660b1..d10871cf 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,21 +1,48 @@ +import pytest +from logging import Logger, getLogger from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.adapters.requests_http_transport import HTTPTransportRequests -from sinch.core.token_manager import TokenManager +from sinch.core.adapters.httpx_adapter import HTTPXTransport +from sinch.core.token_manager import TokenManager, TokenManagerAsync + + +@pytest.mark.parametrize( + "transport_class, token_manager_class, client_fixture", + [ + (HTTPTransportRequests, TokenManager, "sinch_client_sync"), + (HTTPXTransport, TokenManagerAsync, "sinch_client_async") + ] +) +def test_configuration_happy_capy_expects_initialization( + request, transport_class, token_manager_class, client_fixture + ): + """ Test that Configuration can be initialized with all parameters """ + sinch_client = request.getfixturevalue(client_fixture) - -def test_configuration_initialization_happy_path(sinch_client_sync): client_configuration = Configuration( - key_id="Rodney", - key_secret="Mullen", - project_id="Is the King!", - transport=HTTPTransportRequests(sinch_client_sync), - token_manager=TokenManager(sinch_client_sync) + key_id="CapyKey", + key_secret="CapybaraWhisper", + project_id="CapybaraProjectX", + logger=getLogger("CapyTrace"), + connection_timeout=10, + application_key="AppybaraKey", + application_secret="SecretHabitatEntry", + service_plan_id="CappyPremiumPlan", + sms_api_token="HappyCappyToken", + transport=transport_class(sinch_client), + token_manager=token_manager_class(sinch_client) ) - assert client_configuration.key_id == "Rodney" - assert client_configuration.key_secret == "Mullen" - assert client_configuration.project_id == "Is the King!" - assert isinstance(client_configuration.transport, HTTPTransportRequests) - assert isinstance(client_configuration.token_manager, TokenManager) + + assert client_configuration.key_id == "CapyKey" + assert client_configuration.key_secret == "CapybaraWhisper" + assert client_configuration.project_id == "CapybaraProjectX" + assert isinstance(client_configuration.logger, Logger) + assert client_configuration.application_key == "AppybaraKey" + assert client_configuration.application_secret == "SecretHabitatEntry" + assert client_configuration.service_plan_id == "CappyPremiumPlan" + assert client_configuration.sms_api_token == "HappyCappyToken" + assert isinstance(client_configuration.transport, transport_class) + assert isinstance(client_configuration.token_manager, token_manager_class) def test_set_sms_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): From b02b46abc755b4383dbf8743f25faf172608ad9e Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 7 Mar 2025 08:52:52 +0100 Subject: [PATCH 019/106] chore: unify and clean up unit tests --- README.md | 12 +++--- tests/unit/test_client.py | 78 ++++++++++++--------------------------- 2 files changed, 29 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 7381f6e3..ebf9ad93 100644 --- a/README.md +++ b/README.md @@ -35,10 +35,10 @@ You can install this package by typing: ## Products -Sinch client provides access to the following Sinch products: -- Numbers -- SMS -- Verification +The Sinch client provides access to the following Sinch products: +- Numbers API +- SMS API +- Verification API - Voice API - Conversation API (beta release) @@ -86,9 +86,9 @@ For all other Sinch APIs, including SMS in US and EU regions, use the following from sinch import SinchClient sinch_client = SinchClient( - key_id="key_id", - key_secret="key_secret", project_id="project_id", + key_id="key_id", + key_secret="key_secret" ) ``` diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 0ae159d0..da3b947c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,62 +1,30 @@ +import pytest from sinch import SinchClient, SinchClientAsync from sinch.core.clients.sinch_client_configuration import Configuration - -def test_sinch_client_initialization(): - sinch_client_sync = SinchClient( - key_id="test", - key_secret="test_secret", - project_id="test_project_id" - ) - assert sinch_client_sync - - -def test_sinch_client_async_initialization(): - sinch_client_async = SinchClientAsync( +@pytest.mark.parametrize("client", [SinchClient, SinchClientAsync]) +def test_sinch_client_initialization(client): + """ Test that SinchClient and SinchClientAsync can be initialized with or without parameters """ + sinch_client = client( key_id="test", key_secret="test_secret", project_id="test_project_id" ) - assert sinch_client_async - - -def test_sinch_client_empty_expects_initialization(): - """ Test that SinchClient can be initialized with no parameters """ - sinch_client_sync = SinchClient() - assert sinch_client_sync - - -def test_sinch_client_async_empty_expects_initialization(): - """ Test that SinchClientAsync can be initialized with no parameters """ - sinch_client_async = SinchClientAsync() - assert sinch_client_async - - -def test_sinch_client_has_all_business_domains(sinch_client_sync): - """ Test that SinchClient has all domains """ - assert hasattr(sinch_client_sync, "authentication") - assert hasattr(sinch_client_sync, "sms") - assert hasattr(sinch_client_sync, "conversation") - assert hasattr(sinch_client_sync, "numbers") - assert hasattr(sinch_client_sync, "verification") - assert hasattr(sinch_client_sync, "voice") - - -def test_sinch_client_async_has_all_business_domains(sinch_client_async): - """ Test that SinchClientAsync has all domains """ - assert hasattr(sinch_client_async, "authentication") - assert hasattr(sinch_client_async, "sms") - assert hasattr(sinch_client_async, "conversation") - assert hasattr(sinch_client_async, "numbers") - assert hasattr(sinch_client_async, "verification") - assert hasattr(sinch_client_async, "voice") - - -def test_sinch_client_has_configuration_object(sinch_client_sync): - assert hasattr(sinch_client_sync, "configuration") - assert isinstance(sinch_client_sync.configuration, Configuration) - - -def test_sinch_client_async_has_configuration_object(sinch_client_async): - assert hasattr(sinch_client_async, "configuration") - assert isinstance(sinch_client_async.configuration, Configuration) + assert sinch_client + + sinch_client_empty = client() + assert sinch_client_empty + + +@pytest.mark.parametrize("client", ["sinch_client_sync", "sinch_client_async"]) +def test_sinch_client_expects_all_attributes(request, client): + """ Test that SinchClient and SinchClientAsync have all attributes""" + client_instance = request.getfixturevalue(client) + assert hasattr(client_instance, "authentication") + assert hasattr(client_instance, "sms") + assert hasattr(client_instance, "conversation") + assert hasattr(client_instance, "numbers") + assert hasattr(client_instance, "verification") + assert hasattr(client_instance, "voice") + assert hasattr(client_instance, "configuration") + assert isinstance(client_instance.configuration, Configuration) From ad9773fc2a399e8f2c60dda89803e4b2f4f2b789 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 7 Mar 2025 14:17:17 +0100 Subject: [PATCH 020/106] chore: remove pydantic model --- sinch/domains/numbers/available_numbers.py | 28 ++++----- .../available/activate_number_endpoint.py | 7 ++- .../numbers/models/available/__init__.py | 2 - .../available/activate_number_response.py | 5 -- .../numbers/features/steps/numbers.steps.py | 4 +- .../test_activate_number_response_model.py | 60 ------------------- .../domains/numbers/models/test_numbers.py | 35 +++++++++++ .../domains/numbers/test_available_numbers.py | 6 +- 8 files changed, 59 insertions(+), 88 deletions(-) delete mode 100644 sinch/domains/numbers/models/available/activate_number_response.py diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/available_numbers.py index 582ab60c..815db3ea 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/available_numbers.py @@ -10,10 +10,10 @@ ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest ) from sinch.domains.numbers.models.available import ( - ActivateNumberResponse, CheckNumberAvailabilityResponse, RentAnyNumberResponse + CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.models.numbers import ( - CapabilityTypeValuesList, Number, NumberSearchPatternTypeValues, NumberTypeValues + ActiveNumber, CapabilityTypeValuesList, Number, NumberSearchPatternTypeValues, NumberTypeValues ) @@ -66,7 +66,7 @@ def activate( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None - ) -> ActivateNumberResponse: + ) -> ActiveNumber: pass @overload @@ -76,7 +76,7 @@ def activate( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictEST, callback_url: Optional[StrictStr] = None - ) -> ActivateNumberResponse: + ) -> ActiveNumber: pass @overload @@ -86,7 +86,7 @@ def activate( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictFAX, callback_url: Optional[StrictStr] = None - ) -> ActivateNumberResponse: + ) -> ActiveNumber: pass @overload @@ -96,7 +96,7 @@ def activate( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictRTC, callback_url: Optional[StrictStr] = None - ) -> ActivateNumberResponse: + ) -> ActiveNumber: pass def activate( @@ -106,7 +106,7 @@ def activate( voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, **kwargs - ) -> ActivateNumberResponse: + ) -> ActiveNumber: """ Activate a virtual number to use with SMS, Voice, or both products. @@ -125,7 +125,7 @@ def activate( **kwargs: Additional parameters for the request. Returns: - ActivateNumberResponse: A response object with the activated number and its details. + ActiveNumber: A response object with the activated number and its details. For detailed documentation, visit https://developers.sinch.com """ @@ -147,7 +147,7 @@ def rent_any( voice_configuration: None, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[str] = None, + callback_url: Optional[StrictStr] = None, ) -> RentAnyNumberResponse: pass @@ -160,7 +160,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictRTC, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[str] = None, + callback_url: Optional[StrictStr] = None, ) -> RentAnyNumberResponse: pass @@ -173,7 +173,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictFAX, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[str] = None, + callback_url: Optional[StrictStr] = None, ) -> RentAnyNumberResponse: pass @@ -186,7 +186,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictEST, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[str] = None, + callback_url: Optional[StrictStr] = None, ) -> RentAnyNumberResponse: pass @@ -198,7 +198,7 @@ def rent_any( capabilities: Optional[CapabilityTypeValuesList] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, - callback_url: Optional[str] = None, + callback_url: Optional[StrictStr] = None, **kwargs ) -> RentAnyNumberResponse: """ @@ -219,7 +219,7 @@ def rent_any( - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. - callback_url (str): The callback URL to receive notifications. + callback_url (StrictStr): The callback URL to receive notifications. **kwargs: Additional parameters for the request. Returns: diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py index 183b3f2c..86866ed6 100644 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py @@ -1,9 +1,10 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.models.numbers import ActiveNumber from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException -from sinch.domains.numbers.models.available import ActivateNumberRequest, ActivateNumberResponse +from sinch.domains.numbers.models.available import ActivateNumberRequest class ActivateNumberEndpoint(NumbersEndpoint): @@ -22,10 +23,10 @@ def request_body(self) -> str: request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) return json.dumps(request_data) - def handle_response(self, response: HTTPResponse) -> ActivateNumberResponse: + def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: super(ActivateNumberEndpoint, self).handle_response(response) except NumbersException as ex: raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, is_from_server=ex.is_from_server) - return self.process_response_model(response.body, ActivateNumberResponse) + return self.process_response_model(response.body, ActiveNumber) diff --git a/sinch/domains/numbers/models/available/__init__.py b/sinch/domains/numbers/models/available/__init__.py index 4cb2a5d6..b98e5b12 100644 --- a/sinch/domains/numbers/models/available/__init__.py +++ b/sinch/domains/numbers/models/available/__init__.py @@ -1,5 +1,4 @@ from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest @@ -8,7 +7,6 @@ __all__ = [ "ActivateNumberRequest", - "ActivateNumberResponse", "CheckNumberAvailabilityRequest", "CheckNumberAvailabilityResponse", "ListAvailableNumbersRequest", diff --git a/sinch/domains/numbers/models/available/activate_number_response.py b/sinch/domains/numbers/models/available/activate_number_response.py deleted file mode 100644 index e32b8ae7..00000000 --- a/sinch/domains/numbers/models/available/activate_number_response.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.numbers.models.numbers import ActiveNumber - - -class ActivateNumberResponse(ActiveNumber): - pass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 0ee89316..d61a793f 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,8 +2,8 @@ from datetime import timezone, datetime from behave import given, when, then from decimal import Decimal +from sinch.domains.numbers.models.numbers import ActiveNumber from sinch.domains.numbers.exceptions import NumberNotFoundException -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse from sinch.domains.numbers.models.numbers import NotFoundError @@ -148,7 +148,7 @@ def step_rent_specific_number(context, phone_number): @then('the response contains this rented phone number "{phone_number}"') def step_validate_rented_specific_number(context, phone_number): - data: ActivateNumberResponse = context.response + data: ActiveNumber = context.response assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' @when('I send a request to rent the unavailable phone number "{phone_number}"') diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py index 0d89410b..521ebd58 100644 --- a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py +++ b/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py @@ -1,6 +1,5 @@ import pytest from datetime import datetime, timezone -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse @pytest.fixture def test_data(): @@ -69,62 +68,3 @@ def assert_voice_configuration(voice_config): assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" assert scheduled_voice_provisioning.app_id == "string" - -def test_activate_number_response_expects_all_fields_mapped_correctly(test_data): - """ - Expects all fields to map correctly from camelCase input, - converts nested keys to snake_case, and handles dynamic fields - """ - data = { - "phoneNumber": "+12025550134", - "displayName": "string", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "money": {"currencyCode": "USD", "amount": "2.00"}, - "paymentIntervalMonths": 0, - "nextChargeDate": "2025-01-22T13:19:31.095Z", - "expireAt": "2025-03-29T13:19:31.095Z", - "smsConfiguration": { - "servicePlanId": "string", - "campaignId": "string", - "scheduledProvisioning": { - "servicePlanId": "string", - "campaignId": "string", - "status": "PROVISIONING_STATUS_UNSPECIFIED", - "lastUpdatedTime": "2025-02-21T13:19:31.095Z", - "errorCodes": ["ERROR_CODE_UNSPECIFIED"], - }, - }, - "voiceConfiguration": { - "type": "RTC", - "lastUpdatedTime": "2025-01-25T13:49:31.095Z", - "scheduledVoiceProvisioning": { - "type": "RTC", - "lastUpdatedTime": "2025-02-22T13:19:31.095Z", - "status": "PROVISIONING_STATUS_UNSPECIFIED", - "appId": "string", - }, - "appId": "string", - }, - "callbackUrl": "https://www.your-callback-server.com/callback", - } - response = ActivateNumberResponse(**data) - - assert response.phone_number == "+12025550134" - assert response.display_name == "string" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS"] - assert response.money.currency_code == "USD" - assert response.payment_interval_months == 0 - expected_next_charge_data = ( - datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) - assert response.next_charge_date == expected_next_charge_data - expected_expire_at = ( - datetime(2025, 3, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) - assert response.expire_at == expected_expire_at - assert response.callback_url == "https://www.your-callback-server.com/callback" - # Assert sms_configuration and voice_configuration using helper functions - assert_sms_configuration(response.sms_configuration) - assert_voice_configuration(response.voice_configuration) diff --git a/tests/unit/domains/numbers/models/test_numbers.py b/tests/unit/domains/numbers/models/test_numbers.py index e006652c..71a20207 100644 --- a/tests/unit/domains/numbers/models/test_numbers.py +++ b/tests/unit/domains/numbers/models/test_numbers.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone from sinch.domains.numbers.models.numbers import ( + ActiveNumber, ScheduledProvisioningSmsConfiguration, SmsConfigurationResponse, VoiceConfigurationResponse, NotFoundError, @@ -135,3 +136,37 @@ def test_not_found_error_deserialize_with_snake_case(): assert not_found_error.details[0].resource_name == '+1234567890' assert not_found_error.details[0].owner == '' assert not_found_error.details[0].description == '' + +def test_activate_number_response_expects_all_fields_mapped_correctly(): + """ + Expects all fields to map correctly from camelCase input, + converts nested keys to snake_case, and handles dynamic fields + """ + data = { + "phoneNumber": "+12025550134", + "displayName": "string", + "regionCode": "US", + "type": "MOBILE", + "capability": ["SMS"], + "money": {"currencyCode": "USD", "amount": "2.00"}, + "paymentIntervalMonths": 0, + "nextChargeDate": "2025-01-22T13:19:31.095Z", + "expireAt": "2025-03-29T13:19:31.095Z", + "callbackUrl": "https://www.your-callback-server.com/callback", + } + response = ActiveNumber(**data) + + assert response.phone_number == "+12025550134" + assert response.display_name == "string" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.payment_interval_months == 0 + expected_next_charge_data = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_data + expected_expire_at = ( + datetime(2025, 3, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + assert response.callback_url == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/test_available_numbers.py b/tests/unit/domains/numbers/test_available_numbers.py index f3829fc4..fb1d9dd6 100644 --- a/tests/unit/domains/numbers/test_available_numbers.py +++ b/tests/unit/domains/numbers/test_available_numbers.py @@ -1,5 +1,6 @@ import pytest from unittest.mock import MagicMock + from sinch.domains.numbers.available_numbers import AvailableNumbers from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint @@ -10,9 +11,10 @@ from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse -from sinch.domains.numbers.models.available.activate_number_response import ActivateNumberResponse from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.numbers import ActiveNumber + @pytest.fixture def mock_sinch(): """Creates a mocked Sinch client.""" @@ -66,7 +68,7 @@ def test_activate_number_expects_correct_request(mock_sinch, mocker): Test that the AvailableNumbers.activate method sends the correct request and handles the response properly. """ - mock_response = ActivateNumberResponse.model_construct() + mock_response = ActiveNumber.model_construct() mock_sinch.configuration.transport.request.return_value = mock_response spy_endpoint = mocker.spy(ActivateNumberEndpoint, "__init__") From cf221a6e5bf43d3d411aa2ec89c1b952bd6bbced Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 11 Mar 2025 15:39:16 +0100 Subject: [PATCH 021/106] DEVEXP-766: Refactor Numbers domain as expected generated structure Signed-off-by: Jessica Matsuoka --- sinch/domains/numbers/__init__.py | 6 +- sinch/domains/numbers/api/__init__.py | 0 sinch/domains/numbers/api/v1/__init__.py | 0 .../numbers/api/v1/active_numbers/__init__.py | 10 ++ .../v1/active_numbers/active_numbers_apis.py} | 15 +- .../active_numbers_endpoints.py} | 6 +- .../api/v1/available_numbers/__init__.py | 10 ++ .../available_numbers_apis.py} | 30 ++-- .../available_numbers_endpoints.py | 129 ++++++++++++++ sinch/domains/numbers/base_numbers.py | 42 ----- .../numbers/endpoints/active/__init__.py | 2 - .../active/get_number_configuration.py | 2 +- .../active/release_number_from_project.py | 2 +- .../active/update_number_configuration.py | 2 +- .../numbers/endpoints/available/__init__.py | 11 -- .../available/activate_number_endpoint.py | 32 ---- .../list_available_numbers_endpoint.py | 36 ---- .../available/rent_any_number_endpoint.py | 38 ----- .../available/search_for_number_endpoint.py | 35 ---- .../endpoints/callbacks/get_configuration.py | 2 +- .../callbacks/update_configuration.py | 2 +- .../regions/list_available_regions.py | 2 +- .../domains/numbers/models/active/__init__.py | 7 - .../numbers/models/available/__init__.py | 15 -- sinch/domains/numbers/models/numbers.py | 161 ------------------ sinch/domains/numbers/models/v1/__init__.py | 16 ++ .../numbers/models/v1/active_number.py | 25 +++ .../numbers/models/v1/available_number.py | 19 +++ .../numbers/models/v1/capability_type.py | 9 + .../check_number_availability_response.py | 6 +- .../numbers/models/v1/errors/__init__.py | 7 + .../numbers/models/v1/errors/error_details.py | 11 ++ .../numbers/models/v1/errors/not_found.py | 13 ++ .../numbers/models/v1/internal/__init__.py | 18 ++ .../internal}/activate_number_request.py | 4 +- .../internal/base_model_config.py} | 0 .../check_number_availability_request.py | 2 +- .../internal}/list_active_numbers_request.py | 10 +- .../list_available_numbers_request.py | 10 +- .../internal}/rent_any_number_request.py | 12 +- .../v1/internal/sms_configuration_request.py | 8 + .../internal/voice_configuration_request.py | 28 +++ .../list_active_numbers_response.py | 2 +- .../list_available_numbers_response.py | 4 +- sinch/domains/numbers/models/v1/money.py | 8 + .../numbers/models/v1/number_pattern.py | 9 + .../numbers/models/v1/number_pattern_dict.py | 8 + .../models/v1/number_search_pattern_type.py | 9 + .../domains/numbers/models/v1/number_type.py | 10 ++ .../numbers/models/v1/order_by_values.py | 4 + .../rent_any_number_response.py | 9 +- .../models/v1/scheduled_sms_provisioning.py | 15 ++ .../models/v1/scheduled_voice_provisioning.py | 11 ++ .../v1/scheduled_voice_provisioning_custom.py | 6 + .../v1/scheduled_voice_provisioning_est.py | 7 + .../v1/scheduled_voice_provisioning_fax.py | 7 + .../v1/scheduled_voice_provisioning_rtc.py | 7 + .../models/v1/sms_configuration_dict.py | 7 + .../models/v1/sms_configuration_response.py | 13 ++ .../v1/status_scheduled_provisioning.py | 7 + .../numbers/models/v1/utils/__init__.py | 0 .../{ => models/v1/utils}/validators.py | 10 +- .../models/v1/voice_configuration_dict.py | 29 ++++ .../models/v1/voice_configuration_response.py | 21 +++ .../{endpoints => }/numbers_endpoint.py | 2 +- tests/conftest.py | 2 +- .../numbers/features/steps/numbers.steps.py | 5 +- .../test_list_active_numbers_endpoint.py | 6 +- .../test_activate_number_endpoint.py | 4 +- .../test_list_available_numbers_endpoint.py | 4 +- .../test_rent_any_number_endpoint.py | 6 +- .../test_search_for_number_endpoint.py | 6 +- .../test_list_active_numbers_request_model.py | 2 +- ...test_list_active_numbers_response_model.py | 2 +- .../test_activate_number_request_model.py | 2 +- ...st_list_available_numbers_request_model.py | 2 +- .../test_rent_any_number_request_model.py | 2 +- .../test_search_for_number_request_model.py | 2 +- .../test_activate_number_response_model.py | 0 ...t_list_available_numbers_response_model.py | 2 +- .../test_rent_any_number_response_model.py | 2 +- .../test_search_for_number_response_model.py | 2 +- .../models/base/test_base_model_requests.py | 2 +- .../models/base/test_base_model_response.py | 2 +- .../numbers/{ => v1}/models/test_numbers.py | 15 +- .../{ => v1}/test_available_numbers.py | 20 +-- 86 files changed, 600 insertions(+), 490 deletions(-) create mode 100644 sinch/domains/numbers/api/__init__.py create mode 100644 sinch/domains/numbers/api/v1/__init__.py create mode 100644 sinch/domains/numbers/api/v1/active_numbers/__init__.py rename sinch/domains/numbers/{active_numbers.py => api/v1/active_numbers/active_numbers_apis.py} (91%) rename sinch/domains/numbers/{endpoints/active/list_active_numbers_endpoint.py => api/v1/active_numbers/active_numbers_endpoints.py} (78%) create mode 100644 sinch/domains/numbers/api/v1/available_numbers/__init__.py rename sinch/domains/numbers/{available_numbers.py => api/v1/available_numbers/available_numbers_apis.py} (89%) create mode 100644 sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py delete mode 100644 sinch/domains/numbers/endpoints/available/__init__.py delete mode 100644 sinch/domains/numbers/endpoints/available/activate_number_endpoint.py delete mode 100644 sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py delete mode 100644 sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py delete mode 100644 sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py delete mode 100644 sinch/domains/numbers/models/available/__init__.py delete mode 100644 sinch/domains/numbers/models/numbers.py create mode 100644 sinch/domains/numbers/models/v1/__init__.py create mode 100644 sinch/domains/numbers/models/v1/active_number.py create mode 100644 sinch/domains/numbers/models/v1/available_number.py create mode 100644 sinch/domains/numbers/models/v1/capability_type.py rename sinch/domains/numbers/models/{available => v1}/check_number_availability_response.py (74%) create mode 100644 sinch/domains/numbers/models/v1/errors/__init__.py create mode 100644 sinch/domains/numbers/models/v1/errors/error_details.py create mode 100644 sinch/domains/numbers/models/v1/errors/not_found.py create mode 100644 sinch/domains/numbers/models/v1/internal/__init__.py rename sinch/domains/numbers/models/{available => v1/internal}/activate_number_request.py (80%) rename sinch/domains/numbers/models/{base_model_numbers.py => v1/internal/base_model_config.py} (100%) rename sinch/domains/numbers/models/{available => v1/internal}/check_number_availability_request.py (65%) rename sinch/domains/numbers/models/{active => v1/internal}/list_active_numbers_request.py (66%) rename sinch/domains/numbers/models/{available => v1/internal}/list_available_numbers_request.py (60%) rename sinch/domains/numbers/models/{available => v1/internal}/rent_any_number_request.py (67%) create mode 100644 sinch/domains/numbers/models/v1/internal/sms_configuration_request.py create mode 100644 sinch/domains/numbers/models/v1/internal/voice_configuration_request.py rename sinch/domains/numbers/models/{active => v1}/list_active_numbers_response.py (90%) rename sinch/domains/numbers/models/{available => v1}/list_available_numbers_response.py (55%) create mode 100644 sinch/domains/numbers/models/v1/money.py create mode 100644 sinch/domains/numbers/models/v1/number_pattern.py create mode 100644 sinch/domains/numbers/models/v1/number_pattern_dict.py create mode 100644 sinch/domains/numbers/models/v1/number_search_pattern_type.py create mode 100644 sinch/domains/numbers/models/v1/number_type.py create mode 100644 sinch/domains/numbers/models/v1/order_by_values.py rename sinch/domains/numbers/models/{available => v1}/rent_any_number_response.py (69%) create mode 100644 sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py create mode 100644 sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py create mode 100644 sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py create mode 100644 sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py create mode 100644 sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py create mode 100644 sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py create mode 100644 sinch/domains/numbers/models/v1/sms_configuration_dict.py create mode 100644 sinch/domains/numbers/models/v1/sms_configuration_response.py create mode 100644 sinch/domains/numbers/models/v1/status_scheduled_provisioning.py create mode 100644 sinch/domains/numbers/models/v1/utils/__init__.py rename sinch/domains/numbers/{ => models/v1/utils}/validators.py (77%) create mode 100644 sinch/domains/numbers/models/v1/voice_configuration_dict.py create mode 100644 sinch/domains/numbers/models/v1/voice_configuration_response.py rename sinch/domains/numbers/{endpoints => }/numbers_endpoint.py (97%) rename tests/unit/domains/numbers/{ => v1}/endpoints/active/test_list_active_numbers_endpoint.py (92%) rename tests/unit/domains/numbers/{ => v1}/endpoints/available/test_activate_number_endpoint.py (93%) rename tests/unit/domains/numbers/{ => v1}/endpoints/available/test_list_available_numbers_endpoint.py (93%) rename tests/unit/domains/numbers/{ => v1}/endpoints/available/test_rent_any_number_endpoint.py (95%) rename tests/unit/domains/numbers/{ => v1}/endpoints/available/test_search_for_number_endpoint.py (90%) rename tests/unit/domains/numbers/{ => v1}/models/active/requests/test_list_active_numbers_request_model.py (95%) rename tests/unit/domains/numbers/{ => v1}/models/active/response/test_list_active_numbers_response_model.py (97%) rename tests/unit/domains/numbers/{ => v1}/models/available/requests/test_activate_number_request_model.py (96%) rename tests/unit/domains/numbers/{ => v1}/models/available/requests/test_list_available_numbers_request_model.py (97%) rename tests/unit/domains/numbers/{ => v1}/models/available/requests/test_rent_any_number_request_model.py (95%) rename tests/unit/domains/numbers/{ => v1}/models/available/requests/test_search_for_number_request_model.py (92%) rename tests/unit/domains/numbers/{ => v1}/models/available/response/test_activate_number_response_model.py (100%) rename tests/unit/domains/numbers/{ => v1}/models/available/response/test_list_available_numbers_response_model.py (93%) rename tests/unit/domains/numbers/{ => v1}/models/available/response/test_rent_any_number_response_model.py (98%) rename tests/unit/domains/numbers/{ => v1}/models/available/response/test_search_for_number_response_model.py (96%) rename tests/unit/domains/numbers/{ => v1}/models/base/test_base_model_requests.py (95%) rename tests/unit/domains/numbers/{ => v1}/models/base/test_base_model_response.py (84%) rename tests/unit/domains/numbers/{ => v1}/models/test_numbers.py (91%) rename tests/unit/domains/numbers/{ => v1}/test_available_numbers.py (76%) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 5afb5c76..931b9ea7 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,5 +1,7 @@ -from sinch.domains.numbers.available_numbers import AvailableNumbers -from sinch.domains.numbers.active_numbers import ActiveNumbers, ActiveNumbersWithAsyncPagination +from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1.active_numbers.active_numbers_apis import ( + ActiveNumbers, ActiveNumbersWithAsyncPagination +) from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.regions.list_available_regions import ListAvailableRegionsEndpoint diff --git a/sinch/domains/numbers/api/__init__.py b/sinch/domains/numbers/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/numbers/api/v1/active_numbers/__init__.py b/sinch/domains/numbers/api/v1/active_numbers/__init__.py new file mode 100644 index 00000000..65cddfd7 --- /dev/null +++ b/sinch/domains/numbers/api/v1/active_numbers/__init__.py @@ -0,0 +1,10 @@ +from sinch.domains.numbers.api.v1.active_numbers.active_numbers_endpoints import ListActiveNumbersEndpoint +from sinch.domains.numbers.api.v1.active_numbers.active_numbers_apis import ( + ActiveNumbers, ActiveNumbersWithAsyncPagination +) + +__all__ = [ + "ListActiveNumbersEndpoint", + "ActiveNumbers", + "ActiveNumbersWithAsyncPagination" +] diff --git a/sinch/domains/numbers/active_numbers.py b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py similarity index 91% rename from sinch/domains/numbers/active_numbers.py rename to sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py index 254a7fca..9ed5a4a5 100644 --- a/sinch/domains/numbers/active_numbers.py +++ b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py @@ -2,22 +2,23 @@ from pydantic import StrictStr, StrictInt from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator from sinch.domains.numbers.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.active_numbers import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active import ( - GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, + GetNumberConfigurationEndpoint, ReleaseNumberFromProjectEndpoint, UpdateNumberConfigurationEndpoint ) -from sinch.domains.numbers.models.active import ( - ListActiveNumbersRequest -) from sinch.domains.numbers.models.active.requests import ( GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest ) from sinch.domains.numbers.models.active.responses import ( UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) -from sinch.domains.numbers.models.numbers import ( - CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues, OrderByValues, ActiveNumber -) +from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1.number_type import NumberTypeValues +from sinch.domains.numbers.models.v1.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues class ActiveNumbers(BaseNumbers): diff --git a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py similarity index 78% rename from sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py rename to sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py index 570c1525..db110039 100644 --- a/sinch/domains/numbers/endpoints/active/list_active_numbers_endpoint.py +++ b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py @@ -1,8 +1,8 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1 import ListActiveNumbersResponse class ListActiveNumbersEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/api/v1/available_numbers/__init__.py b/sinch/domains/numbers/api/v1/available_numbers/__init__.py new file mode 100644 index 00000000..601d5599 --- /dev/null +++ b/sinch/domains/numbers/api/v1/available_numbers/__init__.py @@ -0,0 +1,10 @@ +from sinch.domains.numbers.api.v1.available_numbers.available_numbers_endpoints import ( + ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint +) + +__all__ = [ + "ActivateNumberEndpoint", + "AvailableNumbersEndpoint", + "RentAnyNumberEndpoint", + "SearchForNumberEndpoint" +] diff --git a/sinch/domains/numbers/available_numbers.py b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py similarity index 89% rename from sinch/domains/numbers/available_numbers.py rename to sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py index 815db3ea..f88fb09d 100644 --- a/sinch/domains/numbers/available_numbers.py +++ b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py @@ -1,19 +1,23 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr -from sinch.domains.numbers.base_numbers import ( - BaseNumbers, SmsConfigurationDict, VoiceConfigurationDictRTC, VoiceConfigurationDictType, - VoiceConfigurationDictEST, VoiceConfigurationDictFAX, NumberPatternDict) -from sinch.domains.numbers.endpoints.available import ( - ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint +from sinch.domains.numbers.base_numbers import BaseNumbers +from sinch.domains.numbers.models.v1 import ( + AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse, ) -from sinch.domains.numbers.models.available import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest +from sinch.domains.numbers.api.v1.available_numbers import ( + ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.available import ( - CheckNumberAvailabilityResponse, RentAnyNumberResponse +from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.number_pattern_dict import NumberPatternDict +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.number_type import NumberTypeValues +from sinch.domains.numbers.models.v1.sms_configuration_dict import SmsConfigurationDict +from sinch.domains.numbers.models.v1.voice_configuration_dict import ( + VoiceConfigurationDictType, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC ) -from sinch.domains.numbers.models.numbers import ( - ActiveNumber, CapabilityTypeValuesList, Number, NumberSearchPatternTypeValues, NumberTypeValues +from sinch.domains.numbers.models.v1.internal import ( + ActivateNumberRequest, CheckNumberAvailabilityRequest, RentAnyNumberRequest, ListAvailableNumbersRequest ) @@ -28,7 +32,7 @@ def list( capabilities: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, **kwargs - ) -> list[Number]: + ) -> list[AvailableNumber]: """ Search for available virtual numbers for you to activate using a variety of parameters to filter results. @@ -43,7 +47,7 @@ def list( **kwargs: Additional filters for the request. Returns: - list[Number]: A response array with available numbers and their details. + list[AvailableNumber]: A response array with available numbers and their details. For detailed documentation, visit https://developers.sinch.com """ diff --git a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py new file mode 100644 index 00000000..e1599ee8 --- /dev/null +++ b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py @@ -0,0 +1,129 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.models.v1 import ( + AvailableNumber, CheckNumberAvailabilityResponse, ListAvailableNumbersResponse, + RentAnyNumberResponse) +from sinch.domains.numbers.models.v1.active_number import ActiveNumber + +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException +from sinch.domains.numbers.models.v1.internal import ( + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest +) + + +class ActivateNumberEndpoint(NumbersEndpoint): + """ + Endpoint to activate a virtual number for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ActivateNumberRequest): + super(ActivateNumberEndpoint, self).__init__(project_id, request_data) + + def request_body(self) -> str: + # Convert the request data to a dictionary and remove None values + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(ActivateNumberEndpoint, self).handle_response(response) + except NumbersException as ex: + raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, + is_from_server=ex.is_from_server) + return self.process_response_model(response.body, ActiveNumber) + + +class AvailableNumbersEndpoint(NumbersEndpoint): + """ + Endpoint to list available virtual numbers for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): + super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response(self, response: HTTPResponse) -> list[AvailableNumber]: + """ + Processes the API response and maps it to a response model. + + Args: + response (HTTPResponse): The raw HTTP response object received from the API. + + Returns: + list[AvailableNumber]: The response model containing the parsed response data. + """ + super(AvailableNumbersEndpoint, self).handle_response(response) + response = self.process_response_model(response.body, ListAvailableNumbersResponse) + return response.available_numbers + + +class RentAnyNumberEndpoint(NumbersEndpoint): + """ + Endpoint to rent an available virtual number for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: RentAnyNumberRequest): + super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) + self.request_data = request_data + + def request_body(self) -> str: + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: + """ + Handles the response from the API call. + + Args: + response (HTTPResponse): The response object from the API call. + + Returns: + RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. + """ + error = super(RentAnyNumberEndpoint, self).handle_response(response) + if error: + return error + return self.process_response_model(response.body, RentAnyNumberResponse) + + +class SearchForNumberEndpoint(NumbersEndpoint): + """ + Endpoint to check the availability of a virtual number for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): + super(SearchForNumberEndpoint, self).__init__(project_id, request_data) + + def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: + """ + Processes the API response and maps it to a response + + Args: + response (HTTPResponse): The raw HTTP response object received from the API. + + Returns: + CheckNumberAvailabilityResponse: The response model containing the parsed response data + of the requested phone number. + """ + try: + super(SearchForNumberEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/base_numbers.py b/sinch/domains/numbers/base_numbers.py index 14d49d19..1b6bc1b5 100644 --- a/sinch/domains/numbers/base_numbers.py +++ b/sinch/domains/numbers/base_numbers.py @@ -1,45 +1,3 @@ -from typing import TypedDict, Literal, Union, Annotated -from typing_extensions import NotRequired -from pydantic import Field -from sinch.domains.numbers.models.numbers import NumberSearchPatternTypeValues - - -class SmsConfigurationDict(TypedDict): - service_plan_id: str - campaign_id: NotRequired[str] - - -class VoiceConfigurationDictRTC(TypedDict): - type: Literal["RTC"] - app_id: NotRequired[str] - - -class VoiceConfigurationDictEST(TypedDict): - type: Literal["EST"] - trunk_id: NotRequired[str] - - -class VoiceConfigurationDictFAX(TypedDict): - type: Literal["FAX"] - service_id: NotRequired[str] - - -class VoiceConfigurationDictCustom(TypedDict): - type: str - - -class NumberPatternDict(TypedDict): - pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternTypeValues] - - -VoiceConfigurationDictType = Annotated[ - Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, - VoiceConfigurationDictEST, VoiceConfigurationDictCustom], - Field(discriminator="type") -] - - class BaseNumbers: """Base class for handling Sinch Number operations.""" diff --git a/sinch/domains/numbers/endpoints/active/__init__.py b/sinch/domains/numbers/endpoints/active/__init__.py index 9be81a11..80137a40 100644 --- a/sinch/domains/numbers/endpoints/active/__init__.py +++ b/sinch/domains/numbers/endpoints/active/__init__.py @@ -1,11 +1,9 @@ from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.list_active_numbers_endpoint import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active.release_number_from_project import ReleaseNumberFromProjectEndpoint from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint __all__ = [ "GetNumberConfigurationEndpoint", - "ListActiveNumbersEndpoint", "ReleaseNumberFromProjectEndpoint", "UpdateNumberConfigurationEndpoint" ] diff --git a/sinch/domains/numbers/endpoints/active/get_number_configuration.py b/sinch/domains/numbers/endpoints/active/get_number_configuration.py index 12e90032..7533e7fd 100644 --- a/sinch/domains/numbers/endpoints/active/get_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/get_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import GetNumberConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/active/release_number_from_project.py b/sinch/domains/numbers/endpoints/active/release_number_from_project.py index d49721bd..4c3545c8 100644 --- a/sinch/domains/numbers/endpoints/active/release_number_from_project.py +++ b/sinch/domains/numbers/endpoints/active/release_number_from_project.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import ReleaseNumberFromProjectRequest from sinch.domains.numbers.models.active.responses import ReleaseNumberFromProjectResponse diff --git a/sinch/domains/numbers/endpoints/active/update_number_configuration.py b/sinch/domains/numbers/endpoints/active/update_number_configuration.py index 866bef73..1b182971 100644 --- a/sinch/domains/numbers/endpoints/active/update_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/update_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import UpdateNumberConfigurationRequest from sinch.domains.numbers.models.active.responses import UpdateNumberConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/available/__init__.py b/sinch/domains/numbers/endpoints/available/__init__.py deleted file mode 100644 index bb1a03fe..00000000 --- a/sinch/domains/numbers/endpoints/available/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.rent_any_number_endpoint import RentAnyNumberEndpoint -from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint - -__all__ = [ - "ActivateNumberEndpoint", - "AvailableNumbersEndpoint", - "RentAnyNumberEndpoint", - "SearchForNumberEndpoint" -] diff --git a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py b/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py deleted file mode 100644 index 86866ed6..00000000 --- a/sinch/domains/numbers/endpoints/available/activate_number_endpoint.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.models.numbers import ActiveNumber -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException -from sinch.domains.numbers.models.available import ActivateNumberRequest - - -class ActivateNumberEndpoint(NumbersEndpoint): - """ - Endpoint to activate a virtual number for a project. - """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ActivateNumberRequest): - super(ActivateNumberEndpoint, self).__init__(project_id, request_data) - - def request_body(self) -> str: - # Convert the request data to a dictionary and remove None values - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> ActiveNumber: - try: - super(ActivateNumberEndpoint, self).handle_response(response) - except NumbersException as ex: - raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, - is_from_server=ex.is_from_server) - return self.process_response_model(response.body, ActiveNumber) diff --git a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py b/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py deleted file mode 100644 index a5c9af5b..00000000 --- a/sinch/domains/numbers/endpoints/available/list_available_numbers_endpoint.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse -from sinch.domains.numbers.models.numbers import Number - - -class AvailableNumbersEndpoint(NumbersEndpoint): - """ - Endpoint to list available virtual numbers for a project. - """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): - super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - - def build_query_params(self) -> dict: - return self.request_data.model_dump(exclude_none=True, by_alias=True) - - def handle_response(self, response: HTTPResponse) -> list[Number]: - """ - Processes the API response and maps it to a response model. - - Args: - response (HTTPResponse): The raw HTTP response object received from the API. - - Returns: - list[Number]: The response model containing the parsed response data. - """ - super(AvailableNumbersEndpoint, self).handle_response(response) - response = self.process_response_model(response.body, ListAvailableNumbersResponse) - return response.available_numbers diff --git a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py b/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py deleted file mode 100644 index 6973b3d1..00000000 --- a/sinch/domains/numbers/endpoints/available/rent_any_number_endpoint.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse - - -class RentAnyNumberEndpoint(NumbersEndpoint): - """ - Endpoint to rent an available virtual number for a project. - """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RentAnyNumberRequest): - super(RentAnyNumberEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - - def request_body(self) -> str: - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: - """ - Handles the response from the API call. - - Args: - response (HTTPResponse): The response object from the API call. - - Returns: - RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. - """ - error = super(RentAnyNumberEndpoint, self).handle_response(response) - if error: - return error - return self.process_response_model(response.body, RentAnyNumberResponse) diff --git a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py b/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py deleted file mode 100644 index db23fff6..00000000 --- a/sinch/domains/numbers/endpoints/available/search_for_number_endpoint.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest - - -class SearchForNumberEndpoint(NumbersEndpoint): - """ - Endpoint to check the availability of a virtual number for a project. - """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): - super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - - def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: - """ - Processes the API response and maps it to a response - - Args: - response (HTTPResponse): The raw HTTP response object received from the API. - - Returns: - CheckNumberAvailabilityResponse: The response model containing the parsed response data - of the requested phone number. - """ - try: - super(SearchForNumberEndpoint, self).handle_response(response) - except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) - return self.process_response_model(response.body, CheckNumberAvailabilityResponse) diff --git a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py index 5e05c8bc..1671581d 100644 --- a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import GetNumbersCallbackConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py index 1d7c2a56..318acdb0 100644 --- a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import UpdateNumbersCallbackConfigurationResponse from sinch.domains.numbers.models.callbacks.requests import UpdateNumbersCallbackConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/regions/list_available_regions.py b/sinch/domains/numbers/endpoints/regions/list_available_regions.py index b5897879..70120210 100644 --- a/sinch/domains/numbers/endpoints/regions/list_available_regions.py +++ b/sinch/domains/numbers/endpoints/regions/list_available_regions.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.endpoints.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.regions import Region diff --git a/sinch/domains/numbers/models/active/__init__.py b/sinch/domains/numbers/models/active/__init__.py index 436aed45..e2d393d9 100644 --- a/sinch/domains/numbers/models/active/__init__.py +++ b/sinch/domains/numbers/models/active/__init__.py @@ -2,13 +2,6 @@ from sinch.core.models.base_model import SinchBaseModel from sinch.domains.numbers.enums import NumberType, NumberCapability -from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse - -__all__ = [ - "ListActiveNumbersRequest", - "ListActiveNumbersResponse" -] @dataclass diff --git a/sinch/domains/numbers/models/available/__init__.py b/sinch/domains/numbers/models/available/__init__.py deleted file mode 100644 index b98e5b12..00000000 --- a/sinch/domains/numbers/models/available/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse - -__all__ = [ - "ActivateNumberRequest", - "CheckNumberAvailabilityRequest", - "CheckNumberAvailabilityResponse", - "ListAvailableNumbersRequest", - "RentAnyNumberRequest", - "RentAnyNumberResponse" -] diff --git a/sinch/domains/numbers/models/numbers.py b/sinch/domains/numbers/models/numbers.py deleted file mode 100644 index feca46a2..00000000 --- a/sinch/domains/numbers/models/numbers.py +++ /dev/null @@ -1,161 +0,0 @@ -from datetime import datetime -from decimal import Decimal -from typing import Annotated, Literal, Optional, Union -from pydantic import ConfigDict, conlist, Field, StrictBool, StrictInt, StrictStr -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest, BaseModelConfigResponse - - -NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] -CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) -NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] -OrderByValues = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] - -CapabilityType = Annotated[ - CapabilityTypeValuesList, - Field(default=None) -] - -NumberSearchPatternType = Annotated[ - NumberSearchPatternTypeValues, - Field(default=None) -] - -NumberType = Annotated[ - NumberTypeValues, - Field(default=None) -] - -StatusScheduledProvisioning = Annotated[ - Union[Literal["WAITING", "IN_PROGRESS", "FAILED"], StrictStr], - Field(default=None) -] - - -class SmsConfigurationRequest(BaseModelConfigRequest): - service_plan_id: StrictStr = Field(alias="servicePlanId") - campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - - -class VoiceConfigurationFAX(BaseModelConfigRequest): - type: Literal["FAX"] = "FAX" - service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") - - -class VoiceConfigurationEST(BaseModelConfigRequest): - type: Literal["EST"] = "EST" - trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") - - -class VoiceConfigurationRTC(BaseModelConfigRequest): - type: Literal["RTC"] = "RTC" - app_id: Optional[StrictStr] = Field(default=None, alias="appId") - - -class VoiceConfigurationCustom(BaseModelConfigRequest): - type: StrictStr - - -VoiceConfigurationType = Annotated[ - Union[VoiceConfigurationFAX, VoiceConfigurationEST, VoiceConfigurationRTC], - Field(discriminator="type") -] - - -class ScheduledProvisioningSmsConfiguration(BaseModelConfigResponse): - service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") - campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - status: Optional[StatusScheduledProvisioning] = None - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - error_codes: Optional[conlist(StrictStr, min_length=0)] = Field(default=None, alias="errorCodes") - - -class SmsConfigurationResponse(BaseModelConfigResponse): - service_plan_id: StrictStr = Field(alias="servicePlanId") - campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - scheduled_provisioning: Optional[ScheduledProvisioningSmsConfiguration] = ( - Field(default=None, alias="scheduledProvisioning")) - - -class ScheduledVoiceProvisioningVoiceConfigurationBase(BaseModelConfigResponse): - type: Literal["FAX", "EST", "RTC"] - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - status: Optional[StatusScheduledProvisioning] = None - - -class ScheduledVoiceProvisioningVoiceConfigurationCustom(BaseModelConfigResponse): - type: StrictStr - - -class ScheduledVoiceProvisioningVoiceConfigurationFAX(ScheduledVoiceProvisioningVoiceConfigurationBase): - service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") - - -class ScheduledVoiceProvisioningVoiceConfigurationEST(ScheduledVoiceProvisioningVoiceConfigurationBase): - trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") - - -class ScheduledVoiceProvisioningVoiceConfigurationRTC(ScheduledVoiceProvisioningVoiceConfigurationBase): - app_id: Optional[StrictStr] = Field(default=None, alias="appId") - - -class VoiceConfigurationResponse(BaseModelConfigResponse): - type: Union[Literal["RTC", "EST", "FAX"], StrictStr] - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningVoiceConfigurationRTC, - ScheduledVoiceProvisioningVoiceConfigurationEST, - ScheduledVoiceProvisioningVoiceConfigurationFAX, - ScheduledVoiceProvisioningVoiceConfigurationCustom, - None] = Field( - default=None, alias="scheduledVoiceProvisioning" - ) - app_id: Optional[StrictStr] = Field(default=None, alias="appId") - - -class Money(BaseModelConfigResponse): - currency_code: StrictStr = Field(alias="currencyCode") - amount: Decimal - - -class ActiveNumber(BaseModelConfigResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - project_id: Optional[StrictStr] = Field(default=None, alias="projectId") - display_name: Optional[StrictStr] = Field(default=None, alias="displayName") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capabilities: Optional[CapabilityType] = Field(default=None) - money: Optional[Money] = Field(default=None) - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") - expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") - - -class Number(BaseModelConfigResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) - setup_price: Optional[Money] = Field(default=None, alias="setupPrice") - monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - supporting_documentation_required: Optional[StrictBool] = ( - Field(default=None, alias="supportingDocumentationRequired")) - - -class ErrorDetails(BaseModelConfigResponse): - type: Optional[StrictStr] = Field(default=None, alias="type") - resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") - resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") - owner: Optional[StrictStr] = Field(default=None, alias="owner") - description: Optional[StrictStr] = Field(default=None, alias="description") - - -class NotFoundError(BaseModelConfigResponse): - code: Optional[StrictInt] = Field(default=None, alias="code") - message: Optional[StrictStr] = Field(default=None, alias="message") - status: Optional[StrictStr] = Field(default=None, alias="status") - details: Optional[list[ErrorDetails]] = Field(default=None, alias="details") - - model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigResponse._to_snake_case) diff --git a/sinch/domains/numbers/models/v1/__init__.py b/sinch/domains/numbers/models/v1/__init__.py new file mode 100644 index 00000000..021d9659 --- /dev/null +++ b/sinch/domains/numbers/models/v1/__init__.py @@ -0,0 +1,16 @@ +from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.rent_any_number_response import RentAnyNumberResponse + + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "CheckNumberAvailabilityResponse", + "ListAvailableNumbersResponse", + "RentAnyNumberResponse", + "ListActiveNumbersResponse" +] diff --git a/sinch/domains/numbers/models/v1/active_number.py b/sinch/domains/numbers/models/v1/active_number.py new file mode 100644 index 00000000..a87f6b4a --- /dev/null +++ b/sinch/domains/numbers/models/v1/active_number.py @@ -0,0 +1,25 @@ +from datetime import datetime +from typing import Optional +from pydantic import StrictStr, Field, StrictInt +from sinch.domains.numbers.models.v1.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.money import Money +from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.number_type import NumberType + + +class ActiveNumber(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capabilities: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/v1/available_number.py b/sinch/domains/numbers/models/v1/available_number.py new file mode 100644 index 00000000..6c052ba9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/available_number.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictBool, StrictInt, StrictStr + +from sinch.domains.numbers.models.v1.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.money import Money +from sinch.domains.numbers.models.v1.number_type import NumberType + + +class AvailableNumber(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/models/v1/capability_type.py b/sinch/domains/numbers/models/v1/capability_type.py new file mode 100644 index 00000000..1d6831e9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/capability_type.py @@ -0,0 +1,9 @@ +from pydantic import conlist, Field, StrictStr +from typing import Annotated, Literal, Union + +CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) + +CapabilityType = Annotated[ + CapabilityTypeValuesList, + Field(default=None) +] diff --git a/sinch/domains/numbers/models/available/check_number_availability_response.py b/sinch/domains/numbers/models/v1/check_number_availability_response.py similarity index 74% rename from sinch/domains/numbers/models/available/check_number_availability_response.py rename to sinch/domains/numbers/models/v1/check_number_availability_response.py index f959ae7f..5a4c8718 100644 --- a/sinch/domains/numbers/models/available/check_number_availability_response.py +++ b/sinch/domains/numbers/models/v1/check_number_availability_response.py @@ -1,7 +1,9 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse -from sinch.domains.numbers.models.numbers import CapabilityType, Money, NumberType +from sinch.domains.numbers.models.v1.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.money import Money +from sinch.domains.numbers.models.v1.number_type import NumberType class CheckNumberAvailabilityResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py new file mode 100644 index 00000000..75f4052f --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError +from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails + +__all__ = [ + "NotFoundError", + "ErrorDetails" +] diff --git a/sinch/domains/numbers/models/v1/errors/error_details.py b/sinch/domains/numbers/models/v1/errors/error_details.py new file mode 100644 index 00000000..83381874 --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/error_details.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from typing import Optional +from pydantic import Field, StrictStr + + +class ErrorDetails(BaseModelConfigResponse): + type: Optional[StrictStr] = Field(default=None, alias="type") + resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") + resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") + owner: Optional[StrictStr] = Field(default=None, alias="owner") + description: Optional[StrictStr] = Field(default=None, alias="description") diff --git a/sinch/domains/numbers/models/v1/errors/not_found.py b/sinch/domains/numbers/models/v1/errors/not_found.py new file mode 100644 index 00000000..1e21bb0d --- /dev/null +++ b/sinch/domains/numbers/models/v1/errors/not_found.py @@ -0,0 +1,13 @@ +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails +from typing import Optional +from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr + + +class NotFoundError(BaseModelConfigResponse): + code: Optional[StrictInt] = Field(default=None, alias="code") + message: Optional[StrictStr] = Field(default=None, alias="message") + status: Optional[StrictStr] = Field(default=None, alias="status") + details: Optional[conlist(ErrorDetails, min_length=1)] = Field(default=None, alias="details") + + model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigResponse._to_snake_case) diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py new file mode 100644 index 00000000..f1fd114c --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -0,0 +1,18 @@ +from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest + + +__all__ = [ + "BaseModelConfigRequest", + "BaseModelConfigResponse", + "ActivateNumberRequest", + "CheckNumberAvailabilityRequest", + "ListActiveNumbersRequest", + "ListAvailableNumbersRequest", + "RentAnyNumberRequest", +] diff --git a/sinch/domains/numbers/models/available/activate_number_request.py b/sinch/domains/numbers/models/v1/internal/activate_number_request.py similarity index 80% rename from sinch/domains/numbers/models/available/activate_number_request.py rename to sinch/domains/numbers/models/v1/internal/activate_number_request.py index fc21fb0b..265eaf58 100644 --- a/sinch/domains/numbers/models/available/activate_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/activate_number_request.py @@ -1,7 +1,7 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest class ActivateNumberRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/base_model_numbers.py b/sinch/domains/numbers/models/v1/internal/base_model_config.py similarity index 100% rename from sinch/domains/numbers/models/base_model_numbers.py rename to sinch/domains/numbers/models/v1/internal/base_model_config.py diff --git a/sinch/domains/numbers/models/available/check_number_availability_request.py b/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py similarity index 65% rename from sinch/domains/numbers/models/available/check_number_availability_request.py rename to sinch/domains/numbers/models/v1/internal/check_number_availability_request.py index 12fb87df..49e46fd4 100644 --- a/sinch/domains/numbers/models/available/check_number_availability_request.py +++ b/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py @@ -1,5 +1,5 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest class CheckNumberAvailabilityRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/active/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py similarity index 66% rename from sinch/domains/numbers/models/active/list_active_numbers_request.py rename to sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index b2060c74..795a2a5e 100644 --- a/sinch/domains/numbers/models/active/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,15 +1,17 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import (CapabilityTypeValuesList, NumberTypeValues, - NumberSearchPatternTypeValues, OrderByValues) +from sinch.domains.numbers.models.v1.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.number_type import NumberTypeValues +from sinch.domains.numbers.models.v1.order_by_values import OrderByValues class ListActiveNumbersRequest(BaseModelConfigRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberTypeValues = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") - capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) + capabilities: Optional[CapabilityType] = Field(default=None) number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( Field(default=None, alias="numberPattern.searchPattern") ) diff --git a/sinch/domains/numbers/models/available/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py similarity index 60% rename from sinch/domains/numbers/models/available/list_available_numbers_request.py rename to sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 8f4d5b76..7ea82894 100644 --- a/sinch/domains/numbers/models/available/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,14 +1,14 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import ( - CapabilityTypeValuesList, NumberTypeValues, NumberSearchPatternTypeValues -) +from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.number_type import NumberType class ListAvailableNumbersRequest(BaseModelConfigRequest): region_code: StrictStr = Field(alias="regionCode") - number_type: NumberTypeValues = Field(alias="type") + number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( diff --git a/sinch/domains/numbers/models/available/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py similarity index 67% rename from sinch/domains/numbers/models/available/rent_any_number_request.py rename to sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 090fed5e..01c67ee3 100644 --- a/sinch/domains/numbers/models/available/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,13 +1,9 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest -from sinch.domains.numbers.models.numbers import NumberSearchPatternType, CapabilityType - - -class NumberPattern(BaseModelConfigRequest): - pattern: Optional[StrictStr] - search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") +from sinch.domains.numbers.models.v1.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.number_pattern import NumberPattern +from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest class RentAnyNumberRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py new file mode 100644 index 00000000..a6a8b729 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py @@ -0,0 +1,8 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest + + +class SmsConfigurationRequest(BaseModelConfigRequest): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py new file mode 100644 index 00000000..6fb0a2f7 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py @@ -0,0 +1,28 @@ +from typing import Optional, Union, Annotated, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest + + +class VoiceConfigurationFAX(BaseModelConfigRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") + + +class VoiceConfigurationEST(BaseModelConfigRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") + + +class VoiceConfigurationRTC(BaseModelConfigRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") + + +class VoiceConfigurationCustom(BaseModelConfigRequest): + type: StrictStr + + +VoiceConfigurationType = Annotated[ + Union[VoiceConfigurationFAX, VoiceConfigurationEST, VoiceConfigurationRTC], + Field(discriminator="type") +] diff --git a/sinch/domains/numbers/models/active/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/list_active_numbers_response.py similarity index 90% rename from sinch/domains/numbers/models/active/list_active_numbers_response.py rename to sinch/domains/numbers/models/v1/list_active_numbers_response.py index 6b772cac..a6c7b92b 100644 --- a/sinch/domains/numbers/models/active/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/list_active_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.numbers import ActiveNumber +from sinch.domains.numbers.models.v1.active_number import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/available/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/list_available_numbers_response.py similarity index 55% rename from sinch/domains/numbers/models/available/list_available_numbers_response.py rename to sinch/domains/numbers/models/v1/list_available_numbers_response.py index b8b77b06..569376d0 100644 --- a/sinch/domains/numbers/models/available/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/list_available_numbers_response.py @@ -1,10 +1,10 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.numbers import Number +from sinch.domains.numbers.models.v1 import AvailableNumber class ListAvailableNumbersResponse(BaseModel): - available_numbers: Optional[List[Number]] = Field(default=None, alias="availableNumbers") + available_numbers: Optional[List[AvailableNumber]] = Field(default=None, alias="availableNumbers") model_config = ConfigDict( populate_by_name=True diff --git a/sinch/domains/numbers/models/v1/money.py b/sinch/domains/numbers/models/v1/money.py new file mode 100644 index 00000000..d6bb05f5 --- /dev/null +++ b/sinch/domains/numbers/models/v1/money.py @@ -0,0 +1,8 @@ +from decimal import Decimal +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse + + +class Money(BaseModelConfigResponse): + currency_code: StrictStr = Field(alias="currencyCode") + amount: Decimal diff --git a/sinch/domains/numbers/models/v1/number_pattern.py b/sinch/domains/numbers/models/v1/number_pattern.py new file mode 100644 index 00000000..a87c0a27 --- /dev/null +++ b/sinch/domains/numbers/models/v1/number_pattern.py @@ -0,0 +1,9 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternType + + +class NumberPattern(BaseModelConfigRequest): + pattern: Optional[StrictStr] + search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") diff --git a/sinch/domains/numbers/models/v1/number_pattern_dict.py b/sinch/domains/numbers/models/v1/number_pattern_dict.py new file mode 100644 index 00000000..d81f8391 --- /dev/null +++ b/sinch/domains/numbers/models/v1/number_pattern_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired +from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternTypeValues] diff --git a/sinch/domains/numbers/models/v1/number_search_pattern_type.py b/sinch/domains/numbers/models/v1/number_search_pattern_type.py new file mode 100644 index 00000000..ceea3577 --- /dev/null +++ b/sinch/domains/numbers/models/v1/number_search_pattern_type.py @@ -0,0 +1,9 @@ +from typing import Union, Literal, Annotated +from pydantic import StrictStr, Field + +NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] + +NumberSearchPatternType = Annotated[ + NumberSearchPatternTypeValues, + Field(default=None) +] diff --git a/sinch/domains/numbers/models/v1/number_type.py b/sinch/domains/numbers/models/v1/number_type.py new file mode 100644 index 00000000..f1205dc4 --- /dev/null +++ b/sinch/domains/numbers/models/v1/number_type.py @@ -0,0 +1,10 @@ +from typing import Union, Literal, Annotated +from pydantic import StrictStr, Field + +NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] + + +NumberType = Annotated[ + NumberTypeValues, + Field(default=None) +] diff --git a/sinch/domains/numbers/models/v1/order_by_values.py b/sinch/domains/numbers/models/v1/order_by_values.py new file mode 100644 index 00000000..3135e106 --- /dev/null +++ b/sinch/domains/numbers/models/v1/order_by_values.py @@ -0,0 +1,4 @@ +from typing import Literal, Union +from pydantic import StrictStr + +OrderByValues = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/models/available/rent_any_number_response.py b/sinch/domains/numbers/models/v1/rent_any_number_response.py similarity index 69% rename from sinch/domains/numbers/models/available/rent_any_number_response.py rename to sinch/domains/numbers/models/v1/rent_any_number_response.py index ce6522cb..d751e044 100644 --- a/sinch/domains/numbers/models/available/rent_any_number_response.py +++ b/sinch/domains/numbers/models/v1/rent_any_number_response.py @@ -1,9 +1,12 @@ from datetime import datetime from typing import Optional from pydantic import Field, StrictStr, StrictInt -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse -from sinch.domains.numbers.models.numbers import (CapabilityTypeValuesList, Money, NumberTypeValues, - SmsConfigurationResponse, VoiceConfigurationResponse) +from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.money import Money +from sinch.domains.numbers.models.v1.number_type import NumberTypeValues +from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse class RentAnyNumberResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py new file mode 100644 index 00000000..d9fdbe31 --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py @@ -0,0 +1,15 @@ +from datetime import datetime +from typing import Optional + +from pydantic import StrictStr, Field, conlist + +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.status_scheduled_provisioning import StatusScheduledProvisioning + + +class ScheduledSmsProvisioning(BaseModelConfigResponse): + service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + status: Optional[StatusScheduledProvisioning] = None + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + error_codes: Optional[conlist(StrictStr, min_length=0)] = Field(default=None, alias="errorCodes") diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py new file mode 100644 index 00000000..4405540f --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py @@ -0,0 +1,11 @@ +from datetime import datetime +from typing import Literal, Optional +from pydantic import Field +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.status_scheduled_provisioning import StatusScheduledProvisioning + + +class ScheduledVoiceProvisioning(BaseModelConfigResponse): + type: Literal["FAX", "EST", "RTC"] + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py new file mode 100644 index 00000000..b4f439c8 --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py @@ -0,0 +1,6 @@ +from pydantic import StrictStr +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse + + +class ScheduledVoiceProvisioningCustom(BaseModelConfigResponse): + type: StrictStr diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py new file mode 100644 index 00000000..c13e3845 --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning + + +class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioning): + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py new file mode 100644 index 00000000..13e7f76a --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning + + +class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioning): + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py new file mode 100644 index 00000000..aa928d58 --- /dev/null +++ b/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning + + +class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioning): + app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/sms_configuration_dict.py b/sinch/domains/numbers/models/v1/sms_configuration_dict.py new file mode 100644 index 00000000..65c78434 --- /dev/null +++ b/sinch/domains/numbers/models/v1/sms_configuration_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class SmsConfigurationDict(TypedDict): + service_plan_id: str + campaign_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/sms_configuration_response.py b/sinch/domains/numbers/models/v1/sms_configuration_response.py new file mode 100644 index 00000000..a547984e --- /dev/null +++ b/sinch/domains/numbers/models/v1/sms_configuration_response.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import StrictStr, Field + +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.scheduled_sms_provisioning import ScheduledSmsProvisioning + + +class SmsConfigurationResponse(BaseModelConfigResponse): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") + scheduled_provisioning: Optional[ScheduledSmsProvisioning] = ( + Field(default=None, alias="scheduledProvisioning")) diff --git a/sinch/domains/numbers/models/v1/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/status_scheduled_provisioning.py new file mode 100644 index 00000000..4ffc0d01 --- /dev/null +++ b/sinch/domains/numbers/models/v1/status_scheduled_provisioning.py @@ -0,0 +1,7 @@ +from typing import Annotated, Union, Literal +from pydantic import StrictStr, Field + +StatusScheduledProvisioning = Annotated[ + Union[Literal["WAITING", "IN_PROGRESS", "FAILED"], StrictStr], + Field(default=None) +] diff --git a/sinch/domains/numbers/models/v1/utils/__init__.py b/sinch/domains/numbers/models/v1/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/numbers/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py similarity index 77% rename from sinch/domains/numbers/validators.py rename to sinch/domains/numbers/models/v1/utils/validators.py index 4dd74621..6112a72a 100644 --- a/sinch/domains/numbers/validators.py +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -1,9 +1,11 @@ from typing import Dict, Any -from sinch.domains.numbers.models.numbers import ( - SmsConfigurationRequest, VoiceConfigurationRTC, - VoiceConfigurationEST, VoiceConfigurationFAX, - VoiceConfigurationCustom +from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( + VoiceConfigurationRTC, + VoiceConfigurationEST, + VoiceConfigurationFAX, + VoiceConfigurationCustom, ) +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: diff --git a/sinch/domains/numbers/models/v1/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/voice_configuration_dict.py new file mode 100644 index 00000000..c6f23885 --- /dev/null +++ b/sinch/domains/numbers/models/v1/voice_configuration_dict.py @@ -0,0 +1,29 @@ +from typing import TypedDict, Literal, Union, Annotated +from typing_extensions import NotRequired +from pydantic import Field + + +class VoiceConfigurationDictRTC(TypedDict): + type: Literal["RTC"] + app_id: NotRequired[str] + + +class VoiceConfigurationDictEST(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] + + +class VoiceConfigurationDictFAX(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] + + +class VoiceConfigurationDictCustom(TypedDict): + type: str + + +VoiceConfigurationDictType = Annotated[ + Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST, VoiceConfigurationDictCustom], + Field(discriminator="type") +] diff --git a/sinch/domains/numbers/models/v1/voice_configuration_response.py b/sinch/domains/numbers/models/v1/voice_configuration_response.py new file mode 100644 index 00000000..bbec9823 --- /dev/null +++ b/sinch/domains/numbers/models/v1/voice_configuration_response.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import Literal, Optional, Union +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_custom import ScheduledVoiceProvisioningCustom +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX +from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC + + +class VoiceConfigurationResponse(BaseModelConfigResponse): + type: Union[Literal["RTC", "EST", "FAX"], StrictStr] + last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningRTC, + ScheduledVoiceProvisioningEST, + ScheduledVoiceProvisioningFAX, + ScheduledVoiceProvisioningCustom, + None] = Field( + default=None, alias="scheduledVoiceProvisioning" + ) + app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/endpoints/numbers_endpoint.py b/sinch/domains/numbers/numbers_endpoint.py similarity index 97% rename from sinch/domains/numbers/endpoints/numbers_endpoint.py rename to sinch/domains/numbers/numbers_endpoint.py index 3e207c1c..a6fc8092 100644 --- a/sinch/domains/numbers/endpoints/numbers_endpoint.py +++ b/sinch/domains/numbers/numbers_endpoint.py @@ -4,7 +4,7 @@ from sinch.core.endpoint import HTTPEndpoint from sinch.core.types import BM from sinch.domains.numbers.exceptions import NumbersException -from sinch.domains.numbers.models.numbers import NotFoundError +from sinch.domains.numbers.models.v1.errors import NotFoundError class NumbersEndpoint(HTTPEndpoint, ABC): diff --git a/tests/conftest.py b/tests/conftest.py index ac974936..f589d260 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.numbers import ActiveNumber +from sinch.domains.numbers.models.v1.active_number import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index d61a793f..f0ad0136 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,10 +2,9 @@ from datetime import timezone, datetime from behave import given, when, then from decimal import Decimal -from sinch.domains.numbers.models.numbers import ActiveNumber from sinch.domains.numbers.exceptions import NumberNotFoundException -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse -from sinch.domains.numbers.models.numbers import NotFoundError +from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.models.v1 import ActiveNumber, RentAnyNumberResponse def execute_sync_or_async(context,call): """ diff --git a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py similarity index 92% rename from tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index 27fe8467..114d8717 100644 --- a/tests/unit/domains/numbers/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -2,9 +2,9 @@ from decimal import Decimal import pytest -from sinch.domains.numbers.endpoints.active.list_active_numbers_endpoint import ListActiveNumbersEndpoint -from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.api.v1.active_numbers.active_numbers_endpoints import ListActiveNumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1.list_active_numbers_response import ListActiveNumbersResponse from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py similarity index 93% rename from tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py index 31be528d..f9ea6ea2 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py @@ -1,7 +1,7 @@ import pytest import json -from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.api.v1.available_numbers import ActivateNumberEndpoint +from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py similarity index 93% rename from tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py index e617dc3a..f8eeb6ce 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py @@ -1,6 +1,6 @@ import pytest -from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.api.v1.available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py similarity index 95% rename from tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 97da1476..1d677339 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -2,9 +2,9 @@ import json from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.available_numbers import RentAnyNumberEndpoint -from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse +from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import RentAnyNumberEndpoint +from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest +from sinch.domains.numbers.models.v1 import RentAnyNumberResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py similarity index 90% rename from tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index b5da6d41..3120e92c 100644 --- a/tests/unit/domains/numbers/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,7 +1,7 @@ import pytest -from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.api.v1.available_numbers import SearchForNumberEndpoint +from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py similarity index 95% rename from tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py rename to tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py index 8f34bd97..002a91bf 100644 --- a/tests/unit/domains/numbers/models/active/requests/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.active.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest @pytest.mark.parametrize( "order_by_input, expected_order_by", diff --git a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py similarity index 97% rename from tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py rename to tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py index 28c1bab2..167f9a5f 100644 --- a/tests/unit/domains/numbers/models/active/response/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from decimal import Decimal import pytest -from sinch.domains.numbers.models.active.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1 import ListActiveNumbersResponse @pytest.fixture def test_data(): diff --git a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py similarity index 96% rename from tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py rename to tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py index db5369fa..1aa6f24f 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_activate_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest def test_activate_number_request_expects_snake_case_input(): """ diff --git a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py similarity index 97% rename from tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py rename to tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py index 285a256d..803977c5 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_list_available_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest def test_list_available_numbers_request_expects_snake_case_input(): diff --git a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py similarity index 95% rename from tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py rename to tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py index da72c394..6355e469 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.available.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest def test_rent_any_number_request_expects_valid_data(): diff --git a/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py b/tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py similarity index 92% rename from tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py rename to tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py index 14ed584a..cab0a28a 100644 --- a/tests/unit/domains/numbers/models/available/requests/test_search_for_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest def test_check_number_availability_request_expects_accepts_snake_case_input(): diff --git a/tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_activate_number_response_model.py similarity index 100% rename from tests/unit/domains/numbers/models/available/response/test_activate_number_response_model.py rename to tests/unit/domains/numbers/v1/models/available/response/test_activate_number_response_model.py diff --git a/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py similarity index 93% rename from tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py rename to tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py index 79ace32b..cf9ea982 100644 --- a/tests/unit/domains/numbers/models/available/response/test_list_available_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1 import ListAvailableNumbersResponse @pytest.fixture def test_data(): diff --git a/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py similarity index 98% rename from tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py rename to tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py index c0358e95..3b48afe1 100644 --- a/tests/unit/domains/numbers/models/available/response/test_rent_any_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py @@ -1,7 +1,7 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.numbers.models.available.rent_any_number_response import RentAnyNumberResponse +from sinch.domains.numbers.models.v1 import RentAnyNumberResponse @pytest.fixture def valid_data(): diff --git a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py similarity index 96% rename from tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py rename to tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py index 8c8d41cf..168dafc1 100644 --- a/tests/unit/domains/numbers/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse def test_check_number_availability_response_expects_valid_data(): """ diff --git a/tests/unit/domains/numbers/models/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py similarity index 95% rename from tests/unit/domains/numbers/models/base/test_base_model_requests.py rename to tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py index 858bb657..7efeaa82 100644 --- a/tests/unit/domains/numbers/models/base/test_base_model_requests.py +++ b/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest def test_to_camel_case_expects_parsed_standard_cases(): """ diff --git a/tests/unit/domains/numbers/models/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py similarity index 84% rename from tests/unit/domains/numbers/models/base/test_base_model_response.py rename to tests/unit/domains/numbers/v1/models/base/test_base_model_response.py index cd609f5c..8ad09db6 100644 --- a/tests/unit/domains/numbers/models/base/test_base_model_response.py +++ b/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.base_model_numbers import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse def test_base_model_response_expects_unrecognized_fields_snake_case(): """ diff --git a/tests/unit/domains/numbers/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py similarity index 91% rename from tests/unit/domains/numbers/models/test_numbers.py rename to tests/unit/domains/numbers/v1/models/test_numbers.py index 71a20207..826078b0 100644 --- a/tests/unit/domains/numbers/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,10 +1,9 @@ from datetime import datetime, timezone -from sinch.domains.numbers.models.numbers import ( - ActiveNumber, - ScheduledProvisioningSmsConfiguration, - SmsConfigurationResponse, - VoiceConfigurationResponse, NotFoundError, -) +from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.models.v1.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ @@ -17,7 +16,7 @@ def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): "lastUpdatedTime": "2025-01-24T09:32:27.437Z", "errorCodes": ["ERROR_CODE_1"] } - config = ScheduledProvisioningSmsConfiguration.model_validate(data) + config = ScheduledSmsProvisioning.model_validate(data) assert config.service_plan_id == "test_plan" assert config.campaign_id == "test_campaign" @@ -34,7 +33,7 @@ def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed data = { "servicePlanId": "test_plan" } - config = ScheduledProvisioningSmsConfiguration.model_validate(data) + config = ScheduledSmsProvisioning.model_validate(data) assert config.service_plan_id == "test_plan" assert config.campaign_id is None diff --git a/tests/unit/domains/numbers/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py similarity index 76% rename from tests/unit/domains/numbers/test_available_numbers.py rename to tests/unit/domains/numbers/v1/test_available_numbers.py index fb1d9dd6..4a63c052 100644 --- a/tests/unit/domains/numbers/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,19 +1,17 @@ import pytest from unittest.mock import MagicMock -from sinch.domains.numbers.available_numbers import AvailableNumbers -from sinch.domains.numbers.endpoints.available.list_available_numbers_endpoint import AvailableNumbersEndpoint -from sinch.domains.numbers.endpoints.available.activate_number_endpoint import ActivateNumberEndpoint -from sinch.domains.numbers.endpoints.available.search_for_number_endpoint import SearchForNumberEndpoint +from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1.available_numbers import ( + AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint +) +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal import ( + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest +) +from sinch.domains.numbers.models.v1.active_number import ActiveNumber -from sinch.domains.numbers.models.available.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.available.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.available.check_number_availability_request import CheckNumberAvailabilityRequest -from sinch.domains.numbers.models.available.list_available_numbers_response import ListAvailableNumbersResponse -from sinch.domains.numbers.models.available.check_number_availability_response import CheckNumberAvailabilityResponse - -from sinch.domains.numbers.models.numbers import ActiveNumber @pytest.fixture def mock_sinch(): From 6a46de25603a9436b3874d4a8b6a50720c60bebc Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 13 Mar 2025 08:34:41 +0100 Subject: [PATCH 022/106] refactor: model imports --- .../v1/active_numbers/active_numbers_apis.py | 9 ++-- .../active_numbers_endpoints.py | 3 +- .../available_numbers_apis.py | 19 +++---- .../available_numbers_endpoints.py | 39 +++----------- sinch/domains/numbers/models/v1/__init__.py | 11 +--- .../v1/check_number_availability_response.py | 4 +- .../numbers/models/v1/internal/__init__.py | 4 ++ .../internal/list_active_numbers_request.py | 7 ++- .../list_active_numbers_response.py | 2 +- .../list_available_numbers_request.py | 6 +-- .../list_available_numbers_response.py | 2 +- .../v1/internal/rent_any_number_request.py | 3 +- .../models/v1/rent_any_number_response.py | 9 ++-- .../models/v1/shared_params/__init__.py | 53 +++++++++++++++++++ .../v1/{ => shared_params}/active_number.py | 8 ++- .../{ => shared_params}/available_number.py | 7 ++- .../v1/{ => shared_params}/capability_type.py | 0 .../models/v1/{ => shared_params}/money.py | 0 .../v1/{ => shared_params}/number_pattern.py | 2 +- .../number_pattern_dict.py | 2 +- .../number_search_pattern_type.py | 0 .../v1/{ => shared_params}/number_type.py | 0 .../v1/{ => shared_params}/order_by_values.py | 0 .../scheduled_sms_provisioning.py | 2 +- .../scheduled_voice_provisioning.py | 2 +- .../scheduled_voice_provisioning_custom.py | 0 .../scheduled_voice_provisioning_est.py | 2 +- .../scheduled_voice_provisioning_fax.py | 2 +- .../scheduled_voice_provisioning_rtc.py | 2 +- .../sms_configuration_dict.py | 0 .../sms_configuration_response.py | 2 +- .../status_scheduled_provisioning.py | 0 .../voice_configuration_dict.py | 0 .../voice_configuration_response.py | 8 +-- sinch/domains/sms/endpoints/sms_endpoint.py | 1 + tests/conftest.py | 2 +- .../numbers/features/steps/numbers.steps.py | 4 +- .../test_list_active_numbers_endpoint.py | 3 +- ...test_list_active_numbers_response_model.py | 2 +- ...t_list_available_numbers_response_model.py | 2 +- .../domains/numbers/v1/models/test_numbers.py | 8 +-- .../numbers/v1/test_available_numbers.py | 6 +-- 42 files changed, 123 insertions(+), 115 deletions(-) rename sinch/domains/numbers/models/v1/{ => internal}/list_active_numbers_response.py (88%) rename sinch/domains/numbers/models/v1/{ => internal}/list_available_numbers_response.py (76%) create mode 100644 sinch/domains/numbers/models/v1/shared_params/__init__.py rename sinch/domains/numbers/models/v1/{ => shared_params}/active_number.py (76%) rename sinch/domains/numbers/models/v1/{ => shared_params}/available_number.py (81%) rename sinch/domains/numbers/models/v1/{ => shared_params}/capability_type.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/money.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/number_pattern.py (76%) rename sinch/domains/numbers/models/v1/{ => shared_params}/number_pattern_dict.py (66%) rename sinch/domains/numbers/models/v1/{ => shared_params}/number_search_pattern_type.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/number_type.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/order_by_values.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_sms_provisioning.py (84%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_voice_provisioning.py (78%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_voice_provisioning_custom.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_voice_provisioning_est.py (67%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_voice_provisioning_fax.py (67%) rename sinch/domains/numbers/models/v1/{ => shared_params}/scheduled_voice_provisioning_rtc.py (66%) rename sinch/domains/numbers/models/v1/{ => shared_params}/sms_configuration_dict.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/sms_configuration_response.py (81%) rename sinch/domains/numbers/models/v1/{ => shared_params}/status_scheduled_provisioning.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/voice_configuration_dict.py (100%) rename sinch/domains/numbers/models/v1/{ => shared_params}/voice_configuration_response.py (66%) diff --git a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py index 9ed5a4a5..afc89825 100644 --- a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py @@ -13,12 +13,11 @@ from sinch.domains.numbers.models.active.responses import ( UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) -from sinch.domains.numbers.models.v1.active_number import ActiveNumber from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.number_type import NumberTypeValues -from sinch.domains.numbers.models.v1.order_by_values import OrderByValues -from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityTypeValuesList, NumberTypeValues, OrderByValues, NumberSearchPatternTypeValues, + ActiveNumber +) class ActiveNumbers(BaseNumbers): diff --git a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py index db110039..36210872 100644 --- a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py @@ -1,8 +1,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1 import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse class ListActiveNumbersEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py index f88fb09d..6ac21c41 100644 --- a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py @@ -1,24 +1,19 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr from sinch.domains.numbers.base_numbers import BaseNumbers -from sinch.domains.numbers.models.v1 import ( - AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse, -) +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse from sinch.domains.numbers.api.v1.available_numbers import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList -from sinch.domains.numbers.models.v1.number_pattern_dict import NumberPatternDict -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues -from sinch.domains.numbers.models.v1.number_type import NumberTypeValues -from sinch.domains.numbers.models.v1.sms_configuration_dict import SmsConfigurationDict -from sinch.domains.numbers.models.v1.voice_configuration_dict import ( - VoiceConfigurationDictType, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC -) +from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, RentAnyNumberRequest, ListAvailableNumbersRequest ) +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityTypeValuesList, NumberPatternDict, NumberTypeValues, ActiveNumber, + SmsConfigurationDict, NumberSearchPatternTypeValues, VoiceConfigurationDictType, VoiceConfigurationDictEST, + VoiceConfigurationDictFAX, VoiceConfigurationDictRTC +) class AvailableNumbers(BaseNumbers): diff --git a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py index e1599ee8..4f354ca7 100644 --- a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py @@ -1,15 +1,16 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.shared_params import ActiveNumber from sinch.domains.numbers.models.v1 import ( - AvailableNumber, CheckNumberAvailabilityResponse, ListAvailableNumbersResponse, - RentAnyNumberResponse) -from sinch.domains.numbers.models.v1.active_number import ActiveNumber - + CheckNumberAvailabilityResponse, RentAnyNumberResponse +) from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, + ListAvailableNumbersResponse ) @@ -54,15 +55,6 @@ def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) def handle_response(self, response: HTTPResponse) -> list[AvailableNumber]: - """ - Processes the API response and maps it to a response model. - - Args: - response (HTTPResponse): The raw HTTP response object received from the API. - - Returns: - list[AvailableNumber]: The response model containing the parsed response data. - """ super(AvailableNumbersEndpoint, self).handle_response(response) response = self.process_response_model(response.body, ListAvailableNumbersResponse) return response.available_numbers @@ -85,15 +77,6 @@ def request_body(self) -> str: return json.dumps(request_data) def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: - """ - Handles the response from the API call. - - Args: - response (HTTPResponse): The response object from the API call. - - Returns: - RentAnyNumberResponse: The response data mapped to the RentAnyNumberResponse model. - """ error = super(RentAnyNumberEndpoint, self).handle_response(response) if error: return error @@ -112,16 +95,6 @@ def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest super(SearchForNumberEndpoint, self).__init__(project_id, request_data) def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: - """ - Processes the API response and maps it to a response - - Args: - response (HTTPResponse): The raw HTTP response object received from the API. - - Returns: - CheckNumberAvailabilityResponse: The response model containing the parsed response data - of the requested phone number. - """ try: super(SearchForNumberEndpoint, self).handle_response(response) except NumbersException as e: diff --git a/sinch/domains/numbers/models/v1/__init__.py b/sinch/domains/numbers/models/v1/__init__.py index 021d9659..f78efa41 100644 --- a/sinch/domains/numbers/models/v1/__init__.py +++ b/sinch/domains/numbers/models/v1/__init__.py @@ -1,16 +1,7 @@ -from sinch.domains.numbers.models.v1.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.available_number import AvailableNumber from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.list_active_numbers_response import ListActiveNumbersResponse -from sinch.domains.numbers.models.v1.list_available_numbers_response import ListAvailableNumbersResponse from sinch.domains.numbers.models.v1.rent_any_number_response import RentAnyNumberResponse - __all__ = [ - "ActiveNumber", - "AvailableNumber", "CheckNumberAvailabilityResponse", - "ListAvailableNumbersResponse", - "RentAnyNumberResponse", - "ListActiveNumbersResponse" + "RentAnyNumberResponse" ] diff --git a/sinch/domains/numbers/models/v1/check_number_availability_response.py b/sinch/domains/numbers/models/v1/check_number_availability_response.py index 5a4c8718..199549f5 100644 --- a/sinch/domains/numbers/models/v1/check_number_availability_response.py +++ b/sinch/domains/numbers/models/v1/check_number_availability_response.py @@ -1,9 +1,7 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.domains.numbers.models.v1.capability_type import CapabilityType from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.money import Money -from sinch.domains.numbers.models.v1.number_type import NumberType +from sinch.domains.numbers.models.v1.shared_params import CapabilityType, Money, NumberType class CheckNumberAvailabilityResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index f1fd114c..20ac08f0 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -5,6 +5,8 @@ from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse __all__ = [ @@ -15,4 +17,6 @@ "ListActiveNumbersRequest", "ListAvailableNumbersRequest", "RentAnyNumberRequest", + "ListActiveNumbersResponse", + "ListAvailableNumbersResponse" ] diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 795a2a5e..a025c475 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,10 +1,9 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator -from sinch.domains.numbers.models.v1.capability_type import CapabilityType from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues -from sinch.domains.numbers.models.v1.number_type import NumberTypeValues -from sinch.domains.numbers.models.v1.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityType, NumberTypeValues, OrderByValues, NumberSearchPatternTypeValues +) class ListActiveNumbersRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py similarity index 88% rename from sinch/domains/numbers/models/v1/list_active_numbers_response.py rename to sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index a6c7b92b..9b3fb2fa 100644 --- a/sinch/domains/numbers/models/v1/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 7ea82894..f31d5bf5 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,9 +1,9 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues -from sinch.domains.numbers.models.v1.number_type import NumberType +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityTypeValuesList, NumberType, NumberSearchPatternTypeValues +) class ListAvailableNumbersRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py similarity index 76% rename from sinch/domains/numbers/models/v1/list_available_numbers_response.py rename to sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 569376d0..36772548 100644 --- a/sinch/domains/numbers/models/v1/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.v1 import AvailableNumber +from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 01c67ee3..574281bf 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,7 +1,6 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.capability_type import CapabilityType -from sinch.domains.numbers.models.v1.number_pattern import NumberPattern +from sinch.domains.numbers.models.v1.shared_params import CapabilityType, NumberPattern from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest diff --git a/sinch/domains/numbers/models/v1/rent_any_number_response.py b/sinch/domains/numbers/models/v1/rent_any_number_response.py index d751e044..9cf620a4 100644 --- a/sinch/domains/numbers/models/v1/rent_any_number_response.py +++ b/sinch/domains/numbers/models/v1/rent_any_number_response.py @@ -1,12 +1,11 @@ from datetime import datetime from typing import Optional from pydantic import Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.capability_type import CapabilityTypeValuesList from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.money import Money -from sinch.domains.numbers.models.v1.number_type import NumberTypeValues -from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse -from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityTypeValuesList, Money, NumberTypeValues, SmsConfigurationResponse +) +from sinch.domains.numbers.models.v1.shared_params import VoiceConfigurationResponse class RentAnyNumberResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/__init__.py b/sinch/domains/numbers/models/v1/shared_params/__init__.py new file mode 100644 index 00000000..f1697252 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared_params/__init__.py @@ -0,0 +1,53 @@ +from sinch.domains.numbers.models.v1.shared_params.capability_type import CapabilityType, CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.shared_params.money import Money +from sinch.domains.numbers.models.v1.shared_params.number_search_pattern_type import ( + NumberSearchPatternType, NumberSearchPatternTypeValues +) +from sinch.domains.numbers.models.v1.shared_params.number_pattern import NumberPattern +from sinch.domains.numbers.models.v1.shared_params.number_pattern_dict import NumberPatternDict +from sinch.domains.numbers.models.v1.shared_params.number_type import NumberTypeValues, NumberType +from sinch.domains.numbers.models.v1.shared_params.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_custom import ( + ScheduledVoiceProvisioningCustom +) +from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST +from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC +from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX +from sinch.domains.numbers.models.v1.shared_params.sms_configuration_dict import SmsConfigurationDict +from sinch.domains.numbers.models.v1.shared_params.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.shared_params.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared_params.voice_configuration_dict import ( + VoiceConfigurationDictFAX, VoiceConfigurationDictEST, VoiceConfigurationDictRTC, + VoiceConfigurationDictCustom, VoiceConfigurationDictType +) +from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "CapabilityType", + "CapabilityTypeValuesList", + "Money", + "NumberPattern", + "NumberPatternDict", + "NumberSearchPatternType", + "NumberSearchPatternTypeValues", + "NumberType", + "NumberTypeValues", + "SmsConfigurationDict", + "OrderByValues", + "SmsConfigurationResponse", + "ScheduledVoiceProvisioning", + "ScheduledVoiceProvisioningCustom", + "ScheduledVoiceProvisioningEST", + "ScheduledVoiceProvisioningFAX", + "ScheduledVoiceProvisioningRTC", + "VoiceConfigurationResponse", + "VoiceConfigurationDictFAX", + "VoiceConfigurationDictEST", + "VoiceConfigurationDictRTC", + "VoiceConfigurationDictCustom", + "VoiceConfigurationDictType" +] diff --git a/sinch/domains/numbers/models/v1/active_number.py b/sinch/domains/numbers/models/v1/shared_params/active_number.py similarity index 76% rename from sinch/domains/numbers/models/v1/active_number.py rename to sinch/domains/numbers/models/v1/shared_params/active_number.py index a87f6b4a..ba4c01eb 100644 --- a/sinch/domains/numbers/models/v1/active_number.py +++ b/sinch/domains/numbers/models/v1/shared_params/active_number.py @@ -1,12 +1,10 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, StrictInt -from sinch.domains.numbers.models.v1.capability_type import CapabilityType -from sinch.domains.numbers.models.v1.money import Money -from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse -from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.number_type import NumberType +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityType, Money, NumberType, SmsConfigurationResponse, VoiceConfigurationResponse +) class ActiveNumber(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/available_number.py b/sinch/domains/numbers/models/v1/shared_params/available_number.py similarity index 81% rename from sinch/domains/numbers/models/v1/available_number.py rename to sinch/domains/numbers/models/v1/shared_params/available_number.py index 6c052ba9..343e95f5 100644 --- a/sinch/domains/numbers/models/v1/available_number.py +++ b/sinch/domains/numbers/models/v1/shared_params/available_number.py @@ -1,10 +1,9 @@ from typing import Optional from pydantic import Field, StrictBool, StrictInt, StrictStr - -from sinch.domains.numbers.models.v1.capability_type import CapabilityType from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.money import Money -from sinch.domains.numbers.models.v1.number_type import NumberType +from sinch.domains.numbers.models.v1.shared_params import ( + CapabilityType, Money, NumberType +) class AvailableNumber(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/capability_type.py b/sinch/domains/numbers/models/v1/shared_params/capability_type.py similarity index 100% rename from sinch/domains/numbers/models/v1/capability_type.py rename to sinch/domains/numbers/models/v1/shared_params/capability_type.py diff --git a/sinch/domains/numbers/models/v1/money.py b/sinch/domains/numbers/models/v1/shared_params/money.py similarity index 100% rename from sinch/domains/numbers/models/v1/money.py rename to sinch/domains/numbers/models/v1/shared_params/money.py diff --git a/sinch/domains/numbers/models/v1/number_pattern.py b/sinch/domains/numbers/models/v1/shared_params/number_pattern.py similarity index 76% rename from sinch/domains/numbers/models/v1/number_pattern.py rename to sinch/domains/numbers/models/v1/shared_params/number_pattern.py index a87c0a27..4965f026 100644 --- a/sinch/domains/numbers/models/v1/number_pattern.py +++ b/sinch/domains/numbers/models/v1/shared_params/number_pattern.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, Field from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternType +from sinch.domains.numbers.models.v1.shared_params import NumberSearchPatternType class NumberPattern(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/number_pattern_dict.py b/sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py similarity index 66% rename from sinch/domains/numbers/models/v1/number_pattern_dict.py rename to sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py index d81f8391..246f2f15 100644 --- a/sinch/domains/numbers/models/v1/number_pattern_dict.py +++ b/sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py @@ -1,6 +1,6 @@ from typing import TypedDict from typing_extensions import NotRequired -from sinch.domains.numbers.models.v1.number_search_pattern_type import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.shared_params import NumberSearchPatternTypeValues class NumberPatternDict(TypedDict): diff --git a/sinch/domains/numbers/models/v1/number_search_pattern_type.py b/sinch/domains/numbers/models/v1/shared_params/number_search_pattern_type.py similarity index 100% rename from sinch/domains/numbers/models/v1/number_search_pattern_type.py rename to sinch/domains/numbers/models/v1/shared_params/number_search_pattern_type.py diff --git a/sinch/domains/numbers/models/v1/number_type.py b/sinch/domains/numbers/models/v1/shared_params/number_type.py similarity index 100% rename from sinch/domains/numbers/models/v1/number_type.py rename to sinch/domains/numbers/models/v1/shared_params/number_type.py diff --git a/sinch/domains/numbers/models/v1/order_by_values.py b/sinch/domains/numbers/models/v1/shared_params/order_by_values.py similarity index 100% rename from sinch/domains/numbers/models/v1/order_by_values.py rename to sinch/domains/numbers/models/v1/shared_params/order_by_values.py diff --git a/sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py similarity index 84% rename from sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py index d9fdbe31..6b1124b1 100644 --- a/sinch/domains/numbers/models/v1/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py @@ -4,7 +4,7 @@ from pydantic import StrictStr, Field, conlist from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.shared_params.status_scheduled_provisioning import StatusScheduledProvisioning class ScheduledSmsProvisioning(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py similarity index 78% rename from sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py index 4405540f..5bc50edd 100644 --- a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py @@ -2,7 +2,7 @@ from typing import Literal, Optional from pydantic import Field from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.shared_params.status_scheduled_provisioning import StatusScheduledProvisioning class ScheduledVoiceProvisioning(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_custom.py similarity index 100% rename from sinch/domains/numbers/models/v1/scheduled_voice_provisioning_custom.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_custom.py diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py similarity index 67% rename from sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py index c13e3845..6f1d018d 100644 --- a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_est.py +++ b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py similarity index 67% rename from sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py index 13e7f76a..e2c9e406 100644 --- a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_fax.py +++ b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py similarity index 66% rename from sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py rename to sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py index aa928d58..09b39648 100644 --- a/sinch/domains/numbers/models/v1/scheduled_voice_provisioning_rtc.py +++ b/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/sms_configuration_dict.py b/sinch/domains/numbers/models/v1/shared_params/sms_configuration_dict.py similarity index 100% rename from sinch/domains/numbers/models/v1/sms_configuration_dict.py rename to sinch/domains/numbers/models/v1/shared_params/sms_configuration_dict.py diff --git a/sinch/domains/numbers/models/v1/sms_configuration_response.py b/sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py similarity index 81% rename from sinch/domains/numbers/models/v1/sms_configuration_response.py rename to sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py index a547984e..b68e7028 100644 --- a/sinch/domains/numbers/models/v1/sms_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py @@ -3,7 +3,7 @@ from pydantic import StrictStr, Field from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.shared_params.scheduled_sms_provisioning import ScheduledSmsProvisioning class SmsConfigurationResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/shared_params/status_scheduled_provisioning.py similarity index 100% rename from sinch/domains/numbers/models/v1/status_scheduled_provisioning.py rename to sinch/domains/numbers/models/v1/shared_params/status_scheduled_provisioning.py diff --git a/sinch/domains/numbers/models/v1/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/shared_params/voice_configuration_dict.py similarity index 100% rename from sinch/domains/numbers/models/v1/voice_configuration_dict.py rename to sinch/domains/numbers/models/v1/shared_params/voice_configuration_dict.py diff --git a/sinch/domains/numbers/models/v1/voice_configuration_response.py b/sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py similarity index 66% rename from sinch/domains/numbers/models/v1/voice_configuration_response.py rename to sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py index bbec9823..1bbeac6e 100644 --- a/sinch/domains/numbers/models/v1/voice_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py @@ -2,10 +2,10 @@ from typing import Literal, Optional, Union from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_custom import ScheduledVoiceProvisioningCustom -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX -from sinch.domains.numbers.models.v1.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC +from sinch.domains.numbers.models.v1.shared_params import ( + ScheduledVoiceProvisioningCustom, ScheduledVoiceProvisioningEST, ScheduledVoiceProvisioningFAX, + ScheduledVoiceProvisioningRTC +) class VoiceConfigurationResponse(BaseModelConfigResponse): diff --git a/sinch/domains/sms/endpoints/sms_endpoint.py b/sinch/domains/sms/endpoints/sms_endpoint.py index 89bcb830..e57b2e94 100644 --- a/sinch/domains/sms/endpoints/sms_endpoint.py +++ b/sinch/domains/sms/endpoints/sms_endpoint.py @@ -19,6 +19,7 @@ def __init__(self, request_data, sinch): self.sms_origin = self.sinch.configuration.sms_origin def handle_response(self, response: HTTPResponse): + print(response) if response.status_code >= 400: raise SMSException( message=response.body["text"], diff --git a/tests/conftest.py b/tests/conftest.py index f589d260..74cd02ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index f0ad0136..e6448f72 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -3,8 +3,10 @@ from behave import given, when, then from decimal import Decimal from sinch.domains.numbers.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1 import ActiveNumber, RentAnyNumberResponse +from sinch.domains.numbers.models.v1.shared_params import ActiveNumber + def execute_sync_or_async(context,call): """ diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index 114d8717..96b8816e 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -3,8 +3,7 @@ import pytest from sinch.domains.numbers.api.v1.active_numbers.active_numbers_endpoints import ListActiveNumbersEndpoint -from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py index 167f9a5f..7f516e4f 100644 --- a/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from decimal import Decimal import pytest -from sinch.domains.numbers.models.v1 import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersResponse @pytest.fixture def test_data(): diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py index cf9ea982..31a0b46b 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.models.v1 import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersResponse @pytest.fixture def test_data(): diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index 826078b0..bab62fba 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,9 +1,9 @@ from datetime import datetime, timezone -from sinch.domains.numbers.models.v1.active_number import ActiveNumber from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.scheduled_sms_provisioning import ScheduledSmsProvisioning -from sinch.domains.numbers.models.v1.sms_configuration_response import SmsConfigurationResponse -from sinch.domains.numbers.models.v1.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared_params.active_number import ( + ActiveNumber, SmsConfigurationResponse, VoiceConfigurationResponse +) +from sinch.domains.numbers.models.v1.shared_params.scheduled_sms_provisioning import ScheduledSmsProvisioning def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 4a63c052..a23b4f64 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -5,11 +5,11 @@ from sinch.domains.numbers.api.v1.available_numbers import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber From 85c686d1d2f4fdc73df65dbb04a321f0195f1387 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 13 Mar 2025 13:58:31 +0100 Subject: [PATCH 023/106] refactor: endpoint imports --- README.md | 2 +- sinch/domains/numbers/__init__.py | 4 ++-- sinch/domains/numbers/api/v1/__init__.py | 6 ++++++ .../numbers/api/v1/active_numbers/__init__.py | 10 ---------- .../v1/{active_numbers => }/active_numbers_apis.py | 4 ++-- .../numbers/api/v1/available_numbers/__init__.py | 10 ---------- .../available_numbers_apis.py | 4 ++-- sinch/domains/numbers/{ => api/v1}/base_numbers.py | 0 sinch/domains/numbers/{ => api/v1}/exceptions.py | 0 sinch/domains/numbers/api/v1/internal/__init__.py | 13 +++++++++++++ .../active_numbers_endpoints.py | 2 +- .../available_numbers_endpoints.py | 14 +++++++------- .../{ => api/v1/internal}/numbers_endpoint.py | 2 +- .../endpoints/active/get_number_configuration.py | 2 +- .../active/release_number_from_project.py | 2 +- .../active/update_number_configuration.py | 2 +- .../endpoints/callbacks/get_configuration.py | 2 +- .../endpoints/callbacks/update_configuration.py | 2 +- .../endpoints/regions/list_available_regions.py | 2 +- tests/e2e/numbers/features/steps/numbers.steps.py | 2 +- .../active/test_list_active_numbers_endpoint.py | 2 +- .../available/test_activate_number_endpoint.py | 2 +- .../test_list_available_numbers_endpoint.py | 2 +- .../available/test_rent_any_number_endpoint.py | 2 +- .../available/test_search_for_number_endpoint.py | 2 +- .../domains/numbers/v1/test_available_numbers.py | 4 ++-- tests/unit/test_exceptions.py | 2 +- 27 files changed, 50 insertions(+), 51 deletions(-) delete mode 100644 sinch/domains/numbers/api/v1/active_numbers/__init__.py rename sinch/domains/numbers/api/v1/{active_numbers => }/active_numbers_apis.py (98%) delete mode 100644 sinch/domains/numbers/api/v1/available_numbers/__init__.py rename sinch/domains/numbers/api/v1/{available_numbers => }/available_numbers_apis.py (98%) rename sinch/domains/numbers/{ => api/v1}/base_numbers.py (100%) rename sinch/domains/numbers/{ => api/v1}/exceptions.py (100%) create mode 100644 sinch/domains/numbers/api/v1/internal/__init__.py rename sinch/domains/numbers/api/v1/{active_numbers => internal}/active_numbers_endpoints.py (93%) rename sinch/domains/numbers/api/v1/{available_numbers => internal}/available_numbers_endpoints.py (93%) rename sinch/domains/numbers/{ => api/v1/internal}/numbers_endpoint.py (97%) diff --git a/README.md b/README.md index ebf9ad93..7b9a3697 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Each API throws a custom, API related exception for an unsuccessful backed call. Example for Numbers API: ```python -from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.api.v1.exceptions import NumbersException try: nums = sinch_client.numbers.available.list( diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 931b9ea7..35081160 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,5 +1,5 @@ -from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import AvailableNumbers -from sinch.domains.numbers.api.v1.active_numbers.active_numbers_apis import ( +from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1 import ( ActiveNumbers, ActiveNumbersWithAsyncPagination ) from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index e69de29b..4f2106b6 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -0,0 +1,6 @@ +from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers, ActiveNumbersWithAsyncPagination + +__all__ = [ + "ActiveNumbers", + "ActiveNumbersWithAsyncPagination" +] diff --git a/sinch/domains/numbers/api/v1/active_numbers/__init__.py b/sinch/domains/numbers/api/v1/active_numbers/__init__.py deleted file mode 100644 index 65cddfd7..00000000 --- a/sinch/domains/numbers/api/v1/active_numbers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from sinch.domains.numbers.api.v1.active_numbers.active_numbers_endpoints import ListActiveNumbersEndpoint -from sinch.domains.numbers.api.v1.active_numbers.active_numbers_apis import ( - ActiveNumbers, ActiveNumbersWithAsyncPagination -) - -__all__ = [ - "ListActiveNumbersEndpoint", - "ActiveNumbers", - "ActiveNumbersWithAsyncPagination" -] diff --git a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py similarity index 98% rename from sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py rename to sinch/domains/numbers/api/v1/active_numbers_apis.py index afc89825..f40e1500 100644 --- a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,8 +1,8 @@ from typing import Optional from pydantic import StrictStr, StrictInt from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator -from sinch.domains.numbers.base_numbers import BaseNumbers -from sinch.domains.numbers.api.v1.active_numbers import ListActiveNumbersEndpoint +from sinch.domains.numbers.api.v1.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active import ( GetNumberConfigurationEndpoint, ReleaseNumberFromProjectEndpoint, UpdateNumberConfigurationEndpoint diff --git a/sinch/domains/numbers/api/v1/available_numbers/__init__.py b/sinch/domains/numbers/api/v1/available_numbers/__init__.py deleted file mode 100644 index 601d5599..00000000 --- a/sinch/domains/numbers/api/v1/available_numbers/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from sinch.domains.numbers.api.v1.available_numbers.available_numbers_endpoints import ( - ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint -) - -__all__ = [ - "ActivateNumberEndpoint", - "AvailableNumbersEndpoint", - "RentAnyNumberEndpoint", - "SearchForNumberEndpoint" -] diff --git a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py similarity index 98% rename from sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py rename to sinch/domains/numbers/api/v1/available_numbers_apis.py index 6ac21c41..1bba8497 100644 --- a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,8 +1,8 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr -from sinch.domains.numbers.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.base_numbers import BaseNumbers from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse -from sinch.domains.numbers.api.v1.available_numbers import ( +from sinch.domains.numbers.api.v1.internal import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber diff --git a/sinch/domains/numbers/base_numbers.py b/sinch/domains/numbers/api/v1/base_numbers.py similarity index 100% rename from sinch/domains/numbers/base_numbers.py rename to sinch/domains/numbers/api/v1/base_numbers.py diff --git a/sinch/domains/numbers/exceptions.py b/sinch/domains/numbers/api/v1/exceptions.py similarity index 100% rename from sinch/domains/numbers/exceptions.py rename to sinch/domains/numbers/api/v1/exceptions.py diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py new file mode 100644 index 00000000..3808eadf --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -0,0 +1,13 @@ +from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( + ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint +) +from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint + + +__all__ = [ + "ActivateNumberEndpoint", + "AvailableNumbersEndpoint", + "RentAnyNumberEndpoint", + "SearchForNumberEndpoint", + "ListActiveNumbersEndpoint" +] diff --git a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py similarity index 93% rename from sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py rename to sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index 36210872..3f44037e 100644 --- a/sinch/domains/numbers/api/v1/active_numbers/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse diff --git a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py similarity index 93% rename from sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py rename to sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 4f354ca7..dc760ba7 100644 --- a/sinch/domains/numbers/api/v1/available_numbers/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -1,17 +1,17 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber -from sinch.domains.numbers.models.v1.shared_params import ActiveNumber -from sinch.domains.numbers.models.v1 import ( - CheckNumberAvailabilityResponse, RentAnyNumberResponse -) -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint -from sinch.domains.numbers.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) +from sinch.domains.numbers.models.v1.shared_params import AvailableNumber, ActiveNumber +from sinch.domains.numbers.models.v1 import ( + CheckNumberAvailabilityResponse, RentAnyNumberResponse +) +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException + class ActivateNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/numbers_endpoint.py similarity index 97% rename from sinch/domains/numbers/numbers_endpoint.py rename to sinch/domains/numbers/api/v1/internal/numbers_endpoint.py index a6fc8092..992274df 100644 --- a/sinch/domains/numbers/numbers_endpoint.py +++ b/sinch/domains/numbers/api/v1/internal/numbers_endpoint.py @@ -3,7 +3,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.core.types import BM -from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.api.v1.exceptions import NumbersException from sinch.domains.numbers.models.v1.errors import NotFoundError diff --git a/sinch/domains/numbers/endpoints/active/get_number_configuration.py b/sinch/domains/numbers/endpoints/active/get_number_configuration.py index 7533e7fd..7328e10a 100644 --- a/sinch/domains/numbers/endpoints/active/get_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/get_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import GetNumberConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/active/release_number_from_project.py b/sinch/domains/numbers/endpoints/active/release_number_from_project.py index 4c3545c8..cee6ac1c 100644 --- a/sinch/domains/numbers/endpoints/active/release_number_from_project.py +++ b/sinch/domains/numbers/endpoints/active/release_number_from_project.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import ReleaseNumberFromProjectRequest from sinch.domains.numbers.models.active.responses import ReleaseNumberFromProjectResponse diff --git a/sinch/domains/numbers/endpoints/active/update_number_configuration.py b/sinch/domains/numbers/endpoints/active/update_number_configuration.py index 1b182971..da798b3d 100644 --- a/sinch/domains/numbers/endpoints/active/update_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/update_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import UpdateNumberConfigurationRequest from sinch.domains.numbers.models.active.responses import UpdateNumberConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py index 1671581d..69a8d2d4 100644 --- a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import GetNumbersCallbackConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py index 318acdb0..9051a6d9 100644 --- a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import UpdateNumbersCallbackConfigurationResponse from sinch.domains.numbers.models.callbacks.requests import UpdateNumbersCallbackConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/regions/list_available_regions.py b/sinch/domains/numbers/endpoints/regions/list_available_regions.py index 70120210..6915f56c 100644 --- a/sinch/domains/numbers/endpoints/regions/list_available_regions.py +++ b/sinch/domains/numbers/endpoints/regions/list_available_regions.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.regions import Region diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index e6448f72..273706f9 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -2,7 +2,7 @@ from datetime import timezone, datetime from behave import given, when, then from decimal import Decimal -from sinch.domains.numbers.exceptions import NumberNotFoundException +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError from sinch.domains.numbers.models.v1.shared_params import ActiveNumber diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index 96b8816e..b84cde4b 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -2,7 +2,7 @@ from decimal import Decimal import pytest -from sinch.domains.numbers.api.v1.active_numbers.active_numbers_endpoints import ListActiveNumbersEndpoint +from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py index f9ea6ea2..f6f36804 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py @@ -1,6 +1,6 @@ import pytest import json -from sinch.domains.numbers.api.v1.available_numbers import ActivateNumberEndpoint +from sinch.domains.numbers.api.v1.internal import ActivateNumberEndpoint from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py index f8eeb6ce..e5edf638 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.api.v1.available_numbers import AvailableNumbersEndpoint +from sinch.domains.numbers.api.v1.internal import AvailableNumbersEndpoint from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 1d677339..5196f7ae 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -2,7 +2,7 @@ import json from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import RentAnyNumberEndpoint +from sinch.domains.numbers.api.v1.available_numbers_apis import RentAnyNumberEndpoint from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest from sinch.domains.numbers.models.v1 import RentAnyNumberResponse diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 3120e92c..fd8d4ff4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.api.v1.available_numbers import SearchForNumberEndpoint +from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index a23b4f64..a3b267b0 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,8 +1,8 @@ import pytest from unittest.mock import MagicMock -from sinch.domains.numbers.api.v1.available_numbers.available_numbers_apis import AvailableNumbers -from sinch.domains.numbers.api.v1.available_numbers import ( +from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index f0a01ec4..58204bbd 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,5 +1,5 @@ from sinch.core.exceptions import ValidationException -from sinch.domains.numbers.exceptions import NumbersException +from sinch.domains.numbers.api.v1.exceptions import NumbersException from sinch.domains.conversation.exceptions import ConversationException from sinch.domains.sms.exceptions import SMSException from sinch.domains.authentication.exceptions import AuthenticationException From 26747afd5cf5a51216269a7fae949950e5b92f78 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 13 Mar 2025 14:23:09 +0100 Subject: [PATCH 024/106] chore(lint): fix lint error --- .../numbers/api/v1/internal/available_numbers_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index dc760ba7..d344fff3 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -13,7 +13,6 @@ from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException - class ActivateNumberEndpoint(NumbersEndpoint): """ Endpoint to activate a virtual number for a project. From 580b252cc9dd94a90d2ff8b1273867ad854917c6 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 14 Mar 2025 20:46:08 +0100 Subject: [PATCH 025/106] fix(numbers): circular dependency --- .../numbers/api/v1/active_numbers_apis.py | 8 +-- .../numbers/api/v1/available_numbers_apis.py | 12 ++--- sinch/domains/numbers/api/v1/base/__init__.py | 5 ++ .../numbers/api/v1/{ => base}/base_numbers.py | 0 .../numbers/api/v1/internal/__init__.py | 1 - .../v1/internal/active_numbers_endpoints.py | 2 +- .../internal/available_numbers_endpoints.py | 4 +- .../numbers/api/v1/internal/base/__init__.py | 5 ++ .../internal/{ => base}/numbers_endpoint.py | 0 .../active/get_number_configuration.py | 2 +- .../active/release_number_from_project.py | 2 +- .../active/update_number_configuration.py | 2 +- .../endpoints/callbacks/get_configuration.py | 2 +- .../callbacks/update_configuration.py | 2 +- .../regions/list_available_regions.py | 2 +- .../v1/check_number_availability_response.py | 5 +- .../numbers/models/v1/errors/error_details.py | 2 +- .../numbers/models/v1/errors/not_found.py | 2 +- .../numbers/models/v1/internal/__init__.py | 5 -- .../v1/internal/activate_number_request.py | 2 +- .../models/v1/internal/base/__init__.py | 8 +++ .../internal/{ => base}/base_model_config.py | 0 .../check_number_availability_request.py | 2 +- .../internal/list_active_numbers_request.py | 7 +-- .../internal/list_active_numbers_response.py | 2 +- .../list_available_numbers_request.py | 6 +-- .../list_available_numbers_response.py | 2 +- .../v1/internal/rent_any_number_request.py | 5 +- .../models/v1/internal/shared/__init__.py | 14 +++++ .../{ => shared}/sms_configuration_request.py | 2 +- .../voice_configuration_request.py | 2 +- .../models/v1/rent_any_number_response.py | 8 +-- .../numbers/models/v1/shared/__init__.py | 27 ++++++++++ .../active_number.py | 7 +-- .../available_number.py | 7 ++- .../v1/{shared_params => shared}/money.py | 2 +- .../number_pattern.py | 4 +- .../scheduled_sms_provisioning.py | 6 +-- .../scheduled_voice_provisioning.py | 4 +- .../scheduled_voice_provisioning_custom.py | 2 +- .../scheduled_voice_provisioning_est.py | 2 +- .../scheduled_voice_provisioning_fax.py | 2 +- .../scheduled_voice_provisioning_rtc.py | 2 +- .../sms_configuration_response.py | 4 +- .../voice_configuration_response.py | 4 +- .../models/v1/shared_params/__init__.py | 53 ------------------- .../v1/shared_params/number_pattern_dict.py | 8 --- .../numbers/models/v1/types/__init__.py | 30 +++++++++++ .../capability_type.py | 0 .../number_pattern.py} | 8 +++ .../{shared_params => types}/number_type.py | 0 .../order_by_values.py | 0 .../sms_configuration_dict.py | 0 .../status_scheduled_provisioning.py | 0 .../voice_configuration_dict.py | 0 .../numbers/models/v1/utils/validators.py | 4 +- tests/conftest.py | 2 +- .../numbers/features/steps/numbers.steps.py | 2 +- .../models/base/test_base_model_requests.py | 2 +- .../models/base/test_base_model_response.py | 2 +- .../domains/numbers/v1/models/test_numbers.py | 4 +- .../numbers/v1/test_available_numbers.py | 2 +- 62 files changed, 171 insertions(+), 142 deletions(-) create mode 100644 sinch/domains/numbers/api/v1/base/__init__.py rename sinch/domains/numbers/api/v1/{ => base}/base_numbers.py (100%) create mode 100644 sinch/domains/numbers/api/v1/internal/base/__init__.py rename sinch/domains/numbers/api/v1/internal/{ => base}/numbers_endpoint.py (100%) create mode 100644 sinch/domains/numbers/models/v1/internal/base/__init__.py rename sinch/domains/numbers/models/v1/internal/{ => base}/base_model_config.py (100%) create mode 100644 sinch/domains/numbers/models/v1/internal/shared/__init__.py rename sinch/domains/numbers/models/v1/internal/{ => shared}/sms_configuration_request.py (76%) rename sinch/domains/numbers/models/v1/internal/{ => shared}/voice_configuration_request.py (90%) create mode 100644 sinch/domains/numbers/models/v1/shared/__init__.py rename sinch/domains/numbers/models/v1/{shared_params => shared}/active_number.py (81%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/available_number.py (78%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/money.py (69%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/number_pattern.py (59%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_sms_provisioning.py (74%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_voice_provisioning.py (63%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_voice_provisioning_custom.py (59%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_voice_provisioning_est.py (70%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_voice_provisioning_fax.py (71%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/scheduled_voice_provisioning_rtc.py (70%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/sms_configuration_response.py (67%) rename sinch/domains/numbers/models/v1/{shared_params => shared}/voice_configuration_response.py (87%) delete mode 100644 sinch/domains/numbers/models/v1/shared_params/__init__.py delete mode 100644 sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py create mode 100644 sinch/domains/numbers/models/v1/types/__init__.py rename sinch/domains/numbers/models/v1/{shared_params => types}/capability_type.py (100%) rename sinch/domains/numbers/models/v1/{shared_params/number_search_pattern_type.py => types/number_pattern.py} (56%) rename sinch/domains/numbers/models/v1/{shared_params => types}/number_type.py (100%) rename sinch/domains/numbers/models/v1/{shared_params => types}/order_by_values.py (100%) rename sinch/domains/numbers/models/v1/{shared_params => types}/sms_configuration_dict.py (100%) rename sinch/domains/numbers/models/v1/{shared_params => types}/status_scheduled_provisioning.py (100%) rename sinch/domains/numbers/models/v1/{shared_params => types}/voice_configuration_dict.py (100%) diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index f40e1500..aa581316 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, StrictInt from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator -from sinch.domains.numbers.api.v1.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ListActiveNumbersEndpoint from sinch.domains.numbers.endpoints.active import ( GetNumberConfigurationEndpoint, ReleaseNumberFromProjectEndpoint, @@ -14,9 +14,9 @@ UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityTypeValuesList, NumberTypeValues, OrderByValues, NumberSearchPatternTypeValues, - ActiveNumber +from sinch.domains.numbers.models.v1.shared import ActiveNumber +from sinch.domains.numbers.models.v1.types import ( + CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues ) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 1bba8497..c6447f23 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,18 +1,18 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr -from sinch.domains.numbers.api.v1.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse from sinch.domains.numbers.api.v1.internal import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, RentAnyNumberRequest, ListAvailableNumbersRequest ) -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityTypeValuesList, NumberPatternDict, NumberTypeValues, ActiveNumber, - SmsConfigurationDict, NumberSearchPatternTypeValues, VoiceConfigurationDictType, VoiceConfigurationDictEST, - VoiceConfigurationDictFAX, VoiceConfigurationDictRTC +from sinch.domains.numbers.models.v1.shared import ActiveNumber +from sinch.domains.numbers.models.v1.types import ( + CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberPatternDict, NumberTypeValues, SmsConfigurationDict, + VoiceConfigurationDictType, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC ) diff --git a/sinch/domains/numbers/api/v1/base/__init__.py b/sinch/domains/numbers/api/v1/base/__init__.py new file mode 100644 index 00000000..34162708 --- /dev/null +++ b/sinch/domains/numbers/api/v1/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers + +__all__ = [ + "BaseNumbers" +] diff --git a/sinch/domains/numbers/api/v1/base_numbers.py b/sinch/domains/numbers/api/v1/base/base_numbers.py similarity index 100% rename from sinch/domains/numbers/api/v1/base_numbers.py rename to sinch/domains/numbers/api/v1/base/base_numbers.py diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 3808eadf..b9deb267 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -3,7 +3,6 @@ ) from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint - __all__ = [ "ActivateNumberEndpoint", "AvailableNumbersEndpoint", diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index 3f44037e..e6a71602 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index d344fff3..f4ed5a84 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -5,11 +5,11 @@ ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared_params import AvailableNumber, ActiveNumber +from sinch.domains.numbers.models.v1.shared import AvailableNumber, ActiveNumber from sinch.domains.numbers.models.v1 import ( CheckNumberAvailabilityResponse, RentAnyNumberResponse ) -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException diff --git a/sinch/domains/numbers/api/v1/internal/base/__init__.py b/sinch/domains/numbers/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..a1aebbcd --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint + +__all__ = [ + "NumbersEndpoint" +] diff --git a/sinch/domains/numbers/api/v1/internal/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py similarity index 100% rename from sinch/domains/numbers/api/v1/internal/numbers_endpoint.py rename to sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py diff --git a/sinch/domains/numbers/endpoints/active/get_number_configuration.py b/sinch/domains/numbers/endpoints/active/get_number_configuration.py index 7328e10a..6cc0bd63 100644 --- a/sinch/domains/numbers/endpoints/active/get_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/get_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import GetNumberConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/active/release_number_from_project.py b/sinch/domains/numbers/endpoints/active/release_number_from_project.py index cee6ac1c..1b8f67f7 100644 --- a/sinch/domains/numbers/endpoints/active/release_number_from_project.py +++ b/sinch/domains/numbers/endpoints/active/release_number_from_project.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import ReleaseNumberFromProjectRequest from sinch.domains.numbers.models.active.responses import ReleaseNumberFromProjectResponse diff --git a/sinch/domains/numbers/endpoints/active/update_number_configuration.py b/sinch/domains/numbers/endpoints/active/update_number_configuration.py index da798b3d..3446c19d 100644 --- a/sinch/domains/numbers/endpoints/active/update_number_configuration.py +++ b/sinch/domains/numbers/endpoints/active/update_number_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.active.requests import UpdateNumberConfigurationRequest from sinch.domains.numbers.models.active.responses import UpdateNumberConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py index 69a8d2d4..693e3f07 100644 --- a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import GetNumbersCallbackConfigurationResponse diff --git a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py index 9051a6d9..2051080a 100644 --- a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py +++ b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.callbacks.responses import UpdateNumbersCallbackConfigurationResponse from sinch.domains.numbers.models.callbacks.requests import UpdateNumbersCallbackConfigurationRequest diff --git a/sinch/domains/numbers/endpoints/regions/list_available_regions.py b/sinch/domains/numbers/endpoints/regions/list_available_regions.py index 6915f56c..ced93bde 100644 --- a/sinch/domains/numbers/endpoints/regions/list_available_regions.py +++ b/sinch/domains/numbers/endpoints/regions/list_available_regions.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.regions import Region diff --git a/sinch/domains/numbers/models/v1/check_number_availability_response.py b/sinch/domains/numbers/models/v1/check_number_availability_response.py index 199549f5..4e769653 100644 --- a/sinch/domains/numbers/models/v1/check_number_availability_response.py +++ b/sinch/domains/numbers/models/v1/check_number_availability_response.py @@ -1,7 +1,8 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params import CapabilityType, Money, NumberType +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import Money +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType class CheckNumberAvailabilityResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/errors/error_details.py b/sinch/domains/numbers/models/v1/errors/error_details.py index 83381874..858956b0 100644 --- a/sinch/domains/numbers/models/v1/errors/error_details.py +++ b/sinch/domains/numbers/models/v1/errors/error_details.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse from typing import Optional from pydantic import Field, StrictStr diff --git a/sinch/domains/numbers/models/v1/errors/not_found.py b/sinch/domains/numbers/models/v1/errors/not_found.py index 1e21bb0d..169d8edd 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found.py +++ b/sinch/domains/numbers/models/v1/errors/not_found.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails from typing import Optional from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index 20ac08f0..53b2766f 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,5 +1,3 @@ -from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigResponse from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest @@ -8,10 +6,7 @@ from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse - __all__ = [ - "BaseModelConfigRequest", - "BaseModelConfigResponse", "ActivateNumberRequest", "CheckNumberAvailabilityRequest", "ListActiveNumbersRequest", diff --git a/sinch/domains/numbers/models/v1/internal/activate_number_request.py b/sinch/domains/numbers/models/v1/internal/activate_number_request.py index 265eaf58..44bd40b0 100644 --- a/sinch/domains/numbers/models/v1/internal/activate_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/activate_number_request.py @@ -1,7 +1,7 @@ from typing import Optional, Dict from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest class ActivateNumberRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..de1dde67 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -0,0 +1,8 @@ +from sinch.domains.numbers.models.v1.internal.base.base_model_config import ( + BaseModelConfigRequest, BaseModelConfigResponse +) + +__all__ = [ + "BaseModelConfigRequest", + "BaseModelConfigResponse" +] diff --git a/sinch/domains/numbers/models/v1/internal/base_model_config.py b/sinch/domains/numbers/models/v1/internal/base/base_model_config.py similarity index 100% rename from sinch/domains/numbers/models/v1/internal/base_model_config.py rename to sinch/domains/numbers/models/v1/internal/base/base_model_config.py diff --git a/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py b/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py index 49e46fd4..f3b4fbf7 100644 --- a/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py +++ b/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py @@ -1,5 +1,5 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest class CheckNumberAvailabilityRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index a025c475..1e9494ea 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,8 +1,9 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityType, NumberTypeValues, OrderByValues, NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, OrderByValues, NumberSearchPatternTypeValues, NumberTypeValues, + ) diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index 9b3fb2fa..a04e3de0 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index f31d5bf5..4001d077 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,9 +1,7 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityTypeValuesList, NumberType, NumberSearchPatternTypeValues -) +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberType class ListAvailableNumbersRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 36772548..54b034b9 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 574281bf..3a763bb7 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,8 +1,9 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.shared_params import CapabilityType, NumberPattern +from sinch.domains.numbers.models.v1.shared import NumberPattern +from sinch.domains.numbers.models.v1.types import CapabilityType from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest class RentAnyNumberRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/shared/__init__.py b/sinch/domains/numbers/models/v1/internal/shared/__init__.py new file mode 100644 index 00000000..535540b7 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/shared/__init__.py @@ -0,0 +1,14 @@ +from sinch.domains.numbers.models.v1.internal.shared.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.shared.voice_configuration_request import ( + VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, + VoiceConfigurationRTC, VoiceConfigurationType +) + +__all__ = [ + "SmsConfigurationRequest", + "VoiceConfigurationCustom", + "VoiceConfigurationEST", + "VoiceConfigurationFAX", + "VoiceConfigurationRTC", + "VoiceConfigurationType" +] diff --git a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/shared/sms_configuration_request.py similarity index 76% rename from sinch/domains/numbers/models/v1/internal/sms_configuration_request.py rename to sinch/domains/numbers/models/v1/internal/shared/sms_configuration_request.py index a6a8b729..44e2c86e 100644 --- a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/shared/sms_configuration_request.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest class SmsConfigurationRequest(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/shared/voice_configuration_request.py similarity index 90% rename from sinch/domains/numbers/models/v1/internal/voice_configuration_request.py rename to sinch/domains/numbers/models/v1/internal/shared/voice_configuration_request.py index 6fb0a2f7..4ab42179 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/shared/voice_configuration_request.py @@ -1,6 +1,6 @@ from typing import Optional, Union, Annotated, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest class VoiceConfigurationFAX(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/rent_any_number_response.py b/sinch/domains/numbers/models/v1/rent_any_number_response.py index 9cf620a4..7dc202ea 100644 --- a/sinch/domains/numbers/models/v1/rent_any_number_response.py +++ b/sinch/domains/numbers/models/v1/rent_any_number_response.py @@ -1,11 +1,11 @@ from datetime import datetime from typing import Optional from pydantic import Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.internal.base_model_config import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityTypeValuesList, Money, NumberTypeValues, SmsConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import ( + Money, SmsConfigurationResponse, VoiceConfigurationResponse ) -from sinch.domains.numbers.models.v1.shared_params import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberTypeValues class RentAnyNumberResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py new file mode 100644 index 00000000..c3ecc119 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -0,0 +1,27 @@ +from sinch.domains.numbers.models.v1.shared.money import Money +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( + ScheduledVoiceProvisioningCustom +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX +from sinch.domains.numbers.models.v1.shared.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.shared.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "Money", + "NumberPattern", + "SmsConfigurationResponse", + "ScheduledVoiceProvisioning", + "ScheduledVoiceProvisioningCustom", + "ScheduledVoiceProvisioningEST", + "ScheduledVoiceProvisioningFAX", + "ScheduledVoiceProvisioningRTC", + "VoiceConfigurationResponse" +] diff --git a/sinch/domains/numbers/models/v1/shared_params/active_number.py b/sinch/domains/numbers/models/v1/shared/active_number.py similarity index 81% rename from sinch/domains/numbers/models/v1/shared_params/active_number.py rename to sinch/domains/numbers/models/v1/shared/active_number.py index ba4c01eb..98e3606b 100644 --- a/sinch/domains/numbers/models/v1/shared_params/active_number.py +++ b/sinch/domains/numbers/models/v1/shared/active_number.py @@ -1,10 +1,11 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, StrictInt -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityType, Money, NumberType, SmsConfigurationResponse, VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import ( + Money, SmsConfigurationResponse, VoiceConfigurationResponse ) +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType class ActiveNumber(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/available_number.py b/sinch/domains/numbers/models/v1/shared/available_number.py similarity index 78% rename from sinch/domains/numbers/models/v1/shared_params/available_number.py rename to sinch/domains/numbers/models/v1/shared/available_number.py index 343e95f5..754df56a 100644 --- a/sinch/domains/numbers/models/v1/shared_params/available_number.py +++ b/sinch/domains/numbers/models/v1/shared/available_number.py @@ -1,9 +1,8 @@ from typing import Optional from pydantic import Field, StrictBool, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params import ( - CapabilityType, Money, NumberType -) +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import Money +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType class AvailableNumber(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/money.py b/sinch/domains/numbers/models/v1/shared/money.py similarity index 69% rename from sinch/domains/numbers/models/v1/shared_params/money.py rename to sinch/domains/numbers/models/v1/shared/money.py index d6bb05f5..c8b43a3f 100644 --- a/sinch/domains/numbers/models/v1/shared_params/money.py +++ b/sinch/domains/numbers/models/v1/shared/money.py @@ -1,6 +1,6 @@ from decimal import Decimal from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse class Money(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/number_pattern.py b/sinch/domains/numbers/models/v1/shared/number_pattern.py similarity index 59% rename from sinch/domains/numbers/models/v1/shared_params/number_pattern.py rename to sinch/domains/numbers/models/v1/shared/number_pattern.py index 4965f026..af9ed93a 100644 --- a/sinch/domains/numbers/models/v1/shared_params/number_pattern.py +++ b/sinch/domains/numbers/models/v1/shared/number_pattern.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest -from sinch.domains.numbers.models.v1.shared_params import NumberSearchPatternType +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.types import NumberSearchPatternType class NumberPattern(BaseModelConfigRequest): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py similarity index 74% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py rename to sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index 6b1124b1..1fa7cca2 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -1,10 +1,8 @@ from datetime import datetime from typing import Optional - from pydantic import StrictStr, Field, conlist - -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning class ScheduledSmsProvisioning(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py similarity index 63% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py index 5bc50edd..f9e98512 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py @@ -1,8 +1,8 @@ from datetime import datetime from typing import Literal, Optional from pydantic import Field -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning class ScheduledVoiceProvisioning(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py similarity index 59% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_custom.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py index b4f439c8..e3f89ac2 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_custom.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py @@ -1,5 +1,5 @@ from pydantic import StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse class ScheduledVoiceProvisioningCustom(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py similarity index 70% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py index 6f1d018d..d0d9ac4c 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_est.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py similarity index 71% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py index e2c9e406..6dfb1bda 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_fax.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py similarity index 70% rename from sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py index 09b39648..b2c1b6b5 100644 --- a/sinch/domains/numbers/models/v1/shared_params/scheduled_voice_provisioning_rtc.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py @@ -1,6 +1,6 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared_params import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioning): diff --git a/sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py similarity index 67% rename from sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py rename to sinch/domains/numbers/models/v1/shared/sms_configuration_response.py index b68e7028..c6e5377d 100644 --- a/sinch/domains/numbers/models/v1/shared_params/sms_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py @@ -2,8 +2,8 @@ from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning class SmsConfigurationResponse(BaseModelConfigResponse): diff --git a/sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py similarity index 87% rename from sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py rename to sinch/domains/numbers/models/v1/shared/voice_configuration_response.py index 1bbeac6e..4241354e 100644 --- a/sinch/domains/numbers/models/v1/shared_params/voice_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py @@ -1,8 +1,8 @@ from datetime import datetime from typing import Literal, Optional, Union from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared_params import ( +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import ( ScheduledVoiceProvisioningCustom, ScheduledVoiceProvisioningEST, ScheduledVoiceProvisioningFAX, ScheduledVoiceProvisioningRTC ) diff --git a/sinch/domains/numbers/models/v1/shared_params/__init__.py b/sinch/domains/numbers/models/v1/shared_params/__init__.py deleted file mode 100644 index f1697252..00000000 --- a/sinch/domains/numbers/models/v1/shared_params/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -from sinch.domains.numbers.models.v1.shared_params.capability_type import CapabilityType, CapabilityTypeValuesList -from sinch.domains.numbers.models.v1.shared_params.money import Money -from sinch.domains.numbers.models.v1.shared_params.number_search_pattern_type import ( - NumberSearchPatternType, NumberSearchPatternTypeValues -) -from sinch.domains.numbers.models.v1.shared_params.number_pattern import NumberPattern -from sinch.domains.numbers.models.v1.shared_params.number_pattern_dict import NumberPatternDict -from sinch.domains.numbers.models.v1.shared_params.number_type import NumberTypeValues, NumberType -from sinch.domains.numbers.models.v1.shared_params.order_by_values import OrderByValues -from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_custom import ( - ScheduledVoiceProvisioningCustom -) -from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning import ScheduledVoiceProvisioning -from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST -from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC -from sinch.domains.numbers.models.v1.shared_params.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX -from sinch.domains.numbers.models.v1.shared_params.sms_configuration_dict import SmsConfigurationDict -from sinch.domains.numbers.models.v1.shared_params.sms_configuration_response import SmsConfigurationResponse -from sinch.domains.numbers.models.v1.shared_params.voice_configuration_response import VoiceConfigurationResponse -from sinch.domains.numbers.models.v1.shared_params.voice_configuration_dict import ( - VoiceConfigurationDictFAX, VoiceConfigurationDictEST, VoiceConfigurationDictRTC, - VoiceConfigurationDictCustom, VoiceConfigurationDictType -) -from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.shared_params.available_number import AvailableNumber - -__all__ = [ - "ActiveNumber", - "AvailableNumber", - "CapabilityType", - "CapabilityTypeValuesList", - "Money", - "NumberPattern", - "NumberPatternDict", - "NumberSearchPatternType", - "NumberSearchPatternTypeValues", - "NumberType", - "NumberTypeValues", - "SmsConfigurationDict", - "OrderByValues", - "SmsConfigurationResponse", - "ScheduledVoiceProvisioning", - "ScheduledVoiceProvisioningCustom", - "ScheduledVoiceProvisioningEST", - "ScheduledVoiceProvisioningFAX", - "ScheduledVoiceProvisioningRTC", - "VoiceConfigurationResponse", - "VoiceConfigurationDictFAX", - "VoiceConfigurationDictEST", - "VoiceConfigurationDictRTC", - "VoiceConfigurationDictCustom", - "VoiceConfigurationDictType" -] diff --git a/sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py b/sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py deleted file mode 100644 index 246f2f15..00000000 --- a/sinch/domains/numbers/models/v1/shared_params/number_pattern_dict.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import TypedDict -from typing_extensions import NotRequired -from sinch.domains.numbers.models.v1.shared_params import NumberSearchPatternTypeValues - - -class NumberPatternDict(TypedDict): - pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternTypeValues] diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py new file mode 100644 index 00000000..bd9e3e47 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -0,0 +1,30 @@ +from sinch.domains.numbers.models.v1.types.capability_type import CapabilityType, CapabilityTypeValuesList +from sinch.domains.numbers.models.v1.types.number_pattern import ( + NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues +) +from sinch.domains.numbers.models.v1.types.number_type import NumberTypeValues, NumberType +from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types.voice_configuration_dict import ( + VoiceConfigurationDictCustom, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, + VoiceConfigurationDictRTC, VoiceConfigurationDictType +) + +__all__ = [ + "CapabilityType", + "CapabilityTypeValuesList", + "NumberPatternDict", + "NumberSearchPatternType", + "NumberSearchPatternTypeValues", + "NumberType", + "NumberTypeValues", + "OrderByValues", + "SmsConfigurationDict", + "StatusScheduledProvisioning", + "VoiceConfigurationDictCustom", + "VoiceConfigurationDictEST", + "VoiceConfigurationDictFAX", + "VoiceConfigurationDictRTC", + "VoiceConfigurationDictType" +] diff --git a/sinch/domains/numbers/models/v1/shared_params/capability_type.py b/sinch/domains/numbers/models/v1/types/capability_type.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/capability_type.py rename to sinch/domains/numbers/models/v1/types/capability_type.py diff --git a/sinch/domains/numbers/models/v1/shared_params/number_search_pattern_type.py b/sinch/domains/numbers/models/v1/types/number_pattern.py similarity index 56% rename from sinch/domains/numbers/models/v1/shared_params/number_search_pattern_type.py rename to sinch/domains/numbers/models/v1/types/number_pattern.py index ceea3577..7883bc85 100644 --- a/sinch/domains/numbers/models/v1/shared_params/number_search_pattern_type.py +++ b/sinch/domains/numbers/models/v1/types/number_pattern.py @@ -1,9 +1,17 @@ +from typing import TypedDict +from typing_extensions import NotRequired from typing import Union, Literal, Annotated from pydantic import StrictStr, Field + NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] NumberSearchPatternType = Annotated[ NumberSearchPatternTypeValues, Field(default=None) ] + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternTypeValues] diff --git a/sinch/domains/numbers/models/v1/shared_params/number_type.py b/sinch/domains/numbers/models/v1/types/number_type.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/number_type.py rename to sinch/domains/numbers/models/v1/types/number_type.py diff --git a/sinch/domains/numbers/models/v1/shared_params/order_by_values.py b/sinch/domains/numbers/models/v1/types/order_by_values.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/order_by_values.py rename to sinch/domains/numbers/models/v1/types/order_by_values.py diff --git a/sinch/domains/numbers/models/v1/shared_params/sms_configuration_dict.py b/sinch/domains/numbers/models/v1/types/sms_configuration_dict.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/sms_configuration_dict.py rename to sinch/domains/numbers/models/v1/types/sms_configuration_dict.py diff --git a/sinch/domains/numbers/models/v1/shared_params/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/status_scheduled_provisioning.py rename to sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py diff --git a/sinch/domains/numbers/models/v1/shared_params/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared_params/voice_configuration_dict.py rename to sinch/domains/numbers/models/v1/types/voice_configuration_dict.py diff --git a/sinch/domains/numbers/models/v1/utils/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py index 6112a72a..166f9f5f 100644 --- a/sinch/domains/numbers/models/v1/utils/validators.py +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -1,11 +1,11 @@ from typing import Dict, Any -from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( +from sinch.domains.numbers.models.v1.internal.shared.voice_configuration_request import ( VoiceConfigurationRTC, VoiceConfigurationEST, VoiceConfigurationFAX, VoiceConfigurationCustom, ) -from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.shared.sms_configuration_request import SmsConfigurationRequest def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 74cd02ba..737edc6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 273706f9..dc3d53ca 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -5,7 +5,7 @@ from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.shared_params import ActiveNumber +from sinch.domains.numbers.models.v1.shared import ActiveNumber def execute_sync_or_async(context,call): diff --git a/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py index 7efeaa82..ab7fbbe3 100644 --- a/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py +++ b/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest def test_to_camel_case_expects_parsed_standard_cases(): """ diff --git a/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py index 8ad09db6..00538e78 100644 --- a/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py +++ b/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse def test_base_model_response_expects_unrecognized_fields_snake_case(): """ diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index bab62fba..7cdc1521 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,9 +1,9 @@ from datetime import datetime, timezone from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.shared_params.active_number import ( +from sinch.domains.numbers.models.v1.shared.active_number import ( ActiveNumber, SmsConfigurationResponse, VoiceConfigurationResponse ) -from sinch.domains.numbers.models.v1.shared_params.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index a3b267b0..ebae73a5 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -9,7 +9,7 @@ from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared_params.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber From 22a95ca6ff88321d7ee54622d61cc2e7704d1b7b Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Sun, 16 Mar 2025 19:55:46 +0100 Subject: [PATCH 026/106] refactor(models): reorganize response-related classes --- sinch/domains/numbers/api/v1/active_numbers_apis.py | 2 +- .../domains/numbers/api/v1/available_numbers_apis.py | 11 +++++------ .../api/v1/internal/active_numbers_endpoints.py | 2 +- .../api/v1/internal/available_numbers_endpoints.py | 12 ++++++------ .../v1/internal/list_active_numbers_response.py | 2 +- .../v1/internal/list_available_numbers_response.py | 2 +- sinch/domains/numbers/models/v1/shared/__init__.py | 10 ++++------ .../numbers/models/v1/shared/response/__init__.py | 7 +++++++ .../models/v1/shared/{ => response}/active_number.py | 0 .../v1/shared/{ => response}/available_number.py | 0 .../models/v1/shared/sms_configuration_response.py | 4 +--- tests/conftest.py | 2 +- tests/e2e/numbers/features/steps/numbers.steps.py | 2 +- tests/unit/domains/numbers/v1/models/test_numbers.py | 6 +++--- .../domains/numbers/v1/test_available_numbers.py | 2 +- 15 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/shared/response/__init__.py rename sinch/domains/numbers/models/v1/shared/{ => response}/active_number.py (100%) rename sinch/domains/numbers/models/v1/shared/{ => response}/available_number.py (100%) diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index aa581316..93528db5 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -14,7 +14,7 @@ UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.shared import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues ) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index c6447f23..4f977a24 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,18 +1,17 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr from sinch.domains.numbers.api.v1.base import BaseNumbers -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse from sinch.domains.numbers.api.v1.internal import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, RentAnyNumberRequest, ListAvailableNumbersRequest + ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest ) -from sinch.domains.numbers.models.v1.shared import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber, AvailableNumber from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberPatternDict, NumberTypeValues, SmsConfigurationDict, - VoiceConfigurationDictType, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC + CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, + VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictType ) diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index e6a71602..c91d8594 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,5 +1,5 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index f4ed5a84..21d81431 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -1,16 +1,16 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint +from sinch.domains.numbers.models.v1 import ( + CheckNumberAvailabilityResponse, RentAnyNumberResponse +) from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared import AvailableNumber, ActiveNumber -from sinch.domains.numbers.models.v1 import ( - CheckNumberAvailabilityResponse, RentAnyNumberResponse -) -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException +from sinch.domains.numbers.models.v1.shared.response import AvailableNumber, ActiveNumber class ActivateNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index a04e3de0..d03cc72b 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 54b034b9..3e642af3 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.shared.response import AvailableNumber class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index c3ecc119..6a82e92e 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -3,25 +3,23 @@ from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( ScheduledVoiceProvisioningCustom ) +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC from sinch.domains.numbers.models.v1.shared.sms_configuration_response import SmsConfigurationResponse from sinch.domains.numbers.models.v1.shared.voice_configuration_response import VoiceConfigurationResponse -from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.shared.available_number import AvailableNumber __all__ = [ - "ActiveNumber", - "AvailableNumber", "Money", "NumberPattern", - "SmsConfigurationResponse", + "ScheduledSmsProvisioning", "ScheduledVoiceProvisioning", "ScheduledVoiceProvisioningCustom", "ScheduledVoiceProvisioningEST", "ScheduledVoiceProvisioningFAX", "ScheduledVoiceProvisioningRTC", + "SmsConfigurationResponse", "VoiceConfigurationResponse" ] diff --git a/sinch/domains/numbers/models/v1/shared/response/__init__.py b/sinch/domains/numbers/models/v1/shared/response/__init__.py new file mode 100644 index 00000000..c07261d9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/response/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.numbers.models.v1.shared.response.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response.available_number import AvailableNumber + +__all__ = [ + "ActiveNumber", + "AvailableNumber" +] diff --git a/sinch/domains/numbers/models/v1/shared/active_number.py b/sinch/domains/numbers/models/v1/shared/response/active_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared/active_number.py rename to sinch/domains/numbers/models/v1/shared/response/active_number.py diff --git a/sinch/domains/numbers/models/v1/shared/available_number.py b/sinch/domains/numbers/models/v1/shared/response/available_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared/available_number.py rename to sinch/domains/numbers/models/v1/shared/response/available_number.py diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py index c6e5377d..0c612eb9 100644 --- a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py @@ -1,9 +1,7 @@ from typing import Optional - from pydantic import StrictStr, Field - from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning class SmsConfigurationResponse(BaseModelConfigResponse): diff --git a/tests/conftest.py b/tests/conftest.py index 737edc6f..16ffd4fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index dc3d53ca..fa0f2a49 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -5,7 +5,7 @@ from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.shared import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber def execute_sync_or_async(context,call): diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index 7cdc1521..3cf86e7c 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,9 +1,9 @@ from datetime import datetime, timezone from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.shared.active_number import ( - ActiveNumber, SmsConfigurationResponse, VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared import ( + ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse ) -from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index ebae73a5..a45d3f9c 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -9,7 +9,7 @@ from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.shared.response import ActiveNumber From c694f6340d5ce18a1cf1dd193baf19d15e73881c Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 17 Mar 2025 15:05:33 +0100 Subject: [PATCH 027/106] refactor(models): group response-related classes --- sinch/domains/numbers/api/v1/active_numbers_apis.py | 2 +- sinch/domains/numbers/api/v1/available_numbers_apis.py | 4 ++-- .../numbers/api/v1/internal/available_numbers_endpoints.py | 4 ++-- sinch/domains/numbers/models/v1/__init__.py | 7 ------- .../models/v1/internal/list_active_numbers_response.py | 2 +- .../models/v1/internal/list_available_numbers_response.py | 2 +- sinch/domains/numbers/models/v1/response/__init__.py | 7 +++++++ .../{ => response}/check_number_availability_response.py | 0 .../models/v1/{ => response}/rent_any_number_response.py | 0 .../domains/numbers/models/v1/response/shared/__init__.py | 7 +++++++ .../{shared/response => response/shared}/active_number.py | 0 .../response => response/shared}/available_number.py | 0 .../domains/numbers/models/v1/shared/response/__init__.py | 7 ------- tests/conftest.py | 2 +- tests/e2e/numbers/features/steps/numbers.steps.py | 4 ++-- .../endpoints/available/test_rent_any_number_endpoint.py | 2 +- .../endpoints/available/test_search_for_number_endpoint.py | 2 +- .../response/test_rent_any_number_response_model.py | 2 +- .../response/test_search_for_number_response_model.py | 2 +- tests/unit/domains/numbers/v1/models/test_numbers.py | 2 +- tests/unit/domains/numbers/v1/test_available_numbers.py | 4 ++-- 21 files changed, 31 insertions(+), 31 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/response/__init__.py rename sinch/domains/numbers/models/v1/{ => response}/check_number_availability_response.py (100%) rename sinch/domains/numbers/models/v1/{ => response}/rent_any_number_response.py (100%) create mode 100644 sinch/domains/numbers/models/v1/response/shared/__init__.py rename sinch/domains/numbers/models/v1/{shared/response => response/shared}/active_number.py (100%) rename sinch/domains/numbers/models/v1/{shared/response => response/shared}/available_number.py (100%) diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 93528db5..2267f9ac 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -14,7 +14,7 @@ UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues ) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 4f977a24..212f8f89 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -4,11 +4,11 @@ from sinch.domains.numbers.api.v1.internal import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse, RentAnyNumberResponse from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest ) -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber, AvailableNumber +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse, RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber, AvailableNumber from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictType diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 21d81431..4945ac78 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -3,14 +3,14 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -from sinch.domains.numbers.models.v1 import ( +from sinch.domains.numbers.models.v1.response import ( CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared.response import AvailableNumber, ActiveNumber +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber, AvailableNumber class ActivateNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/models/v1/__init__.py b/sinch/domains/numbers/models/v1/__init__.py index f78efa41..e69de29b 100644 --- a/sinch/domains/numbers/models/v1/__init__.py +++ b/sinch/domains/numbers/models/v1/__init__.py @@ -1,7 +0,0 @@ -from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.rent_any_number_response import RentAnyNumberResponse - -__all__ = [ - "CheckNumberAvailabilityResponse", - "RentAnyNumberResponse" -] diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index d03cc72b..fddeb106 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 3e642af3..2b2fad51 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,6 +1,6 @@ from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.v1.shared.response import AvailableNumber +from sinch.domains.numbers.models.v1.response.shared import AvailableNumber class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py new file mode 100644 index 00000000..6cfa2e19 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse + +__all__ = [ + "CheckNumberAvailabilityResponse", + "RentAnyNumberResponse" +] diff --git a/sinch/domains/numbers/models/v1/check_number_availability_response.py b/sinch/domains/numbers/models/v1/response/check_number_availability_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/check_number_availability_response.py rename to sinch/domains/numbers/models/v1/response/check_number_availability_response.py diff --git a/sinch/domains/numbers/models/v1/rent_any_number_response.py b/sinch/domains/numbers/models/v1/response/rent_any_number_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/rent_any_number_response.py rename to sinch/domains/numbers/models/v1/response/rent_any_number_response.py diff --git a/sinch/domains/numbers/models/v1/response/shared/__init__.py b/sinch/domains/numbers/models/v1/response/shared/__init__.py new file mode 100644 index 00000000..c41ebaca --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/shared/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.numbers.models.v1.response.shared.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.response.shared.available_number import AvailableNumber + +__all__ = [ + "ActiveNumber", + "AvailableNumber" +] diff --git a/sinch/domains/numbers/models/v1/shared/response/active_number.py b/sinch/domains/numbers/models/v1/response/shared/active_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared/response/active_number.py rename to sinch/domains/numbers/models/v1/response/shared/active_number.py diff --git a/sinch/domains/numbers/models/v1/shared/response/available_number.py b/sinch/domains/numbers/models/v1/response/shared/available_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/shared/response/available_number.py rename to sinch/domains/numbers/models/v1/response/shared/available_number.py diff --git a/sinch/domains/numbers/models/v1/shared/response/__init__.py b/sinch/domains/numbers/models/v1/shared/response/__init__.py index c07261d9..e69de29b 100644 --- a/sinch/domains/numbers/models/v1/shared/response/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/response/__init__.py @@ -1,7 +0,0 @@ -from sinch.domains.numbers.models.v1.shared.response.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.shared.response.available_number import AvailableNumber - -__all__ = [ - "ActiveNumber", - "AvailableNumber" -] diff --git a/tests/conftest.py b/tests/conftest.py index 16ffd4fa..83ddeaa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index fa0f2a49..5b53fcd7 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -3,9 +3,9 @@ from behave import given, when, then from decimal import Decimal from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException -from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber def execute_sync_or_async(context,call): diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 5196f7ae..6b854cc4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -4,7 +4,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.available_numbers_apis import RentAnyNumberEndpoint from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest -from sinch.domains.numbers.models.v1 import RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index fd8d4ff4..5b0d99f8 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,6 +1,6 @@ import pytest from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint -from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py index 3b48afe1..c7990e54 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py @@ -1,7 +1,7 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.numbers.models.v1 import RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse @pytest.fixture def valid_data(): diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py index 168dafc1..39bd9837 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse def test_check_number_availability_response_expects_valid_data(): """ diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index 3cf86e7c..c1b89be8 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -3,7 +3,7 @@ from sinch.domains.numbers.models.v1.shared import ( ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse ) -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index a45d3f9c..8d039a28 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -5,11 +5,11 @@ from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.shared.response import ActiveNumber +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response.shared import ActiveNumber From 435d2647b43a806d35e0edac2fb1f39d145990f1 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 17 Mar 2025 15:18:33 +0100 Subject: [PATCH 028/106] chore: standardize import order --- .../api/v1/internal/available_numbers_endpoints.py | 8 ++++---- .../available/test_search_for_number_endpoint.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 4945ac78..fd746d96 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -2,14 +2,14 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException -from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -from sinch.domains.numbers.models.v1.response import ( - CheckNumberAvailabilityResponse, RentAnyNumberResponse -) from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint +from sinch.domains.numbers.models.v1.response import ( + CheckNumberAvailabilityResponse, RentAnyNumberResponse +) from sinch.domains.numbers.models.v1.response.shared import ActiveNumber, AvailableNumber diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 5b0d99f8..0ffb8e36 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,7 +1,7 @@ import pytest from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint -from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse @pytest.fixture From 7de7636e901fc9a3bb1f4a16d525345c3e5ba9a9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 18 Mar 2025 19:45:56 +0100 Subject: [PATCH 029/106] refactor(models): flatten models hierarchy Moved models from subdirectories to match the expected generated structure Signed-off-by: Jessica Matsuoka --- .../numbers/api/v1/active_numbers_apis.py | 2 +- .../numbers/api/v1/available_numbers_apis.py | 5 +++-- sinch/domains/numbers/api/v1/base/__init__.py | 6 ++---- .../numbers/api/v1/internal/__init__.py | 6 ++++-- .../internal/available_numbers_endpoints.py | 7 +++---- .../numbers/api/v1/internal/base/__init__.py | 4 +--- sinch/domains/numbers/models/v1/__init__.py | 13 ++++++++++++ .../v1/{response/shared => }/active_number.py | 0 .../{response/shared => }/available_number.py | 0 .../check_number_availability_response.py | 0 .../numbers/models/v1/errors/__init__.py | 8 ++++--- .../numbers/models/v1/internal/__init__.py | 21 +++++++++++++++---- .../models/v1/internal/base/__init__.py | 7 +++++-- .../internal/list_active_numbers_response.py | 21 ++++++++++++++++++- .../list_available_numbers_response.py | 18 ++++++++++++++-- .../models/v1/internal/shared/__init__.py | 14 ------------- .../{shared => }/sms_configuration_request.py | 0 .../voice_configuration_request.py | 0 .../rent_any_number_response.py | 0 .../numbers/models/v1/response/__init__.py | 7 ------- .../models/v1/response/shared/__init__.py | 7 ------- .../numbers/models/v1/shared/__init__.py | 12 ++++++----- .../models/v1/shared/response/__init__.py | 0 .../numbers/models/v1/types/__init__.py | 10 ++++++--- .../numbers/models/v1/utils/validators.py | 4 ++-- tests/conftest.py | 2 +- .../numbers/features/steps/numbers.steps.py | 3 +-- .../test_rent_any_number_endpoint.py | 2 +- .../test_search_for_number_endpoint.py | 2 +- .../test_rent_any_number_response_model.py | 2 +- .../test_search_for_number_response_model.py | 2 +- .../domains/numbers/v1/models/test_numbers.py | 2 +- .../numbers/v1/test_available_numbers.py | 4 +--- 33 files changed, 114 insertions(+), 77 deletions(-) rename sinch/domains/numbers/models/v1/{response/shared => }/active_number.py (100%) rename sinch/domains/numbers/models/v1/{response/shared => }/available_number.py (100%) rename sinch/domains/numbers/models/v1/{response => }/check_number_availability_response.py (100%) delete mode 100644 sinch/domains/numbers/models/v1/internal/shared/__init__.py rename sinch/domains/numbers/models/v1/internal/{shared => }/sms_configuration_request.py (100%) rename sinch/domains/numbers/models/v1/internal/{shared => }/voice_configuration_request.py (100%) rename sinch/domains/numbers/models/v1/{response => }/rent_any_number_response.py (100%) delete mode 100644 sinch/domains/numbers/models/v1/response/__init__.py delete mode 100644 sinch/domains/numbers/models/v1/response/shared/__init__.py delete mode 100644 sinch/domains/numbers/models/v1/shared/response/__init__.py diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 2267f9ac..b52c35b6 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -13,8 +13,8 @@ from sinch.domains.numbers.models.active.responses import ( UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) +from sinch.domains.numbers.models.v1 import ActiveNumber from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues ) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 212f8f89..9b739c05 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,5 +1,8 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr +from sinch.domains.numbers.models.v1 import ( + ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse +) from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint @@ -7,8 +10,6 @@ from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest ) -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse, RentAnyNumberResponse -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber, AvailableNumber from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictType diff --git a/sinch/domains/numbers/api/v1/base/__init__.py b/sinch/domains/numbers/api/v1/base/__init__.py index 34162708..296c8791 100644 --- a/sinch/domains/numbers/api/v1/base/__init__.py +++ b/sinch/domains/numbers/api/v1/base/__init__.py @@ -1,5 +1,3 @@ -from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers +from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers as BaseNumbers -__all__ = [ - "BaseNumbers" -] +__all__ = ['BaseNumbers'] diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index b9deb267..e52c4758 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -1,12 +1,14 @@ +from __future__ import annotations + +from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint __all__ = [ "ActivateNumberEndpoint", "AvailableNumbersEndpoint", + "ListActiveNumbersEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", - "ListActiveNumbersEndpoint" ] diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index fd746d96..26869176 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -6,11 +6,10 @@ ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -from sinch.domains.numbers.models.v1.response import ( - CheckNumberAvailabilityResponse, RentAnyNumberResponse +from sinch.domains.numbers.models.v1 import ( + ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber, AvailableNumber +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint class ActivateNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/api/v1/internal/base/__init__.py b/sinch/domains/numbers/api/v1/internal/base/__init__.py index a1aebbcd..38ca27d6 100644 --- a/sinch/domains/numbers/api/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/base/__init__.py @@ -1,5 +1,3 @@ from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -__all__ = [ - "NumbersEndpoint" -] +__all__ = ["NumbersEndpoint"] diff --git a/sinch/domains/numbers/models/v1/__init__.py b/sinch/domains/numbers/models/v1/__init__.py index e69de29b..faf18614 100644 --- a/sinch/domains/numbers/models/v1/__init__.py +++ b/sinch/domains/numbers/models/v1/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from sinch.domains.numbers.models.v1.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.rent_any_number_response import RentAnyNumberResponse + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "CheckNumberAvailabilityResponse", + "RentAnyNumberResponse", +] diff --git a/sinch/domains/numbers/models/v1/response/shared/active_number.py b/sinch/domains/numbers/models/v1/active_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/response/shared/active_number.py rename to sinch/domains/numbers/models/v1/active_number.py diff --git a/sinch/domains/numbers/models/v1/response/shared/available_number.py b/sinch/domains/numbers/models/v1/available_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/response/shared/available_number.py rename to sinch/domains/numbers/models/v1/available_number.py diff --git a/sinch/domains/numbers/models/v1/response/check_number_availability_response.py b/sinch/domains/numbers/models/v1/check_number_availability_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/response/check_number_availability_response.py rename to sinch/domains/numbers/models/v1/check_number_availability_response.py diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index 75f4052f..346e8553 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,7 +1,9 @@ -from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError -from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails +from __future__ import annotations + +from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError as NotFoundError +from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails as ErrorDetails __all__ = [ "NotFoundError", - "ErrorDetails" + "ErrorDetails", ] diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index 53b2766f..de4d0e0c 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,17 +1,30 @@ +from __future__ import annotations + from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( + VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, + VoiceConfigurationRTC, VoiceConfigurationType +) __all__ = [ "ActivateNumberRequest", "CheckNumberAvailabilityRequest", "ListActiveNumbersRequest", "ListAvailableNumbersRequest", - "RentAnyNumberRequest", "ListActiveNumbersResponse", - "ListAvailableNumbersResponse" + "ListAvailableNumbersResponse", + "RentAnyNumberRequest", + "SmsConfigurationRequest", + "VoiceConfigurationCustom", + "VoiceConfigurationEST", + "VoiceConfigurationFAX", + "VoiceConfigurationRTC", + "VoiceConfigurationType", ] diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py index de1dde67..eaff20f0 100644 --- a/sinch/domains/numbers/models/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from sinch.domains.numbers.models.v1.internal.base.base_model_config import ( - BaseModelConfigRequest, BaseModelConfigResponse + BaseModelConfigRequest as BaseModelConfigRequest, + BaseModelConfigResponse as BaseModelConfigResponse ) __all__ = [ "BaseModelConfigRequest", - "BaseModelConfigResponse" + "BaseModelConfigResponse", ] diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index fddeb106..fc7fa34a 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,6 +1,25 @@ +from datetime import datetime from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import Money, SmsConfigurationResponse, VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType + + +class ActiveNumber(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capabilities: Optional[CapabilityType] = Field(default=None) + money: Optional[Money] = Field(default=None) + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + expire_at: Optional[datetime] = Field(default=None, alias="expireAt") + sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 2b2fad51..1ed0331b 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,6 +1,20 @@ from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field -from sinch.domains.numbers.models.v1.response.shared import AvailableNumber +from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt, StrictBool +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.shared import Money +from sinch.domains.numbers.models.v1.types import NumberType, CapabilityType + + +class AvailableNumber(BaseModelConfigResponse): + phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + type: Optional[NumberType] = Field(default=None) + capability: Optional[CapabilityType] = Field(default=None) + setup_price: Optional[Money] = Field(default=None, alias="setupPrice") + monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") + payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") + supporting_documentation_required: Optional[StrictBool] = ( + Field(default=None, alias="supportingDocumentationRequired")) class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/shared/__init__.py b/sinch/domains/numbers/models/v1/internal/shared/__init__.py deleted file mode 100644 index 535540b7..00000000 --- a/sinch/domains/numbers/models/v1/internal/shared/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from sinch.domains.numbers.models.v1.internal.shared.sms_configuration_request import SmsConfigurationRequest -from sinch.domains.numbers.models.v1.internal.shared.voice_configuration_request import ( - VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, - VoiceConfigurationRTC, VoiceConfigurationType -) - -__all__ = [ - "SmsConfigurationRequest", - "VoiceConfigurationCustom", - "VoiceConfigurationEST", - "VoiceConfigurationFAX", - "VoiceConfigurationRTC", - "VoiceConfigurationType" -] diff --git a/sinch/domains/numbers/models/v1/internal/shared/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py similarity index 100% rename from sinch/domains/numbers/models/v1/internal/shared/sms_configuration_request.py rename to sinch/domains/numbers/models/v1/internal/sms_configuration_request.py diff --git a/sinch/domains/numbers/models/v1/internal/shared/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py similarity index 100% rename from sinch/domains/numbers/models/v1/internal/shared/voice_configuration_request.py rename to sinch/domains/numbers/models/v1/internal/voice_configuration_request.py diff --git a/sinch/domains/numbers/models/v1/response/rent_any_number_response.py b/sinch/domains/numbers/models/v1/rent_any_number_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/response/rent_any_number_response.py rename to sinch/domains/numbers/models/v1/rent_any_number_response.py diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py deleted file mode 100644 index 6cfa2e19..00000000 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse - -__all__ = [ - "CheckNumberAvailabilityResponse", - "RentAnyNumberResponse" -] diff --git a/sinch/domains/numbers/models/v1/response/shared/__init__.py b/sinch/domains/numbers/models/v1/response/shared/__init__.py deleted file mode 100644 index c41ebaca..00000000 --- a/sinch/domains/numbers/models/v1/response/shared/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from sinch.domains.numbers.models.v1.response.shared.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.response.shared.available_number import AvailableNumber - -__all__ = [ - "ActiveNumber", - "AvailableNumber" -] diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index 6a82e92e..85c4b159 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -1,10 +1,12 @@ -from sinch.domains.numbers.models.v1.shared.money import Money -from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern +from __future__ import annotations + +from sinch.domains.numbers.models.v1.shared.money import Money as Money +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern as NumberPattern +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( ScheduledVoiceProvisioningCustom ) -from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC @@ -21,5 +23,5 @@ "ScheduledVoiceProvisioningFAX", "ScheduledVoiceProvisioningRTC", "SmsConfigurationResponse", - "VoiceConfigurationResponse" + "VoiceConfigurationResponse", ] diff --git a/sinch/domains/numbers/models/v1/shared/response/__init__.py b/sinch/domains/numbers/models/v1/shared/response/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index bd9e3e47..deeb6c85 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,8 +1,12 @@ -from sinch.domains.numbers.models.v1.types.capability_type import CapabilityType, CapabilityTypeValuesList +from __future__ import annotations + +from sinch.domains.numbers.models.v1.types.capability_type import ( + CapabilityType, CapabilityTypeValuesList +) from sinch.domains.numbers.models.v1.types.number_pattern import ( NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues ) -from sinch.domains.numbers.models.v1.types.number_type import NumberTypeValues, NumberType +from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning @@ -26,5 +30,5 @@ "VoiceConfigurationDictEST", "VoiceConfigurationDictFAX", "VoiceConfigurationDictRTC", - "VoiceConfigurationDictType" + "VoiceConfigurationDictType", ] diff --git a/sinch/domains/numbers/models/v1/utils/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py index 166f9f5f..dbaccdef 100644 --- a/sinch/domains/numbers/models/v1/utils/validators.py +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -1,11 +1,11 @@ from typing import Dict, Any -from sinch.domains.numbers.models.v1.internal.shared.voice_configuration_request import ( +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( VoiceConfigurationRTC, VoiceConfigurationEST, VoiceConfigurationFAX, VoiceConfigurationCustom, ) -from sinch.domains.numbers.models.v1.internal.shared.sms_configuration_request import SmsConfigurationRequest def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 83ddeaa5..5c0ed67c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber +from sinch.domains.numbers.models.v1 import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 5b53fcd7..d818f28f 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -3,9 +3,8 @@ from behave import given, when, then from decimal import Decimal from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.v1 import ActiveNumber, RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber def execute_sync_or_async(context,call): diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 6b854cc4..00767a64 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -3,8 +3,8 @@ from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.available_numbers_apis import RentAnyNumberEndpoint +from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest -from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 0ffb8e36..884a6b88 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,6 +1,6 @@ import pytest +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py index c7990e54..3b48afe1 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py @@ -1,7 +1,7 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse +from sinch.domains.numbers.models.v1 import RentAnyNumberResponse @pytest.fixture def valid_data(): diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py index 39bd9837..168dafc1 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse def test_check_number_availability_response_expects_valid_data(): """ diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index c1b89be8..35aa6b42 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,9 +1,9 @@ from datetime import datetime, timezone +from sinch.domains.numbers.models.v1 import ActiveNumber from sinch.domains.numbers.models.v1.errors import NotFoundError from sinch.domains.numbers.models.v1.shared import ( ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse ) -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 8d039a28..2d614896 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -5,12 +5,10 @@ from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) +from sinch.domains.numbers.models.v1 import ActiveNumber, CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.response.shared import ActiveNumber - @pytest.fixture From bedd600882a48d172eb11cac90da554378eda5bc Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 19 Mar 2025 11:37:12 +0100 Subject: [PATCH 030/106] refactor(models): reorganize response-related classes --- .../numbers/api/v1/active_numbers_apis.py | 2 +- .../numbers/api/v1/available_numbers_apis.py | 2 +- .../internal/available_numbers_endpoints.py | 2 +- sinch/domains/numbers/models/v1/__init__.py | 13 ------------ .../internal/list_active_numbers_response.py | 21 +------------------ .../list_available_numbers_response.py | 18 ++-------------- .../numbers/models/v1/response/__init__.py | 13 ++++++++++++ .../models/v1/{ => response}/active_number.py | 0 .../v1/{ => response}/available_number.py | 0 .../check_number_availability_response.py | 0 .../rent_any_number_response.py | 0 tests/conftest.py | 2 +- .../numbers/features/steps/numbers.steps.py | 2 +- .../test_rent_any_number_endpoint.py | 2 +- .../test_search_for_number_endpoint.py | 2 +- .../test_rent_any_number_response_model.py | 2 +- .../test_search_for_number_response_model.py | 2 +- .../domains/numbers/v1/models/test_numbers.py | 2 +- .../numbers/v1/test_available_numbers.py | 2 +- 19 files changed, 27 insertions(+), 60 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/response/__init__.py rename sinch/domains/numbers/models/v1/{ => response}/active_number.py (100%) rename sinch/domains/numbers/models/v1/{ => response}/available_number.py (100%) rename sinch/domains/numbers/models/v1/{ => response}/check_number_availability_response.py (100%) rename sinch/domains/numbers/models/v1/{ => response}/rent_any_number_response.py (100%) diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index b52c35b6..5cc230df 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -13,7 +13,7 @@ from sinch.domains.numbers.models.active.responses import ( UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse ) -from sinch.domains.numbers.models.v1 import ActiveNumber +from sinch.domains.numbers.models.v1.response import ActiveNumber from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 9b739c05..11e7e790 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,6 +1,6 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr -from sinch.domains.numbers.models.v1 import ( +from sinch.domains.numbers.models.v1.response import ( ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.api.v1.base import BaseNumbers diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 26869176..569431ff 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -6,7 +6,7 @@ ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, ListAvailableNumbersResponse ) -from sinch.domains.numbers.models.v1 import ( +from sinch.domains.numbers.models.v1.response import ( ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint diff --git a/sinch/domains/numbers/models/v1/__init__.py b/sinch/domains/numbers/models/v1/__init__.py index faf18614..e69de29b 100644 --- a/sinch/domains/numbers/models/v1/__init__.py +++ b/sinch/domains/numbers/models/v1/__init__.py @@ -1,13 +0,0 @@ -from __future__ import annotations - -from sinch.domains.numbers.models.v1.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.available_number import AvailableNumber -from sinch.domains.numbers.models.v1.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.rent_any_number_response import RentAnyNumberResponse - -__all__ = [ - "ActiveNumber", - "AvailableNumber", - "CheckNumberAvailabilityResponse", - "RentAnyNumberResponse", -] diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index fc7fa34a..95f73f60 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,25 +1,6 @@ -from datetime import datetime from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared import Money, SmsConfigurationResponse, VoiceConfigurationResponse -from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType - - -class ActiveNumber(BaseModelConfigResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - project_id: Optional[StrictStr] = Field(default=None, alias="projectId") - display_name: Optional[StrictStr] = Field(default=None, alias="displayName") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capabilities: Optional[CapabilityType] = Field(default=None) - money: Optional[Money] = Field(default=None) - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") - expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") +from sinch.domains.numbers.models.v1.response import ActiveNumber class ListActiveNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 1ed0331b..35bb988e 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,20 +1,6 @@ from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt, StrictBool -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse -from sinch.domains.numbers.models.v1.shared import Money -from sinch.domains.numbers.models.v1.types import NumberType, CapabilityType - - -class AvailableNumber(BaseModelConfigResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) - setup_price: Optional[Money] = Field(default=None, alias="setupPrice") - monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - supporting_documentation_required: Optional[StrictBool] = ( - Field(default=None, alias="supportingDocumentationRequired")) +from pydantic import BaseModel, ConfigDict, Field +from sinch.domains.numbers.models.v1.response import AvailableNumber class ListAvailableNumbersResponse(BaseModel): diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py new file mode 100644 index 00000000..b4c976e7 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber +from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse + +__all__ = [ + "ActiveNumber", + "AvailableNumber", + "CheckNumberAvailabilityResponse", + "RentAnyNumberResponse", +] diff --git a/sinch/domains/numbers/models/v1/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/active_number.py rename to sinch/domains/numbers/models/v1/response/active_number.py diff --git a/sinch/domains/numbers/models/v1/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py similarity index 100% rename from sinch/domains/numbers/models/v1/available_number.py rename to sinch/domains/numbers/models/v1/response/available_number.py diff --git a/sinch/domains/numbers/models/v1/check_number_availability_response.py b/sinch/domains/numbers/models/v1/response/check_number_availability_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/check_number_availability_response.py rename to sinch/domains/numbers/models/v1/response/check_number_availability_response.py diff --git a/sinch/domains/numbers/models/v1/rent_any_number_response.py b/sinch/domains/numbers/models/v1/response/rent_any_number_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/rent_any_number_response.py rename to sinch/domains/numbers/models/v1/response/rent_any_number_response.py diff --git a/tests/conftest.py b/tests/conftest.py index 5c0ed67c..ca5fc1fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.numbers.models.v1 import ActiveNumber +from sinch.domains.numbers.models.v1.response import ActiveNumber @dataclass diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index d818f28f..7ba7b01c 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -3,8 +3,8 @@ from behave import given, when, then from decimal import Decimal from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException -from sinch.domains.numbers.models.v1 import ActiveNumber, RentAnyNumberResponse from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.models.v1.response import ActiveNumber, RentAnyNumberResponse def execute_sync_or_async(context,call): diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 00767a64..6b854cc4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -3,8 +3,8 @@ from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.available_numbers_apis import RentAnyNumberEndpoint -from sinch.domains.numbers.models.v1 import RentAnyNumberResponse from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest +from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 884a6b88..0ba16674 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,7 +1,7 @@ import pytest -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse from sinch.core.models.http_response import HTTPResponse @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py index 3b48afe1..c7990e54 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py @@ -1,7 +1,7 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.numbers.models.v1 import RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse @pytest.fixture def valid_data(): diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py index 168dafc1..39bd9837 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1 import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse def test_check_number_availability_response_expects_valid_data(): """ diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/test_numbers.py index 35aa6b42..4d68bb2a 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/test_numbers.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sinch.domains.numbers.models.v1 import ActiveNumber from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.models.v1.response import ActiveNumber from sinch.domains.numbers.models.v1.shared import ( ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse ) diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 2d614896..6b2d3c5b 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -5,10 +5,10 @@ from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) -from sinch.domains.numbers.models.v1 import ActiveNumber, CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.internal import ( ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse ) +from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse @pytest.fixture From 97dd25f3bec9f8fff32da53725c1e8941c36c224 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 19 Mar 2025 12:12:46 +0100 Subject: [PATCH 031/106] chore: remove unused imports --- sinch/domains/numbers/api/v1/internal/__init__.py | 2 -- sinch/domains/numbers/models/v1/errors/__init__.py | 2 -- sinch/domains/numbers/models/v1/internal/__init__.py | 2 -- sinch/domains/numbers/models/v1/internal/base/__init__.py | 2 -- sinch/domains/numbers/models/v1/response/__init__.py | 2 -- sinch/domains/numbers/models/v1/shared/__init__.py | 2 -- sinch/domains/numbers/models/v1/types/__init__.py | 2 -- 7 files changed, 14 deletions(-) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index e52c4758..97c45036 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index 346e8553..7ac15d9e 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError as NotFoundError from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails as ErrorDetails diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index de4d0e0c..4c0f9a13 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py index eaff20f0..f2f303fb 100644 --- a/sinch/domains/numbers/models/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.internal.base.base_model_config import ( BaseModelConfigRequest as BaseModelConfigRequest, BaseModelConfigResponse as BaseModelConfigResponse diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index b4c976e7..b87880a7 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index 85c4b159..d1b7b661 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.shared.money import Money as Money from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern as NumberPattern from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index deeb6c85..0e01b321 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from sinch.domains.numbers.models.v1.types.capability_type import ( CapabilityType, CapabilityTypeValuesList ) From f1feb150a3ca8e3e6e5e972733bf5849215da1f5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 19 Mar 2025 14:28:40 +0100 Subject: [PATCH 032/106] chore: standardize import paths --- sinch/domains/numbers/__init__.py | 2 +- sinch/domains/numbers/api/v1/__init__.py | 4 +++- sinch/domains/numbers/models/v1/errors/__init__.py | 4 ++-- sinch/domains/numbers/models/v1/shared/__init__.py | 4 ++-- sinch/domains/sms/endpoints/sms_endpoint.py | 1 - .../v1/endpoints/active/test_list_active_numbers_endpoint.py | 2 +- .../v1/endpoints/available/test_rent_any_number_endpoint.py | 2 +- .../active/requests/test_list_active_numbers_request_model.py | 2 +- tests/unit/domains/numbers/v1/test_available_numbers.py | 2 +- 9 files changed, 12 insertions(+), 11 deletions(-) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 35081160..591e190c 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1 import AvailableNumbers from sinch.domains.numbers.api.v1 import ( ActiveNumbers, ActiveNumbersWithAsyncPagination ) diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index 4f2106b6..07ba7637 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -1,6 +1,8 @@ from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers, ActiveNumbersWithAsyncPagination +from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers __all__ = [ "ActiveNumbers", - "ActiveNumbersWithAsyncPagination" + "ActiveNumbersWithAsyncPagination", + "AvailableNumbers" ] diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index 7ac15d9e..1f135a55 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,5 +1,5 @@ -from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError as NotFoundError -from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails as ErrorDetails +from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError +from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails __all__ = [ "NotFoundError", diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index d1b7b661..a3ec7b7b 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -1,5 +1,5 @@ -from sinch.domains.numbers.models.v1.shared.money import Money as Money -from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern as NumberPattern +from sinch.domains.numbers.models.v1.shared.money import Money +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( diff --git a/sinch/domains/sms/endpoints/sms_endpoint.py b/sinch/domains/sms/endpoints/sms_endpoint.py index e57b2e94..89bcb830 100644 --- a/sinch/domains/sms/endpoints/sms_endpoint.py +++ b/sinch/domains/sms/endpoints/sms_endpoint.py @@ -19,7 +19,6 @@ def __init__(self, request_data, sinch): self.sms_origin = self.sinch.configuration.sms_origin def handle_response(self, response: HTTPResponse): - print(response) if response.status_code >= 400: raise SMSException( message=response.body["text"], diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index b84cde4b..545e6ae7 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -2,7 +2,7 @@ from decimal import Decimal import pytest -from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint +from sinch.domains.numbers.api.v1.internal import ListActiveNumbersEndpoint from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse from sinch.core.models.http_response import HTTPResponse diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 6b854cc4..82aa73ab 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -2,7 +2,7 @@ import json from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.available_numbers_apis import RentAnyNumberEndpoint +from sinch.domains.numbers.api.v1.internal import RentAnyNumberEndpoint from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse diff --git a/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py index 002a91bf..a38ea77d 100644 --- a/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest +from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest @pytest.mark.parametrize( "order_by_input, expected_order_by", diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 6b2d3c5b..ea5ef6e1 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import MagicMock -from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1 import AvailableNumbers from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) From 47c2220554aceae38b23e8463fa2fbc54d4e43f4 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Tue, 25 Mar 2025 16:39:30 +0100 Subject: [PATCH 033/106] DEVEXP-782: Numbers API - Active Numbers (#53) * DEVEXP-782: Numers API - Active Numbers - Update a rented phone number - Get a rented phone number - Release a rented phone number Signed-off-by: Jessica Matsuoka --- .github/workflows/run-tests.yml | 7 +- .../numbers/api/v1/active_numbers_apis.py | 221 ++++++++++++------ .../numbers/api/v1/available_numbers_apis.py | 134 +++++------ .../numbers/api/v1/internal/__init__.py | 8 +- .../v1/internal/active_numbers_endpoints.py | 72 +++++- .../internal/available_numbers_endpoints.py | 6 +- .../numbers/endpoints/active/__init__.py | 9 - .../active/get_number_configuration.py | 41 ---- .../active/release_number_from_project.py | 40 ---- .../active/update_number_configuration.py | 44 ---- .../domains/numbers/models/active/__init__.py | 20 -- .../domains/numbers/models/active/requests.py | 22 -- .../numbers/models/active/responses.py | 17 -- .../numbers/models/v1/internal/__init__.py | 8 +- ...ilability_request.py => number_request.py} | 2 +- .../update_number_configuration_request.py | 20 ++ tests/conftest.py | 10 +- tests/e2e/numbers/features/environment.py | 6 +- .../numbers/features/steps/numbers.steps.py | 132 +++++++++-- .../test_get_active_numbers_endpoint.py | 72 ++++++ .../test_list_active_numbers_endpoint.py | 6 + .../test_release_active_numbers_endpoint.py | 72 ++++++ .../test_update_active_numbers_endpoint.py | 107 +++++++++ .../test_activate_number_endpoint.py | 7 + .../test_list_available_numbers_endpoint.py | 8 +- .../test_search_for_number_endpoint.py | 5 +- .../v1/models/errors/test_errors_model.py | 29 +++ .../base/test_base_model_requests.py | 5 + .../base/test_base_model_response.py | 1 + .../test_activate_number_request_model.py | 6 +- .../test_list_active_numbers_request_model.py | 7 +- ...test_list_active_numbers_response_model.py | 5 + ...st_list_available_numbers_request_model.py | 4 +- ...t_list_available_numbers_response_model.py | 42 ++-- .../test_number_request_model.py} | 13 +- .../test_rent_any_number_request_model.py | 5 +- ...est_update_active_numbers_request_model.py | 65 ++++++ .../test_active_number_model.py} | 53 ++++- .../test_rent_any_number_response_model.py | 23 +- .../test_search_for_number_response_model.py | 12 +- .../v1/models/{ => shared}/test_numbers.py | 68 +----- .../domains/numbers/v1/test_active_numbers.py | 119 ++++++++++ .../numbers/v1/test_available_numbers.py | 39 ++-- tests/unit/test_client.py | 1 + tests/unit/test_configuration.py | 6 +- tests/unit/test_pagination.py | 12 +- tests/unit/test_user_agent_header.py | 1 - 47 files changed, 1086 insertions(+), 526 deletions(-) delete mode 100644 sinch/domains/numbers/endpoints/active/__init__.py delete mode 100644 sinch/domains/numbers/endpoints/active/get_number_configuration.py delete mode 100644 sinch/domains/numbers/endpoints/active/release_number_from_project.py delete mode 100644 sinch/domains/numbers/endpoints/active/update_number_configuration.py delete mode 100644 sinch/domains/numbers/models/active/__init__.py delete mode 100644 sinch/domains/numbers/models/active/requests.py delete mode 100644 sinch/domains/numbers/models/active/responses.py rename sinch/domains/numbers/models/v1/internal/{check_number_availability_request.py => number_request.py} (74%) create mode 100644 sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/models/errors/test_errors_model.py rename tests/unit/domains/numbers/v1/models/{ => internal}/base/test_base_model_requests.py (99%) rename tests/unit/domains/numbers/v1/models/{ => internal}/base/test_base_model_response.py (99%) rename tests/unit/domains/numbers/v1/models/{available/requests => internal}/test_activate_number_request_model.py (99%) rename tests/unit/domains/numbers/v1/models/{active/requests => internal}/test_list_active_numbers_request_model.py (98%) rename tests/unit/domains/numbers/v1/models/{active/response => internal}/test_list_active_numbers_response_model.py (99%) rename tests/unit/domains/numbers/v1/models/{available/requests => internal}/test_list_available_numbers_request_model.py (98%) rename tests/unit/domains/numbers/v1/models/{available/response => internal}/test_list_available_numbers_response_model.py (63%) rename tests/unit/domains/numbers/v1/models/{available/requests/test_search_for_number_request_model.py => internal/test_number_request_model.py} (75%) rename tests/unit/domains/numbers/v1/models/{available/requests => internal}/test_rent_any_number_request_model.py (94%) create mode 100644 tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py rename tests/unit/domains/numbers/v1/models/{available/response/test_activate_number_response_model.py => response/test_active_number_model.py} (58%) rename tests/unit/domains/numbers/v1/models/{available => }/response/test_rent_any_number_response_model.py (91%) rename tests/unit/domains/numbers/v1/models/{available => }/response/test_search_for_number_response_model.py (88%) rename tests/unit/domains/numbers/v1/models/{ => shared}/test_numbers.py (60%) create mode 100644 tests/unit/domains/numbers/v1/test_active_numbers.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f9f549c8..e05cfa46 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -39,7 +39,7 @@ jobs: - name: Lint with flake8 run: | - flake8 sinch --count --max-complexity=10 --max-line-length=120 --statistics + flake8 sinch tests --count --max-complexity=10 --max-line-length=120 --statistics - name: Test with Pytest run: | @@ -79,8 +79,3 @@ jobs: run: | export SINCH_CLIENT_MODE=sync behave tests/e2e/**/features - - - name: Run e2e tests async - run: | - export SINCH_CLIENT_MODE=async - behave tests/e2e/**/features \ No newline at end of file diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 5cc230df..621ac288 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,22 +1,20 @@ -from typing import Optional +from typing import Optional, overload from pydantic import StrictStr, StrictInt from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers -from sinch.domains.numbers.api.v1.internal import ListActiveNumbersEndpoint -from sinch.domains.numbers.endpoints.active import ( - GetNumberConfigurationEndpoint, ReleaseNumberFromProjectEndpoint, +from sinch.domains.numbers.api.v1.internal import ( + GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, UpdateNumberConfigurationEndpoint ) -from sinch.domains.numbers.models.active.requests import ( - GetNumberConfigurationRequest, UpdateNumberConfigurationRequest, ReleaseNumberFromProjectRequest -) -from sinch.domains.numbers.models.active.responses import ( - UpdateNumberConfigurationResponse, GetNumberConfigurationResponse, ReleaseNumberFromProjectResponse -) from sinch.domains.numbers.models.v1.response import ActiveNumber -from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest + +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, NumberRequest, UpdateNumberConfigurationRequest +) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues + CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST ) @@ -37,21 +35,35 @@ def list( """ Search for all active virtual numbers associated with a certain project. - Args: - region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. - number_type (NumberTypeValues): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. - number_search_pattern (Optional[NumberSearchPatternTypeValues]): - Pattern to apply (e.g., "START", "CONTAINS", "END"). - capabilities (Optional[CapabilityTypeValuesList]): Capabilities required for the number. - (e.g., ["SMS", "VOICE"]) - page_size (StrictInt): Maximum number of items to return. - page_token (Optional[StrictStr]): Token for the next page of results. - order_by (Optional[OrderByValues]): Field to order the results by. (e.g., "phoneNumber", "displayName") - **kwargs: Additional filters for the request. - - Returns: - TokenBasedPaginatorNumbers: A paginator for iterating through the results. + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: StrictStr + + :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + :type number_type: NumberTypeValues + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[StrictStr] + + :param number_search_pattern: Pattern to apply (e.g., "START", "CONTAINS", "END"). + :type number_search_pattern: Optional[NumberSearchPatternTypeValues] + + :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). + :type capabilities: Optional[CapabilityTypeValuesList] + + :param page_size: Maximum number of items to return. + :type page_size: StrictInt + + :param page_token: Token for the next page of results. + :type page_token: Optional[StrictStr] + + :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). + :type order_by: Optional[OrderByValues] + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A paginator for iterating through the results. + :rtype: TokenBasedPaginatorNumbers For detailed documentation, visit https://developers.sinch.com """ @@ -73,63 +85,136 @@ def list( ) ) - # TODO: Refactor the update(), get(), release() functions to use Pydantic models: - # - Replace primitive types with Pydantic for better validation and maintainability. - # - Define Pydantic models for request and response data. - # - Improve readability and maintainability through refactoring. + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + def update( self, - phone_number: str = None, - display_name: str = None, - sms_configuration: dict = None, - voice_configuration: dict = None, - app_id: str = None - ) -> UpdateNumberConfigurationResponse: + phone_number: StrictStr, + display_name: Optional[StrictStr] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> ActiveNumber: """ Make updates to the configuration of your virtual number. Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. - For additional documentation, see https://www.sinch.com and visit our developer portal. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param display_name: The display name for the virtual number. + :type display_name: Optional[str] + + :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: + - ``VoiceConfigurationDictRTC``: type 'RTC' with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type 'EST' with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type 'FAX' with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + + :param callback_url: The callback URL for the virtual number. + :type callback_url: Optional[str] + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + For detailed documentation, visit https://developers.sinch.com """ - return self._sinch.configuration.transport.request( - UpdateNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateNumberConfigurationRequest( - phone_number=phone_number, - display_name=display_name, - sms_configuration=sms_configuration, - voice_configuration=voice_configuration, - app_id=app_id - ) - ) + request_data = UpdateNumberConfigurationRequest( + phone_number=phone_number, + display_name=display_name, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs ) + return self._request(UpdateNumberConfigurationEndpoint, request_data) - def get(self, phone_number: str) -> GetNumberConfigurationResponse: + def get( + self, + phone_number: StrictStr, + **kwargs + ) -> ActiveNumber: """ List of configuration settings for your virtual number. - For additional documentation, see https://www.sinch.com and visit our developer portal. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings for the virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com """ - return self._sinch.configuration.transport.request( - GetNumberConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetNumberConfigurationRequest( - phone_number=phone_number - ) - ) + request_data = NumberRequest( + phone_number=phone_number, + **kwargs ) + return self._request(GetNumberConfigurationEndpoint, request_data) - def release(self, phone_number: str) -> ReleaseNumberFromProjectResponse: + def release( + self, + phone_number: StrictStr, + **kwargs + ) -> ActiveNumber: """ - Release numbers you no longer need from your project. - For additional documentation, see https://www.sinch.com and visit our developer portal. + Release virtual numbers you no longer need from your project. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings of the released virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com """ - return self._sinch.configuration.transport.request( - ReleaseNumberFromProjectEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ReleaseNumberFromProjectRequest( - phone_number=phone_number - ) - ) + request_data = NumberRequest( + phone_number=phone_number, + **kwargs ) + return self._request(ReleaseNumberFromProjectEndpoint, request_data) class ActiveNumbersWithAsyncPagination(ActiveNumbers): diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 11e7e790..2a30db45 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -8,7 +8,7 @@ ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest + ActivateNumberRequest, ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, @@ -31,20 +31,31 @@ def list( """ Search for available virtual numbers for you to activate using a variety of parameters to filter results. - Args: - region_code (StrictStr): ISO 3166-1 alpha-2 country code of the phone number. - number_type (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - number_pattern (Optional[StrictStr]): Specific sequence of digits to search for. - number_search_pattern (Optional[NumberSearchPatternType]): - Pattern to apply (e.g., "START", "CONTAINS", "END"). - capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) - page_size (StrictInt): Maximum number of items to return. - **kwargs: Additional filters for the request. + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: StrictStr - Returns: - list[AvailableNumber]: A response array with available numbers and their details. + :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :type number_type: NumberType - For detailed documentation, visit https://developers.sinch.com + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[StrictStr] + + :param number_search_pattern: Pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). + :type number_search_pattern: Optional[NumberSearchPatternType] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[CapabilityType] + + :param page_size: Maximum number of items to return. + :type page_size: StrictInt + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A response array with available numbers and their details. + :rtype: list[AvailableNumber] + + For detailed documentation, visit: https://developers.sinch.com """ request_data = ListAvailableNumbersRequest( region_code=region_code, @@ -58,16 +69,6 @@ def list( return self._request(AvailableNumbersEndpoint, request_data) - @overload - def activate( - self, - phone_number: StrictStr, - sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - @overload def activate( self, @@ -137,19 +138,6 @@ def activate( ) return self._request(ActivateNumberEndpoint, request_data) - @overload - def rent_any( - self, - region_code: StrictStr, - type_: NumberTypeValues, - sms_configuration: None, - voice_configuration: None, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None, - ) -> RentAnyNumberResponse: - pass - @overload def rent_any( self, @@ -159,7 +147,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictRTC, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None ) -> RentAnyNumberResponse: pass @@ -172,7 +160,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictFAX, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None ) -> RentAnyNumberResponse: pass @@ -185,7 +173,7 @@ def rent_any( voice_configuration: VoiceConfigurationDictEST, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None ) -> RentAnyNumberResponse: pass @@ -202,29 +190,41 @@ def rent_any( ) -> RentAnyNumberResponse: """ Search for and activate an available Sinch virtual number all in one API call. - Currently, the rentAny operation works only for US 10DLC numbers + Currently, the ``rent_any`` operation works only for US 10DLC numbers. - Args: - region_code (str): ISO 3166-1 alpha-2 country code of the phone number. - type_ (NumberType): Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - number_pattern (Optional[NumberPatternDict]): Specific sequence of digits to search for. - capabilities (Optional[CapabilityType]): Capabilities required for the number. (e.g., ["SMS", "VOICE"]) - sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. - Including fields such as: - - service_plan_id (str): The service plan ID. - - campaign_id (Optional[str]): The campaign ID. - voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. - Supported types include: - - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. - - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. - - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. - callback_url (StrictStr): The callback URL to receive notifications. - **kwargs: Additional parameters for the request. + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: str - Returns: - RentAnyNumberRequest: A response object with the activated number and its details. + :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :type type_: NumberType - For detailed documentation, visit https://developers.sinch.com + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[NumberPatternDict] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[CapabilityType] + + :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: + - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + + :param callback_url: The callback URL to receive notifications. + :type callback_url: StrictStr + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the activated number and its details. + :rtype: RentAnyNumberRequest + + For detailed documentation, visit: https://developers.sinch.com """ request_data = RentAnyNumberRequest( region_code=region_code, @@ -242,14 +242,16 @@ def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAv """ Enter a specific phone number to check availability. - Args: - phone_number (str): The phone number in E.164 format with leading +. - **kwargs: Additional parameters for the request. + :param phone_number: The phone number in E.164 format with leading ``+``. + :type phone_number: str - Returns: - CheckNumberAvailabilityResponse: A response object with the availability status of the number. + :param kwargs: Additional parameters for the request. + :type kwargs: dict - For detailed documentation, visit https://developers.sinch.com + :returns: A response object with the availability status of the number. + :rtype: CheckNumberAvailabilityResponse + + For detailed documentation, visit: https://developers.sinch.com """ - request_data = CheckNumberAvailabilityRequest(phone_number=phone_number, **kwargs) + request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(SearchForNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 97c45036..85712fa3 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -1,4 +1,7 @@ -from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ListActiveNumbersEndpoint +from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ( + GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint +) from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) @@ -6,7 +9,10 @@ __all__ = [ "ActivateNumberEndpoint", "AvailableNumbersEndpoint", + "GetNumberConfigurationEndpoint", "ListActiveNumbersEndpoint", + "ReleaseNumberFromProjectEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", + "UpdateNumberConfigurationEndpoint" ] diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index c91d8594..cd79f4e9 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,7 +1,34 @@ +import json + from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, ListActiveNumbersResponse, NumberRequest, UpdateNumberConfigurationRequest +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +class GetNumberConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to get the configuration of a specific number + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: NumberRequest): + super(GetNumberConfigurationEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(GetNumberConfigurationEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, ActiveNumber) class ListActiveNumbersEndpoint(NumbersEndpoint): @@ -23,3 +50,46 @@ def build_query_params(self) -> dict: def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: super(ListActiveNumbersEndpoint, self).handle_response(response) return self.process_response_model(response.body, ListActiveNumbersResponse) + + +class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}:release" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id, request_data: NumberRequest): + super(ReleaseNumberFromProjectEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(ReleaseNumberFromProjectEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, ActiveNumber) + + +class UpdateNumberConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to update the configuration of a specific number + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: UpdateNumberConfigurationRequest): + super(UpdateNumberConfigurationEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> ActiveNumber: + try: + super(UpdateNumberConfigurationEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, ActiveNumber) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 569431ff..36ebb07d 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -3,8 +3,8 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, RentAnyNumberRequest, - ListAvailableNumbersResponse + ActivateNumberRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse, + NumberRequest, RentAnyNumberRequest ) from sinch.domains.numbers.models.v1.response import ( ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse @@ -89,7 +89,7 @@ class SearchForNumberEndpoint(NumbersEndpoint): HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: CheckNumberAvailabilityRequest): + def __init__(self, project_id: str, request_data: NumberRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: diff --git a/sinch/domains/numbers/endpoints/active/__init__.py b/sinch/domains/numbers/endpoints/active/__init__.py deleted file mode 100644 index 80137a40..00000000 --- a/sinch/domains/numbers/endpoints/active/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from sinch.domains.numbers.endpoints.active.get_number_configuration import GetNumberConfigurationEndpoint -from sinch.domains.numbers.endpoints.active.release_number_from_project import ReleaseNumberFromProjectEndpoint -from sinch.domains.numbers.endpoints.active.update_number_configuration import UpdateNumberConfigurationEndpoint - -__all__ = [ - "GetNumberConfigurationEndpoint", - "ReleaseNumberFromProjectEndpoint", - "UpdateNumberConfigurationEndpoint" -] diff --git a/sinch/domains/numbers/endpoints/active/get_number_configuration.py b/sinch/domains/numbers/endpoints/active/get_number_configuration.py deleted file mode 100644 index 6cc0bd63..00000000 --- a/sinch/domains/numbers/endpoints/active/get_number_configuration.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods - -from sinch.domains.numbers.models.active.requests import GetNumberConfigurationRequest -from sinch.domains.numbers.models.active.responses import GetNumberConfigurationResponse - - -class GetNumberConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetNumberConfigurationRequest): - super(GetNumberConfigurationEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> GetNumberConfigurationResponse: - super(GetNumberConfigurationEndpoint, self).handle_response(response) - return GetNumberConfigurationResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/endpoints/active/release_number_from_project.py b/sinch/domains/numbers/endpoints/active/release_number_from_project.py deleted file mode 100644 index 1b8f67f7..00000000 --- a/sinch/domains/numbers/endpoints/active/release_number_from_project.py +++ /dev/null @@ -1,40 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active.requests import ReleaseNumberFromProjectRequest -from sinch.domains.numbers.models.active.responses import ReleaseNumberFromProjectResponse - - -class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}:release" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data: ReleaseNumberFromProjectRequest): - super(ReleaseNumberFromProjectEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def handle_response(self, response: HTTPResponse) -> ReleaseNumberFromProjectResponse: - super(ReleaseNumberFromProjectEndpoint, self).handle_response(response) - return ReleaseNumberFromProjectResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/endpoints/active/update_number_configuration.py b/sinch/domains/numbers/endpoints/active/update_number_configuration.py deleted file mode 100644 index 3446c19d..00000000 --- a/sinch/domains/numbers/endpoints/active/update_number_configuration.py +++ /dev/null @@ -1,44 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.active.requests import UpdateNumberConfigurationRequest -from sinch.domains.numbers.models.active.responses import UpdateNumberConfigurationResponse - - -class UpdateNumberConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateNumberConfigurationRequest): - super(UpdateNumberConfigurationEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id, - phone_number=self.request_data.phone_number - ) - - def request_body(self): - self.request_data.phone_number = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateNumberConfigurationResponse: - super(UpdateNumberConfigurationEndpoint, self).handle_response(response) - return UpdateNumberConfigurationResponse( - phone_number=response.body["phoneNumber"], - project_id=response.body["projectId"], - display_name=response.body["displayName"], - region_code=response.body["regionCode"], - type=response.body["type"], - capability=response.body["capability"], - money=response.body["money"], - payment_interval_months=response.body["paymentIntervalMonths"], - next_charge_date=response.body["nextChargeDate"], - expire_at=response.body["expireAt"], - sms_configuration=response.body["smsConfiguration"], - voice_configuration=response.body["voiceConfiguration"] - ) diff --git a/sinch/domains/numbers/models/active/__init__.py b/sinch/domains/numbers/models/active/__init__.py deleted file mode 100644 index e2d393d9..00000000 --- a/sinch/domains/numbers/models/active/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.enums import NumberType, NumberCapability - - -@dataclass -class ActiveNumber(SinchBaseModel): - phone_number: str - project_id: str - display_name: str - region_code: str - type: NumberType - capability: NumberCapability - money: dict - payment_interval_months: int - next_charge_date: str - expire_at: str - sms_configuration: dict - voice_configuration: dict diff --git a/sinch/domains/numbers/models/active/requests.py b/sinch/domains/numbers/models/active/requests.py deleted file mode 100644 index 1f0d0647..00000000 --- a/sinch/domains/numbers/models/active/requests.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class GetNumberConfigurationRequest(SinchRequestBaseModel): - phone_number: str - - -@dataclass -class UpdateNumberConfigurationRequest(SinchRequestBaseModel): - phone_number: str - display_name: str - sms_configuration: dict - voice_configuration: dict - app_id: str - - -@dataclass -class ReleaseNumberFromProjectRequest(SinchRequestBaseModel): - phone_number: str diff --git a/sinch/domains/numbers/models/active/responses.py b/sinch/domains/numbers/models/active/responses.py deleted file mode 100644 index 442cd20a..00000000 --- a/sinch/domains/numbers/models/active/responses.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass -from sinch.domains.numbers.models.active import ActiveNumber - - -@dataclass -class UpdateNumberConfigurationResponse(ActiveNumber): - pass - - -@dataclass -class GetNumberConfigurationResponse(ActiveNumber): - pass - - -@dataclass -class ReleaseNumberFromProjectResponse(ActiveNumber): - pass diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index 4c0f9a13..6453bf28 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,11 +1,14 @@ from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest -from sinch.domains.numbers.models.v1.internal.check_number_availability_request import CheckNumberAvailabilityRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal.number_request import NumberRequest from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.update_number_configuration_request import ( + UpdateNumberConfigurationRequest +) from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, VoiceConfigurationRTC, VoiceConfigurationType @@ -13,13 +16,14 @@ __all__ = [ "ActivateNumberRequest", - "CheckNumberAvailabilityRequest", "ListActiveNumbersRequest", "ListAvailableNumbersRequest", "ListActiveNumbersResponse", "ListAvailableNumbersResponse", + "NumberRequest", "RentAnyNumberRequest", "SmsConfigurationRequest", + "UpdateNumberConfigurationRequest", "VoiceConfigurationCustom", "VoiceConfigurationEST", "VoiceConfigurationFAX", diff --git a/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py b/sinch/domains/numbers/models/v1/internal/number_request.py similarity index 74% rename from sinch/domains/numbers/models/v1/internal/check_number_availability_request.py rename to sinch/domains/numbers/models/v1/internal/number_request.py index f3b4fbf7..25eb4f67 100644 --- a/sinch/domains/numbers/models/v1/internal/check_number_availability_request.py +++ b/sinch/domains/numbers/models/v1/internal/number_request.py @@ -2,5 +2,5 @@ from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest -class CheckNumberAvailabilityRequest(BaseModelConfigRequest): +class NumberRequest(BaseModelConfigRequest): phone_number: StrictStr = Field(alias="phoneNumber") diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py new file mode 100644 index 00000000..8345de75 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -0,0 +1,20 @@ +from typing import Optional, Dict +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration + + +class UpdateNumberConfigurationRequest(BaseModelConfigRequest): + phone_number: StrictStr = Field(alias="phoneNumber") + display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") + callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + + def __init__(self, **data): + """ + Custom initializer to validate nested dictionaries. + """ + if data.get("sms_configuration") or data.get("voice_configuration"): + validate_sms_voice_configuration(data) + super().__init__(**data) diff --git a/tests/conftest.py b/tests/conftest.py index ca5fc1fb..dbc32620 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ # This file that contains fixtures that are shared across all tests in the tests directory. import os from dataclasses import dataclass -from unittest.mock import Mock +from unittest.mock import Mock, MagicMock import pytest @@ -289,16 +289,21 @@ def sinch_client_async( voice_origin ) + @pytest.fixture def mock_sinch_client_numbers(): class MockConfiguration: numbers_origin = "https://mock-numbers-api.sinch.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() class MockSinchClient: configuration = MockConfiguration() return MockSinchClient() + @pytest.fixture def mock_pagination_active_number_responses(): return [ @@ -312,8 +317,9 @@ def mock_pagination_active_number_responses(): next_page_token=None) ] + @pytest.fixture def mock_pagination_expected_phone_numbers_response(): return [ "+12345678901", "+12345678902", "+12345678903", "+12345678904", "+12345678905" - ] \ No newline at end of file + ] diff --git a/tests/e2e/numbers/features/environment.py b/tests/e2e/numbers/features/environment.py index f959d2fa..7cd40b6c 100644 --- a/tests/e2e/numbers/features/environment.py +++ b/tests/e2e/numbers/features/environment.py @@ -3,6 +3,7 @@ import asyncio from sinch import SinchClient, SinchClientAsync + def get_logger(): """Creates and returns a logger instance for this module only.""" log = logging.getLogger(__name__) @@ -15,8 +16,10 @@ def get_logger(): return log + logger = get_logger() + def before_all(context): """ Initializes the appropriate Sinch client based on the environment variable SINCH_CLIENT_MODE. @@ -43,9 +46,10 @@ def before_all(context): context.sinch.configuration.auth_origin = 'http://localhost:3011' context.sinch.configuration.numbers_origin = 'http://localhost:3013' + def after_all(context): """ Closes the Async event loop if it was created during the test """ if hasattr(context, 'loop'): - context.loop.close() \ No newline at end of file + context.loop.close() diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 7ba7b01c..fe6a73dd 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -7,7 +7,7 @@ from sinch.domains.numbers.models.v1.response import ActiveNumber, RentAnyNumberResponse -def execute_sync_or_async(context,call): +def execute_sync_or_async(context, call): """ Ensures proper execution of both synchronous and asynchronous calls. - If the call is synchronous, it executes directly. @@ -22,11 +22,13 @@ def execute_sync_or_async(context,call): else: return call + @given('the Numbers service is available') def step_service_is_available(context): """Ensures the Sinch client is initialized""" assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + @when('I send a request to search for available phone numbers') def step_search_available_numbers(context): response = context.sinch.numbers.available.list( @@ -35,11 +37,13 @@ def step_search_available_numbers(context): ) context.response = execute_sync_or_async(context, response) + @then('the response contains "{count}" available phone numbers') def step_check_available_numbers_count(context, count): data = context.response assert len(data) == int(count), f'Expected {count}, got {len(data)}' + @then('a phone number contains all the expected properties') def step_check_number_properties(context): number = context.response[0] @@ -52,7 +56,8 @@ def step_check_number_properties(context): assert number.monthly_price.currency_code == 'EUR' assert number.monthly_price.amount == Decimal('0.80') assert number.payment_interval_months == 1 - assert number.supporting_documentation_required == True + assert number.supporting_documentation_required is True + @when('I send a request to check the availability of the phone number "{phone_number}"') def step_check_number_availability(context, phone_number): @@ -62,11 +67,13 @@ def step_check_number_availability(context, phone_number): except NumberNotFoundException as e: context.error = e + @then('the response displays the phone number "{phone_number}" details') def step_validate_number_details(context, phone_number): data = context.response assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + @then('the response contains an error about the number "{phone_number}" not being available') def step_check_unavailable_number(context, phone_number): data: NotFoundError = context.error.args[0] @@ -74,26 +81,28 @@ def step_check_unavailable_number(context, phone_number): assert data.status == 'NOT_FOUND' assert data.details[0].resource_name == phone_number + @when('I send a request to rent a number with some criteria') def step_rent_any_number(context): response = context.sinch.numbers.available.rent_any( - region_code = 'US', - type_ = 'LOCAL', - capabilities = ['SMS', 'VOICE'], - sms_configuration = { + region_code='US', + type_='LOCAL', + capabilities=['SMS', 'VOICE'], + sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', }, - voice_configuration = { + voice_configuration={ 'type': 'RTC', 'app_id': 'sunshine-rain-drop-very-beautifulday' }, - number_pattern = { + number_pattern={ 'pattern': '7654321', 'search_pattern': 'END' }, ) context.response = execute_sync_or_async(context, response) + @then('the response contains a rented phone number') def step_validate_rented_number(context): data: RentAnyNumberResponse = context.response @@ -109,7 +118,7 @@ def step_validate_rented_number(context): assert data.next_charge_date == datetime.fromisoformat( '2024-06-06T14:42:42.022227+00:00' ).astimezone(tz=timezone.utc) - assert data.expire_at == None + assert data.expire_at is None assert data.callback_url == '' assert data.sms_configuration.service_plan_id == '' assert data.sms_configuration.campaign_id == '' @@ -133,24 +142,27 @@ def step_validate_rented_number(context): '2024-06-06T14:42:42.604092+00:00' ).astimezone(tz=timezone.utc) + @when('I send a request to rent the phone number "{phone_number}"') def step_rent_specific_number(context, phone_number): response = context.sinch.numbers.available.activate( - phone_number = phone_number, - sms_configuration= { + phone_number=phone_number, + sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', }, - voice_configuration= { + voice_configuration={ 'app_id': 'sunshine-rain-drop-very-beautifulday' } ) context.response = execute_sync_or_async(context, response) + @then('the response contains this rented phone number "{phone_number}"') def step_validate_rented_specific_number(context, phone_number): data: ActiveNumber = context.response assert data.phone_number == phone_number, f'Expected {phone_number}, got {data.phone_number}' + @when('I send a request to rent the unavailable phone number "{phone_number}"') def step_rent_unavailable_number(context, phone_number): try: @@ -167,6 +179,7 @@ def step_rent_unavailable_number(context, phone_number): except NumberNotFoundException as e: context.error = e + @when("I send a request to list the phone numbers") def step_when_list_phone_numbers(context): response = context.sinch.numbers.active.list( @@ -183,6 +196,7 @@ def step_then_response_contains_x_phone_numbers(context, count): assert len(context.response) == int(count), \ f'Expected {count}, got {len(context.response)}' + @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): response = context.sinch.numbers.active.list( @@ -204,6 +218,7 @@ async def collect_async_numbers(): context.active_numbers_list = active_numbers_list + @then('the phone numbers list contains "{count}" phone numbers') def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): assert len(context.active_numbers_list) == int(count), f'Expected {count}, got {len(context.active_numbers_list)}' @@ -217,34 +232,111 @@ def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): assert phone_number3.voice_configuration.type == 'RTC' assert phone_number3.voice_configuration.app_id == 'sunshine-rain-drop-very-beautifulday' + @when('I send a request to update the phone number "{phone_number}"') def step_when_update_phone_number(context, phone_number): - pass # Placeholder + response = context.sinch.numbers.active.update( + phone_number=phone_number, + display_name='Updated description during E2E tests', + sms_configuration={ + 'service_plan_id': 'SingingMooseSociety' + }, + voice_configuration={ + 'type': 'FAX', + 'service_id': '01W4FFL35P4NC4K35FAXSERVICE' + }, + callback_url='https://my-callback-server.com/numbers' + ) + response = execute_sync_or_async(context, response) + context.response = response + @then('the response contains a phone number with updated parameters') def step_then_response_contains_updated_number(context): - pass # Placeholder + data: ActiveNumber = context.response + assert data.display_name == 'Updated description during E2E tests' + assert data.sms_configuration.service_plan_id == 'SpaceMonkeySquadron' + assert data.sms_configuration.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SingingMooseSociety' + assert data.sms_configuration.scheduled_provisioning.campaign_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'WAITING' + assert data.sms_configuration.scheduled_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T20:02:20.432220+00:00' + ).astimezone(tz=timezone.utc) + assert data.sms_configuration.scheduled_provisioning.error_codes == [] + assert data.voice_configuration.type == 'RTC' + assert data.voice_configuration.app_id == 'sunshine-rain-drop-very-beautifulday' + assert data.voice_configuration.trunk_id == '' + assert data.voice_configuration.service_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.status == 'WAITING' + assert data.voice_configuration.scheduled_voice_provisioning.type == 'FAX' + assert data.voice_configuration.scheduled_voice_provisioning.app_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.trunk_id == '' + assert data.voice_configuration.scheduled_voice_provisioning.service_id == '01W4FFL35P4NC4K35FAXSERVICE' + assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat( + '2024-06-06T20:02:20.437509+00:00' + ).astimezone(tz=timezone.utc) + assert data.callback_url == 'https://my-callback-server.com/numbers' + @when('I send a request to retrieve the phone number "{phone_number}"') def step_when_retrieve_phone_number(context, phone_number): - pass # Placeholder + try: + response = context.sinch.numbers.active.get( + phone_number=phone_number, + ) + context.response = execute_sync_or_async(context, response) + except NumberNotFoundException as e: + context.error = e + @then('the response contains details about the phone number "{phone_number}"') def step_then_response_contains_phone_details(context, phone_number): - pass # Placeholder + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) + assert data.expire_at is None + assert data.sms_configuration.service_plan_id == 'SpaceMonkeySquadron' + @then('the response contains details about the phone number "{phone_number}" with an SMS provisioning error') def step_then_response_contains_sms_provisioning_error(context, phone_number): - pass # Placeholder + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat('2024-07-06T14:42:42.677575+00:00').astimezone( + tz=timezone.utc) + assert data.expire_at is None + assert data.sms_configuration.service_plan_id == '' + assert data.sms_configuration.scheduled_provisioning.status == 'FAILED' + assert data.sms_configuration.scheduled_provisioning.error_codes == ['SMS_PROVISIONING_FAILED'] + @then('the response contains an error about the number "{phone_number}" not being a rented number') def step_then_response_contains_error_not_rented(context, phone_number): - pass # Placeholder + data: NotFoundError = context.error.args[0] + assert data.code == 404 + assert data.status == 'NOT_FOUND' + assert data.details[0].resource_name == phone_number + @when('I send a request to release the phone number "{phone_number}"') def step_when_release_phone_number(context, phone_number): - pass # Placeholder + response = context.sinch.numbers.active.release( + phone_number=phone_number + ) + response = execute_sync_or_async(context, response) + context.response = response + @then('the response contains details about the phone number "{phone_number}" to be released') def step_then_response_contains_released_number(context, phone_number): - pass # Placeholder + data: ActiveNumber = context.response + assert data.phone_number == phone_number + assert data.next_charge_date == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) + assert data.expire_at == datetime.fromisoformat( + '2024-06-06T14:42:42.677575+00:00' + ).astimezone(tz=timezone.utc) diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py new file mode 100644 index 00000000..3f5dc4d9 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py @@ -0,0 +1,72 @@ +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import GetNumberConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal import NumberRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return NumberRequest( + phone_number="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return GetNumberConfigurationEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.callback_url == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index 545e6ae7..0ff5fc4b 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -6,6 +6,7 @@ from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest, ListActiveNumbersResponse from sinch.core.models.http_response import HTTPResponse + @pytest.fixture def request_data(): return ListActiveNumbersRequest( @@ -17,6 +18,7 @@ def request_data(): number_search_pattern="STARTS_WITH" ) + @pytest.fixture def mock_response(): return HTTPResponse( @@ -46,14 +48,17 @@ def mock_response(): headers={"Content-Type": "application/json"} ) + @pytest.fixture def endpoint(request_data): return ListActiveNumbersEndpoint("test_project_id", request_data) + def test_build_url(endpoint, mock_sinch_client_numbers): assert (endpoint.build_url(mock_sinch_client_numbers) == "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers") + def test_build_query_params_expects_correct_mapping(endpoint): """ Check if Query params is handled and mapped to the appropriate fields correctly. @@ -68,6 +73,7 @@ def test_build_query_params_expects_correct_mapping(endpoint): } assert endpoint.build_query_params() == expected_params + def test_handle_response_expects_correct_mapping(endpoint, mock_response): """ Check if response is handled and mapped to the appropriate fields correctly. diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py new file mode 100644 index 00000000..1588b3ba --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py @@ -0,0 +1,72 @@ +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import ReleaseNumberFromProjectEndpoint +from sinch.domains.numbers.models.v1.internal import NumberRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return NumberRequest( + phone_number="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return ReleaseNumberFromProjectEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890:release") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.callback_url == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py new file mode 100644 index 00000000..2e4fbbfa --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py @@ -0,0 +1,107 @@ +import json + +import pytest +from datetime import datetime, timezone +from decimal import Decimal +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import UpdateNumberConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateNumberConfigurationRequest +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +@pytest.fixture +def request_data(): + return UpdateNumberConfigurationRequest( + phone_number="+1234567890", + display_name="Display Name", + sms_configuration={ + "service_plan_id": "Service Plan Id" + }, + voice_configuration={ + "type": "RTC", + "app_id": "App Id" + } + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "phoneNumber": "+1234567890", + "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", + "displayName": "Display Name", + "regionCode": "US", + "type": "LOCAL", + "capability": ["SMS", "VOICE"], + "money": { + "currencyCode": "EUR", + "amount": "0.80" + }, + "paymentIntervalMonths": 1, + "nextChargeDate": "2025-02-28T14:04:26.190127Z", + "expireAt": "2025-02-28T14:04:26.190127Z", + "callbackUrl": "https://yourcallback/numbers" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "phoneNumber": "+1234567890", + "displayName": "Display Name", + "smsConfiguration": { + "servicePlanId": "Service Plan Id" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "App Id" + } + } + return json.dumps(expected_body) + + +@pytest.fixture +def endpoint(request_data): + return UpdateNumberConfigurationEndpoint("test_project_id", request_data) + + +def test_request_body_expects_correct_json(request_data, mock_response_body): + """ + Check if request body is constructed correctly based on input data. + """ + endpoint = UpdateNumberConfigurationEndpoint(project_id="test_project", request_data=request_data) + request_body = endpoint.request_body() + assert request_body == mock_response_body + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/activeNumbers/+1234567890") + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ActiveNumber) + assert parsed_response.phone_number == "+1234567890" + assert parsed_response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" + assert parsed_response.display_name == "Display Name" + assert parsed_response.region_code == "US" + assert parsed_response.type == "LOCAL" + assert parsed_response.capability == ["SMS", "VOICE"] + assert parsed_response.money.currency_code == "EUR" + assert parsed_response.money.amount == Decimal("0.80") + assert parsed_response.payment_interval_months == 1 + expected_next_charge_date = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.next_charge_date == expected_next_charge_date + expected_expire_at = ( + datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) + assert parsed_response.expire_at == expected_expire_at + assert parsed_response.callback_url == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py index f6f36804..ca5173e0 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py @@ -4,6 +4,7 @@ from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest from sinch.core.models.http_response import HTTPResponse + @pytest.fixture def mock_request_data(): return ActivateNumberRequest( @@ -12,6 +13,7 @@ def mock_request_data(): voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} ) + @pytest.fixture def mock_request_data_snake_case(): return ActivateNumberRequest( @@ -20,6 +22,7 @@ def mock_request_data_snake_case(): voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} ) + @pytest.fixture def mock_response(): return HTTPResponse( @@ -33,6 +36,7 @@ def mock_response(): headers={"Content-Type": "application/json"} ) + @pytest.fixture def mock_response_body(): expected_body = { @@ -56,6 +60,7 @@ def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_d expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + def test_request_body_expects_correct_json(mock_request_data, mock_response_body): """ Check if request body is constructed correctly based on input data. @@ -64,6 +69,7 @@ def test_request_body_expects_correct_json(mock_request_data, mock_response_body request_body = endpoint.request_body() assert request_body == mock_response_body + def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_snake_case, mock_response_body): """ Check if request body is constructed correctly based on input data. @@ -73,6 +79,7 @@ def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_sna assert request_body == mock_response_body + def test_handle_response_expects_correct_mapping(mock_request_data, mock_response): """ Check if response is handled and mapped to the appropriate fields correctly. diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py index e5edf638..7390bfe5 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py @@ -3,6 +3,7 @@ from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest from sinch.core.models.http_response import HTTPResponse + @pytest.fixture def request_data(): return ListAvailableNumbersRequest( @@ -15,6 +16,7 @@ def request_data(): extra_field="extra value" ) + @pytest.fixture def mock_response(): return HTTPResponse( @@ -64,10 +66,12 @@ def mock_response(): headers={"Content-Type": "application/json"} ) + @pytest.fixture def endpoint(request_data): return AvailableNumbersEndpoint("test_project_id", request_data) + def test_build_url(endpoint, mock_sinch_client_numbers): """ Check if endpoint URL is constructed correctly based on input data. @@ -75,6 +79,7 @@ def test_build_url(endpoint, mock_sinch_client_numbers): expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/availableNumbers" assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + def test_build_query_params_expects_correct_mapping(endpoint): """ Check if Query params is handled and mapped to the appropriate fields correctly. @@ -90,6 +95,7 @@ def test_build_query_params_expects_correct_mapping(endpoint): } assert endpoint.build_query_params() == expected_params + def test_handle_response_expects_correct_mapping(endpoint, mock_response): """ Check if response is handled and mapped to the appropriate fields correctly. @@ -98,4 +104,4 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): assert isinstance(parsed_response, list) assert len(parsed_response) == 2 assert parsed_response[0].phone_number == "+1234567890" - assert parsed_response[1].phone_number == "+2345678901" \ No newline at end of file + assert parsed_response[1].phone_number == "+2345678901" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 0ba16674..06eaea58 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,15 +1,16 @@ import pytest from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint -from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.internal import NumberRequest from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse from sinch.core.models.http_response import HTTPResponse + @pytest.fixture def mock_request_data(): """ Mock the request data for the endpoint. """ - return CheckNumberAvailabilityRequest(phone_number="+1234567890") + return NumberRequest(phone_number="+1234567890") @pytest.fixture diff --git a/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py b/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py new file mode 100644 index 00000000..4fe383e8 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/errors/test_errors_model.py @@ -0,0 +1,29 @@ +from sinch.domains.numbers.models.v1.errors import NotFoundError + + +def test_not_found_error_deserialize_with_snake_case(): + data = { + 'code': 404, + 'message': '', + 'status': 'NOT_FOUND', + 'details': [ + { + 'type': 'ResourceInfo', + 'resourceType': 'AvailableNumber', + 'resourceName': '+1234567890', + 'owner': '', + 'description': '' + } + ] + } + + not_found_error = NotFoundError.model_validate(data) + + assert not_found_error.code == 404 + assert not_found_error.message == '' + assert not_found_error.status == 'NOT_FOUND' + assert not_found_error.details[0].type == 'ResourceInfo' + assert not_found_error.details[0].resource_type == 'AvailableNumber' + assert not_found_error.details[0].resource_name == '+1234567890' + assert not_found_error.details[0].owner == '' + assert not_found_error.details[0].description == '' diff --git a/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py similarity index 99% rename from tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py rename to tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py index ab7fbbe3..376253bb 100644 --- a/tests/unit/domains/numbers/v1/models/base/test_base_model_requests.py +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py @@ -1,5 +1,6 @@ from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest + def test_to_camel_case_expects_parsed_standard_cases(): """ Test standard snake_case to camelCase conversion. @@ -10,6 +11,7 @@ def test_to_camel_case_expects_parsed_standard_cases(): assert BaseModelConfigRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" assert BaseModelConfigRequest._to_camel_case("appId") == "appId" + def test_to_camel_case_expects_parsed_edge_cases(): """ Test edge cases like leading/trailing underscores and multiple underscores. @@ -18,12 +20,14 @@ def test_to_camel_case_expects_parsed_edge_cases(): assert BaseModelConfigRequest._to_camel_case("foo___bar") == "foo__Bar" assert BaseModelConfigRequest._to_camel_case("trailing_") == "trailing_" + def test_to_camel_case_expects_empty_string(): """ Test empty string case. """ assert BaseModelConfigRequest._to_camel_case("") == "" + def test_to_camel_case_expects_single_word(): """ Test single-word cases. @@ -31,6 +35,7 @@ def test_to_camel_case_expects_single_word(): assert BaseModelConfigRequest._to_camel_case("word") == "word" assert BaseModelConfigRequest._to_camel_case("single") == "single" + def test_dict_expects_camel_case_input(): """ Test that the model correctly handles camelCase input. diff --git a/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py similarity index 99% rename from tests/unit/domains/numbers/v1/models/base/test_base_model_response.py rename to tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py index 00538e78..10347499 100644 --- a/tests/unit/domains/numbers/v1/models/base/test_base_model_response.py +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py @@ -1,5 +1,6 @@ from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse + def test_base_model_response_expects_unrecognized_fields_snake_case(): """ Expects unrecognized fields to be dynamically added as snake_case attributes. diff --git a/tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py similarity index 99% rename from tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py index 1aa6f24f..91cb8316 100644 --- a/tests/unit/domains/numbers/v1/models/available/requests/test_activate_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest + def test_activate_number_request_expects_snake_case_input(): """ Test that the model correctly handles snake_case input. @@ -28,6 +29,7 @@ def test_activate_number_request_expects_snake_case_input(): } assert request.callback_url == "https://example.com/callback" + def test_activate_number_request_expects_mixed_case_input(): """ Test that the model correctly handles mixed camelCase and snake_case input. @@ -52,6 +54,7 @@ def test_activate_number_request_expects_mixed_case_input(): } assert request.callback_url == "https://example.com/callback" + def test_activate_number_request_expects_validation_error_for_missing_field(): """ Test that the model raises a validation error for missing required fields. @@ -70,6 +73,7 @@ def test_activate_number_request_expects_validation_error_for_missing_field(): # Assert the error mentions the missing phone_number field assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) + def test_activate_number_request_expects_optional_param_none(): """ Test that the model correctly handles snake_case input. @@ -87,4 +91,4 @@ def test_activate_number_request_expects_optional_param_none(): assert request.phone_number == "+1234567890" assert request.sms_configuration == {"service_plan_id": "YOUR_SMS_servicePlanId"} assert request.voice_configuration is None - assert request.callback_url == "https://example.com/callback" \ No newline at end of file + assert request.callback_url == "https://example.com/callback" diff --git a/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py similarity index 98% rename from tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py index a38ea77d..28e891e6 100644 --- a/tests/unit/domains/numbers/v1/models/active/requests/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest + @pytest.mark.parametrize( "order_by_input, expected_order_by", [ @@ -14,7 +15,6 @@ (None, None) ] ) - def test_list_active_numbers_orderby_field_request_expects_camel_case_input(order_by_input, expected_order_by): """ Test that the model correctly parses order_by field. @@ -31,6 +31,7 @@ def test_list_active_numbers_orderby_field_request_expects_camel_case_input(orde assert request.number_type == "MOBILE" assert request.order_by == expected_order_by + def test_list_active_numbers_request_expects_parsed_input(): """ Test that the model correctly parses input. @@ -57,6 +58,7 @@ def test_list_active_numbers_request_expects_parsed_input(): assert request.page_token == "abc123" assert request.order_by == "phoneNumber" + def test_list_available_numbers_request_expects_camel_case_input(): """ Test that the model correctly handles camelCase input. @@ -69,10 +71,11 @@ def test_list_available_numbers_request_expects_camel_case_input(): assert request.region_code == "US" assert request.number_type == "MOBILE" + def test_list_active_numbers_request_expects_validation_error_for_missing_field(): """ Test that missing required fields raise a ValidationError. """ data = {} with pytest.raises(ValidationError): - ListActiveNumbersRequest(**data) \ No newline at end of file + ListActiveNumbersRequest(**data) diff --git a/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py similarity index 99% rename from tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py index 7f516e4f..58239de1 100644 --- a/tests/unit/domains/numbers/v1/models/active/response/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py @@ -3,6 +3,7 @@ import pytest from sinch.domains.numbers.models.v1.internal import ListActiveNumbersResponse + @pytest.fixture def test_data(): return { @@ -51,6 +52,7 @@ def test_data(): "totalSize": 10 } + def assert_voice_configuration(voice_config): assert voice_config.app_id == "" assert voice_config.scheduled_voice_provisioning.app_id == "123456" @@ -62,11 +64,13 @@ def assert_voice_configuration(voice_config): assert voice_config.scheduled_voice_provisioning.trunk_id == "" assert voice_config.scheduled_voice_provisioning.service_id == "" + def assert_sms_configuration(sms_config): assert sms_config.service_plan_id == "al_2308" assert sms_config.scheduled_provisioning is None assert sms_config.campaign_id == "" + def test_list_active_numbers_response_expects_correct_mapping(test_data): """ Check if response is handled and mapped to the appropriate fields correctly. @@ -91,6 +95,7 @@ def test_list_active_numbers_response_expects_correct_mapping(test_data): assert response.next_page_token == "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==" assert response.total_size == 10 + def test_list_active_numbers_response_expects_content_mapping(test_data): response = ListActiveNumbersResponse(**test_data) assert response.content == response.active_numbers diff --git a/tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py similarity index 98% rename from tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py index 803977c5..fa78f7f4 100644 --- a/tests/unit/domains/numbers/v1/models/available/requests/test_list_available_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py @@ -111,6 +111,7 @@ def test_list_available_numbers_expects_parsed_extra_field_snake_case(): # Assert known fields assert response.extraField == "Extra Value" + def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_case(): """ Expects unrecognized fields to be dynamically added as snake_case attributes. @@ -127,6 +128,7 @@ def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_c # Assert known fields assert response.extra_field == "Extra Value" + def test_list_available_numbers_expects_extra_capability(): """ Expects unrecognized fields to be dynamically added as snake_case attributes. @@ -141,4 +143,4 @@ def test_list_available_numbers_expects_extra_capability(): response = ListAvailableNumbersRequest(**data) # Assert known fields - assert response.capabilities == ["SMS", "VOICE", "EXTRA"] \ No newline at end of file + assert response.capabilities == ["SMS", "VOICE", "EXTRA"] diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py similarity index 63% rename from tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py index 31a0b46b..9f579975 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_list_available_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py @@ -1,32 +1,34 @@ import pytest from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersResponse + @pytest.fixture def test_data(): return { "availableNumbers": [ - { - "phoneNumber": "+12025550134", - "regionCode": "US", - "type": "MOBILE", - "capability": [ - "SMS", - "VOICE" - ], - "setupPrice": { - "currencyCode": "USD", - "amount": "2.00" - }, - "monthlyPrice": { - "currencyCode": "USD", - "amount": "2.00" - }, - "paymentIntervalMonths": 0, - "supportingDocumentationRequired": True - } + { + "phoneNumber": "+12025550134", + "regionCode": "US", + "type": "MOBILE", + "capability": [ + "SMS", + "VOICE" + ], + "setupPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "monthlyPrice": { + "currencyCode": "USD", + "amount": "2.00" + }, + "paymentIntervalMonths": 0, + "supportingDocumentationRequired": True + } ] } + def test_list_available_numbers_response_expects_correct_mapping(test_data): """ Check if response is handled and mapped to the appropriate fields correctly. @@ -41,4 +43,4 @@ def test_list_available_numbers_response_expects_correct_mapping(test_data): assert response.available_numbers[0].monthly_price.currency_code == "USD" assert response.available_numbers[0].monthly_price.amount == 2.00 assert response.available_numbers[0].payment_interval_months == 0 - assert response.available_numbers[0].supporting_documentation_required == True + assert response.available_numbers[0].supporting_documentation_required is True diff --git a/tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py similarity index 75% rename from tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py index cab0a28a..9e82b238 100644 --- a/tests/unit/domains/numbers/v1/models/available/requests/test_search_for_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_number_request_model.py @@ -1,14 +1,13 @@ import pytest from pydantic import ValidationError - -from sinch.domains.numbers.models.v1.internal import CheckNumberAvailabilityRequest +from sinch.domains.numbers.models.v1.internal import NumberRequest def test_check_number_availability_request_expects_accepts_snake_case_input(): """ Test that the model accepts snake_case input when allow_population_by_field_name is True. """ - request = CheckNumberAvailabilityRequest(phone_number="+1234567890") + request = NumberRequest(phone_number="+1234567890") assert request.phone_number == "+1234567890" @@ -17,7 +16,7 @@ def test_check_number_availability_request_expects_accepts_camel_case_input(): """ Test that the model accepts snake_case input when allow_population_by_field_name is True. """ - request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + request = NumberRequest(phoneNumber="+1234567890") assert request.phone_number == "+1234567890" @@ -26,7 +25,7 @@ def test_check_number_availability_request_expects_alias_mapping_correct(): """ Test that the model correctly handles alias mappings for phoneNumber. """ - request = CheckNumberAvailabilityRequest(phoneNumber="+1234567890") + request = NumberRequest(phoneNumber="+1234567890") assert request.model_dump(by_alias=True)["phoneNumber"] == "+1234567890" assert request.model_dump(by_alias=False)["phone_number"] == "+1234567890" @@ -39,9 +38,9 @@ def test_search_number_request_expects_validation_error_for_missing_field(): data = {} with pytest.raises(ValidationError) as excinfo: - CheckNumberAvailabilityRequest(**data) + NumberRequest(**data) error_message = str(excinfo.value) assert "Field required" in error_message or "field required" in error_message - assert "phoneNumber" in error_message \ No newline at end of file + assert "phoneNumber" in error_message diff --git a/tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py similarity index 94% rename from tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py index 6355e469..0ccc4580 100644 --- a/tests/unit/domains/numbers/v1/models/available/requests/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py @@ -27,11 +27,11 @@ def test_rent_any_number_request_expects_valid_data(): request = RentAnyNumberRequest(**data) assert request.number_pattern.pattern == "string" - assert request.number_pattern.search_pattern =="START" + assert request.number_pattern.search_pattern == "START" assert request.region_code == "string" assert request.type_ == "MOBILE" assert request.capabilities == ["SMS"] - assert request.sms_configuration == { + assert request.sms_configuration == { "servicePlanId": "string", "campaignId": "string" } @@ -61,4 +61,3 @@ def test_rent_any_number_request_expects_missing_optional_fields(): assert request.sms_configuration is None assert request.voice_configuration is None assert request.callback_url is None - diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py new file mode 100644 index 00000000..b2cd9e3e --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py @@ -0,0 +1,65 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import UpdateNumberConfigurationRequest + + +def test_update_number_configuration_request_valid_expects_parsed_response(): + """ Test that the model correctly handles request. """ + data = { + "phoneNumber": "+1234567890", + "displayName": "Test Number", + "smsConfiguration": { + "servicePlanId": "string", + "campaignId": "YOUR_campaignId_from_TCR" + }, + "voiceConfiguration": { + "type": "RTC", + "appId": "YOUR_Voice_appId" + }, + "callbackUrl": "https://www.your-callback-server.com/callback" + } + request = UpdateNumberConfigurationRequest(**data) + assert request.phone_number == "+1234567890" + assert request.display_name == "Test Number" + assert request.sms_configuration == { + "servicePlanId": "string", + "campaignId": "YOUR_campaignId_from_TCR" + } + assert request.voice_configuration == { + "type": "RTC", + "appId": "YOUR_Voice_appId" + } + assert request.callback_url == "https://www.your-callback-server.com/callback" + + +def test_update_number_configuration_request_missing_phone_number_expects_error(): + """Test that the model raises a validation error for missing required fields. """ + data = { + "displayName": "Test Number", + "callbackUrl": "https://www.your-callback-server.com/callback" + } + with pytest.raises(ValidationError): + UpdateNumberConfigurationRequest(**data) + + +def test_update_number_configuration_request_invalid_phone_number(): + """Test that the model raises a validation error for invalid phone number type. """ + data = { + "phoneNumber": 1234567890, + "displayName": "Test Number", + "callbackUrl": "https://www.your-callback-server.com/callback" + } + with pytest.raises(ValidationError): + UpdateNumberConfigurationRequest(**data) + + +def test_update_number_configuration_request_optional_fields(): + data = { + "phoneNumber": "+1234567890" + } + request = UpdateNumberConfigurationRequest(**data) + assert request.phone_number == "+1234567890" + assert request.display_name is None + assert request.sms_configuration is None + assert request.voice_configuration is None + assert request.callback_url is None diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_activate_number_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py similarity index 58% rename from tests/unit/domains/numbers/v1/models/available/response/test_activate_number_response_model.py rename to tests/unit/domains/numbers/v1/models/response/test_active_number_model.py index 521ebd58..e52066bb 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_activate_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py @@ -1,5 +1,7 @@ -import pytest from datetime import datetime, timezone +import pytest +from sinch.domains.numbers.models.v1.response import ActiveNumber + @pytest.fixture def test_data(): @@ -25,9 +27,10 @@ def test_data(): }, }, "voiceConfiguration": { + "type": "EST", "lastUpdatedTime": "2025-01-25T18:19:31.095Z", "scheduledVoiceProvisioning": { - "type": "RTC", + "type": "EST", "lastUpdatedTime": "2025-01-26T18:19:31.095Z", "status": "PROVISIONING_STATUS_UNSPECIFIED", "trunkId": "string", @@ -35,8 +38,11 @@ def test_data(): "appId": "string", }, "callbackUrl": "https://www.your-callback-server.com/callback", + "extraField": "Extra content", + "extraDict": {"key": "value"} } + def assert_sms_configuration(sms_config): """ Assert sms_configuration fields. @@ -48,23 +54,54 @@ def assert_sms_configuration(sms_config): assert scheduled_provisioning.campaign_id == "string" assert scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" expected_last_updated_time = ( - datetime(2025, 2, 21, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 1, 24, 13, 19, 31, 95000, tzinfo=timezone.utc)) assert scheduled_provisioning.last_updated_time == expected_last_updated_time assert scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] + def assert_voice_configuration(voice_config): """ Assert voice_configuration fields. """ - assert voice_config.type == "RTC" + assert voice_config.type == "EST" expected_last_updated_time = ( - datetime(2025, 1, 25, 13, 49, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 1, 25, 18, 19, 31, 95000, tzinfo=timezone.utc)) assert voice_config.last_updated_time == expected_last_updated_time assert voice_config.app_id == "string" scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning - assert scheduled_voice_provisioning.type == "RTC" + assert scheduled_voice_provisioning.type == "EST" expected_last_updated_time = ( - datetime(2025, 2, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + datetime(2025, 1, 26, 18, 19, 31, 95000, tzinfo=timezone.utc)) assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" - assert scheduled_voice_provisioning.app_id == "string" + assert scheduled_voice_provisioning.trunk_id == "string" + + +def test_active_number_response_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + converts nested keys to snake_case, and handles dynamic fields + """ + + response = ActiveNumber(**test_data) + + assert response.phone_number == "+12025550134" + assert response.display_name == "string" + assert response.region_code == "US" + assert response.type == "MOBILE" + assert response.capability == ["SMS"] + assert response.money.currency_code == "USD" + assert response.payment_interval_months == 0 + expected_next_charge_data = ( + datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) + assert response.next_charge_date == expected_next_charge_data + expected_expire_at = ( + datetime(2025, 2, 4, 13, 15, 31, 95000, tzinfo=timezone.utc)) + assert response.expire_at == expected_expire_at + assert response.callback_url == "https://www.your-callback-server.com/callback" + + assert_sms_configuration(response.sms_configuration) + assert_voice_configuration(response.voice_configuration) + + assert response.extra_field == "Extra content" + assert response.extra_dict == {"key": "value"} diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py similarity index 91% rename from tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py rename to tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py index c7990e54..cad02e4c 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_rent_any_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py @@ -3,6 +3,7 @@ from pydantic import ValidationError from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse + @pytest.fixture def valid_data(): """ @@ -44,11 +45,11 @@ def valid_data(): "callbackUrl": "https://www.your-callback-server.com/callback", } + def test_rent_any_number_response_expects_valid_data(valid_data): """ Test that RentAnyNumberResponse correctly parses valid data. """ - response = RentAnyNumberResponse(**valid_data) assert response.phone_number == "+12025550134" @@ -60,10 +61,12 @@ def test_rent_any_number_response_expects_valid_data(valid_data): assert response.money.amount == 2.00 assert response.payment_interval_months == 0 expected_next_charge_date = ( - datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) + datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc) + ) assert response.next_charge_date == expected_next_charge_date expected_expire_at = ( - datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc)) + datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc) + ) assert response.expire_at == expected_expire_at sms_config = response.sms_configuration @@ -90,6 +93,7 @@ def test_rent_any_number_response_expects_valid_data(valid_data): assert voice_config.app_id == "string" assert response.callback_url == "https://www.your-callback-server.com/callback" + def test_rent_any_number_response_expects_missing_optional_fields(): """ Test that RentAnyNumberResponse handles missing optional fields correctly. @@ -106,21 +110,13 @@ def test_rent_any_number_response_expects_missing_optional_fields(): response = RentAnyNumberResponse(**data) - assert response.phone_number == "+12025550134" - assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS"] - assert response.money.currency_code == "USD" - assert response.money.amount == 2.00 - assert response.payment_interval_months == 0 - assert response.next_charge_date is None assert response.expire_at is None assert response.sms_configuration is None assert response.voice_configuration is None assert response.callback_url is None + def test_rent_any_number_response_expects_validation_error_for_missing_required_fields(): """ Test that RentAnyNumberResponse raises a validation error for missing required fields. @@ -139,6 +135,7 @@ def test_rent_any_number_response_expects_validation_error_for_missing_required_ # Assert the validation error mentions missing fields assert "smsConfiguration.servicePlanId" in str(exc_info.value) + def test_rent_any_number_response_expects_ignore_extra_fields(): """ Test that RentAnyNumberResponse ignores extra fields. @@ -160,4 +157,4 @@ def test_rent_any_number_response_expects_ignore_extra_fields(): assert response.phone_number == "+12025550134" assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" assert response.region_code == "US" - assert response.extra_field == "unexpected" \ No newline at end of file + assert response.extra_field == "unexpected" diff --git a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py similarity index 88% rename from tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py rename to tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py index 39bd9837..dd3428c9 100644 --- a/tests/unit/domains/numbers/v1/models/available/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse + def test_check_number_availability_response_expects_valid_data(): """ Expects CheckNumberAvailabilityResponse to be created with valid data. @@ -30,6 +31,7 @@ def test_check_number_availability_response_expects_valid_data(): assert response.payment_interval_months == 1 assert response.supporting_documentation_required is True + def test_check_number_availability_response_missing_optional_fields_expects_valid_data(): """ Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, @@ -46,17 +48,10 @@ def test_check_number_availability_response_missing_optional_fields_expects_vali response = CheckNumberAvailabilityResponse(**data) - assert response.phone_number == "+1234567890" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS", "VOICE"] - assert response.setup_price.amount == 10.00 - assert response.setup_price.currency_code == "USD" - assert response.monthly_price.amount == 5.00 - assert response.monthly_price.currency_code == "USD" assert response.payment_interval_months is None assert response.supporting_documentation_required is None + def test_check_number_availability_response_expects_parsed_new_type(): """ Test CheckNumberAvailabilityResponse with invalid data. @@ -73,6 +68,7 @@ def test_check_number_availability_response_expects_parsed_new_type(): response = CheckNumberAvailabilityResponse(**data) assert response.type == "NEW_TYPE" + def test_check_number_availability_response_expects_validation_error_for_missing_required_fields(): """ Check if validation fails when required fields are missing. diff --git a/tests/unit/domains/numbers/v1/models/test_numbers.py b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py similarity index 60% rename from tests/unit/domains/numbers/v1/models/test_numbers.py rename to tests/unit/domains/numbers/v1/models/shared/test_numbers.py index 4d68bb2a..77f01ee4 100644 --- a/tests/unit/domains/numbers/v1/models/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py @@ -1,10 +1,9 @@ from datetime import datetime, timezone -from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.response import ActiveNumber from sinch.domains.numbers.models.v1.shared import ( ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse ) + def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): """ Test a valid instance of ScheduledProvisioningSmsConfiguration @@ -26,6 +25,7 @@ def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): assert config.last_updated_time == expected_last_updated_time assert config.error_codes == ["ERROR_CODE_1"] + def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed_data(): """ Test missing optional fields in ScheduledProvisioningSmsConfiguration @@ -41,6 +41,7 @@ def test_scheduled_provisioning_sms_configuration_optional_fields_expects_parsed assert config.last_updated_time is None assert config.error_codes is None + def test_sms_configuration_valid_expects_parsed_data(): """ Test a valid instance of SmsConfiguration @@ -61,6 +62,7 @@ def test_sms_configuration_valid_expects_parsed_data(): assert config.scheduled_provisioning.service_plan_id == "test_plan" assert config.scheduled_provisioning.status == "ACTIVE" + def test_voice_configuration_rtc_valid_expects_parsed_data(): """ Test a valid RTC voice configuration @@ -87,6 +89,7 @@ def test_voice_configuration_rtc_valid_expects_parsed_data(): assert config.scheduled_voice_provisioning.type == "RTC" assert config.scheduled_voice_provisioning.status == "ACTIVE" + def test_voice_configuration_fax_valid_expects_parsed_data(): """ Test a valid FAX voice configuration @@ -108,64 +111,3 @@ def test_voice_configuration_fax_valid_expects_parsed_data(): assert config.scheduled_voice_provisioning.type == "FAX" assert config.scheduled_voice_provisioning.status == "ACTIVE" assert config.scheduled_voice_provisioning.service_id == "test_service" - -def test_not_found_error_deserialize_with_snake_case(): - data = { - 'code': 404, - 'message': '', - 'status': 'NOT_FOUND', - 'details': [ - { - 'type': 'ResourceInfo', - 'resourceType': 'AvailableNumber', - 'resourceName': '+1234567890', - 'owner': '', - 'description': '' - } - ] - } - - not_found_error = NotFoundError.model_validate(data) - - assert not_found_error.code == 404 - assert not_found_error.message == '' - assert not_found_error.status == 'NOT_FOUND' - assert not_found_error.details[0].type == 'ResourceInfo' - assert not_found_error.details[0].resource_type == 'AvailableNumber' - assert not_found_error.details[0].resource_name == '+1234567890' - assert not_found_error.details[0].owner == '' - assert not_found_error.details[0].description == '' - -def test_activate_number_response_expects_all_fields_mapped_correctly(): - """ - Expects all fields to map correctly from camelCase input, - converts nested keys to snake_case, and handles dynamic fields - """ - data = { - "phoneNumber": "+12025550134", - "displayName": "string", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "money": {"currencyCode": "USD", "amount": "2.00"}, - "paymentIntervalMonths": 0, - "nextChargeDate": "2025-01-22T13:19:31.095Z", - "expireAt": "2025-03-29T13:19:31.095Z", - "callbackUrl": "https://www.your-callback-server.com/callback", - } - response = ActiveNumber(**data) - - assert response.phone_number == "+12025550134" - assert response.display_name == "string" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS"] - assert response.money.currency_code == "USD" - assert response.payment_interval_months == 0 - expected_next_charge_data = ( - datetime(2025, 1, 22, 13, 19, 31, 95000, tzinfo=timezone.utc)) - assert response.next_charge_date == expected_next_charge_data - expected_expire_at = ( - datetime(2025, 3, 29, 13, 19, 31, 95000, tzinfo=timezone.utc)) - assert response.expire_at == expected_expire_at - assert response.callback_url == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/v1/test_active_numbers.py b/tests/unit/domains/numbers/v1/test_active_numbers.py new file mode 100644 index 00000000..11bdec4d --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_active_numbers.py @@ -0,0 +1,119 @@ +from sinch.domains.numbers.api.v1 import ActiveNumbers +from sinch.domains.numbers.api.v1.internal import ( + ListActiveNumbersEndpoint, GetNumberConfigurationEndpoint, UpdateNumberConfigurationEndpoint, + ReleaseNumberFromProjectEndpoint +) +from sinch.domains.numbers.models.v1.internal import ( + ListActiveNumbersRequest, ListActiveNumbersResponse, NumberRequest, UpdateNumberConfigurationRequest +) +from sinch.domains.numbers.models.v1.response import ActiveNumber + + +def test_list_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListActiveNumbersResponse(activeNumbers=[]) + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + # Spy on the ActiveNumbersEndpoint to capture calls + spy_endpoint = mocker.spy(ListActiveNumbersEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.list( + region_code="US", + number_type="LOCAL", + capabilities=["SMS", "VOICE"], + page_size=10, + number_search_pattern="START" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListActiveNumbersRequest( + region_code="US", + number_type="LOCAL", + page_size=10, + capabilities=["SMS", "VOICE"], + number_search_pattern="START", + ) + + assert hasattr(response, 'has_next_page') + assert response.result == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() + + +def test_check_availability_expects_correct_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.get() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(GetNumberConfigurationEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.get(phone_number="+1234567890") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == NumberRequest(phone_number="+1234567890") + + assert response == mock_response + + +def test_release_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.update() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ReleaseNumberFromProjectEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.release( + phone_number="+1234567890", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == NumberRequest( + phone_number="+1234567890", + ) + + assert response == mock_response + + +def test_update_active_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the ActiveNumbers.update() method sends the correct request + and handles the response properly. + """ + mock_response = ActiveNumber.model_construct() + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(UpdateNumberConfigurationEndpoint, "__init__") + + active_numbers = ActiveNumbers(mock_sinch_client_numbers) + response = active_numbers.update( + phone_number="+1234567890", + display_name="Test Display Name" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == UpdateNumberConfigurationRequest( + phone_number="+1234567890", + display_name="Test Display Name" + ) + + assert response == mock_response diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index ea5ef6e1..1042f2e5 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,38 +1,25 @@ -import pytest -from unittest.mock import MagicMock - from sinch.domains.numbers.api.v1 import AvailableNumbers from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, CheckNumberAvailabilityRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse + ActivateNumberRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse, NumberRequest ) from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse -@pytest.fixture -def mock_sinch(): - """Creates a mocked Sinch client.""" - mock_sinch = MagicMock() - mock_sinch.configuration.project_id = "test_project_id" - mock_sinch.configuration.transport.request = MagicMock() - return mock_sinch - - -def test_list_available_numbers_expects_valid_request(mock_sinch, mocker): +def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): """ Test that the AvailableNumbers.list method sends the correct request and handles the response properly. """ - # Use construct to create a mock response without Pydantic validation mock_response = ListAvailableNumbersResponse(availableNumbers=[]) - mock_sinch.configuration.transport.request.return_value = mock_response + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response # Spy on the AvailableNumbersEndpoint to capture calls spy_endpoint = mocker.spy(AvailableNumbersEndpoint, "__init__") - available_numbers = AvailableNumbers(mock_sinch) + available_numbers = AvailableNumbers(mock_sinch_client_numbers) response = available_numbers.list( region_code="US", number_type="LOCAL", @@ -56,20 +43,21 @@ def test_list_available_numbers_expects_valid_request(mock_sinch, mocker): ) assert response == mock_response - mock_sinch.configuration.transport.request.assert_called_once() + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() -def test_activate_number_expects_correct_request(mock_sinch, mocker): +def test_activate_number_expects_correct_request(mock_sinch_client_numbers, mocker): """ Test that the AvailableNumbers.activate method sends the correct request and handles the response properly. """ + # Use construct to create a mock response without Pydantic validation mock_response = ActiveNumber.model_construct() - mock_sinch.configuration.transport.request.return_value = mock_response + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response spy_endpoint = mocker.spy(ActivateNumberEndpoint, "__init__") - available_numbers = AvailableNumbers(mock_sinch) + available_numbers = AvailableNumbers(mock_sinch_client_numbers) response = available_numbers.activate(phone_number="+1234567890") spy_endpoint.assert_called_once() @@ -79,22 +67,23 @@ def test_activate_number_expects_correct_request(mock_sinch, mocker): assert response == mock_response -def test_check_availability_expects_correct_request(mock_sinch, mocker): + +def test_check_availability_expects_correct_request(mock_sinch_client_numbers, mocker): """ Test that the AvailableNumbers.check_availability method sends the correct request and handles the response properly. """ mock_response = CheckNumberAvailabilityResponse.model_construct() - mock_sinch.configuration.transport.request.return_value = mock_response + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response spy_endpoint = mocker.spy(SearchForNumberEndpoint, "__init__") - available_numbers = AvailableNumbers(mock_sinch) + available_numbers = AvailableNumbers(mock_sinch_client_numbers) response = available_numbers.check_availability(phone_number="+1234567890") spy_endpoint.assert_called_once() _, kwargs = spy_endpoint.call_args assert kwargs["project_id"] == "test_project_id" - assert kwargs["request_data"] == CheckNumberAvailabilityRequest(phone_number="+1234567890") + assert kwargs["request_data"] == NumberRequest(phone_number="+1234567890") assert response == mock_response diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index da3b947c..b950f85a 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -2,6 +2,7 @@ from sinch import SinchClient, SinchClientAsync from sinch.core.clients.sinch_client_configuration import Configuration + @pytest.mark.parametrize("client", [SinchClient, SinchClientAsync]) def test_sinch_client_initialization(client): """ Test that SinchClient and SinchClientAsync can be initialized with or without parameters """ diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index d10871cf..2f93e520 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -14,8 +14,8 @@ ] ) def test_configuration_happy_capy_expects_initialization( - request, transport_class, token_manager_class, client_fixture - ): + request, transport_class, token_manager_class, client_fixture +): """ Test that Configuration can be initialized with all parameters """ sinch_client = request.getfixturevalue(client_fixture) @@ -67,7 +67,7 @@ def test_set_conversation_region_property_and_check_that_sms_origin_was_updated( def test_set_conversation_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.conversation_domain= "My_brain_hurts" + sinch_client_sync.configuration.conversation_domain = "My_brain_hurts" assert "brain" in sinch_client_sync.configuration.conversation_origin assert "hurts" in sinch_client_sync.configuration.conversation_origin diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 60b3c526..b2d53c20 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -7,6 +7,7 @@ AsyncTokenBasedPaginator ) + def test_page_int_iterator_sync_using_manual_pagination( first_int_based_pagination_response, second_int_based_pagination_response, @@ -71,6 +72,7 @@ def test_page_int_iterator_sync_using_auto_pagination( assert page_counter == 2 + async def test_page_int_iterator_async_using_manual_pagination( first_int_based_pagination_response, second_int_based_pagination_response, @@ -176,11 +178,12 @@ def test_page_token_iterator_sync_using_manual_pagination( assert page_counter == 3 assert active_numbers_list == mock_pagination_expected_phone_numbers_response + def test_page_token_iterator_sync_using_auto_pagination_expects_iter( - token_based_pagination_request_data, - mock_pagination_active_number_responses, - mock_pagination_expected_phone_numbers_response - ): + token_based_pagination_request_data, + mock_pagination_active_number_responses, + mock_pagination_expected_phone_numbers_response +): """Test that the pagination iterates correctly through multiple items.""" token_based_paginator = initialize_token_paginator( endpoint_mock=Mock(), @@ -227,6 +230,7 @@ async def test_page_token_iterator_async_using_manual_pagination_expects_iter( assert page_counter == 3 assert active_numbers_list == mock_pagination_expected_phone_numbers_response + @pytest.mark.asyncio async def test_page_token_iterator_async_using_auto_pagination_expects_iter( token_based_pagination_request_data, diff --git a/tests/unit/test_user_agent_header.py b/tests/unit/test_user_agent_header.py index 159d1c17..df97e35f 100644 --- a/tests/unit/test_user_agent_header.py +++ b/tests/unit/test_user_agent_header.py @@ -7,4 +7,3 @@ def test_user_agent_header_creation(sinch_client_sync): http_endpoint = DeleteConversationAppEndpoint(sinch_client_sync, endpoint) http_request = sinch_client_sync.configuration.transport.prepare_request(http_endpoint) assert "User-Agent" in http_request.headers - From 93e2c394f1b06e784fe2caa7c654c65d40822208 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Thu, 27 Mar 2025 08:52:04 +0100 Subject: [PATCH 034/106] DEVEXP-839: Use Paginator interface for ListAvailableNumbers (#54) Provide SDK users with a consistent interface that supports pagination for all operations returning a list of entities. Signed-off-by: Jessica Matsuoka --- .../numbers/api/v1/active_numbers_apis.py | 2 +- .../numbers/api/v1/available_numbers_apis.py | 32 ++++++---- .../internal/available_numbers_endpoints.py | 7 +-- .../list_available_numbers_response.py | 5 ++ tests/e2e/numbers/features/environment.py | 48 +-------------- .../numbers/features/steps/numbers.steps.py | 59 ++++--------------- .../test_list_active_numbers_endpoint.py | 39 +++++++----- .../test_list_available_numbers_endpoint.py | 45 ++++++++++---- ...test_list_active_numbers_response_model.py | 41 +++++++------ ...t_list_available_numbers_response_model.py | 25 ++++---- .../domains/numbers/v1/test_active_numbers.py | 2 + .../numbers/v1/test_available_numbers.py | 5 +- 12 files changed, 142 insertions(+), 168 deletions(-) diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 621ac288..858e45e8 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -63,7 +63,7 @@ def list( :type kwargs: dict :returns: A paginator for iterating through the results. - :rtype: TokenBasedPaginatorNumbers + :rtype: Paginator[ActiveNumber] For detailed documentation, visit https://developers.sinch.com """ diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 2a30db45..19d709c1 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,5 +1,7 @@ from typing import Optional, overload from pydantic import StrictInt, StrictStr + +from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) @@ -27,7 +29,7 @@ def list( capabilities: Optional[CapabilityTypeValuesList] = None, page_size: Optional[StrictInt] = None, **kwargs - ) -> list[AvailableNumber]: + ) -> Paginator[AvailableNumber]: """ Search for available virtual numbers for you to activate using a variety of parameters to filter results. @@ -52,23 +54,27 @@ def list( :param kwargs: Additional filters for the request. :type kwargs: dict - :returns: A response array with available numbers and their details. - :rtype: list[AvailableNumber] + :returns: A paginator for iterating through the results. + :rtype: Paginator[AvailableNumber] For detailed documentation, visit: https://developers.sinch.com """ - request_data = ListAvailableNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_search_pattern=number_search_pattern, - number_pattern=number_pattern, - **kwargs + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=AvailableNumbersEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListAvailableNumbersRequest( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + **kwargs + ) + ) ) - return self._request(AvailableNumbersEndpoint, request_data) - @overload def activate( self, diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 36ebb07d..15609da3 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -7,7 +7,7 @@ NumberRequest, RentAnyNumberRequest ) from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse + ActiveNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint @@ -52,10 +52,9 @@ def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def handle_response(self, response: HTTPResponse) -> list[AvailableNumber]: + def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: super(AvailableNumbersEndpoint, self).handle_response(response) - response = self.process_response_model(response.body, ListAvailableNumbersResponse) - return response.available_numbers + return self.process_response_model(response.body, ListAvailableNumbersResponse) class RentAnyNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 35bb988e..a06e648f 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -9,3 +9,8 @@ class ListAvailableNumbersResponse(BaseModel): model_config = ConfigDict( populate_by_name=True ) + + @property + def content(self): + """Returns the available numbers as part of the response object to be used in the pagination.""" + return self.available_numbers or [] diff --git a/tests/e2e/numbers/features/environment.py b/tests/e2e/numbers/features/environment.py index 7cd40b6c..75842367 100644 --- a/tests/e2e/numbers/features/environment.py +++ b/tests/e2e/numbers/features/environment.py @@ -1,55 +1,13 @@ -import os -import logging -import asyncio -from sinch import SinchClient, SinchClientAsync - - -def get_logger(): - """Creates and returns a logger instance for this module only.""" - log = logging.getLogger(__name__) - log.setLevel(logging.INFO) - if not log.hasHandlers(): - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) - log.addHandler(handler) - log.propagate = False - - return log - - -logger = get_logger() +from sinch import SinchClient def before_all(context): - """ - Initializes the appropriate Sinch client based on the environment variable SINCH_CLIENT_MODE. - If it's set to 'async', a single event loop is created for all tests. - Otherwise, we use the synchronous client. - """ - client_mode = os.getenv('SINCH_CLIENT_MODE', 'sync') - - logger.info(f" Running E2E tests in **{client_mode.upper()}** mode") - + """Initializes the Sinch client""" client_params = { 'project_id': 'tinyfrog-jump-high-over-lilypadbasin', 'key_id': 'keyId', 'key_secret': 'keySecret', } - if client_mode == 'async': - # Create and set a single event loop for the entire test run - context.loop = asyncio.new_event_loop() - asyncio.set_event_loop(context.loop) - context.sinch = SinchClientAsync(**client_params) - else: - # Sync client does not need an event loop - context.sinch = SinchClient(**client_params) + context.sinch = SinchClient(**client_params) context.sinch.configuration.auth_origin = 'http://localhost:3011' context.sinch.configuration.numbers_origin = 'http://localhost:3013' - - -def after_all(context): - """ - Closes the Async event loop if it was created during the test - """ - if hasattr(context, 'loop'): - context.loop.close() diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index fe6a73dd..f5fd3bb6 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -1,4 +1,3 @@ -import inspect from datetime import timezone, datetime from behave import given, when, then from decimal import Decimal @@ -7,22 +6,6 @@ from sinch.domains.numbers.models.v1.response import ActiveNumber, RentAnyNumberResponse -def execute_sync_or_async(context, call): - """ - Ensures proper execution of both synchronous and asynchronous calls. - - If the call is synchronous, it executes directly. - - If the call is a coroutine (async), it runs using asyncio - This abstracts away execution differences, allowing test steps to be written uniformly. - """ - if call is None: - return None - if inspect.iscoroutine(call): - # Reuse the single loop created in before_all - return context.loop.run_until_complete(call) - else: - return call - - @given('the Numbers service is available') def step_service_is_available(context): """Ensures the Sinch client is initialized""" @@ -35,13 +18,13 @@ def step_search_available_numbers(context): region_code='US', number_type='LOCAL' ) - context.response = execute_sync_or_async(context, response) + context.response = response.content() @then('the response contains "{count}" available phone numbers') def step_check_available_numbers_count(context, count): - data = context.response - assert len(data) == int(count), f'Expected {count}, got {len(data)}' + assert len(context.response) == int(count), \ + f'Expected {count}, got {len(context.response)}' @then('a phone number contains all the expected properties') @@ -62,8 +45,7 @@ def step_check_number_properties(context): @when('I send a request to check the availability of the phone number "{phone_number}"') def step_check_number_availability(context, phone_number): try: - response = context.sinch.numbers.available.check_availability(phone_number) - context.response = execute_sync_or_async(context, response) + context.response = context.sinch.numbers.available.check_availability(phone_number) except NumberNotFoundException as e: context.error = e @@ -84,7 +66,7 @@ def step_check_unavailable_number(context, phone_number): @when('I send a request to rent a number with some criteria') def step_rent_any_number(context): - response = context.sinch.numbers.available.rent_any( + context.response = context.sinch.numbers.available.rent_any( region_code='US', type_='LOCAL', capabilities=['SMS', 'VOICE'], @@ -100,7 +82,6 @@ def step_rent_any_number(context): 'search_pattern': 'END' }, ) - context.response = execute_sync_or_async(context, response) @then('the response contains a rented phone number') @@ -145,7 +126,7 @@ def step_validate_rented_number(context): @when('I send a request to rent the phone number "{phone_number}"') def step_rent_specific_number(context, phone_number): - response = context.sinch.numbers.available.activate( + context.response = context.sinch.numbers.available.activate( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -154,7 +135,6 @@ def step_rent_specific_number(context, phone_number): 'app_id': 'sunshine-rain-drop-very-beautifulday' } ) - context.response = execute_sync_or_async(context, response) @then('the response contains this rented phone number "{phone_number}"') @@ -166,7 +146,7 @@ def step_validate_rented_specific_number(context, phone_number): @when('I send a request to rent the unavailable phone number "{phone_number}"') def step_rent_unavailable_number(context, phone_number): try: - response = context.sinch.numbers.available.activate( + context.response = context.sinch.numbers.available.activate( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -175,7 +155,6 @@ def step_rent_unavailable_number(context, phone_number): 'app_id': 'sunshine-rain-drop-very-beautifulday' } ) - context.response = execute_sync_or_async(context, response) except NumberNotFoundException as e: context.error = e @@ -187,7 +166,6 @@ def step_when_list_phone_numbers(context): number_type='LOCAL' ) # Get the first page - response = execute_sync_or_async(context, response) context.response = response.content() @@ -205,16 +183,8 @@ def step_when_list_all_phone_numbers(context): ) active_numbers_list = [] - response = execute_sync_or_async(context, response) - if inspect.isasyncgen(response.iterator()): - async def collect_async_numbers(): - async for number in response.iterator(): - active_numbers_list.append(number) - - execute_sync_or_async(context, collect_async_numbers()) - else: - for number in response.iterator(): - active_numbers_list.append(number) + for number in response.iterator(): + active_numbers_list.append(number) context.active_numbers_list = active_numbers_list @@ -235,7 +205,7 @@ def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): @when('I send a request to update the phone number "{phone_number}"') def step_when_update_phone_number(context, phone_number): - response = context.sinch.numbers.active.update( + context.response = context.sinch.numbers.active.update( phone_number=phone_number, display_name='Updated description during E2E tests', sms_configuration={ @@ -247,8 +217,6 @@ def step_when_update_phone_number(context, phone_number): }, callback_url='https://my-callback-server.com/numbers' ) - response = execute_sync_or_async(context, response) - context.response = response @then('the response contains a phone number with updated parameters') @@ -282,10 +250,9 @@ def step_then_response_contains_updated_number(context): @when('I send a request to retrieve the phone number "{phone_number}"') def step_when_retrieve_phone_number(context, phone_number): try: - response = context.sinch.numbers.active.get( + context.response = context.sinch.numbers.active.get( phone_number=phone_number, ) - context.response = execute_sync_or_async(context, response) except NumberNotFoundException as e: context.error = e @@ -323,11 +290,9 @@ def step_then_response_contains_error_not_rented(context, phone_number): @when('I send a request to release the phone number "{phone_number}"') def step_when_release_phone_number(context, phone_number): - response = context.sinch.numbers.active.release( + context.response = context.sinch.numbers.active.release( phone_number=phone_number ) - response = execute_sync_or_async(context, response) - context.response = response @then('the response contains details about the phone number "{phone_number}" to be released') diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index 0ff5fc4b..e674d5c6 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -80,21 +80,28 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): """ parsed_response = endpoint.handle_response(mock_response) assert isinstance(parsed_response, ListActiveNumbersResponse) - assert parsed_response.active_numbers[0].phone_number == "+1234567890" - assert parsed_response.active_numbers[0].project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" - assert parsed_response.active_numbers[0].display_name == "" - assert parsed_response.active_numbers[0].region_code == "US" - assert parsed_response.active_numbers[0].type == "LOCAL" - assert parsed_response.active_numbers[0].capability == ["SMS", "VOICE"] - assert parsed_response.active_numbers[0].money.currency_code == "EUR" - assert parsed_response.active_numbers[0].money.amount == Decimal("0.80") - assert parsed_response.active_numbers[0].payment_interval_months == 1 - expected_next_charge_date = ( - datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) - assert parsed_response.active_numbers[0].next_charge_date == expected_next_charge_date - expected_expire_at = ( - datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) - assert parsed_response.active_numbers[0].expire_at == expected_expire_at - assert parsed_response.active_numbers[0].callback_url == "https://yourcallback/numbers" + assert hasattr(parsed_response, "content") + assert parsed_response.content == parsed_response.active_numbers + assert len(parsed_response.active_numbers) == 1 + + number = parsed_response.active_numbers[0] + assert number.phone_number == "+1234567890" + assert number.project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert number.display_name == "" + assert number.region_code == "US" + assert number.type == "LOCAL" + assert number.capability == ["SMS", "VOICE"] + assert number.money.currency_code == "EUR" + assert number.money.amount == Decimal("0.80") + assert number.payment_interval_months == 1 + expected_next_charge_date = datetime( + 2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc + ) + assert number.next_charge_date == expected_next_charge_date + expected_expire_at = datetime( + 2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc + ) + assert number.expire_at == expected_expire_at + assert number.callback_url == "https://yourcallback/numbers" assert parsed_response.next_page_token == "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==" assert parsed_response.total_size == 10 diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py index 7390bfe5..da912345 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_list_available_numbers_endpoint.py @@ -1,7 +1,8 @@ +from decimal import Decimal import pytest -from sinch.domains.numbers.api.v1.internal import AvailableNumbersEndpoint -from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import AvailableNumbersEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableNumbersRequest, ListAvailableNumbersResponse @pytest.fixture @@ -37,13 +38,13 @@ def mock_response(): }, "monthlyPrice": { "currencyCode": "EUR", - "amount": "0.80" + "amount": "0.85" }, "paymentIntervalMonths": 1, "supportingDocumentationRequired": True }, { - "phoneNumber": "+2345678901", + "phoneNumber": "+13456789012", "regionCode": "US", "type": "LOCAL", "capability": [ @@ -56,9 +57,9 @@ def mock_response(): }, "monthlyPrice": { "currencyCode": "EUR", - "amount": "0.80" + "amount": "1.00" }, - "paymentIntervalMonths": 1, + "paymentIntervalMonths": 2, "supportingDocumentationRequired": True } ], @@ -101,7 +102,31 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): Check if response is handled and mapped to the appropriate fields correctly. """ parsed_response = endpoint.handle_response(mock_response) - assert isinstance(parsed_response, list) - assert len(parsed_response) == 2 - assert parsed_response[0].phone_number == "+1234567890" - assert parsed_response[1].phone_number == "+2345678901" + assert isinstance(parsed_response, ListAvailableNumbersResponse) + assert hasattr(parsed_response, "content") + assert parsed_response.content == parsed_response.available_numbers + assert len(parsed_response.available_numbers) == 2 + + first_number = parsed_response.available_numbers[0] + assert first_number.phone_number == "+1234567890" + assert first_number.region_code == "US" + assert first_number.type == "LOCAL" + assert first_number.capability == ["SMS", "VOICE"] + assert first_number.setup_price.currency_code == "EUR" + assert first_number.setup_price.amount == Decimal("0.80") + assert first_number.monthly_price.currency_code == "EUR" + assert first_number.monthly_price.amount == Decimal("0.85") + assert first_number.payment_interval_months == 1 + assert first_number.supporting_documentation_required is True + + second_number = parsed_response.available_numbers[1] + assert second_number.phone_number == "+13456789012" + assert second_number.region_code == "US" + assert second_number.type == "LOCAL" + assert second_number.capability == ["SMS", "VOICE"] + assert second_number.setup_price.currency_code == "EUR" + assert second_number.setup_price.amount == Decimal("0.80") + assert second_number.monthly_price.currency_code == "EUR" + assert second_number.monthly_price.amount == 1.00 + assert second_number.payment_interval_months == 2 + assert second_number.supporting_documentation_required is True diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py index 58239de1..851396fd 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_response_model.py @@ -76,26 +76,25 @@ def test_list_active_numbers_response_expects_correct_mapping(test_data): Check if response is handled and mapped to the appropriate fields correctly. """ response = ListActiveNumbersResponse(**test_data) - assert response.active_numbers[0].phone_number == "+12085088605" - assert response.active_numbers[0].project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" - assert response.active_numbers[0].display_name == "" - assert response.active_numbers[0].region_code == "US" - assert response.active_numbers[0].type == "LOCAL" - assert response.active_numbers[0].capability == ["SMS", "VOICE"] - assert response.active_numbers[0].money.currency_code == "EUR" - # Floats have precision issues; using Decimal for exact comparison. - assert response.active_numbers[0].money.amount == Decimal("0.80") - assert response.active_numbers[0].payment_interval_months == 1 - expected_next_charge_date = ( - datetime(2025, 3, 4, 15, 28, 16, 449951, tzinfo=timezone.utc)) - assert response.active_numbers[0].next_charge_date == expected_next_charge_date - assert response.active_numbers[0].expire_at is None - assert_sms_configuration(response.active_numbers[0].sms_configuration) - assert_voice_configuration(response.active_numbers[0].voice_configuration) + assert hasattr(response, "content") + assert response.content == response.active_numbers + + number = response.active_numbers[0] + assert number.phone_number == "+12085088605" + assert number.project_id == "37b62a7b-0177-429a-bb0b-e10f848de0b8" + assert number.display_name == "" + assert number.region_code == "US" + assert number.type == "LOCAL" + assert number.capability == ["SMS", "VOICE"] + assert number.money.currency_code == "EUR" + assert number.money.amount == Decimal("0.80") + assert number.payment_interval_months == 1 + expected_next_charge_date = datetime( + 2025, 3, 4, 15, 28, 16, 449951, tzinfo=timezone.utc + ) + assert number.next_charge_date == expected_next_charge_date + assert number.expire_at is None + assert_sms_configuration(number.sms_configuration) + assert_voice_configuration(number.voice_configuration) assert response.next_page_token == "CgtwaG9uZU51bWJlchJnCjl0eXBlLmdvb2dsZWFwaXMuY29tL3NpbmNoLn==" assert response.total_size == 10 - - -def test_list_active_numbers_response_expects_content_mapping(test_data): - response = ListActiveNumbersResponse(**test_data) - assert response.content == response.active_numbers diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py index 9f579975..5dbb7644 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_response_model.py @@ -34,13 +34,18 @@ def test_list_available_numbers_response_expects_correct_mapping(test_data): Check if response is handled and mapped to the appropriate fields correctly. """ response = ListAvailableNumbersResponse(**test_data) - assert response.available_numbers[0].phone_number == "+12025550134" - assert response.available_numbers[0].region_code == "US" - assert response.available_numbers[0].type == "MOBILE" - assert response.available_numbers[0].capability == ["SMS", "VOICE"] - assert response.available_numbers[0].setup_price.currency_code == "USD" - assert response.available_numbers[0].setup_price.amount == 2.00 - assert response.available_numbers[0].monthly_price.currency_code == "USD" - assert response.available_numbers[0].monthly_price.amount == 2.00 - assert response.available_numbers[0].payment_interval_months == 0 - assert response.available_numbers[0].supporting_documentation_required is True + # Verify content property for pagination compatibility + assert hasattr(response, "content") + assert response.content == response.available_numbers + + number = response.content[0] + assert number.phone_number == "+12025550134" + assert number.region_code == "US" + assert number.type == "MOBILE" + assert number.capability == ["SMS", "VOICE"] + assert number.setup_price.currency_code == "USD" + assert number.setup_price.amount == 2.00 + assert number.monthly_price.currency_code == "USD" + assert number.monthly_price.amount == 2.00 + assert number.payment_interval_months == 0 + assert number.supporting_documentation_required is True diff --git a/tests/unit/domains/numbers/v1/test_active_numbers.py b/tests/unit/domains/numbers/v1/test_active_numbers.py index 11bdec4d..d87ac199 100644 --- a/tests/unit/domains/numbers/v1/test_active_numbers.py +++ b/tests/unit/domains/numbers/v1/test_active_numbers.py @@ -1,3 +1,4 @@ +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.numbers.api.v1 import ActiveNumbers from sinch.domains.numbers.api.v1.internal import ( ListActiveNumbersEndpoint, GetNumberConfigurationEndpoint, UpdateNumberConfigurationEndpoint, @@ -41,6 +42,7 @@ def test_list_active_numbers_expects_valid_request(mock_sinch_client_numbers, mo number_search_pattern="START", ) + assert isinstance(response, TokenBasedPaginator) assert hasattr(response, 'has_next_page') assert response.result == mock_response mock_sinch_client_numbers.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 1042f2e5..5c003b1e 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,3 +1,4 @@ +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.numbers.api.v1 import AvailableNumbers from sinch.domains.numbers.api.v1.internal import ( AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint @@ -42,7 +43,9 @@ def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, number_search_pattern="START", ) - assert response == mock_response + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, 'has_next_page') + assert response.result == mock_response mock_sinch_client_numbers.configuration.transport.request.assert_called_once() From c81b6e75ee89a5859299add06d20b2b3e791665b Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Thu, 27 Mar 2025 16:14:46 +0100 Subject: [PATCH 035/106] DEVEXP-837: Remove async support inherited from v1 (#55) Signed-off-by: Jessica Matsuoka --- README.md | 14 +- pyproject.toml | 1 - pytest.ini | 2 - requirements-dev.txt | 2 - sinch/__init__.py | 7 +- sinch/core/adapters/httpx_adapter.py | 62 -------- sinch/core/clients/sinch_client_async.py | 51 ------- sinch/core/clients/sinch_client_base.py | 3 +- .../clients/sinch_client_configuration.py | 5 +- sinch/core/pagination.py | 70 ---------- sinch/core/ports/http_transport.py | 29 ---- sinch/core/token_manager.py | 10 -- sinch/domains/authentication/__init__.py | 8 -- sinch/domains/conversation/__init__.py | 101 +------------- sinch/domains/numbers/__init__.py | 18 +-- sinch/domains/numbers/api/v1/__init__.py | 3 +- .../numbers/api/v1/active_numbers_apis.py | 33 +---- sinch/domains/sms/__init__.py | 113 --------------- sinch/domains/verification/__init__.py | 12 -- sinch/domains/voice/__init__.py | 14 -- tests/conftest.py | 35 +---- tests/unit/test_client.py | 35 ++--- tests/unit/test_configuration.py | 35 ++--- tests/unit/test_pagination.py | 132 +----------------- tests/unit/test_token_manager.py | 14 +- 25 files changed, 49 insertions(+), 760 deletions(-) delete mode 100644 sinch/core/adapters/httpx_adapter.py delete mode 100644 sinch/core/clients/sinch_client_async.py diff --git a/README.md b/README.md index 7b9a3697..faf73355 100644 --- a/README.md +++ b/README.md @@ -151,17 +151,15 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su ## Custom HTTP client implementation -By default, two HTTP implementations are provided: -- Synchronous using `requests` HTTP library -- Asynchronous using `httpx` HTTP library +By default, the HTTP implementation uses the `requests` library. -For creating custom HTTP client code, use either `SinchClient` or `SinchClientAsync` client and inject your transport during initialisation: +To use a custom HTTP client, inject your own transport during initialization: ```python -sinch_client = SinchClientAsync( +sinch_client = SinchClient( key_id="key_id", key_secret="key_secret", project_id="some_project", - transport=MyHTTPAsyncImplementation + transport=MyHTTPImplementation ) ``` @@ -172,6 +170,10 @@ class HTTPTransport(ABC): def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: pass ``` + +Note: Asynchronous HTTP clients are not supported. +The transport must be a synchronous implementation. + ## License This project is licensed under the Apache License. See the [LICENSE](license.md) file for the license text. diff --git a/pyproject.toml b/pyproject.toml index 2c9529c7..e912a815 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,6 @@ keywords = ["sinch", "sdk"] [tool.poetry.dependencies] python = ">=3.9" requests = "*" -httpx = "*" pydantic = ">=2.0.0" [build-system] diff --git a/pytest.ini b/pytest.ini index 2f4c80e3..e69de29b 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +0,0 @@ -[pytest] -asyncio_mode = auto diff --git a/requirements-dev.txt b/requirements-dev.txt index 04a0f33f..467451a6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,5 @@ # Testing pytest -pytest-asyncio pytest-mock coverage behave @@ -9,7 +8,6 @@ behave flake8 # HTTP Libraries -httpx requests # Data Validation diff --git a/sinch/__init__.py b/sinch/__init__.py index 6ee7635c..9212e61c 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,9 +1,6 @@ -""" Sinch Python SDK -To access Sinch resources, use the Sync or Async version of the Sinch Client. -""" +""" Sinch Python SDK""" __version__ = "1.1.1" from sinch.core.clients.sinch_client_sync import SinchClient -from sinch.core.clients.sinch_client_async import SinchClientAsync -__all__ = (SinchClient, SinchClientAsync) +__all__ = SinchClient diff --git a/sinch/core/adapters/httpx_adapter.py b/sinch/core/adapters/httpx_adapter.py deleted file mode 100644 index 54820037..00000000 --- a/sinch/core/adapters/httpx_adapter.py +++ /dev/null @@ -1,62 +0,0 @@ -import httpx -from sinch.core.ports.http_transport import AsyncHTTPTransport, HttpRequest -from sinch.core.endpoint import HTTPEndpoint -from sinch.core.models.http_response import HTTPResponse - - -class HTTPXTransport(AsyncHTTPTransport): - def __init__(self, sinch): - super().__init__(sinch) - self.http_session = None - - async def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - request_data: HttpRequest = self.prepare_request(endpoint) - request_data: HttpRequest = await self.authenticate(endpoint, request_data) - - if not self.http_session: - self.http_session = httpx.AsyncClient() - - self.sinch.configuration.logger.debug( - f"Async HTTP {request_data.http_method} call with headers:" - f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" - ) - - if isinstance(request_data.request_body, str): - response = await self.http_session.request( - method=request_data.http_method, - headers=request_data.headers, - url=request_data.url, - content=request_data.request_body, - auth=request_data.auth, - params=request_data.query_params, - timeout=self.sinch.configuration.connection_timeout - ) - else: - response = await self.http_session.request( - method=request_data.http_method, - headers=request_data.headers, - url=request_data.url, - data=request_data.request_body, - auth=request_data.auth, - params=request_data.query_params, - timeout=self.sinch.configuration.connection_timeout, - ) - - response_body = self.deserialize_json_response(response) - - self.sinch.configuration.logger.debug( - f"Async HTTP {response.status_code} response with headers: {response.headers}" - f"and body: {response_body} from URL: {request_data.url}" - ) - - return await self.handle_response( - endpoint=endpoint, - http_response=HTTPResponse( - status_code=response.status_code, - body=response_body, - headers=response.headers - ) - ) - - async def close_session(self): - await self.http_session.aclose() diff --git a/sinch/core/clients/sinch_client_async.py b/sinch/core/clients/sinch_client_async.py deleted file mode 100644 index 65c4b2c4..00000000 --- a/sinch/core/clients/sinch_client_async.py +++ /dev/null @@ -1,51 +0,0 @@ -from logging import Logger -from sinch.core.clients.sinch_client_base import SinchClientBase -from sinch.core.clients.sinch_client_configuration import Configuration -from sinch.core.token_manager import TokenManagerAsync -from sinch.core.adapters.httpx_adapter import HTTPXTransport -from sinch.domains.authentication import AuthenticationAsync -from sinch.domains.numbers import NumbersAsync -from sinch.domains.conversation import ConversationAsync -from sinch.domains.sms import SMSAsync -from sinch.domains.verification import VerificationAsync -from sinch.domains.voice import VoiceAsync - - -class SinchClientAsync(SinchClientBase): - """ - Asynchronous implementation of the Sinch Client - By default this implementation uses HTTPXTransport based on httpx library - Custom Async HTTPTransport implementation can be provided via `transport` argument - """ - def __init__( - self, - key_id: str = None, - key_secret: str = None, - project_id: str = None, - logger_name: str = None, - logger: Logger = None, - application_key: str = None, - application_secret: str = None, - service_plan_id: str = None, - sms_api_token: str = None - ): - self.configuration = Configuration( - key_id=key_id, - key_secret=key_secret, - project_id=project_id, - logger_name=logger_name, - logger=logger, - transport=HTTPXTransport(self), - token_manager=TokenManagerAsync(self), - application_secret=application_secret, - application_key=application_key, - service_plan_id=service_plan_id, - sms_api_token=sms_api_token - ) - - self.authentication = AuthenticationAsync(self) - self.numbers = NumbersAsync(self) - self.conversation = ConversationAsync(self) - self.sms = SMSAsync(self) - self.verification = VerificationAsync(self) - self.voice = VoiceAsync(self) diff --git a/sinch/core/clients/sinch_client_base.py b/sinch/core/clients/sinch_client_base.py index 6072bfe0..3e533bbd 100644 --- a/sinch/core/clients/sinch_client_base.py +++ b/sinch/core/clients/sinch_client_base.py @@ -10,8 +10,7 @@ class SinchClientBase(ABC): """ - Sinch abstract base class for concrete Sinch Client implementations. - By default, this SDK provides two implementations - sync and async. + Sinch abstract base class for concrete Sinch Client implementation. Feel free to utilize any of them for you custom implementation. """ configuration = Configuration diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 12385fe9..d969f7fd 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -1,9 +1,8 @@ import logging from logging import Logger -from typing import Union from sinch.core.ports.http_transport import HTTPTransport -from sinch.core.token_manager import TokenManager, TokenManagerAsync +from sinch.core.token_manager import TokenManager from sinch.core.enums import HTTPAuthentication @@ -17,7 +16,7 @@ def __init__( key_secret: str, project_id: str, transport: HTTPTransport, - token_manager: Union[TokenManager, TokenManagerAsync], + token_manager: TokenManager, logger: Logger = None, logger_name: str = None, connection_timeout=10, diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 1fc77ca5..47cab729 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -24,28 +24,6 @@ def __next__(self): raise StopIteration -class AsyncPageIterator: - def __init__(self, paginator, yield_first_page=False): - self.paginator = paginator - self.started = not yield_first_page - - def __aiter__(self): - return self - - async def __anext__(self): - if not self.started: - self.started = True - return self.paginator - - if self.paginator.has_next_page: - next_paginator = await self.paginator.next_page() - if next_paginator: - self.paginator = next_paginator - return self.paginator - else: - raise StopAsyncIteration - - class Paginator(ABC, Generic[BM]): """ Pagination response object. @@ -108,24 +86,6 @@ def _initialize(cls, sinch, endpoint): return cls(sinch, endpoint, result) -class AsyncIntBasedPaginator(IntBasedPaginator): - __doc__ = IntBasedPaginator.__doc__ - - async def next_page(self): - self.endpoint.request_data.page += 1 - self.result = await self._sinch.configuration.transport.request(self.endpoint) - self._calculate_next_page() - return self - - def auto_paging_iter(self): - return AsyncPageIterator(self, yield_first_page=True) - - @classmethod - async def _initialize(cls, sinch, endpoint): - result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) - - class TokenBasedPaginator(Paginator[BM]): """Base paginator for token-based pagination with explicit page navigation and metadata.""" @@ -164,33 +124,3 @@ def _initialize(cls, sinch, endpoint): """Creates an instance of the paginator skipping first page.""" result = sinch.configuration.transport.request(endpoint) return cls(sinch, endpoint, result=result) - - -class AsyncTokenBasedPaginator(TokenBasedPaginator): - """Asynchronous token-based paginator.""" - - async def next_page(self): - if not self.has_next_page: - return None - - self.endpoint.request_data.page_token = self.result.next_page_token - next_result = await self._sinch.configuration.transport.request(self.endpoint) - - return self.__class__(self._sinch, self.endpoint, result=next_result) - - async def iterator(self): - """Iterates asynchronously over individual items across all pages.""" - paginator = self - while paginator: - for item in paginator.content(): - yield item - - next_page_instance = await paginator.next_page() - if not next_page_instance: - break - paginator = next_page_instance - - @classmethod - async def _initialize(cls, sinch, endpoint): - result = await sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result=result) diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 18afd47f..29a6acc8 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,4 +1,3 @@ -import httpx from abc import ABC, abstractmethod from platform import python_version from sinch.core.endpoint import HTTPEndpoint @@ -121,31 +120,3 @@ def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): return self.request(endpoint=endpoint) return endpoint.handle_response(http_response) - - -class AsyncHTTPTransport(HTTPTransport): - async def authenticate(self, endpoint, request_data): - if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.BASIC.value: - request_data.auth = httpx.BasicAuth( - self.sinch.configuration.key_id, - self.sinch.configuration.key_secret - ) - else: - request_data.auth = None - - if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: - token_response = await self.sinch.authentication.get_auth_token() - request_data.headers = { - "Authorization": f"Bearer {token_response.access_token}", - "Content-Type": "application/json" - } - - return request_data - - async def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse): - if http_response.status_code == 401 and endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value: - self.sinch.configuration.token_manager.handle_invalid_token(http_response) - if self.sinch.configuration.token_manager.token_state == TokenState.EXPIRED: - return await self.request(endpoint=endpoint) - - return endpoint.handle_response(http_response) diff --git a/sinch/core/token_manager.py b/sinch/core/token_manager.py index 8713ef8e..28beb57d 100644 --- a/sinch/core/token_manager.py +++ b/sinch/core/token_manager.py @@ -49,13 +49,3 @@ def get_auth_token(self) -> OAuthToken: self.token = self.sinch.configuration.transport.request(OAuthEndpoint()) self.token_state = TokenState.VALID return self.token - - -class TokenManagerAsync(TokenManagerBase): - async def get_auth_token(self) -> OAuthToken: - if self.token: - return self.token - - self.token = await self.sinch.configuration.transport.request(OAuthEndpoint()) - self.token_state = TokenState.VALID - return self.token diff --git a/sinch/domains/authentication/__init__.py b/sinch/domains/authentication/__init__.py index 8bcc576a..8774f7ef 100644 --- a/sinch/domains/authentication/__init__.py +++ b/sinch/domains/authentication/__init__.py @@ -20,11 +20,3 @@ def get_auth_token(self): def set_auth_token(self, token): self.sinch.configuration.token_manager.set_auth_token(token) - - -class AuthenticationAsync(AuthenticationBase): - async def get_auth_token(self): - return await self.sinch.configuration.token_manager.get_auth_token() - - async def set_auth_token(self, token): - return await self.sinch.configuration.token_manager.set_auth_token() diff --git a/sinch/domains/conversation/__init__.py b/sinch/domains/conversation/__init__.py index 62f823dd..8b6035bc 100644 --- a/sinch/domains/conversation/__init__.py +++ b/sinch/domains/conversation/__init__.py @@ -1,6 +1,6 @@ from typing import List -from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.conversation.models import ( SinchConversationChannelIdentities, @@ -261,36 +261,6 @@ def list( ) -class ConversationMessageWithAsyncPagination(ConversationMessage): - async def list( - self, - conversation_id: str = None, - contact_id: str = None, - app_id: str = None, - page_size: int = None, - page_token: str = None, - view: str = None, - messages_source: str = None, - only_recipient_originated: bool = None - ) -> ListConversationMessagesResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationMessagesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationMessagesRequest( - contact_id=contact_id, - conversation_id=conversation_id, - app_id=app_id, - page_size=page_size, - page_token=page_token, - view=view, - messages_source=messages_source, - only_recipient_originated=only_recipient_originated - ) - ) - ) - - class ConversationApp: def __init__(self, sinch): self._sinch = sinch @@ -554,30 +524,6 @@ def get_channel_profile( ) -class ConversationContactWithAsyncPagination(ConversationContact): - async def list( - self, - page_size: int = None, - page_token: str = None, - external_id: str = None, - channel: str = None, - identity: str = None - ) -> ListConversationContactsResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationContactRequest( - page_size=page_size, - page_token=page_token, - external_id=external_id, - channel=channel, - identity=identity - ) - ) - ) - - class ConversationEvent: def __init__(self, sinch): self._sinch = sinch @@ -1017,30 +963,6 @@ def inject_message_to_conversation( ) -class ConversationConversationWithAsyncPagination(ConversationConversation): - async def list( - self, - only_active: bool, - page_size: int = None, - page_token: str = None, - app_id: str = None, - contact_id: str = None - ) -> SinchListConversationsResponse: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationsRequest( - only_active=only_active, - page_size=page_size, - page_token=page_token, - app_id=app_id, - contact_id=contact_id - ) - ) - ) - - class ConversationBase: """ Documentation for the Conversation API: https://developers.sinch.com/docs/conversation/ @@ -1069,24 +991,3 @@ def __init__(self, sinch): self.template = ConversationTemplate(self._sinch) self.webhook = ConversationWebhook(self._sinch) self.conversation = ConversationConversation(self._sinch) - - -class ConversationAsync(ConversationBase): - """ - Asynchronous version of the Conversation Domain - """ - __doc__ += ConversationBase.__doc__ - - def __init__(self, sinch): - super(ConversationAsync, self).__init__(sinch) - self.message = ConversationMessageWithAsyncPagination(self._sinch) - self.app = ConversationApp(self._sinch) - self.contact = ConversationContactWithAsyncPagination(self._sinch) - self.event = ConversationEvent(self._sinch) - self.transcoding = ConversationTranscoding(self._sinch) - self.opt_in = ConversationOptIn(self._sinch) - self.opt_out = ConversationOptOut(self._sinch) - self.capability = ConversationCapability(self._sinch) - self.template = ConversationTemplate(self._sinch) - self.webhook = ConversationWebhook(self._sinch) - self.conversation = ConversationConversationWithAsyncPagination(self._sinch) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 591e190c..8dae6a6b 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,7 +1,5 @@ from sinch.domains.numbers.api.v1 import AvailableNumbers -from sinch.domains.numbers.api.v1 import ( - ActiveNumbers, ActiveNumbersWithAsyncPagination -) +from sinch.domains.numbers.api.v1 import ActiveNumbers from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.regions.list_available_regions import ListAvailableRegionsEndpoint @@ -85,17 +83,3 @@ def __init__(self, sinch): self.regions = AvailableRegions(self._sinch) self.active = ActiveNumbers(self._sinch) self.callbacks = Callbacks(self._sinch) - - -class NumbersAsync(NumbersBase): - """ - Asynchronous version of the Numbers Domain - """ - __doc__ += NumbersBase.__doc__ - - def __init__(self, sinch): - super(NumbersAsync, self).__init__(sinch) - self.available = AvailableNumbers(self._sinch) - self.regions = AvailableRegions(self._sinch) - self.active = ActiveNumbersWithAsyncPagination(self._sinch) - self.callbacks = Callbacks(self._sinch) diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index 07ba7637..e86e6aae 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -1,8 +1,7 @@ -from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers, ActiveNumbersWithAsyncPagination +from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers __all__ = [ "ActiveNumbers", - "ActiveNumbersWithAsyncPagination", "AvailableNumbers" ] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 858e45e8..7a574a1a 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,6 +1,6 @@ from typing import Optional, overload from pydantic import StrictStr, StrictInt -from sinch.core.pagination import TokenBasedPaginator, AsyncTokenBasedPaginator, Paginator +from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, @@ -215,34 +215,3 @@ def release( **kwargs ) return self._request(ReleaseNumberFromProjectEndpoint, request_data) - - -class ActiveNumbersWithAsyncPagination(ActiveNumbers): - async def list( - self, - region_code: StrictStr, - number_type: NumberTypeValues, - number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, - page_size: Optional[StrictInt] = None, - page_token: Optional[StrictStr] = None, - order_by: Optional[OrderByValues] = None, - **kwargs - ) -> Paginator[ActiveNumber]: - return await AsyncTokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListActiveNumbersEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListActiveNumbersRequest( - region_code=region_code, - number_type=number_type, - page_size=page_size, - capabilities=capabilities, - number_pattern=number_pattern, - number_search_pattern=number_search_pattern, - page_token=page_token, - order_by=order_by, - ) - ) - ) diff --git a/sinch/domains/sms/__init__.py b/sinch/domains/sms/__init__.py index d2223066..a1ebb8f8 100644 --- a/sinch/domains/sms/__init__.py +++ b/sinch/domains/sms/__init__.py @@ -1,5 +1,4 @@ from sinch.core.pagination import IntBasedPaginator -from sinch.core.pagination import AsyncIntBasedPaginator from sinch.domains.sms.endpoints.batches.send_batch import SendBatchSMSEndpoint from sinch.domains.sms.endpoints.batches.list_batches import ListSMSBatchesEndpoint @@ -159,34 +158,6 @@ def get_for_number( ) -class SMSDeliveryReportsWithAsyncPagination(SMSDeliveryReports): - async def list( - self, - page: int = 0, - start_date: str = None, - end_date: str = None, - status: str = None, - code: str = None, - page_size: int = None, - client_reference: str = None - ) -> ListSMSDeliveryReportsResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListDeliveryReportsEndpoint( - sinch=self._sinch, - request_data=ListSMSDeliveryReportsRequest( - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - status=status, - code=code, - client_reference=client_reference - ) - ) - ) - - class SMSInbounds: def __init__(self, sinch): self._sinch = sinch @@ -226,32 +197,6 @@ def get(self, inbound_id: str) -> GetInboundMessagesResponse: ) -class SMSInboundsWithAsyncPagination(SMSInbounds): - async def list( - self, - page: int = 0, - start_date: str = None, - to: str = None, - end_date: str = None, - page_size: int = None, - client_reference: str = None - ) -> SinchListInboundMessagesResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListInboundMessagesEndpoint( - sinch=self._sinch, - request_data=ListSMSInboundMessageRequest( - page=page, - page_size=page_size, - to=to, - end_date=end_date, - start_date=start_date, - client_reference=client_reference - ) - ) - ) - - class SMSBatches: def __init__(self, sinch): self._sinch = sinch @@ -468,32 +413,6 @@ def send_delivery_feedback( ) -class SMSBatchesWithAsyncPagination(SMSBatches): - async def list( - self, - page: int = 0, - page_size: int = None, - from_s: str = None, - start_date: str = None, - end_date: str = None, - client_reference: str = None - ) -> ListSMSBatchesResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSBatchesEndpoint( - sinch=self._sinch, - request_data=ListBatchesRequest( - page=page, - page_size=page_size, - from_s=from_s, - start_date=start_date, - end_date=end_date, - client_reference=client_reference - ) - ) - ) - - class SMSGroups: def __init__(self, sinch): self._sinch = sinch @@ -615,24 +534,6 @@ def replace( ) -class SMSGroupsWithAsyncPagination(SMSGroups): - async def list( - self, - page=0, - page_size=None - ) -> SinchListSMSGroupResponse: - return await AsyncIntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSGroupEndpoint( - sinch=self._sinch, - request_data=ListSMSGroupRequest( - page=page, - page_size=page_size - ) - ) - ) - - class SMSBase: """ Documentation for the SMS API: https://developers.sinch.com/docs/sms/ @@ -653,17 +554,3 @@ def __init__(self, sinch): self.batches = SMSBatches(self._sinch) self.inbounds = SMSInbounds(self._sinch) self.delivery_reports = SMSDeliveryReports(self._sinch) - - -class SMSAsync(SMSBase): - """ - Asynchronous version of the SMS Domain - """ - __doc__ += SMSBase.__doc__ - - def __init__(self, sinch): - super(SMSAsync, self).__init__(sinch) - self.groups = SMSGroupsWithAsyncPagination(self._sinch) - self.batches = SMSBatchesWithAsyncPagination(self._sinch) - self.inbounds = SMSInboundsWithAsyncPagination(self._sinch) - self.delivery_reports = SMSDeliveryReportsWithAsyncPagination(self._sinch) diff --git a/sinch/domains/verification/__init__.py b/sinch/domains/verification/__init__.py index a8231116..1a39699d 100644 --- a/sinch/domains/verification/__init__.py +++ b/sinch/domains/verification/__init__.py @@ -343,15 +343,3 @@ def __init__(self, sinch): super(Verification, self).__init__(sinch) self.verifications = Verifications(self._sinch) self.verification_status = VerificationStatus(self._sinch) - - -class VerificationAsync(VerificationBase): - """ - Asynchronous version of the Verification Domain - """ - __doc__ += VerificationBase.__doc__ - - def __init__(self, sinch): - super(VerificationAsync, self).__init__(sinch) - self.verifications = Verifications(self._sinch) - self.verification_status = VerificationStatus(self._sinch) diff --git a/sinch/domains/voice/__init__.py b/sinch/domains/voice/__init__.py index 8f997afa..7cc1c647 100644 --- a/sinch/domains/voice/__init__.py +++ b/sinch/domains/voice/__init__.py @@ -410,17 +410,3 @@ def __init__(self, sinch): self.calls = Calls(self._sinch) self.conferences = Conferences(self._sinch) self.applications = Applications(self._sinch) - - -class VoiceAsync(VoiceBase): - """ - Asynchronous version of the Voice Domain - """ - __doc__ += VoiceBase.__doc__ - - def __init__(self, sinch): - super().__init__(sinch) - self.callouts = Callouts(self._sinch) - self.calls = Calls(self._sinch) - self.conferences = Conferences(self._sinch) - self.applications = Applications(self._sinch) diff --git a/tests/conftest.py b/tests/conftest.py index dbc32620..ce341d79 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest -from sinch import SinchClient, SinchClientAsync +from sinch import SinchClient from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.authentication import OAuthToken @@ -257,39 +257,6 @@ def sinch_client_sync( ) -@pytest.fixture -def sinch_client_async( - key_id, - key_secret, - application_key, - application_secret, - numbers_origin, - conversation_origin, - templates_origin, - auth_origin, - sms_origin, - verification_origin, - voice_origin, - project_id -): - return configure_origin( - SinchClientAsync( - key_id=key_id, - key_secret=key_secret, - project_id=project_id, - application_key=application_key, - application_secret=application_secret - ), - numbers_origin, - conversation_origin, - templates_origin, - auth_origin, - sms_origin, - verification_origin, - voice_origin - ) - - @pytest.fixture def mock_sinch_client_numbers(): class MockConfiguration: diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b950f85a..76b86c72 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,31 +1,24 @@ -import pytest -from sinch import SinchClient, SinchClientAsync +from sinch import SinchClient from sinch.core.clients.sinch_client_configuration import Configuration -@pytest.mark.parametrize("client", [SinchClient, SinchClientAsync]) -def test_sinch_client_initialization(client): - """ Test that SinchClient and SinchClientAsync can be initialized with or without parameters """ - sinch_client = client( +def test_sinch_client_initialization(): + """ Test that SinchClient can be initialized with or without parameters """ + sinch_client = SinchClient( key_id="test", key_secret="test_secret", project_id="test_project_id" ) assert sinch_client - sinch_client_empty = client() - assert sinch_client_empty - -@pytest.mark.parametrize("client", ["sinch_client_sync", "sinch_client_async"]) -def test_sinch_client_expects_all_attributes(request, client): - """ Test that SinchClient and SinchClientAsync have all attributes""" - client_instance = request.getfixturevalue(client) - assert hasattr(client_instance, "authentication") - assert hasattr(client_instance, "sms") - assert hasattr(client_instance, "conversation") - assert hasattr(client_instance, "numbers") - assert hasattr(client_instance, "verification") - assert hasattr(client_instance, "voice") - assert hasattr(client_instance, "configuration") - assert isinstance(client_instance.configuration, Configuration) +def test_sinch_client_expects_all_attributes(sinch_client_sync): + """ Test that SinchClient has all attributes""" + assert hasattr(sinch_client_sync, "authentication") + assert hasattr(sinch_client_sync, "sms") + assert hasattr(sinch_client_sync, "conversation") + assert hasattr(sinch_client_sync, "numbers") + assert hasattr(sinch_client_sync, "verification") + assert hasattr(sinch_client_sync, "voice") + assert hasattr(sinch_client_sync, "configuration") + assert isinstance(sinch_client_sync.configuration, Configuration) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 2f93e520..d6af9ff1 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,24 +1,11 @@ -import pytest from logging import Logger, getLogger from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.adapters.requests_http_transport import HTTPTransportRequests -from sinch.core.adapters.httpx_adapter import HTTPXTransport -from sinch.core.token_manager import TokenManager, TokenManagerAsync - - -@pytest.mark.parametrize( - "transport_class, token_manager_class, client_fixture", - [ - (HTTPTransportRequests, TokenManager, "sinch_client_sync"), - (HTTPXTransport, TokenManagerAsync, "sinch_client_async") - ] -) -def test_configuration_happy_capy_expects_initialization( - request, transport_class, token_manager_class, client_fixture -): - """ Test that Configuration can be initialized with all parameters """ - sinch_client = request.getfixturevalue(client_fixture) +from sinch.core.token_manager import TokenManager + +def test_configuration_happy_capy_expects_initialization(sinch_client_sync): + """ Test that Configuration can be initialized with all parameters """ client_configuration = Configuration( key_id="CapyKey", key_secret="CapybaraWhisper", @@ -29,8 +16,8 @@ def test_configuration_happy_capy_expects_initialization( application_secret="SecretHabitatEntry", service_plan_id="CappyPremiumPlan", sms_api_token="HappyCappyToken", - transport=transport_class(sinch_client), - token_manager=token_manager_class(sinch_client) + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync) ) assert client_configuration.key_id == "CapyKey" @@ -41,8 +28,8 @@ def test_configuration_happy_capy_expects_initialization( assert client_configuration.application_secret == "SecretHabitatEntry" assert client_configuration.service_plan_id == "CappyPremiumPlan" assert client_configuration.sms_api_token == "HappyCappyToken" - assert isinstance(client_configuration.transport, transport_class) - assert isinstance(client_configuration.token_manager, token_manager_class) + assert isinstance(client_configuration.transport, HTTPTransportRequests) + assert isinstance(client_configuration.token_manager, TokenManager) def test_set_sms_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): @@ -72,15 +59,15 @@ def test_set_conversation_domain_property_and_check_that_sms_origin_was_updated( assert "hurts" in sinch_client_sync.configuration.conversation_origin -def test_if_logger_name_was_preserved_correctly(sinch_client_async): +def test_if_logger_name_was_preserved_correctly(sinch_client_sync): clever_monty_python_quote = "Its_just_a_flesh_wound" client_configuration = Configuration( key_id="Do", key_secret="a", project_id="Kickflip!", logger_name=clever_monty_python_quote, - transport=HTTPTransportRequests(sinch_client_async), - token_manager=TokenManager(sinch_client_async) + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync) ) client_configuration.logger.name = clever_monty_python_quote assert client_configuration.logger.name == clever_monty_python_quote diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index b2d53c20..6d846ce0 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,10 +1,7 @@ -import pytest -from unittest.mock import Mock, AsyncMock +from unittest.mock import Mock from sinch.core.pagination import ( IntBasedPaginator, - AsyncIntBasedPaginator, - TokenBasedPaginator, - AsyncTokenBasedPaginator + TokenBasedPaginator ) @@ -73,80 +70,13 @@ def test_page_int_iterator_sync_using_auto_pagination( assert page_counter == 2 -async def test_page_int_iterator_async_using_manual_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data -): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - int_based_paginator = await AsyncIntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert int_based_paginator - - page_counter = 0 - assert int_based_paginator.result.page == page_counter - - while int_based_paginator.has_next_page: - int_based_paginator = await int_based_paginator.next_page() - page_counter += 1 - assert int_based_paginator.result.page == page_counter - - assert page_counter == 2 - - -async def test_page_int_iterator_async_using_auto_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data -): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = AsyncMock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - - int_based_paginator = await AsyncIntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint - ) - assert int_based_paginator - - page_counter = 0 - assert int_based_paginator.result.page == page_counter - - async for page in int_based_paginator.auto_paging_iter(): - page_counter += 1 - assert isinstance(page, AsyncIntBasedPaginator) - - assert page_counter == 3 - assert not int_based_paginator.result.pig_dogs - - # Helper function to initialize token paginator -def initialize_token_paginator(endpoint_mock, request_data, responses, is_async=False): - client = AsyncMock() if is_async else Mock() +def initialize_token_paginator(endpoint_mock, request_data, responses): + client = Mock() client.configuration.transport.request.side_effect = responses endpoint_mock.request_data = request_data - if is_async: - return AsyncTokenBasedPaginator._initialize(sinch=client, endpoint=endpoint_mock) return TokenBasedPaginator(sinch=client, endpoint=endpoint_mock) @@ -198,57 +128,3 @@ def test_page_token_iterator_sync_using_auto_pagination_expects_iter( assert len(active_numbers_list) == len(mock_pagination_expected_phone_numbers_response) assert active_numbers_list == mock_pagination_expected_phone_numbers_response - - -@pytest.mark.asyncio -async def test_page_token_iterator_async_using_manual_pagination_expects_iter( - token_based_pagination_request_data, - mock_pagination_active_number_responses, - mock_pagination_expected_phone_numbers_response -): - """Test that the async pagination iterates correctly through multiple items.""" - async_token_based_paginator = await initialize_token_paginator( - endpoint_mock=AsyncMock(), - request_data=token_based_pagination_request_data, - responses=mock_pagination_active_number_responses, - is_async=True - ) - assert async_token_based_paginator is not None - - active_numbers_list = [] - page_counter = 1 - reached_last_page = False - while not reached_last_page: - active_numbers_list.extend([num.phone_number for num in async_token_based_paginator.content()]) - if async_token_based_paginator.has_next_page: - async_token_based_paginator = await async_token_based_paginator.next_page() - page_counter += 1 - assert isinstance(async_token_based_paginator, AsyncTokenBasedPaginator) - else: - reached_last_page = True - - assert page_counter == 3 - assert active_numbers_list == mock_pagination_expected_phone_numbers_response - - -@pytest.mark.asyncio -async def test_page_token_iterator_async_using_auto_pagination_expects_iter( - token_based_pagination_request_data, - mock_pagination_active_number_responses, - mock_pagination_expected_phone_numbers_response -): - """Test that the async pagination iterates correctly through multiple items.""" - async_token_based_paginator = await initialize_token_paginator( - endpoint_mock=AsyncMock(), - request_data=token_based_pagination_request_data, - responses=mock_pagination_active_number_responses, - is_async=True - ) - assert async_token_based_paginator is not None - - active_numbers_list = [] - async for number in async_token_based_paginator.iterator(): - active_numbers_list.append(number.phone_number) - - assert len(active_numbers_list) == len(mock_pagination_expected_phone_numbers_response) - assert active_numbers_list == mock_pagination_expected_phone_numbers_response diff --git a/tests/unit/test_token_manager.py b/tests/unit/test_token_manager.py index 4bd316b5..2eb24802 100644 --- a/tests/unit/test_token_manager.py +++ b/tests/unit/test_token_manager.py @@ -1,7 +1,7 @@ import pytest -from unittest.mock import Mock, AsyncMock +from unittest.mock import Mock -from sinch.core.token_manager import TokenManager, TokenManagerAsync +from sinch.core.token_manager import TokenManager from sinch.domains.authentication.models.authentication import OAuthToken from sinch.core.exceptions import ValidationException @@ -30,13 +30,3 @@ def test_get_auth_token_and_check_if_cached(sinch_client_sync, auth_token): assert isinstance(access_token, OAuthToken) assert token_manager.token is auth_token - - -async def test_get_auth_token_and_check_if_cached_async(sinch_client_async, auth_token): - sinch_client_async = AsyncMock() - sinch_client_async.configuration.transport.request.return_value = auth_token - token_manager = TokenManagerAsync(sinch_client_async) - access_token = await token_manager.get_auth_token() - - assert isinstance(access_token, OAuthToken) - assert token_manager.token is auth_token From 5eb33b99493029815c14c7506512320d49bfce4f Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Mon, 31 Mar 2025 09:14:06 +0200 Subject: [PATCH 036/106] DEVEXP-783: Numbers API - Available Regions (#56) --- .github/workflows/run-tests.yml | 1 + sinch/domains/numbers/__init__.py | 35 +-------- sinch/domains/numbers/api/v1/__init__.py | 4 +- .../numbers/api/v1/available_regions_apis.py | 43 +++++++++++ .../numbers/api/v1/internal/__init__.py | 2 + .../internal/available_regions_endpoints.py | 30 ++++++++ .../numbers/endpoints/regions/__init__.py | 0 .../regions/list_available_regions.py | 46 ------------ .../numbers/models/regions/__init__.py | 10 --- .../numbers/models/regions/requests.py | 9 --- .../numbers/models/regions/responses.py | 10 --- .../numbers/models/v1/internal/__init__.py | 8 ++ .../list_available_regions_request.py | 8 ++ .../list_available_regions_response.py | 16 ++++ .../numbers/models/v1/response/__init__.py | 2 + .../models/v1/response/available_region.py | 10 +++ .../numbers/models/v1/types/__init__.py | 4 + .../models/v1/types/number_types_regions.py | 6 ++ .../features/steps/available-regions.steps.py | 57 +++++++++++++++ .../test_list_available_regions_endpoint.py | 73 +++++++++++++++++++ ...st_list_available_regions_request_model.py | 33 +++++++++ ...t_list_available_regions_response_model.py | 48 ++++++++++++ .../response/test_available_region_model.py | 50 +++++++++++++ 23 files changed, 397 insertions(+), 108 deletions(-) create mode 100644 sinch/domains/numbers/api/v1/available_regions_apis.py create mode 100644 sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py delete mode 100644 sinch/domains/numbers/endpoints/regions/__init__.py delete mode 100644 sinch/domains/numbers/endpoints/regions/list_available_regions.py delete mode 100644 sinch/domains/numbers/models/regions/__init__.py delete mode 100644 sinch/domains/numbers/models/regions/requests.py delete mode 100644 sinch/domains/numbers/models/regions/responses.py create mode 100644 sinch/domains/numbers/models/v1/internal/list_available_regions_request.py create mode 100644 sinch/domains/numbers/models/v1/internal/list_available_regions_response.py create mode 100644 sinch/domains/numbers/models/v1/response/available_region.py create mode 100644 sinch/domains/numbers/models/v1/types/number_types_regions.py create mode 100644 tests/e2e/numbers/features/steps/available-regions.steps.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py create mode 100644 tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py create mode 100644 tests/unit/domains/numbers/v1/models/response/test_available_region_model.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e05cfa46..e2a10dc3 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -70,6 +70,7 @@ jobs: - name: Copy feature files run: | cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/available-regions.feature ./tests/e2e/numbers/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 8dae6a6b..f3c8310b 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,11 +1,8 @@ -from sinch.domains.numbers.api.v1 import AvailableNumbers -from sinch.domains.numbers.api.v1 import ActiveNumbers +from sinch.domains.numbers.api.v1 import ( + ActiveNumbers, AvailableNumbers, AvailableRegions +) from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint -from sinch.domains.numbers.endpoints.regions.list_available_regions import ListAvailableRegionsEndpoint - -from sinch.domains.numbers.models.regions.requests import ListAvailableRegionsForProjectRequest -from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse from sinch.domains.numbers.models.callbacks.responses import ( GetNumbersCallbackConfigurationResponse, UpdateNumbersCallbackConfigurationResponse @@ -15,32 +12,6 @@ ) -class AvailableRegions: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - number_type: str = None, - number_types: list = None - ) -> ListAvailableRegionsResponse: - """ - Lists all regions for numbers provided using the project ID. - Some numbers can be configured for multiple regions. - See which regions apply to your virtual number. - For additional documentation, see https://www.sinch.com and visit our developer portal. - """ - return self._sinch.configuration.transport.request( - ListAvailableRegionsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListAvailableRegionsForProjectRequest( - number_type=number_type, - number_types=number_types - ) - ) - ) - - class Callbacks: def __init__(self, sinch): self._sinch = sinch diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index e86e6aae..4f1c6325 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -1,7 +1,9 @@ from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers +from sinch.domains.numbers.api.v1.available_regions_apis import AvailableRegions __all__ = [ "ActiveNumbers", - "AvailableNumbers" + "AvailableNumbers", + "AvailableRegions" ] diff --git a/sinch/domains/numbers/api/v1/available_regions_apis.py b/sinch/domains/numbers/api/v1/available_regions_apis.py new file mode 100644 index 00000000..308cbb88 --- /dev/null +++ b/sinch/domains/numbers/api/v1/available_regions_apis.py @@ -0,0 +1,43 @@ +from typing import Optional +from sinch.core.pagination import TokenBasedPaginator, Paginator +from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest +from sinch.domains.numbers.models.v1.response import AvailableRegion +from sinch.domains.numbers.models.v1.types import NumberTypesRegionsValuesList + + +class AvailableRegions: + def __init__(self, sinch): + self._sinch = sinch + + def list( + self, + types: Optional[NumberTypesRegionsValuesList] = None, + **kwargs + ) -> Paginator[AvailableRegion]: + """ + Lists all regions for numbers provided using the project ID. + Some numbers can be configured for multiple regions. + See which regions apply to your virtual number. + + :param types: List of number types to filter the regions. + :type types: Optional[NumberTypesRegionsValuesList] + + :param kwargs: Additional parameters for the request. + :type kwargs: Optional[dict] + + :return: A paginator object containing the list of available regions. + :rtype: Paginator[Region] + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListAvailableRegionsEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListAvailableRegionsRequest( + types=types, + **kwargs + ) + ) + ) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 85712fa3..dc4c31d8 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -5,12 +5,14 @@ from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) +from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint __all__ = [ "ActivateNumberEndpoint", "AvailableNumbersEndpoint", "GetNumberConfigurationEndpoint", "ListActiveNumbersEndpoint", + "ListAvailableRegionsEndpoint", "ReleaseNumberFromProjectEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", diff --git a/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py new file mode 100644 index 00000000..55bf86a1 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py @@ -0,0 +1,30 @@ +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest, ListAvailableRegionsResponse + + +class ListAvailableRegionsEndpoint(NumbersEndpoint): + """ + Endpoint to list all the regions that have numbers assigned to a project + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableRegions" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListAvailableRegionsRequest): + super(ListAvailableRegionsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response(self, response: HTTPResponse) -> ListAvailableRegionsResponse: + try: + super(ListAvailableRegionsEndpoint, self).handle_response(response) + except NumbersException as ex: + raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, + is_from_server=ex.is_from_server) + return self.process_response_model(response.body, ListAvailableRegionsResponse) diff --git a/sinch/domains/numbers/endpoints/regions/__init__.py b/sinch/domains/numbers/endpoints/regions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/numbers/endpoints/regions/list_available_regions.py b/sinch/domains/numbers/endpoints/regions/list_available_regions.py deleted file mode 100644 index ced93bde..00000000 --- a/sinch/domains/numbers/endpoints/regions/list_available_regions.py +++ /dev/null @@ -1,46 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.regions import Region - -from sinch.domains.numbers.models.regions.responses import ListAvailableRegionsResponse -from sinch.domains.numbers.models.regions.requests import ListAvailableRegionsForProjectRequest - - -class ListAvailableRegionsEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableRegions" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListAvailableRegionsForProjectRequest): - super(ListAvailableRegionsEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def build_query_params(self) -> dict: - query_params = {} - if self.request_data.number_type: - query_params["type"] = self.request_data.number_type - - if self.request_data.number_types: - query_params["types"] = self.request_data.number_types - - return query_params - - def handle_response(self, response: HTTPResponse) -> ListAvailableRegionsResponse: - super(ListAvailableRegionsEndpoint, self).handle_response(response) - return ListAvailableRegionsResponse( - [ - Region( - region_code=region["regionCode"], - region_name=region["regionName"], - types=region["types"] - ) for region in response.body["availableRegions"] - ] - ) diff --git a/sinch/domains/numbers/models/regions/__init__.py b/sinch/domains/numbers/models/regions/__init__.py deleted file mode 100644 index 0db21448..00000000 --- a/sinch/domains/numbers/models/regions/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class Region(SinchBaseModel): - region_code: str - region_name: str - types: list diff --git a/sinch/domains/numbers/models/regions/requests.py b/sinch/domains/numbers/models/regions/requests.py deleted file mode 100644 index 6d44c2be..00000000 --- a/sinch/domains/numbers/models/regions/requests.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListAvailableRegionsForProjectRequest(SinchRequestBaseModel): - number_type: str - number_types: list diff --git a/sinch/domains/numbers/models/regions/responses.py b/sinch/domains/numbers/models/regions/responses.py deleted file mode 100644 index 9c80879f..00000000 --- a/sinch/domains/numbers/models/regions/responses.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.numbers.models.regions import Region - - -@dataclass -class ListAvailableRegionsResponse(SinchBaseModel): - available_regions: List[Region] diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index 6453bf28..123767ef 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -3,6 +3,12 @@ from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal.list_available_regions_request import ( + ListAvailableRegionsRequest +) +from sinch.domains.numbers.models.v1.internal.list_available_regions_response import ( + ListAvailableRegionsResponse +) from sinch.domains.numbers.models.v1.internal.number_request import NumberRequest from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest @@ -20,6 +26,8 @@ "ListAvailableNumbersRequest", "ListActiveNumbersResponse", "ListAvailableNumbersResponse", + "ListAvailableRegionsRequest", + "ListAvailableRegionsResponse", "NumberRequest", "RentAnyNumberRequest", "SmsConfigurationRequest", diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py new file mode 100644 index 00000000..23bc56fd --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -0,0 +1,8 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.types import NumberTypesRegionsValuesList + + +class ListAvailableRegionsRequest(BaseModelConfigRequest): + types: Optional[NumberTypesRegionsValuesList] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py new file mode 100644 index 00000000..c6c61ef0 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py @@ -0,0 +1,16 @@ +from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field +from sinch.domains.numbers.models.v1.response import AvailableRegion + + +class ListAvailableRegionsResponse(BaseModel): + available_regions: Optional[List[AvailableRegion]] = Field(default=None, alias="availableRegions") + + model_config = ConfigDict( + populate_by_name=True + ) + + @property + def content(self): + """Returns the available regions as part of the response object to be used in the pagination.""" + return self.available_regions or [] diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index b87880a7..c57553ab 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -1,11 +1,13 @@ from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber +from sinch.domains.numbers.models.v1.response.available_region import AvailableRegion from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse __all__ = [ "ActiveNumber", "AvailableNumber", + "AvailableRegion", "CheckNumberAvailabilityResponse", "RentAnyNumberResponse", ] diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py new file mode 100644 index 00000000..7ac19e18 --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import StrictStr, Field, conlist +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.types import NumberType + + +class AvailableRegion(BaseModelConfigResponse): + region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + region_name: Optional[StrictStr] = Field(default=None, alias="regionName") + types: Optional[conlist(NumberType, min_length=1)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 0e01b321..c5dd2e40 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -5,6 +5,9 @@ NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues ) from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues +from sinch.domains.numbers.models.v1.types.number_types_regions import ( + NumberTypesRegionsValuesList +) from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning @@ -21,6 +24,7 @@ "NumberSearchPatternTypeValues", "NumberType", "NumberTypeValues", + "NumberTypesRegionsValuesList", "OrderByValues", "SmsConfigurationDict", "StatusScheduledProvisioning", diff --git a/sinch/domains/numbers/models/v1/types/number_types_regions.py b/sinch/domains/numbers/models/v1/types/number_types_regions.py new file mode 100644 index 00000000..b37be594 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/number_types_regions.py @@ -0,0 +1,6 @@ +from pydantic import conlist, StrictStr +from typing import Literal, Union + +NumberTypesRegionsValuesList = conlist( + Union[Literal["MOBILE", "LOCAL", "TOLL_FREE", "NUMBER_TYPE_UNSPECIFIED"], StrictStr], min_length=1 +) diff --git a/tests/e2e/numbers/features/steps/available-regions.steps.py b/tests/e2e/numbers/features/steps/available-regions.steps.py new file mode 100644 index 00000000..b28b6a4f --- /dev/null +++ b/tests/e2e/numbers/features/steps/available-regions.steps.py @@ -0,0 +1,57 @@ +from behave import given, when, then + + +def count_region_type(regions, number_types): + """Count the number of regions that have a specific number type.""" + return sum(number_types in region.types for region in regions if region.types) + + +@given('the Numbers service "Regions" is available') +def step_regions_service_is_available(context): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + + +@when('I send a request to list all the regions') +def step_list_all_regions(context): + response = context.sinch.numbers.regions.list() + context.response = response.content() + + +@when('I send a request to list the TOLL_FREE regions') +def step_list_toll_free_regions(context): + response = context.sinch.numbers.regions.list( + types=['TOLL_FREE'] + ) + context.response = response.content() + + +@when('I send a request to list the TOLL_FREE or MOBILE regions') +def step_list_toll_free_or_mobile_regions(context): + response = context.sinch.numbers.regions.list( + types=['TOLL_FREE', 'MOBILE'] + ) + context.response = response.content() + + +@then('the response contains "{count}" regions') +def step_check_regions_count(context, count): + assert len(context.response) == int(count), f'Expected {count}, got {len(context.response)}' + + +@then('the response contains "{count}" TOLL_FREE regions') +def step_check_toll_free_regions_count(context, count): + toll_free_count = count_region_type(context.response, 'TOLL_FREE') + assert toll_free_count == int(count), f'Expected {count}, got {toll_free_count}' + + +@then('the response contains "{count}" MOBILE regions') +def step_check_mobile_regions_count(context, count): + mobile_count = count_region_type(context.response, 'MOBILE') + assert mobile_count == int(count), f'Expected {count}, got {mobile_count}' + + +@then('the response contains "{count}" LOCAL regions') +def step_check_local_regions_count(context, count): + local_count = count_region_type(context.response, 'LOCAL') + assert local_count == int(count), f'Expected {count}, got {local_count}' diff --git a/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py new file mode 100644 index 00000000..c5d3e588 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/regions/test_list_available_regions_endpoint.py @@ -0,0 +1,73 @@ +import pytest +from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest, ListAvailableRegionsResponse +from sinch.core.models.http_response import HTTPResponse + + +@pytest.fixture +def request_data(): + return ListAvailableRegionsRequest( + types=["LOCAL", "MOBILE"] + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "availableRegions": [ + { + "regionCode": "US", + "regionName": "United States", + "types": ["LOCAL", "MOBILE", "TOLL_FREE"] + }, + { + "regionCode": "SE", + "regionName": "Sweden", + "types": ["LOCAL", "MOBILE"] + } + ] + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint(request_data): + return ListAvailableRegionsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + assert (endpoint.build_url(mock_sinch_client_numbers) == + "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/availableRegions") + + +def test_build_query_params_expects_correct_mapping(endpoint): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "types": ["LOCAL", "MOBILE"] + } + assert endpoint.build_query_params() == expected_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, ListAvailableRegionsResponse) + assert hasattr(parsed_response, "available_regions") + assert len(parsed_response.available_regions) == 2 + + region = parsed_response.available_regions[0] + assert region.region_code == "US" + assert region.region_name == "United States" + assert len(region.types) == 3 + + region = parsed_response.available_regions[1] + assert region.region_code == "SE" + assert region.region_name == "Sweden" + assert len(region.types) == 2 diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py new file mode 100644 index 00000000..6d437bf3 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_request_model.py @@ -0,0 +1,33 @@ +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest + + +def test_list_available_regions_request_expects_parsed_input(): + """ + Test that the model correctly parses input with valid number types. + """ + data = { + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + } + + request = ListAvailableRegionsRequest(**data) + assert request.types == ["MOBILE", "LOCAL", "TOLL_FREE"] + + +def test_list_available_regions_request_expects_optional_fields_handled(): + """ + Test that optional fields are properly handled when not provided. + """ + data = {} + request = ListAvailableRegionsRequest(**data) + assert request.types is None + + +def test_list_available_regions_request_expects_validation_for_extra_type(): + """ + Test that validation errors are raised for invalid number types. + """ + data = { + "types": ["EXTRA_TYPE"] + } + request = ListAvailableRegionsRequest(**data) + assert request.types == ["EXTRA_TYPE"] diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py new file mode 100644 index 00000000..a769ff55 --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_regions_response_model.py @@ -0,0 +1,48 @@ +import pytest +from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsResponse + + +@pytest.fixture +def test_data(): + return { + "availableRegions": [ + { + "regionCode": "CA", + "regionName": "Canada", + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + }, + { + "regionCode": "SE", + "regionName": "Sweden", + "types": ["MOBILE", "LOCAL"] + } + ] + } + + +def test_list_available_regions_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListAvailableRegionsResponse(**test_data) + assert hasattr(response, "content") + assert response.content == response.available_regions + + region = response.available_regions[0] + assert region.region_code == "CA" + assert region.region_name == "Canada" + assert len(region.types) == 3 + + region = response.available_regions[1] + assert region.region_code == "SE" + assert region.region_name == "Sweden" + assert len(region.types) == 2 + + +def test_list_available_regions_response_expects_empty_list_handled(): + """ + Expects empty list to be handled correctly. + """ + response = ListAvailableRegionsResponse(available_regions=[]) + assert response.available_regions == [] + assert response.content == [] diff --git a/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py new file mode 100644 index 00000000..7fd76d4f --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.response import AvailableRegion + + +@pytest.fixture +def test_data(): + return { + "regionCode": "US", + "regionName": "United States", + "types": ["MOBILE", "LOCAL", "TOLL_FREE"] + } + + +def test_available_region_expects_all_fields_mapped_correctly(test_data): + """ + Expects all fields to map correctly from camelCase input, + and handles type conversions properly + """ + response = AvailableRegion(**test_data) + + assert response.region_code == "US" + assert response.region_name == "United States" + assert len(response.types) == 3 + + +def test_available_region_expects_validation_error_on_empty_types_list(): + """ + Expects validation error when types list is empty due to min_length=1 constraint + """ + invalid_data = { + "regionCode": "US", + "regionName": "United States", + "types": [] + } + with pytest.raises(ValidationError): + AvailableRegion(**invalid_data) + + +def test_available_region_expects_validation_error_on_non_string_fields(): + """ + Expects validation error when StrictStr fields receive non-string values + """ + invalid_data = { + "regionCode": 123, + "regionName": "United States", + "types": ["MOBILE"] + } + with pytest.raises(ValidationError): + AvailableRegion(**invalid_data) From c7b5eb78a4284bd8d4fa3a980a62a3d3060f3aef Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Thu, 3 Apr 2025 10:17:15 +0200 Subject: [PATCH 037/106] DEVEXP-784: Numbers API - Callbacks (#57) - Get callback configuration - Update callback configuration --- sinch/domains/numbers/__init__.py | 35 +-------- sinch/domains/numbers/api/v1/__init__.py | 4 +- .../api/v1/callback_configuration_apis.py | 55 +++++++++++++ .../numbers/api/v1/internal/__init__.py | 5 ++ .../v1/internal/active_numbers_endpoints.py | 3 +- .../callback_configuration_endpoints.py | 66 ++++++++++++++++ sinch/domains/numbers/endpoints/__init__.py | 0 .../numbers/endpoints/callbacks/__init__.py | 0 .../endpoints/callbacks/get_configuration.py | 27 ------- .../callbacks/update_configuration.py | 32 -------- .../numbers/models/callbacks/__init__.py | 0 .../numbers/models/callbacks/requests.py | 8 -- .../numbers/models/callbacks/responses.py | 19 ----- .../numbers/models/v1/errors/error_details.py | 4 +- .../numbers/models/v1/errors/not_found.py | 6 +- .../numbers/models/v1/internal/__init__.py | 4 + .../v1/internal/activate_number_request.py | 4 +- .../models/v1/internal/base/__init__.py | 9 +-- ..._config.py => base_model_configuration.py} | 4 +- .../internal/list_active_numbers_request.py | 4 +- .../list_available_numbers_request.py | 4 +- .../list_available_regions_request.py | 4 +- .../models/v1/internal/number_request.py | 4 +- .../v1/internal/rent_any_number_request.py | 4 +- .../v1/internal/sms_configuration_request.py | 4 +- .../update_callback_configuration_request.py | 7 ++ .../update_number_configuration_request.py | 4 +- .../internal/voice_configuration_request.py | 10 +-- .../numbers/models/v1/response/__init__.py | 2 + .../models/v1/response/active_number.py | 4 +- .../models/v1/response/available_number.py | 4 +- .../models/v1/response/available_region.py | 4 +- .../check_number_availability_response.py | 4 +- .../models/v1/response/numbers_callback.py | 8 ++ .../v1/response/rent_any_number_response.py | 4 +- .../domains/numbers/models/v1/shared/money.py | 4 +- .../models/v1/shared/number_pattern.py | 4 +- .../v1/shared/scheduled_sms_provisioning.py | 4 +- .../v1/shared/scheduled_voice_provisioning.py | 4 +- .../scheduled_voice_provisioning_custom.py | 4 +- .../v1/shared/sms_configuration_response.py | 4 +- .../v1/shared/voice_configuration_response.py | 4 +- .../steps/callback-configuration.steps.py | 44 +++++++++++ .../test_get_numbers_callback_endpoint.py | 78 +++++++++++++++++++ .../test_update_numbers_callback_endpoint.py | 64 +++++++++++++++ .../internal/base/test_base_model_requests.py | 26 +++---- .../internal/base/test_base_model_response.py | 4 +- ...est_update_active_numbers_request_model.py | 15 +++- ...st_update_callback_config_request_model.py | 45 +++++++++++ .../response/test_numbers_callback_model.py | 25 ++++++ .../numbers/v1/test_available_regions.py | 35 +++++++++ .../numbers/v1/test_numbers_callback.py | 68 ++++++++++++++++ 52 files changed, 595 insertions(+), 197 deletions(-) create mode 100644 sinch/domains/numbers/api/v1/callback_configuration_apis.py create mode 100644 sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py delete mode 100644 sinch/domains/numbers/endpoints/__init__.py delete mode 100644 sinch/domains/numbers/endpoints/callbacks/__init__.py delete mode 100644 sinch/domains/numbers/endpoints/callbacks/get_configuration.py delete mode 100644 sinch/domains/numbers/endpoints/callbacks/update_configuration.py delete mode 100644 sinch/domains/numbers/models/callbacks/__init__.py delete mode 100644 sinch/domains/numbers/models/callbacks/requests.py delete mode 100644 sinch/domains/numbers/models/callbacks/responses.py rename sinch/domains/numbers/models/v1/internal/base/{base_model_config.py => base_model_configuration.py} (97%) create mode 100644 sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py create mode 100644 sinch/domains/numbers/models/v1/response/numbers_callback.py create mode 100644 tests/e2e/numbers/features/steps/callback-configuration.steps.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py create mode 100644 tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py create mode 100644 tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py create mode 100644 tests/unit/domains/numbers/v1/test_available_regions.py create mode 100644 tests/unit/domains/numbers/v1/test_numbers_callback.py diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index f3c8310b..b1b1d160 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,37 +1,6 @@ from sinch.domains.numbers.api.v1 import ( - ActiveNumbers, AvailableNumbers, AvailableRegions + ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration ) -from sinch.domains.numbers.endpoints.callbacks.get_configuration import GetNumbersCallbackConfigurationEndpoint -from sinch.domains.numbers.endpoints.callbacks.update_configuration import UpdateNumbersCallbackConfigurationEndpoint -from sinch.domains.numbers.models.callbacks.responses import ( - GetNumbersCallbackConfigurationResponse, - UpdateNumbersCallbackConfigurationResponse -) -from sinch.domains.numbers.models.callbacks.requests import ( - UpdateNumbersCallbackConfigurationRequest -) - - -class Callbacks: - def __init__(self, sinch): - self._sinch = sinch - - def get_configuration(self) -> GetNumbersCallbackConfigurationResponse: - return self._sinch.configuration.transport.request( - GetNumbersCallbackConfigurationEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def update_configuration(self, hmac_secret) -> UpdateNumbersCallbackConfigurationResponse: - return self._sinch.configuration.transport.request( - UpdateNumbersCallbackConfigurationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateNumbersCallbackConfigurationRequest( - hmac_secret=hmac_secret - ) - ) - ) class NumbersBase: @@ -53,4 +22,4 @@ def __init__(self, sinch): self.available = AvailableNumbers(self._sinch) self.regions = AvailableRegions(self._sinch) self.active = ActiveNumbers(self._sinch) - self.callbacks = Callbacks(self._sinch) + self.callback_configuration = CallbackConfiguration(self._sinch) diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index 4f1c6325..d0b7927c 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -1,9 +1,11 @@ from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers from sinch.domains.numbers.api.v1.available_regions_apis import AvailableRegions +from sinch.domains.numbers.api.v1.callback_configuration_apis import CallbackConfiguration __all__ = [ "ActiveNumbers", "AvailableNumbers", - "AvailableRegions" + "AvailableRegions", + "CallbackConfiguration" ] diff --git a/sinch/domains/numbers/api/v1/callback_configuration_apis.py b/sinch/domains/numbers/api/v1/callback_configuration_apis.py new file mode 100644 index 00000000..57c5a549 --- /dev/null +++ b/sinch/domains/numbers/api/v1/callback_configuration_apis.py @@ -0,0 +1,55 @@ +from sinch.domains.numbers.api.v1.base import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ( + GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint +) +from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +class CallbackConfiguration(BaseNumbers): + + def get( + self, + **kwargs + ) -> CallbackConfigurationResponse: + """ + Returns the callback configuration for the specified project + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The callback configuration for the project. + :rtype: NumbersCallbackConfigResponse + + For detailed documentation, visit: https://developers.sinch.com + """ + request_data = None + if kwargs: + request_data = BaseModelConfigurationRequest(**kwargs) + return self._request(GetCallbackConfigurationEndpoint, request_data) + + def update( + self, + hmac_secret, + **kwargs + ) -> CallbackConfigurationResponse: + """ + Updates the callback configuration for the specified project + + :param hmac_secret: The HMAC secret used to sign the callback requests. + :type hmac_secret: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The updated callback configuration for the project. + :rtype: NumbersCallbackConfigResponse + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = UpdateCallbackConfigurationRequest( + hmac_secret=hmac_secret, + **kwargs + ) + return self._request(UpdateCallbackConfigurationEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index dc4c31d8..9beb3892 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -6,15 +6,20 @@ ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint +from sinch.domains.numbers.api.v1.internal.callback_configuration_endpoints import ( + GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint +) __all__ = [ "ActivateNumberEndpoint", "AvailableNumbersEndpoint", + "GetCallbackConfigurationEndpoint", "GetNumberConfigurationEndpoint", "ListActiveNumbersEndpoint", "ListAvailableRegionsEndpoint", "ReleaseNumberFromProjectEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", + "UpdateCallbackConfigurationEndpoint", "UpdateNumberConfigurationEndpoint" ] diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index cd79f4e9..35f070b2 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,9 +1,8 @@ import json - +from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.numbers.models.v1.internal import ( ListActiveNumbersRequest, ListActiveNumbersResponse, NumberRequest, UpdateNumberConfigurationRequest ) diff --git a/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py b/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py new file mode 100644 index 00000000..1ab8d6e5 --- /dev/null +++ b/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py @@ -0,0 +1,66 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException +from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +class GetCallbackConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to get the callbacks configuration for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data=None): + super(GetCallbackConfigurationEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + if self.request_data: + super(GetCallbackConfigurationEndpoint, self).build_url(sinch) + return self.ENDPOINT_URL.format( + origin=sinch.configuration.numbers_origin, + project_id=self.project_id + ) + + def build_query_params(self) -> dict: + if self.request_data: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + return {} + + def handle_response(self, response: HTTPResponse) -> CallbackConfigurationResponse: + try: + super(GetCallbackConfigurationEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, CallbackConfigurationResponse) + + +class UpdateCallbackConfigurationEndpoint(NumbersEndpoint): + """ + Endpoint to update the callbacks configuration for a project. + """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: UpdateCallbackConfigurationRequest): + super(UpdateCallbackConfigurationEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> CallbackConfigurationResponse: + try: + super(UpdateCallbackConfigurationEndpoint, self).handle_response(response) + except NumbersException as e: + raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + return self.process_response_model(response.body, CallbackConfigurationResponse) diff --git a/sinch/domains/numbers/endpoints/__init__.py b/sinch/domains/numbers/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/numbers/endpoints/callbacks/__init__.py b/sinch/domains/numbers/endpoints/callbacks/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py b/sinch/domains/numbers/endpoints/callbacks/get_configuration.py deleted file mode 100644 index 693e3f07..00000000 --- a/sinch/domains/numbers/endpoints/callbacks/get_configuration.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.callbacks.responses import GetNumbersCallbackConfigurationResponse - - -class GetNumbersCallbackConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str): - super().__init__(project_id, None) - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> GetNumbersCallbackConfigurationResponse: - super().handle_response(response) - return GetNumbersCallbackConfigurationResponse( - project_id=response.body['projectId'], - hmac_secret=response.body['hmacSecret'] - ) diff --git a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py b/sinch/domains/numbers/endpoints/callbacks/update_configuration.py deleted file mode 100644 index 2051080a..00000000 --- a/sinch/domains/numbers/endpoints/callbacks/update_configuration.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.callbacks.responses import UpdateNumbersCallbackConfigurationResponse -from sinch.domains.numbers.models.callbacks.requests import UpdateNumbersCallbackConfigurationRequest - - -class UpdateNumbersCallbackConfigurationEndpoint(NumbersEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateNumbersCallbackConfigurationRequest): - super().__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.numbers_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateNumbersCallbackConfigurationResponse: - super().handle_response(response) - return UpdateNumbersCallbackConfigurationResponse( - project_id=response.body['projectId'], - hmac_secret=response.body['hmacSecret'] - ) diff --git a/sinch/domains/numbers/models/callbacks/__init__.py b/sinch/domains/numbers/models/callbacks/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/numbers/models/callbacks/requests.py b/sinch/domains/numbers/models/callbacks/requests.py deleted file mode 100644 index 621fbad7..00000000 --- a/sinch/domains/numbers/models/callbacks/requests.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class UpdateNumbersCallbackConfigurationRequest(SinchRequestBaseModel): - hmac_secret: str diff --git a/sinch/domains/numbers/models/callbacks/responses.py b/sinch/domains/numbers/models/callbacks/responses.py deleted file mode 100644 index 73fe758b..00000000 --- a/sinch/domains/numbers/models/callbacks/responses.py +++ /dev/null @@ -1,19 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class NumbersCallbackConfigurationResponse(SinchBaseModel): - project_id: str - hmac_secret: str - - -@dataclass -class GetNumbersCallbackConfigurationResponse(NumbersCallbackConfigurationResponse): - pass - - -@dataclass -class UpdateNumbersCallbackConfigurationResponse(NumbersCallbackConfigurationResponse): - pass diff --git a/sinch/domains/numbers/models/v1/errors/error_details.py b/sinch/domains/numbers/models/v1/errors/error_details.py index 858956b0..b0c40047 100644 --- a/sinch/domains/numbers/models/v1/errors/error_details.py +++ b/sinch/domains/numbers/models/v1/errors/error_details.py @@ -1,9 +1,9 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from typing import Optional from pydantic import Field, StrictStr -class ErrorDetails(BaseModelConfigResponse): +class ErrorDetails(BaseModelConfigurationResponse): type: Optional[StrictStr] = Field(default=None, alias="type") resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") diff --git a/sinch/domains/numbers/models/v1/errors/not_found.py b/sinch/domains/numbers/models/v1/errors/not_found.py index 169d8edd..1573ba80 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found.py +++ b/sinch/domains/numbers/models/v1/errors/not_found.py @@ -1,13 +1,13 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails from typing import Optional from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr -class NotFoundError(BaseModelConfigResponse): +class NotFoundError(BaseModelConfigurationResponse): code: Optional[StrictInt] = Field(default=None, alias="code") message: Optional[StrictStr] = Field(default=None, alias="message") status: Optional[StrictStr] = Field(default=None, alias="status") details: Optional[conlist(ErrorDetails, min_length=1)] = Field(default=None, alias="details") - model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigResponse._to_snake_case) + model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigurationResponse._to_snake_case) diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index 123767ef..dfa483cf 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -12,6 +12,9 @@ from sinch.domains.numbers.models.v1.internal.number_request import NumberRequest from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.update_callback_configuration_request import ( + UpdateCallbackConfigurationRequest +) from sinch.domains.numbers.models.v1.internal.update_number_configuration_request import ( UpdateNumberConfigurationRequest ) @@ -31,6 +34,7 @@ "NumberRequest", "RentAnyNumberRequest", "SmsConfigurationRequest", + "UpdateCallbackConfigurationRequest", "UpdateNumberConfigurationRequest", "VoiceConfigurationCustom", "VoiceConfigurationEST", diff --git a/sinch/domains/numbers/models/v1/internal/activate_number_request.py b/sinch/domains/numbers/models/v1/internal/activate_number_request.py index 44bd40b0..b221dba9 100644 --- a/sinch/domains/numbers/models/v1/internal/activate_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/activate_number_request.py @@ -1,10 +1,10 @@ from typing import Optional, Dict from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class ActivateNumberRequest(BaseModelConfigRequest): +class ActivateNumberRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") # Accepts only dictionary input, not Pydantic models sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py index f2f303fb..19c2b9d0 100644 --- a/sinch/domains/numbers/models/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -1,9 +1,8 @@ -from sinch.domains.numbers.models.v1.internal.base.base_model_config import ( - BaseModelConfigRequest as BaseModelConfigRequest, - BaseModelConfigResponse as BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, BaseModelConfigurationResponse ) __all__ = [ - "BaseModelConfigRequest", - "BaseModelConfigResponse", + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", ] diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_config.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py similarity index 97% rename from sinch/domains/numbers/models/v1/internal/base/base_model_config.py rename to sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py index 824d622e..c22c3107 100644 --- a/sinch/domains/numbers/models/v1/internal/base/base_model_config.py +++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict -class BaseModelConfigRequest(BaseModel): +class BaseModelConfigurationRequest(BaseModel): """ A base model that allows extra fields and converts snake_case to camelCase. """ @@ -81,7 +81,7 @@ def model_dump(self, **kwargs) -> dict: return final_dict -class BaseModelConfigResponse(BaseModel): +class BaseModelConfigurationResponse(BaseModel): """ A base model that allows extra fields and converts camelCase to snake_case """ diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 1e9494ea..aec42c55 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,13 +1,13 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import ( CapabilityType, OrderByValues, NumberSearchPatternTypeValues, NumberTypeValues, ) -class ListActiveNumbersRequest(BaseModelConfigRequest): +class ListActiveNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberTypeValues = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 4001d077..725d44d2 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,10 +1,10 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberType -class ListAvailableNumbersRequest(BaseModelConfigRequest): +class ListAvailableNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py index 23bc56fd..dd1a4bbd 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -1,8 +1,8 @@ from typing import Optional from pydantic import Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import NumberTypesRegionsValuesList -class ListAvailableRegionsRequest(BaseModelConfigRequest): +class ListAvailableRegionsRequest(BaseModelConfigurationRequest): types: Optional[NumberTypesRegionsValuesList] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/number_request.py b/sinch/domains/numbers/models/v1/internal/number_request.py index 25eb4f67..f7d2e19c 100644 --- a/sinch/domains/numbers/models/v1/internal/number_request.py +++ b/sinch/domains/numbers/models/v1/internal/number_request.py @@ -1,6 +1,6 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class NumberRequest(BaseModelConfigRequest): +class NumberRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 3a763bb7..1cbee690 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -3,10 +3,10 @@ from sinch.domains.numbers.models.v1.shared import NumberPattern from sinch.domains.numbers.models.v1.types import CapabilityType from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class RentAnyNumberRequest(BaseModelConfigRequest): +class RentAnyNumberRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(default=None, alias="regionCode") type_: StrictStr = Field(default=None, alias="type") number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") diff --git a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py index 44e2c86e..63072233 100644 --- a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py @@ -1,8 +1,8 @@ from typing import Optional from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class SmsConfigurationRequest(BaseModelConfigRequest): +class SmsConfigurationRequest(BaseModelConfigurationRequest): service_plan_id: StrictStr = Field(alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") diff --git a/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py new file mode 100644 index 00000000..6d6e14cf --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +class UpdateCallbackConfigurationRequest(BaseModelConfigurationRequest): + hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py index 8345de75..d741834b 100644 --- a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -1,10 +1,10 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -class UpdateNumberConfigurationRequest(BaseModelConfigRequest): +class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") display_name: Optional[StrictStr] = Field(default=None, alias="displayName") sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py index 4ab42179..f01685fb 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py @@ -1,24 +1,24 @@ from typing import Optional, Union, Annotated, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class VoiceConfigurationFAX(BaseModelConfigRequest): +class VoiceConfigurationFAX(BaseModelConfigurationRequest): type: Literal["FAX"] = "FAX" service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") -class VoiceConfigurationEST(BaseModelConfigRequest): +class VoiceConfigurationEST(BaseModelConfigurationRequest): type: Literal["EST"] = "EST" trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") -class VoiceConfigurationRTC(BaseModelConfigRequest): +class VoiceConfigurationRTC(BaseModelConfigurationRequest): type: Literal["RTC"] = "RTC" app_id: Optional[StrictStr] = Field(default=None, alias="appId") -class VoiceConfigurationCustom(BaseModelConfigRequest): +class VoiceConfigurationCustom(BaseModelConfigurationRequest): type: StrictStr diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index c57553ab..c80f56a0 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -2,12 +2,14 @@ from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber from sinch.domains.numbers.models.v1.response.available_region import AvailableRegion from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response.numbers_callback import CallbackConfigurationResponse from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse __all__ = [ "ActiveNumber", "AvailableNumber", "AvailableRegion", + "CallbackConfigurationResponse", "CheckNumberAvailabilityResponse", "RentAnyNumberResponse", ] diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index 98e3606b..a8ce9252 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -1,14 +1,14 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, StrictInt -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import ( Money, SmsConfigurationResponse, VoiceConfigurationResponse ) from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType -class ActiveNumber(BaseModelConfigResponse): +class ActiveNumber(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") project_id: Optional[StrictStr] = Field(default=None, alias="projectId") display_name: Optional[StrictStr] = Field(default=None, alias="displayName") diff --git a/sinch/domains/numbers/models/v1/response/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py index 754df56a..2c1d2b8c 100644 --- a/sinch/domains/numbers/models/v1/response/available_number.py +++ b/sinch/domains/numbers/models/v1/response/available_number.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictBool, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import Money from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType -class AvailableNumber(BaseModelConfigResponse): +class AvailableNumber(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py index 7ac19e18..f284d2cf 100644 --- a/sinch/domains/numbers/models/v1/response/available_region.py +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -1,10 +1,10 @@ from typing import Optional from pydantic import StrictStr, Field, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.types import NumberType -class AvailableRegion(BaseModelConfigResponse): +class AvailableRegion(BaseModelConfigurationResponse): region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") region_name: Optional[StrictStr] = Field(default=None, alias="regionName") types: Optional[conlist(NumberType, min_length=1)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/response/check_number_availability_response.py b/sinch/domains/numbers/models/v1/response/check_number_availability_response.py index 4e769653..03767ec8 100644 --- a/sinch/domains/numbers/models/v1/response/check_number_availability_response.py +++ b/sinch/domains/numbers/models/v1/response/check_number_availability_response.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import Money from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType -class CheckNumberAvailabilityResponse(BaseModelConfigResponse): +class CheckNumberAvailabilityResponse(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = None diff --git a/sinch/domains/numbers/models/v1/response/numbers_callback.py b/sinch/domains/numbers/models/v1/response/numbers_callback.py new file mode 100644 index 00000000..c9921b8b --- /dev/null +++ b/sinch/domains/numbers/models/v1/response/numbers_callback.py @@ -0,0 +1,8 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse + + +class CallbackConfigurationResponse(BaseModelConfigurationResponse): + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/models/v1/response/rent_any_number_response.py b/sinch/domains/numbers/models/v1/response/rent_any_number_response.py index 7dc202ea..0755e3ba 100644 --- a/sinch/domains/numbers/models/v1/response/rent_any_number_response.py +++ b/sinch/domains/numbers/models/v1/response/rent_any_number_response.py @@ -1,14 +1,14 @@ from datetime import datetime from typing import Optional from pydantic import Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import ( Money, SmsConfigurationResponse, VoiceConfigurationResponse ) from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberTypeValues -class RentAnyNumberResponse(BaseModelConfigResponse): +class RentAnyNumberResponse(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") project_id: Optional[StrictStr] = Field(default=None, alias="projectId") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") diff --git a/sinch/domains/numbers/models/v1/shared/money.py b/sinch/domains/numbers/models/v1/shared/money.py index c8b43a3f..f81d0ab2 100644 --- a/sinch/domains/numbers/models/v1/shared/money.py +++ b/sinch/domains/numbers/models/v1/shared/money.py @@ -1,8 +1,8 @@ from decimal import Decimal from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -class Money(BaseModelConfigResponse): +class Money(BaseModelConfigurationResponse): currency_code: StrictStr = Field(alias="currencyCode") amount: Decimal diff --git a/sinch/domains/numbers/models/v1/shared/number_pattern.py b/sinch/domains/numbers/models/v1/shared/number_pattern.py index af9ed93a..e5d47d4d 100644 --- a/sinch/domains/numbers/models/v1/shared/number_pattern.py +++ b/sinch/domains/numbers/models/v1/shared/number_pattern.py @@ -1,9 +1,9 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import NumberSearchPatternType -class NumberPattern(BaseModelConfigRequest): +class NumberPattern(BaseModelConfigurationRequest): pattern: Optional[StrictStr] search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index 1fa7cca2..ebd60cad 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -1,11 +1,11 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning -class ScheduledSmsProvisioning(BaseModelConfigResponse): +class ScheduledSmsProvisioning(BaseModelConfigurationResponse): service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py index f9e98512..4bd1adca 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py @@ -1,11 +1,11 @@ from datetime import datetime from typing import Literal, Optional from pydantic import Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning -class ScheduledVoiceProvisioning(BaseModelConfigResponse): +class ScheduledVoiceProvisioning(BaseModelConfigurationResponse): type: Literal["FAX", "EST", "RTC"] last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py index e3f89ac2..580ee7ae 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py @@ -1,6 +1,6 @@ from pydantic import StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -class ScheduledVoiceProvisioningCustom(BaseModelConfigResponse): +class ScheduledVoiceProvisioningCustom(BaseModelConfigurationResponse): type: StrictStr diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py index 0c612eb9..1daad846 100644 --- a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py @@ -1,10 +1,10 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning -class SmsConfigurationResponse(BaseModelConfigResponse): +class SmsConfigurationResponse(BaseModelConfigurationResponse): service_plan_id: StrictStr = Field(alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") scheduled_provisioning: Optional[ScheduledSmsProvisioning] = ( diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py index 4241354e..4e5c6263 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py @@ -1,14 +1,14 @@ from datetime import datetime from typing import Literal, Optional, Union from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import ( ScheduledVoiceProvisioningCustom, ScheduledVoiceProvisioningEST, ScheduledVoiceProvisioningFAX, ScheduledVoiceProvisioningRTC ) -class VoiceConfigurationResponse(BaseModelConfigResponse): +class VoiceConfigurationResponse(BaseModelConfigurationResponse): type: Union[Literal["RTC", "EST", "FAX"], StrictStr] last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningRTC, diff --git a/tests/e2e/numbers/features/steps/callback-configuration.steps.py b/tests/e2e/numbers/features/steps/callback-configuration.steps.py new file mode 100644 index 00000000..f73d453e --- /dev/null +++ b/tests/e2e/numbers/features/steps/callback-configuration.steps.py @@ -0,0 +1,44 @@ +from behave import given, when, then + +from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException +from sinch.domains.numbers.models.v1.errors import NotFoundError + + +@given('the Numbers service "Callback Configuration" is available') +def step_callback_config_service_is_available(context): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + + +@when('I send a request to retrieve the callback configuration') +def step_retrieve_callback_configuration(context): + context.response = context.sinch.numbers.callback_configuration.get() + + +@then('the response contains the project\'s callback configuration') +def step_check_callback_configuration(context): + assert context.response.project_id == '12c0ffee-dada-beef-cafe-baadc0de5678' + assert context.response.hmac_secret == '0default-pass-word-*max-36characters' + + +@when('I send a request to update the callback configuration with the secret "{hmac_secret}"') +def step_update_callback_configuration(context, hmac_secret): + try: + context.response = context.sinch.numbers.callback_configuration.update(hmac_secret=hmac_secret) + context.error = None + except NumberNotFoundException as e: + context.error = e + + +@then('the response contains the updated project\'s callback configuration') +def step_check_updated_callback_configuration(context): + assert context.response.project_id == '12c0ffee-dada-beef-cafe-baadc0de5678' + assert context.response.hmac_secret == 'strongPa$$PhraseWith36CharactersMax' + + +@then('the response contains an error') +def step_check_error_response(context): + data: NotFoundError = context.error.args[0] + assert data is not None + assert data.code == 404 + assert data.status == 'NOT_FOUND' diff --git a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py new file mode 100644 index 00000000..8555146f --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py @@ -0,0 +1,78 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import GetCallbackConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "projectId": "j55aa9aa-b888-777c-dd6d-ee55e1010101010", + "hmacSecret": "your_hmac_secret" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def endpoint_empty_request_data(): + return GetCallbackConfigurationEndpoint("test_project_id", request_data=None) + + +@pytest.fixture +def endpoint_extra_request_data(): + data = { + "key": "value", + "extra_field": "extra value" + } + request_model = BaseModelConfigurationRequest(**data) + return GetCallbackConfigurationEndpoint("test_project_id", request_data=request_model) + + +endpoint_fixtures = pytest.mark.parametrize("endpoint_fixture", [ + "endpoint_empty_request_data", + "endpoint_extra_request_data" +]) + + +@endpoint_fixtures +def test_build_url(endpoint_fixture, mock_sinch_client_numbers, request): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + endpoint = request.getfixturevalue(endpoint_fixture) + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/callbackConfiguration" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_build_empty_query_params_expects_correct_mapping(endpoint_empty_request_data): + """ + Check if empty Query params is handled and mapped to the appropriate fields correctly. + """ + assert endpoint_empty_request_data.build_query_params() == {} + + +def test_build_query_params_expects_correct_mapping(endpoint_extra_request_data): + """ + Check if Query params is handled and mapped to the appropriate fields correctly. + """ + expected_params = { + "key": "value", + "extraField": "extra value" + } + assert endpoint_extra_request_data.build_query_params() == expected_params + + +@endpoint_fixtures +def test_handle_response_expects_correct_mapping(endpoint_fixture, mock_response, request): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + endpoint = request.getfixturevalue(endpoint_fixture) + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, CallbackConfigurationResponse) + assert parsed_response.project_id == "j55aa9aa-b888-777c-dd6d-ee55e1010101010" + assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py new file mode 100644 index 00000000..fe20ed24 --- /dev/null +++ b/tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py @@ -0,0 +1,64 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.numbers.api.v1.internal import UpdateCallbackConfigurationEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +@pytest.fixture +def mock_request_data(): + return UpdateCallbackConfigurationRequest( + hmac_secret="your_hmac_secret" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "projectId": "a99aa9aa-b888-777c-dd6d-ee55e5555555", + "hmacSecret": "your_hmac_secret" + }, + headers={"Content-Type": "application/json"} + ) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "hmacSecret": "your_hmac_secret" + } + return json.dumps(expected_body) + + +@pytest.fixture +def endpoint(mock_request_data): + return UpdateCallbackConfigurationEndpoint("test_project_id", mock_request_data) + + +def test_build_url(endpoint, mock_sinch_client_numbers): + """ + Check if endpoint URL is constructed correctly based on input data. + """ + expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project_id/callbackConfiguration" + assert endpoint.build_url(mock_sinch_client_numbers) == expected_url + + +def test_request_body_expects_correct_mapping(endpoint, mock_response_body): + """ + Check if request body is properly formatted as JSON. + """ + request_body = endpoint.request_body() + assert request_body == mock_response_body + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, CallbackConfigurationResponse) + assert parsed_response.project_id == "a99aa9aa-b888-777c-dd6d-ee55e5555555" + assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py index 376253bb..c6cf1fca 100644 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py @@ -1,39 +1,39 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest def test_to_camel_case_expects_parsed_standard_cases(): """ Test standard snake_case to camelCase conversion. """ - assert BaseModelConfigRequest._to_camel_case("foo_bar") == "fooBar" - assert BaseModelConfigRequest._to_camel_case("hello_world") == "helloWorld" - assert BaseModelConfigRequest._to_camel_case("this_is_a_test") == "thisIsATest" - assert BaseModelConfigRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" - assert BaseModelConfigRequest._to_camel_case("appId") == "appId" + assert BaseModelConfigurationRequest._to_camel_case("foo_bar") == "fooBar" + assert BaseModelConfigurationRequest._to_camel_case("hello_world") == "helloWorld" + assert BaseModelConfigurationRequest._to_camel_case("this_is_a_test") == "thisIsATest" + assert BaseModelConfigurationRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber" + assert BaseModelConfigurationRequest._to_camel_case("appId") == "appId" def test_to_camel_case_expects_parsed_edge_cases(): """ Test edge cases like leading/trailing underscores and multiple underscores. """ - assert BaseModelConfigRequest._to_camel_case("foo__bar") == "foo_Bar" - assert BaseModelConfigRequest._to_camel_case("foo___bar") == "foo__Bar" - assert BaseModelConfigRequest._to_camel_case("trailing_") == "trailing_" + assert BaseModelConfigurationRequest._to_camel_case("foo__bar") == "foo_Bar" + assert BaseModelConfigurationRequest._to_camel_case("foo___bar") == "foo__Bar" + assert BaseModelConfigurationRequest._to_camel_case("trailing_") == "trailing_" def test_to_camel_case_expects_empty_string(): """ Test empty string case. """ - assert BaseModelConfigRequest._to_camel_case("") == "" + assert BaseModelConfigurationRequest._to_camel_case("") == "" def test_to_camel_case_expects_single_word(): """ Test single-word cases. """ - assert BaseModelConfigRequest._to_camel_case("word") == "word" - assert BaseModelConfigRequest._to_camel_case("single") == "single" + assert BaseModelConfigurationRequest._to_camel_case("word") == "word" + assert BaseModelConfigurationRequest._to_camel_case("single") == "single" def test_dict_expects_camel_case_input(): @@ -47,7 +47,7 @@ def test_dict_expects_camel_case_input(): "type": "RTC" } } - request = BaseModelConfigRequest(**data) + request = BaseModelConfigurationRequest(**data) response = request.model_dump() assert response == { diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py index 10347499..2b2767cf 100644 --- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py +++ b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigResponse +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse def test_base_model_response_expects_unrecognized_fields_snake_case(): @@ -9,7 +9,7 @@ def test_base_model_response_expects_unrecognized_fields_snake_case(): "unexpectedField": "unexpectedValue", "anotherExtraField": 42, } - response = BaseModelConfigResponse(**data) + response = BaseModelConfigurationResponse(**data) # Assert unrecognized fields are dynamically added assert response.unexpected_field == "unexpectedValue" diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py index b2cd9e3e..12bf9ca8 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py @@ -4,7 +4,9 @@ def test_update_number_configuration_request_valid_expects_parsed_response(): - """ Test that the model correctly handles request. """ + """ + Test that the model correctly handles request. + """ data = { "phoneNumber": "+1234567890", "displayName": "Test Number", @@ -33,7 +35,9 @@ def test_update_number_configuration_request_valid_expects_parsed_response(): def test_update_number_configuration_request_missing_phone_number_expects_error(): - """Test that the model raises a validation error for missing required fields. """ + """ + Test that the model raises a validation error for missing required fields. + """ data = { "displayName": "Test Number", "callbackUrl": "https://www.your-callback-server.com/callback" @@ -43,7 +47,9 @@ def test_update_number_configuration_request_missing_phone_number_expects_error( def test_update_number_configuration_request_invalid_phone_number(): - """Test that the model raises a validation error for invalid phone number type. """ + """ + Test that the model raises a validation error for invalid phone number type. + """ data = { "phoneNumber": 1234567890, "displayName": "Test Number", @@ -54,6 +60,9 @@ def test_update_number_configuration_request_invalid_phone_number(): def test_update_number_configuration_request_optional_fields(): + """ + Test that optional fields are handled correctly. + """ data = { "phoneNumber": "+1234567890" } diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py new file mode 100644 index 00000000..1344c31a --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py @@ -0,0 +1,45 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest + + +def test_update_numbers_callback_config_request_expects_parsed_input(): + """ + Test that the model correctly handles valid input. + """ + data = { + "hmacSecret": "test-secret-key" + } + request = UpdateCallbackConfigurationRequest(**data) + assert request.hmac_secret == "test-secret-key" + + +def test_update_numbers_callback_request_expects_validation_for_extra_type(): + """ + Test that validation errors are raised for invalid number types. + """ + data = { + "extra": "Extra Value" + } + request = UpdateCallbackConfigurationRequest(**data) + assert request.extra == "Extra Value" + + +def test_update_numbers_callback_config_request_expects_optional_field_handled(): + """ + Test that hmac_secret is optional and can be None. + """ + data = {} + request = UpdateCallbackConfigurationRequest(**data) + assert request.hmac_secret is None + + +def test_update_numbers_callback_config_request_expects_validation_error(): + """ + Test that the model raises a validation error for invalid hmac_secret type. + """ + data = { + "hmacSecret": 12345 + } + with pytest.raises(ValidationError): + UpdateCallbackConfigurationRequest(**data) diff --git a/tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py b/tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py new file mode 100644 index 00000000..1caf4baa --- /dev/null +++ b/tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py @@ -0,0 +1,25 @@ +import pytest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +@pytest.fixture +def test_data(): + return { + "projectId": "project-test-id", + "hmacSecret": "secret-key-456", + "extraField": "Extra content", + "extraDict": {"key": "value"} + } + + +def test_numbers_callback_config_response_all_fields(test_data): + """ + Expects all fields to map correctly from camelCase input + and handle extra fields appropriately + """ + response = CallbackConfigurationResponse(**test_data) + + assert response.project_id == "project-test-id" + assert response.hmac_secret == "secret-key-456" + assert response.extra_field == "Extra content" + assert response.extra_dict == {"key": "value"} diff --git a/tests/unit/domains/numbers/v1/test_available_regions.py b/tests/unit/domains/numbers/v1/test_available_regions.py new file mode 100644 index 00000000..2742322c --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_available_regions.py @@ -0,0 +1,35 @@ +from sinch.core.pagination import TokenBasedPaginator +from sinch.domains.numbers.api.v1 import AvailableRegions +from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, ListAvailableRegionsResponse, +) + + +def test_list_available_regions_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the AvailableRegions.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListAvailableRegionsResponse(availableRegions=[]) + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ListAvailableRegionsEndpoint, "__init__") + + available_regions = AvailableRegions(mock_sinch_client_numbers) + response = available_regions.list( + types=["MOBILE", "LOCAL", "TOLL_FREE"] + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListAvailableRegionsRequest( + types=["MOBILE", "LOCAL", "TOLL_FREE"] + ) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, 'has_next_page') + assert response.result == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/numbers/v1/test_numbers_callback.py b/tests/unit/domains/numbers/v1/test_numbers_callback.py new file mode 100644 index 00000000..610d705b --- /dev/null +++ b/tests/unit/domains/numbers/v1/test_numbers_callback.py @@ -0,0 +1,68 @@ +import pytest +from sinch.domains.numbers.api.v1 import CallbackConfiguration +from sinch.domains.numbers.api.v1.internal import ( + GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint +) +from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse + + +@pytest.mark.parametrize( + "test_name,config_kwargs,expected_request_data", + [ + ( + "without_extra_params", {}, None + ), + ( + "with_extra_params", {"kwargs": {"extra_param": "value"}}, + BaseModelConfigurationRequest(kwargs={"extra_param": "value"}) + ), + ], +) +def test_get_numbers_callback_config_expects_valid_request( + mock_sinch_client_numbers, mocker, test_name, config_kwargs, expected_request_data +): + """ + Test that the get() method sends the correct request + and handles the response properly with or without extra parameters. + """ + mock_response = CallbackConfigurationResponse(project_id="test_project_id", hmac_secret="test_secret") + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + spy_endpoint = mocker.spy(GetCallbackConfigurationEndpoint, "__init__") + + callback_configuration = CallbackConfiguration(mock_sinch_client_numbers) + response = callback_configuration.get(**config_kwargs) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + if expected_request_data: + assert kwargs["request_data"] == expected_request_data + + assert response == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() + + +def test_update_numbers_callback_config_expects_valid_request(mock_sinch_client_numbers, mocker): + """ + Test that the update() method sends the correct request + and handles the response properly. + """ + mock_response = CallbackConfigurationResponse(project_id="test_project_id", hmac_secret="new_secret") + mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(UpdateCallbackConfigurationEndpoint, "__init__") + + callback_configuration = CallbackConfiguration(mock_sinch_client_numbers) + response = callback_configuration.update(hmac_secret="new_secret") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == UpdateCallbackConfigurationRequest(hmac_secret="new_secret") + + assert response == mock_response + mock_sinch_client_numbers.configuration.transport.request.assert_called_once() From 1d7e7e8c4d369bd405e20a57d6d4070ee237a64c Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Mon, 7 Apr 2025 16:03:05 +0200 Subject: [PATCH 038/106] DEVEXP-858: Numbers API - Refactor Available and Active endpoints (#58) * DEVEXP-858: Numbers API - Refactor Available and Active endpoints Signed-off-by: Jessica Matsuoka --- .github/workflows/run-tests.yml | 4 +- sinch/core/clients/sinch_client_base.py | 39 -- sinch/core/clients/sinch_client_sync.py | 3 +- sinch/domains/numbers/__init__.py | 26 +- .../numbers/api/v1/active_numbers_apis.py | 130 +---- .../numbers/api/v1/available_numbers_apis.py | 196 +------- .../numbers/api/v1/internal/__init__.py | 4 +- .../internal/available_numbers_endpoints.py | 14 +- sinch/domains/numbers/enums.py | 12 - .../numbers/models/v1/internal/__init__.py | 4 +- ...mber_request.py => rent_number_request.py} | 2 +- sinch/domains/numbers/numbers_facade.py | 445 ++++++++++++++++++ .../numbers/features/steps/numbers.steps.py | 20 +- ...dpoint.py => test_rent_number_endpoint.py} | 16 +- ...l.py => test_rent_number_request_model.py} | 18 +- .../numbers/v1/test_available_numbers.py | 14 +- 16 files changed, 510 insertions(+), 437 deletions(-) delete mode 100644 sinch/core/clients/sinch_client_base.py delete mode 100644 sinch/domains/numbers/enums.py rename sinch/domains/numbers/models/v1/internal/{activate_number_request.py => rent_number_request.py} (93%) create mode 100644 sinch/domains/numbers/numbers_facade.py rename tests/unit/domains/numbers/v1/endpoints/available/{test_activate_number_endpoint.py => test_rent_number_endpoint.py} (80%) rename tests/unit/domains/numbers/v1/models/internal/{test_activate_number_request_model.py => test_rent_number_request_model.py} (84%) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e2a10dc3..3402abc4 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -69,8 +69,9 @@ jobs: - name: Copy feature files run: | - cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/available-regions.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh @@ -78,5 +79,4 @@ jobs: - name: Run e2e tests sync run: | - export SINCH_CLIENT_MODE=sync behave tests/e2e/**/features diff --git a/sinch/core/clients/sinch_client_base.py b/sinch/core/clients/sinch_client_base.py deleted file mode 100644 index 3e533bbd..00000000 --- a/sinch/core/clients/sinch_client_base.py +++ /dev/null @@ -1,39 +0,0 @@ -from logging import Logger -from abc import ABC, abstractmethod -from sinch.core.clients.sinch_client_configuration import Configuration -from sinch.domains.authentication import AuthenticationBase -from sinch.domains.numbers import NumbersBase -from sinch.domains.conversation import ConversationBase -from sinch.domains.sms import SMSBase -from sinch.domains.voice import VoiceBase - - -class SinchClientBase(ABC): - """ - Sinch abstract base class for concrete Sinch Client implementation. - Feel free to utilize any of them for you custom implementation. - """ - configuration = Configuration - authentication = AuthenticationBase - numbers = NumbersBase - conversation = ConversationBase - sms = SMSBase - voice = VoiceBase - - @abstractmethod - def __init__( - self, - key_id: str = None, - key_secret: str = None, - project_id: str = None, - logger_name: str = None, - logger: Logger = None, - application_key: str = None, - application_secret: str = None, - service_plan_id: str = None, - sms_api_token: str = None - ): - pass - - def __repr__(self): - return "Sinch SDK client" diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index b2e6055c..c288b8c0 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -1,5 +1,4 @@ from logging import Logger -from sinch.core.clients.sinch_client_base import SinchClientBase from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.token_manager import TokenManager from sinch.core.adapters.requests_http_transport import HTTPTransportRequests @@ -11,7 +10,7 @@ from sinch.domains.voice import Voice -class SinchClient(SinchClientBase): +class SinchClient: """ Synchronous implementation of the Sinch Client By default this implementation uses HTTPTransportRequests based on Requests library diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index b1b1d160..7a0c4885 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,25 +1,3 @@ -from sinch.domains.numbers.api.v1 import ( - ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration -) +from sinch.domains.numbers.numbers_facade import Numbers - -class NumbersBase: - """ - Documentation for Sinch virtual Numbers is found at https://developers.sinch.com/docs/numbers/. - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Numbers(NumbersBase): - """ - Synchronous version of the Numbers Domain - """ - __doc__ += NumbersBase.__doc__ - - def __init__(self, sinch): - super(Numbers, self).__init__(sinch) - self.available = AvailableNumbers(self._sinch) - self.regions = AvailableRegions(self._sinch) - self.active = ActiveNumbers(self._sinch) - self.callback_configuration = CallbackConfiguration(self._sinch) +__all__ = ['Numbers'] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 7a574a1a..18197e66 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,4 +1,4 @@ -from typing import Optional, overload +from typing import Optional from pydantic import StrictStr, StrictInt from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers @@ -13,8 +13,7 @@ ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, - SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, - VoiceConfigurationDictEST + SmsConfigurationDict, VoiceConfigurationDictType ) @@ -32,41 +31,6 @@ def list( order_by: Optional[OrderByValues] = None, **kwargs ) -> Paginator[ActiveNumber]: - """ - Search for all active virtual numbers associated with a certain project. - - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. - :type region_code: StrictStr - - :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - :type number_type: NumberTypeValues - - :param number_pattern: Specific sequence of digits to search for. - :type number_pattern: Optional[StrictStr] - - :param number_search_pattern: Pattern to apply (e.g., "START", "CONTAINS", "END"). - :type number_search_pattern: Optional[NumberSearchPatternTypeValues] - - :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). - :type capabilities: Optional[CapabilityTypeValuesList] - - :param page_size: Maximum number of items to return. - :type page_size: StrictInt - - :param page_token: Token for the next page of results. - :type page_token: Optional[StrictStr] - - :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). - :type order_by: Optional[OrderByValues] - - :param kwargs: Additional filters for the request. - :type kwargs: dict - - :returns: A paginator for iterating through the results. - :rtype: Paginator[ActiveNumber] - - For detailed documentation, visit https://developers.sinch.com - """ return TokenBasedPaginator( sinch=self._sinch, endpoint=ListActiveNumbersEndpoint( @@ -85,39 +49,6 @@ def list( ) ) - @overload - def update( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - - @overload - def update( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - - @overload - def update( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - def update( self, phone_number: StrictStr, @@ -127,35 +58,6 @@ def update( callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: - """ - Make updates to the configuration of your virtual number. - Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. - - :param phone_number: The phone number in E.164 format with leading +. - :type phone_number: str - - :param display_name: The display name for the virtual number. - :type display_name: Optional[str] - - :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as: - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. - :type sms_configuration: Optional[SmsConfigurationDict] - - :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: - - ``VoiceConfigurationDictRTC``: type 'RTC' with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type 'EST' with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type 'FAX' with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] - - :param callback_url: The callback URL for the virtual number. - :type callback_url: Optional[str] - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - For detailed documentation, visit https://developers.sinch.com - """ request_data = UpdateNumberConfigurationRequest( phone_number=phone_number, display_name=display_name, @@ -171,20 +73,6 @@ def get( phone_number: StrictStr, **kwargs ) -> ActiveNumber: - """ - List of configuration settings for your virtual number. - - :param phone_number: The phone number in E.164 format with leading +. - :type phone_number: str - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: The configuration settings for the virtual number. - :rtype: ActiveNumber - - For detailed documentation, visit https://developers.sinch.com - """ request_data = NumberRequest( phone_number=phone_number, **kwargs @@ -196,20 +84,6 @@ def release( phone_number: StrictStr, **kwargs ) -> ActiveNumber: - """ - Release virtual numbers you no longer need from your project. - - :param phone_number: The phone number in E.164 format with leading +. - :type phone_number: str - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: The configuration settings of the released virtual number. - :rtype: ActiveNumber - - For detailed documentation, visit https://developers.sinch.com - """ request_data = NumberRequest( phone_number=phone_number, **kwargs diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 19d709c1..68e6a4a1 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,4 +1,4 @@ -from typing import Optional, overload +from typing import Optional from pydantic import StrictInt, StrictStr from sinch.core.pagination import Paginator, TokenBasedPaginator @@ -7,19 +7,23 @@ ) from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( - ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint + AvailableNumbersEndpoint, RentAnyNumberEndpoint, RentNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest + ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, - VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictType + CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, + NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDictType ) class AvailableNumbers(BaseNumbers): + def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + request_data = NumberRequest(phone_number=phone_number, **kwargs) + return self._request(SearchForNumberEndpoint, request_data) + def list( self, region_code: StrictStr, @@ -30,35 +34,6 @@ def list( page_size: Optional[StrictInt] = None, **kwargs ) -> Paginator[AvailableNumber]: - """ - Search for available virtual numbers for you to activate using a variety of parameters to filter results. - - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. - :type region_code: StrictStr - - :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). - :type number_type: NumberType - - :param number_pattern: Specific sequence of digits to search for. - :type number_pattern: Optional[StrictStr] - - :param number_search_pattern: Pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). - :type number_search_pattern: Optional[NumberSearchPatternType] - - :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). - :type capabilities: Optional[CapabilityType] - - :param page_size: Maximum number of items to return. - :type page_size: StrictInt - - :param kwargs: Additional filters for the request. - :type kwargs: dict - - :returns: A paginator for iterating through the results. - :rtype: Paginator[AvailableNumber] - - For detailed documentation, visit: https://developers.sinch.com - """ return TokenBasedPaginator( sinch=self._sinch, endpoint=AvailableNumbersEndpoint( @@ -75,37 +50,7 @@ def list( ) ) - @overload - def activate( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - - @overload - def activate( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - - @overload - def activate( - self, - phone_number: StrictStr, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, - callback_url: Optional[StrictStr] = None - ) -> ActiveNumber: - pass - - def activate( + def rent( self, phone_number: StrictStr, sms_configuration: Optional[SmsConfigurationDict] = None, @@ -113,75 +58,14 @@ def activate( callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: - """ - Activate a virtual number to use with SMS, Voice, or both products. - - Args: - phone_number (StrictStr): The phone number in E.164 format with leading +. - sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. - Including fields such as: - - service_plan_id (str): The service plan ID. - - campaign_id (Optional[str]): The campaign ID. - voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. - Supported types include: - - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. - - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. - - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. - callback_url (Optional[StrictStr]): The callback URL to be called. - **kwargs: Additional parameters for the request. - - Returns: - ActiveNumber: A response object with the activated number and its details. - - For detailed documentation, visit https://developers.sinch.com - """ - request_data = ActivateNumberRequest( + request_data = RentNumberRequest( phone_number=phone_number, sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, **kwargs ) - return self._request(ActivateNumberEndpoint, request_data) - - @overload - def rent_any( - self, - region_code: StrictStr, - type_: NumberTypeValues, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: - pass - - @overload - def rent_any( - self, - region_code: StrictStr, - type_: NumberTypeValues, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: - pass - - @overload - def rent_any( - self, - region_code: StrictStr, - type_: NumberTypeValues, - sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, - callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: - pass + return self._request(RentNumberEndpoint, request_data) def rent_any( self, @@ -194,44 +78,6 @@ def rent_any( callback_url: Optional[StrictStr] = None, **kwargs ) -> RentAnyNumberResponse: - """ - Search for and activate an available Sinch virtual number all in one API call. - Currently, the ``rent_any`` operation works only for US 10DLC numbers. - - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. - :type region_code: str - - :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). - :type type_: NumberType - - :param number_pattern: Specific sequence of digits to search for. - :type number_pattern: Optional[NumberPatternDict] - - :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). - :type capabilities: Optional[CapabilityType] - - :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as: - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. - :type sms_configuration: Optional[SmsConfigurationDict] - - :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: - - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] - - :param callback_url: The callback URL to receive notifications. - :type callback_url: StrictStr - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: A response object with the activated number and its details. - :rtype: RentAnyNumberRequest - - For detailed documentation, visit: https://developers.sinch.com - """ request_data = RentAnyNumberRequest( region_code=region_code, type_=type_, @@ -243,21 +89,3 @@ def rent_any( **kwargs ) return self._request(RentAnyNumberEndpoint, request_data) - - def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: - """ - Enter a specific phone number to check availability. - - :param phone_number: The phone number in E.164 format with leading ``+``. - :type phone_number: str - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: A response object with the availability status of the number. - :rtype: CheckNumberAvailabilityResponse - - For detailed documentation, visit: https://developers.sinch.com - """ - request_data = NumberRequest(phone_number=phone_number, **kwargs) - return self._request(SearchForNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 9beb3892..98d24236 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -3,7 +3,7 @@ UpdateNumberConfigurationEndpoint ) from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( - ActivateNumberEndpoint, AvailableNumbersEndpoint, RentAnyNumberEndpoint, SearchForNumberEndpoint + AvailableNumbersEndpoint, RentAnyNumberEndpoint, RentNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint from sinch.domains.numbers.api.v1.internal.callback_configuration_endpoints import ( @@ -11,13 +11,13 @@ ) __all__ = [ - "ActivateNumberEndpoint", "AvailableNumbersEndpoint", "GetCallbackConfigurationEndpoint", "GetNumberConfigurationEndpoint", "ListActiveNumbersEndpoint", "ListAvailableRegionsEndpoint", "ReleaseNumberFromProjectEndpoint", + "RentNumberEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", "UpdateCallbackConfigurationEndpoint", diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 15609da3..004cf25c 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -3,8 +3,8 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse, - NumberRequest, RentAnyNumberRequest + ListAvailableNumbersRequest, ListAvailableNumbersResponse, + NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.response import ( ActiveNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse @@ -12,16 +12,16 @@ from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -class ActivateNumberEndpoint(NumbersEndpoint): +class RentNumberEndpoint(NumbersEndpoint): """ - Endpoint to activate a virtual number for a project. + Endpoint to rent a virtual number for a project. """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: ActivateNumberRequest): - super(ActivateNumberEndpoint, self).__init__(project_id, request_data) + def __init__(self, project_id: str, request_data: RentNumberRequest): + super(RentNumberEndpoint, self).__init__(project_id, request_data) def request_body(self) -> str: # Convert the request data to a dictionary and remove None values @@ -30,7 +30,7 @@ def request_body(self) -> str: def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: - super(ActivateNumberEndpoint, self).handle_response(response) + super(RentNumberEndpoint, self).handle_response(response) except NumbersException as ex: raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, is_from_server=ex.is_from_server) diff --git a/sinch/domains/numbers/enums.py b/sinch/domains/numbers/enums.py deleted file mode 100644 index 558a8e80..00000000 --- a/sinch/domains/numbers/enums.py +++ /dev/null @@ -1,12 +0,0 @@ -from enum import Enum - - -class NumberCapability(Enum): - SMS = "SMS" - VOICE = "VOICE" - - -class NumberType(Enum): - MOBILE = "MOBILE" - LOCAL = "LOCAL" - TOLL_FREE = "TOLL_FREE" diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index dfa483cf..a0ba87a8 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.internal.activate_number_request import ActivateNumberRequest +from sinch.domains.numbers.models.v1.internal.rent_number_request import RentNumberRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest @@ -24,7 +24,6 @@ ) __all__ = [ - "ActivateNumberRequest", "ListActiveNumbersRequest", "ListAvailableNumbersRequest", "ListActiveNumbersResponse", @@ -33,6 +32,7 @@ "ListAvailableRegionsResponse", "NumberRequest", "RentAnyNumberRequest", + "RentNumberRequest", "SmsConfigurationRequest", "UpdateCallbackConfigurationRequest", "UpdateNumberConfigurationRequest", diff --git a/sinch/domains/numbers/models/v1/internal/activate_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_number_request.py similarity index 93% rename from sinch/domains/numbers/models/v1/internal/activate_number_request.py rename to sinch/domains/numbers/models/v1/internal/rent_number_request.py index b221dba9..0f20a890 100644 --- a/sinch/domains/numbers/models/v1/internal/activate_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_number_request.py @@ -4,7 +4,7 @@ from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -class ActivateNumberRequest(BaseModelConfigurationRequest): +class RentNumberRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") # Accepts only dictionary input, not Pydantic models sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") diff --git a/sinch/domains/numbers/numbers_facade.py b/sinch/domains/numbers/numbers_facade.py new file mode 100644 index 00000000..8a5070f6 --- /dev/null +++ b/sinch/domains/numbers/numbers_facade.py @@ -0,0 +1,445 @@ +from typing import Optional, overload +from pydantic import StrictStr, StrictInt +from sinch.domains.numbers.api.v1 import ( + ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration +) +from sinch.core.pagination import Paginator +from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse, \ + RentAnyNumberResponse, AvailableNumber +from sinch.domains.numbers.models.v1.types import ( + CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, + VoiceConfigurationDictEST, NumberPatternDict +) + + +class Numbers: + """ + Synchronous version of the Numbers domain. + + Documentation for Sinch virtual Numbers is found at + https://developers.sinch.com/docs/numbers/. + """ + + def __init__(self, sinch): + self._sinch = sinch + self.regions = AvailableRegions(self._sinch) + self.callback_configuration = CallbackConfiguration(self._sinch) + + self._active = ActiveNumbers(self._sinch) + self._available = AvailableNumbers(self._sinch) + + # ====== High-Level Convenience Methods ====== + + def list( + self, + region_code: StrictStr, + number_type: NumberTypeValues, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + page_size: Optional[StrictInt] = None, + page_token: Optional[StrictStr] = None, + order_by: Optional[OrderByValues] = None, + **kwargs + ) -> Paginator[ActiveNumber]: + """ + Search for all active virtual numbers associated with a certain project. + + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: StrictStr + + :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + :type number_type: NumberTypeValues + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[StrictStr] + + :param number_search_pattern: Pattern to apply (e.g., "START", "CONTAINS", "END"). + :type number_search_pattern: Optional[NumberSearchPatternTypeValues] + + :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). + :type capabilities: Optional[CapabilityTypeValuesList] + + :param page_size: Maximum number of items to return. + :type page_size: StrictInt + + :param page_token: Token for the next page of results. + :type page_token: Optional[StrictStr] + + :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). + :type order_by: Optional[OrderByValues] + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A paginator for iterating through the results. + :rtype: Paginator[ActiveNumber] + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.list( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + page_token=page_token, + order_by=order_by, + **kwargs + ) + + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def update( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + display_name: Optional[StrictStr] = None, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + def update( + self, + phone_number: StrictStr, + display_name: Optional[StrictStr] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> ActiveNumber: + """ + Make updates to the configuration of your virtual number. + Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param display_name: The display name for the virtual number. + :type display_name: Optional[str] + + :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: + - ``VoiceConfigurationDictRTC``: type 'RTC' with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type 'EST' with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type 'FAX' with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + + :param callback_url: The callback URL for the virtual number. + :type callback_url: Optional[str] + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.update( + phone_number=phone_number, + display_name=display_name, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, **kwargs + ) + + def get( + self, + phone_number: StrictStr, + **kwargs + ) -> ActiveNumber: + """ + List of configuration settings for your virtual number. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings for the virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.get(phone_number=phone_number, **kwargs) + + def release( + self, + phone_number: StrictStr, + **kwargs + ) -> ActiveNumber: + """ + Release virtual numbers you no longer need from your project. + + :param phone_number: The phone number in E.164 format with leading +. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The configuration settings of the released virtual number. + :rtype: ActiveNumber + + For detailed documentation, visit https://developers.sinch.com + """ + return self._active.release(phone_number=phone_number, **kwargs) + + def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + """ + Enter a specific phone number to check availability. + + :param phone_number: The phone number in E.164 format with leading ``+``. + :type phone_number: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the availability status of the number. + :rtype: CheckNumberAvailabilityResponse + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.check_availability(phone_number=phone_number, **kwargs) + + @overload + def rent( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def rent( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + @overload + def rent( + self, + phone_number: StrictStr, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + callback_url: Optional[StrictStr] = None + ) -> ActiveNumber: + pass + + def rent( + self, + phone_number: StrictStr, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> ActiveNumber: + """ + Rent a virtual number to use with SMS, Voice, or both products. + + Args: + phone_number (StrictStr): The phone number in E.164 format with leading +. + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (Optional[StrictStr]): The callback URL to be called. + **kwargs: Additional parameters for the request. + + Returns: + ActiveNumber: A response object with the rented number and its details. + + For detailed documentation, visit https://developers.sinch.com + """ + return self._available.rent( + phone_number=phone_number, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictRTC, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + callback_url: Optional[StrictStr] = None + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictFAX, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + callback_url: Optional[StrictStr] = None + ) -> RentAnyNumberResponse: + pass + + @overload + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + sms_configuration: SmsConfigurationDict, + voice_configuration: VoiceConfigurationDictEST, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + callback_url: Optional[StrictStr] = None + ) -> RentAnyNumberResponse: + pass + + def rent_any( + self, + region_code: StrictStr, + type_: NumberTypeValues, + number_pattern: Optional[NumberPatternDict] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + sms_configuration: Optional[SmsConfigurationDict] = None, + voice_configuration: Optional[VoiceConfigurationDictType] = None, + callback_url: Optional[StrictStr] = None, + **kwargs + ) -> RentAnyNumberResponse: + """ + Search for and activate an available Sinch virtual number all in one API call. + Currently, the ``rent_any`` operation works only for US 10DLC numbers. + + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: str + + :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :type type_: NumberType + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[NumberPatternDict] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[CapabilityType] + + :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: + - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + + :param callback_url: The callback URL to receive notifications. + :type callback_url: StrictStr + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the activated number and its details. + :rtype: RentAnyNumberRequest + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.rent_any( + region_code=region_code, + type_=type_, + number_pattern=number_pattern, + capabilities=capabilities, + sms_configuration=sms_configuration, + voice_configuration=voice_configuration, + callback_url=callback_url, + **kwargs + ) + + def search_for_available_numbers( + self, + region_code: StrictStr, + number_type: NumberTypeValues, + number_pattern: Optional[StrictStr] = None, + number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, + capabilities: Optional[CapabilityTypeValuesList] = None, + page_size: Optional[StrictInt] = None, + **kwargs + ) -> Paginator[AvailableNumber]: + """ + Search for available virtual numbers for you to rent using a variety of parameters to filter results. + + :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :type region_code: StrictStr + + :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :type number_type: NumberType + + :param number_pattern: Specific sequence of digits to search for. + :type number_pattern: Optional[StrictStr] + + :param number_search_pattern: Pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). + :type number_search_pattern: Optional[NumberSearchPatternType] + + :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). + :type capabilities: Optional[CapabilityType] + + :param page_size: Maximum number of items to return. + :type page_size: StrictInt + + :param kwargs: Additional filters for the request. + :type kwargs: dict + + :returns: A paginator for iterating through the results. + :rtype: Paginator[AvailableNumber] + + For detailed documentation, visit: https://developers.sinch.com + """ + return self._available.list( + region_code=region_code, + number_type=number_type, + page_size=page_size, + capabilities=capabilities, + number_pattern=number_pattern, + number_search_pattern=number_search_pattern, + **kwargs + ) diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index f5fd3bb6..11027280 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -14,7 +14,7 @@ def step_service_is_available(context): @when('I send a request to search for available phone numbers') def step_search_available_numbers(context): - response = context.sinch.numbers.available.list( + response = context.sinch.numbers.search_for_available_numbers( region_code='US', number_type='LOCAL' ) @@ -45,7 +45,7 @@ def step_check_number_properties(context): @when('I send a request to check the availability of the phone number "{phone_number}"') def step_check_number_availability(context, phone_number): try: - context.response = context.sinch.numbers.available.check_availability(phone_number) + context.response = context.sinch.numbers.check_availability(phone_number) except NumberNotFoundException as e: context.error = e @@ -66,7 +66,7 @@ def step_check_unavailable_number(context, phone_number): @when('I send a request to rent a number with some criteria') def step_rent_any_number(context): - context.response = context.sinch.numbers.available.rent_any( + context.response = context.sinch.numbers.rent_any( region_code='US', type_='LOCAL', capabilities=['SMS', 'VOICE'], @@ -126,7 +126,7 @@ def step_validate_rented_number(context): @when('I send a request to rent the phone number "{phone_number}"') def step_rent_specific_number(context, phone_number): - context.response = context.sinch.numbers.available.activate( + context.response = context.sinch.numbers.rent( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -146,7 +146,7 @@ def step_validate_rented_specific_number(context, phone_number): @when('I send a request to rent the unavailable phone number "{phone_number}"') def step_rent_unavailable_number(context, phone_number): try: - context.response = context.sinch.numbers.available.activate( + context.response = context.sinch.numbers.rent( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -161,7 +161,7 @@ def step_rent_unavailable_number(context, phone_number): @when("I send a request to list the phone numbers") def step_when_list_phone_numbers(context): - response = context.sinch.numbers.active.list( + response = context.sinch.numbers.list( region_code='US', number_type='LOCAL' ) @@ -177,7 +177,7 @@ def step_then_response_contains_x_phone_numbers(context, count): @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): - response = context.sinch.numbers.active.list( + response = context.sinch.numbers.list( region_code='US', number_type='LOCAL' ) @@ -205,7 +205,7 @@ def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): @when('I send a request to update the phone number "{phone_number}"') def step_when_update_phone_number(context, phone_number): - context.response = context.sinch.numbers.active.update( + context.response = context.sinch.numbers.update( phone_number=phone_number, display_name='Updated description during E2E tests', sms_configuration={ @@ -250,7 +250,7 @@ def step_then_response_contains_updated_number(context): @when('I send a request to retrieve the phone number "{phone_number}"') def step_when_retrieve_phone_number(context, phone_number): try: - context.response = context.sinch.numbers.active.get( + context.response = context.sinch.numbers.get( phone_number=phone_number, ) except NumberNotFoundException as e: @@ -290,7 +290,7 @@ def step_then_response_contains_error_not_rented(context, phone_number): @when('I send a request to release the phone number "{phone_number}"') def step_when_release_phone_number(context, phone_number): - context.response = context.sinch.numbers.active.release( + context.response = context.sinch.numbers.release( phone_number=phone_number ) diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py similarity index 80% rename from tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py index ca5173e0..edd8efb4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_activate_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_number_endpoint.py @@ -1,13 +1,13 @@ import pytest import json -from sinch.domains.numbers.api.v1.internal import ActivateNumberEndpoint -from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest +from sinch.domains.numbers.api.v1.internal import RentNumberEndpoint +from sinch.domains.numbers.models.v1.internal import RentNumberRequest from sinch.core.models.http_response import HTTPResponse @pytest.fixture def mock_request_data(): - return ActivateNumberRequest( + return RentNumberRequest( phone_number="+1234567890", sms_configuration={"servicePlanId": "YOUR_SMS_servicePlanId"}, voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} @@ -16,7 +16,7 @@ def mock_request_data(): @pytest.fixture def mock_request_data_snake_case(): - return ActivateNumberRequest( + return RentNumberRequest( phone_number="+1234567890", sms_configuration={"service_plan_id": "YOUR_SMS_servicePlanId"}, voice_configuration={"type": "RTC", "appId": "YOUR_Voice_appId"} @@ -56,7 +56,7 @@ def test_build_url_expects_correct_url(mock_sinch_client_numbers, mock_request_d """ Check if endpoint URL is constructed correctly based on input data. """ - endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) expected_url = "https://mock-numbers-api.sinch.com/v1/projects/test_project/availableNumbers/+1234567890:rent" assert endpoint.build_url(mock_sinch_client_numbers) == expected_url @@ -65,7 +65,7 @@ def test_request_body_expects_correct_json(mock_request_data, mock_response_body """ Check if request body is constructed correctly based on input data. """ - endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) request_body = endpoint.request_body() assert request_body == mock_response_body @@ -74,7 +74,7 @@ def test_request_body_snake_case_dict_expects_correct_json(mock_request_data_sna """ Check if request body is constructed correctly based on input data. """ - endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data_snake_case) + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data_snake_case) request_body = endpoint.request_body() assert request_body == mock_response_body @@ -84,7 +84,7 @@ def test_handle_response_expects_correct_mapping(mock_request_data, mock_respons """ Check if response is handled and mapped to the appropriate fields correctly. """ - endpoint = ActivateNumberEndpoint(project_id="test_project", request_data=mock_request_data) + endpoint = RentNumberEndpoint(project_id="test_project", request_data=mock_request_data) response = endpoint.handle_response(mock_response) # Verify each field is mapped as expected diff --git a/tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py similarity index 84% rename from tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py index 91cb8316..6a3d6567 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_activate_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_number_request_model.py @@ -1,9 +1,9 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1.internal import ActivateNumberRequest +from sinch.domains.numbers.models.v1.internal import RentNumberRequest -def test_activate_number_request_expects_snake_case_input(): +def test_rent_number_request_expects_snake_case_input(): """ Test that the model correctly handles snake_case input. """ @@ -18,7 +18,7 @@ def test_activate_number_request_expects_snake_case_input(): } # Instantiate the model - request = ActivateNumberRequest(**data) + request = RentNumberRequest(**data) # Assert the field values assert request.phone_number == "+1234567890" @@ -30,7 +30,7 @@ def test_activate_number_request_expects_snake_case_input(): assert request.callback_url == "https://example.com/callback" -def test_activate_number_request_expects_mixed_case_input(): +def test_rent_number_request_expects_mixed_case_input(): """ Test that the model correctly handles mixed camelCase and snake_case input. """ @@ -43,7 +43,7 @@ def test_activate_number_request_expects_mixed_case_input(): }, "callback_url": "https://example.com/callback" } - request = ActivateNumberRequest(**data) + request = RentNumberRequest(**data) # Assert fields are populated correctly assert request.phone_number == "+1234567890" @@ -55,7 +55,7 @@ def test_activate_number_request_expects_mixed_case_input(): assert request.callback_url == "https://example.com/callback" -def test_activate_number_request_expects_validation_error_for_missing_field(): +def test_rent_number_request_expects_validation_error_for_missing_field(): """ Test that the model raises a validation error for missing required fields. """ @@ -68,13 +68,13 @@ def test_activate_number_request_expects_validation_error_for_missing_field(): "callback_url": "https://example.com/callback" } with pytest.raises(ValidationError) as exc_info: - ActivateNumberRequest(**data) + RentNumberRequest(**data) # Assert the error mentions the missing phone_number field assert "phone_number" in str(exc_info.value) or "phoneNumber" in str(exc_info.value) -def test_activate_number_request_expects_optional_param_none(): +def test_rent_number_request_expects_optional_param_none(): """ Test that the model correctly handles snake_case input. """ @@ -85,7 +85,7 @@ def test_activate_number_request_expects_optional_param_none(): } # Instantiate the model - request = ActivateNumberRequest(**data) + request = RentNumberRequest(**data) # Assert the field values assert request.phone_number == "+1234567890" diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 5c003b1e..3d8432e9 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -1,10 +1,10 @@ from sinch.core.pagination import TokenBasedPaginator from sinch.domains.numbers.api.v1 import AvailableNumbers from sinch.domains.numbers.api.v1.internal import ( - AvailableNumbersEndpoint, ActivateNumberEndpoint, SearchForNumberEndpoint + AvailableNumbersEndpoint, RentNumberEndpoint, SearchForNumberEndpoint ) from sinch.domains.numbers.models.v1.internal import ( - ActivateNumberRequest, ListAvailableNumbersRequest, ListAvailableNumbersResponse, NumberRequest + ListAvailableNumbersRequest, ListAvailableNumbersResponse, NumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse @@ -49,24 +49,24 @@ def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, mock_sinch_client_numbers.configuration.transport.request.assert_called_once() -def test_activate_number_expects_correct_request(mock_sinch_client_numbers, mocker): +def test_rent_number_expects_correct_request(mock_sinch_client_numbers, mocker): """ - Test that the AvailableNumbers.activate method sends the correct request + Test that the AvailableNumbers.rent method sends the correct request and handles the response properly. """ # Use construct to create a mock response without Pydantic validation mock_response = ActiveNumber.model_construct() mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response - spy_endpoint = mocker.spy(ActivateNumberEndpoint, "__init__") + spy_endpoint = mocker.spy(RentNumberEndpoint, "__init__") available_numbers = AvailableNumbers(mock_sinch_client_numbers) - response = available_numbers.activate(phone_number="+1234567890") + response = available_numbers.rent(phone_number="+1234567890") spy_endpoint.assert_called_once() _, kwargs = spy_endpoint.call_args assert kwargs["project_id"] == "test_project_id" - assert kwargs["request_data"] == ActivateNumberRequest(phone_number="+1234567890") + assert kwargs["request_data"] == RentNumberRequest(phone_number="+1234567890") assert response == mock_response From 39834b3528f65861b70a43166b5db72b122c495c Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Wed, 9 Apr 2025 15:39:13 +0200 Subject: [PATCH 039/106] DEVEXP-809: Numbers API - Webhooks (#59) A notification of an event sent to your configured callback URL Implement two methods: - Parse event: Transforms the JSON body into a Pydantic object - Validate Authentication Header: Checks if the body hashed with the secret is equal to the x-sinch-signature header Signed-off-by: Jessica Matsuoka --- .github/workflows/run-tests.yml | 4 +- sinch/core/token_manager.py | 4 +- .../authentication/endpoints/v1/__init__.py | 0 .../endpoints/{ => v1}/oauth.py | 2 +- .../authentication/models/v1/__init__.py | 0 .../models/{ => v1}/authentication.py | 0 .../authentication/webhooks/__init__.py | 0 .../authentication/webhooks/v1/__init__.py | 0 .../webhooks/v1/authentication_validation.py | 61 +++++++++++++ sinch/domains/numbers/__init__.py | 2 +- sinch/domains/numbers/api/v1/__init__.py | 1 + .../numbers/models/v1/response/__init__.py | 2 +- .../numbers/{numbers_facade.py => numbers.py} | 17 +++- sinch/domains/numbers/webhooks/__init__.py | 0 sinch/domains/numbers/webhooks/v1/__init__.py | 3 + .../numbers/webhooks/v1/events/__init__.py | 3 + .../v1/events/numbers_webhooks_event.py | 38 +++++++++ .../numbers/webhooks/v1/internal/__init__.py | 3 + .../webhooks/v1/internal/webhook_event.py | 7 ++ .../numbers/webhooks/v1/numbers_webhooks.py | 85 +++++++++++++++++++ tests/conftest.py | 2 +- .../numbers/features/steps/webhooks.steps.py | 54 ++++++++++++ .../test_authentication_validation.py | 39 +++++++++ .../numbers/v1/test_numbers_callback.py | 2 +- .../test_numbers_webhooks_event_model.py | 79 +++++++++++++++++ .../v1/webhooks/test_numbers_webhooks.py | 82 ++++++++++++++++++ tests/unit/test_token_manager.py | 2 +- 27 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 sinch/domains/authentication/endpoints/v1/__init__.py rename sinch/domains/authentication/endpoints/{ => v1}/oauth.py (94%) create mode 100644 sinch/domains/authentication/models/v1/__init__.py rename sinch/domains/authentication/models/{ => v1}/authentication.py (100%) create mode 100644 sinch/domains/authentication/webhooks/__init__.py create mode 100644 sinch/domains/authentication/webhooks/v1/__init__.py create mode 100644 sinch/domains/authentication/webhooks/v1/authentication_validation.py rename sinch/domains/numbers/{numbers_facade.py => numbers.py} (96%) create mode 100644 sinch/domains/numbers/webhooks/__init__.py create mode 100644 sinch/domains/numbers/webhooks/v1/__init__.py create mode 100644 sinch/domains/numbers/webhooks/v1/events/__init__.py create mode 100644 sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py create mode 100644 sinch/domains/numbers/webhooks/v1/internal/__init__.py create mode 100644 sinch/domains/numbers/webhooks/v1/internal/webhook_event.py create mode 100644 sinch/domains/numbers/webhooks/v1/numbers_webhooks.py create mode 100644 tests/e2e/numbers/features/steps/webhooks.steps.py create mode 100644 tests/unit/domains/authentication/test_authentication_validation.py create mode 100644 tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py create mode 100644 tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 3402abc4..e5758410 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -69,9 +69,7 @@ jobs: - name: Copy feature files run: | - cp sinch-sdk-mockserver/features/numbers/available-regions.feature ./tests/e2e/numbers/features/ - cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ - cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/*.feature ./tests/e2e/numbers/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/core/token_manager.py b/sinch/core/token_manager.py index 28beb57d..b66bba85 100644 --- a/sinch/core/token_manager.py +++ b/sinch/core/token_manager.py @@ -1,7 +1,7 @@ from enum import Enum from abc import ABC, abstractmethod -from sinch.domains.authentication.models.authentication import OAuthToken -from sinch.domains.authentication.endpoints.oauth import OAuthEndpoint +from sinch.domains.authentication.models.v1.authentication import OAuthToken +from sinch.domains.authentication.endpoints.v1.oauth import OAuthEndpoint from sinch.core.exceptions import ValidationException diff --git a/sinch/domains/authentication/endpoints/v1/__init__.py b/sinch/domains/authentication/endpoints/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/authentication/endpoints/oauth.py b/sinch/domains/authentication/endpoints/v1/oauth.py similarity index 94% rename from sinch/domains/authentication/endpoints/oauth.py rename to sinch/domains/authentication/endpoints/v1/oauth.py index 13fb3a80..b8b867c5 100644 --- a/sinch/domains/authentication/endpoints/oauth.py +++ b/sinch/domains/authentication/endpoints/v1/oauth.py @@ -2,7 +2,7 @@ from sinch.core.endpoint import HTTPEndpoint from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.domains.authentication.exceptions import AuthenticationException -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.authentication.models.v1.authentication import OAuthToken class OAuthEndpoint(HTTPEndpoint): diff --git a/sinch/domains/authentication/models/v1/__init__.py b/sinch/domains/authentication/models/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/authentication/models/authentication.py b/sinch/domains/authentication/models/v1/authentication.py similarity index 100% rename from sinch/domains/authentication/models/authentication.py rename to sinch/domains/authentication/models/v1/authentication.py diff --git a/sinch/domains/authentication/webhooks/__init__.py b/sinch/domains/authentication/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/authentication/webhooks/v1/__init__.py b/sinch/domains/authentication/webhooks/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py new file mode 100644 index 00000000..94135cab --- /dev/null +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -0,0 +1,61 @@ +import hashlib +import hmac +from typing import Dict, Union, Optional, List + + +def validate_signature_header( + callback_secret: str, + headers: Dict[str, str], + body: str +) -> bool: + """ + Validate signature headers for Numbers callback. + + Note: A ``callback_url`` must be associated with the number. + + :param callback_secret: Secret associated with the rented number. + :type callback_secret: str + :param headers: Incoming request's headers. + :type headers: Dict[str, str] + :param body: Incoming request's body. + :type body: str + :returns: True if the signature header is valid. + :rtype: bool + """ + + normalized_headers = normalize_headers(headers) + signature = get_header(normalized_headers.get('x-sinch-signature')) + if signature is None: + return False + + expected_signature = compute_hmac_signature(body, callback_secret) + return signature == expected_signature + + +def normalize_headers(headers: Dict[str, str]) -> Dict[str, str]: + """ + Normalize headers by converting keys to lowercase and filtering out None values + """ + return {k.lower(): v for k, v in headers.items() if v is not None} + + +def compute_hmac_signature(body: str, secret: str) -> str: + """ + Compute HMAC-SHA1 signature + """ + return hmac.new( + key=secret.encode('utf-8'), + msg=body.encode('utf-8') if isinstance(body, str) else body, + digestmod=hashlib.sha1 + ).hexdigest() + + +def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: + """ + Extract header value, handling both string and list cases + """ + if header_value is None: + return None + if isinstance(header_value, list): + return header_value[0] if header_value else None + return header_value diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index 7a0c4885..ab4d778c 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,3 +1,3 @@ -from sinch.domains.numbers.numbers_facade import Numbers +from sinch.domains.numbers.numbers import Numbers __all__ = ['Numbers'] diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index d0b7927c..1ad0cf75 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -3,6 +3,7 @@ from sinch.domains.numbers.api.v1.available_regions_apis import AvailableRegions from sinch.domains.numbers.api.v1.callback_configuration_apis import CallbackConfiguration + __all__ = [ "ActiveNumbers", "AvailableNumbers", diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index c80f56a0..f111e358 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -11,5 +11,5 @@ "AvailableRegion", "CallbackConfigurationResponse", "CheckNumberAvailabilityResponse", - "RentAnyNumberResponse", + "RentAnyNumberResponse" ] diff --git a/sinch/domains/numbers/numbers_facade.py b/sinch/domains/numbers/numbers.py similarity index 96% rename from sinch/domains/numbers/numbers_facade.py rename to sinch/domains/numbers/numbers.py index 8a5070f6..b4f3a42e 100644 --- a/sinch/domains/numbers/numbers_facade.py +++ b/sinch/domains/numbers/numbers.py @@ -4,13 +4,15 @@ ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration ) from sinch.core.pagination import Paginator -from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse, \ - RentAnyNumberResponse, AvailableNumber +from sinch.domains.numbers.models.v1.response import ( + ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse +) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictEST, NumberPatternDict ) +from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks class Numbers: @@ -29,6 +31,17 @@ def __init__(self, sinch): self._active = ActiveNumbers(self._sinch) self._available = AvailableNumbers(self._sinch) + def webhooks(self, callback_secret: StrictStr) -> NumbersWebhooks: + """ + Create a Numbers webhooks handler with the specified callback secret. + + :param callback_secret: Secret used for webhook validation. + :type callback_secret: StrictStr + :returns: A configured webhooks handler + :rtype: NumbersWebhooks + """ + return NumbersWebhooks(callback_secret) + # ====== High-Level Convenience Methods ====== def list( diff --git a/sinch/domains/numbers/webhooks/__init__.py b/sinch/domains/numbers/webhooks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/numbers/webhooks/v1/__init__.py b/sinch/domains/numbers/webhooks/v1/__init__.py new file mode 100644 index 00000000..c6b1d66f --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.numbers.webhooks.v1.numbers_webhooks import NumbersWebhooks + +__all__ = ["NumbersWebhooks"] diff --git a/sinch/domains/numbers/webhooks/v1/events/__init__.py b/sinch/domains/numbers/webhooks/v1/events/__init__.py new file mode 100644 index 00000000..e1263943 --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/events/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import NumbersWebhooksEvent + +__all__ = ["NumbersWebhooksEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py new file mode 100644 index 00000000..523d2589 --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py @@ -0,0 +1,38 @@ +from datetime import datetime +from typing import Optional, Union, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.webhooks.v1.internal import WebhookEvent + + +class NumbersWebhooksEvent(WebhookEvent): + event_id: Optional[StrictStr] = Field(default=None, alias="eventId") + timestamp: Optional[datetime] = Field(default=None) + project_id: Optional[StrictStr] = Field(default=None, alias="projectId") + resource_id: Optional[StrictStr] = Field(default=None, alias="resourceId") + resource_type: Optional[Union[Literal["ACTIVE_NUMBER"], StrictStr]] = Field(default=None, alias="resourceType") + event_type: Optional[Union[Literal[ + "PROVISIONING_TO_CAMPAIGN", + "DEPROVISIONING_FROM_CAMPAIGN", + "PROVISIONING_TO_SMS_PLATFORM", + "DEPROVISIONING_FROM_SMS_PLATFORM", + "PROVISIONING_TO_VOICE_PLATFORM", + "DEPROVISIONING_TO_VOICE_PLATFORM" + ], StrictStr]] = Field(default=None, alias="eventType") + status: Optional[StrictStr] = None + failure_code: Optional[Union[Literal[ + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_REVIEW", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_NOT_AVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "CAMPAIGN_PROVISIONING_FAILED", + "EXCEEDED_10DLC_LIMIT", + "INSUFFICIENT_BALANCE", + "INVALID_NNID", + "MNO_SHARING_ERROR", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "TFN_NOT_ALLOWED" + ], StrictStr]] = Field(default=None, alias="failureCode") diff --git a/sinch/domains/numbers/webhooks/v1/internal/__init__.py b/sinch/domains/numbers/webhooks/v1/internal/__init__.py new file mode 100644 index 00000000..8b88aa81 --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/internal/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.numbers.webhooks.v1.internal.webhook_event import WebhookEvent + +__all__ = ["WebhookEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py b/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py new file mode 100644 index 00000000..9e03f150 --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py @@ -0,0 +1,7 @@ +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse + + +# Alias for NumbersWebhooksEvent used for request modeling. +# Not to be confused with a response as in BaseModelConfigurationResponse. +class WebhookEvent(BaseModelConfigurationResponse): + pass diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py new file mode 100644 index 00000000..4359a69c --- /dev/null +++ b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py @@ -0,0 +1,85 @@ +import json +from typing import Any, Dict, Union +from datetime import datetime +import re +from pydantic import StrictBool, StrictStr +from sinch.domains.authentication.webhooks.v1.authentication_validation import validate_signature_header +from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent + + +class NumbersWebhooks: + def __init__(self, callback_secret: StrictStr): + self.callback_secret = callback_secret + + def validate_authentication_header( + self, + headers: Dict[StrictStr, StrictStr], + json_payload: StrictStr + ) -> StrictBool: + """ + Validate the authorization header for a callback request + + :param headers: Incoming request's headers + :type headers: Dict[str, str] + :param json_payload: Incoming request's raw body + :type json_payload: StrictStr + :returns: True if the X-Sinch-Signature header is valid + :rtype: bool + """ + return validate_signature_header( + self.callback_secret, + headers, + json_payload + ) + + def parse_event(self, event_body: Union[StrictStr, Dict[StrictStr, Any]]) -> NumbersWebhooksEvent: + """ + Parses the event payload into a NumbersWebhooksEvent object. + + Handles a known issue where the server omits timezone information from + the ``timestamp`` field. If the timezone is missing, the method assumes + UTC and returns a timezone-aware ``datetime`` object. + + :param event_body: The event payload. + :type event_body: Union[StrictStr, Dict[StrictStr, Any]] + :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. + :rtype: NumbersWebhooksEvent + """ + if isinstance(event_body, str): + event_body = self._parse_json(event_body) + timestamp = event_body.get('timestamp') + if timestamp: + event_body["timestamp"] = self._normalize_iso_timestamp(timestamp) + try: + return NumbersWebhooksEvent(**event_body) + except Exception as e: + raise ValueError(f"Failed to parse event body: {e}") + + def _parse_json(self, payload: StrictStr) -> Dict[StrictStr, Any]: + """ + Parse JSON string into a dictionary. + """ + try: + return json.loads(payload) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON: {e}") + + def _normalize_iso_timestamp(self, timestamp: StrictStr) -> datetime: + """ + Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()` + - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. + - Replaces trailing "Z" with "+00:00" to indicate UTC. + - Trims microseconds to 6 digits. + """ + if timestamp.endswith("Z"): + timestamp = timestamp.replace("Z", "+00:00") + elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): + timestamp += "+00:00" + match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) + if match_ms: + micro_trimmed = match_ms.group(1)[:6] + timestamp = re.sub(r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp) + try: + return datetime.fromisoformat(timestamp) + except ValueError as e: + raise ValueError(f"Invalid timestamp format: {e}") diff --git a/tests/conftest.py b/tests/conftest.py index ce341d79..90284021 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,7 @@ from sinch import SinchClient from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.domains.numbers.models.v1.response import ActiveNumber diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py new file mode 100644 index 00000000..ac871587 --- /dev/null +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -0,0 +1,54 @@ +import re +import json +import requests +from behave import given, when, then + +SINCH_NUMBERS_CALLBACK_SECRET = 'strongPa$$PhraseWith36CharactersMax' + + +def parse_event(context, response): + context.headers = response.headers + # Strip all whitespace characters from the raw event text. + raw_event = re.sub(r'\s+', '', response.text) + context.raw_event = raw_event + return json.loads(raw_event) + + +@given('the Numbers Webhooks handler is available') +def step_webhook_handler_is_available(context): + context.numbers_webhook = context.sinch.numbers.webhooks(SINCH_NUMBERS_CALLBACK_SECRET) + + +@when('I send a request to trigger the success to provision to voice platform event') +def step_send_trigger_success_event(context): + response = requests.get('http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/succeeded') + event_json = parse_event(context, response) + context.event = context.numbers_webhook.parse_event(event_json) + + +@then('the event header contains a valid signature') +def step_check_valid_signature(context): + assert context.numbers_webhook.validate_authentication_header( + context.headers, context.raw_event + ), "Signature validation failed" + + +@then('the event describes a success to provision to voice platform event') +def step_check_success_event_details(context): + assert context.event.event_type == 'PROVISIONING_TO_VOICE_PLATFORM' + assert context.event.status == 'SUCCEEDED' + assert context.event.failure_code is None + + +@when('I send a request to trigger the failure to provision to voice platform event') +def step_send_trigger_failure_event(context): + response = requests.get('http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/failed') + event_json = parse_event(context, response) + context.event = context.numbers_webhook.parse_event(event_json) + + +@then('the event describes a failure to provision to voice platform event') +def step_check_failure_event_details(context): + assert context.event.event_type == 'PROVISIONING_TO_VOICE_PLATFORM' + assert context.event.status == 'FAILED' + assert context.event.failure_code == 'PROVISIONING_TO_VOICE_PLATFORM_FAILED' diff --git a/tests/unit/domains/authentication/test_authentication_validation.py b/tests/unit/domains/authentication/test_authentication_validation.py new file mode 100644 index 00000000..53b6c569 --- /dev/null +++ b/tests/unit/domains/authentication/test_authentication_validation.py @@ -0,0 +1,39 @@ +import pytest +from sinch.domains.authentication.webhooks.v1.authentication_validation import validate_signature_header + + +@pytest.fixture +def string_to_sign(): + return ( + '{"eventId":"event_id","timestamp":"2025-04-08T10:38:04.854087603",' + '"projectId":"project-id","resourceId":"+1234567890",' + '"resourceType":"ACTIVE_NUMBER","eventType":"PROVISIONING_TO_VOICE_PLATFORM",' + '"status":"SUCCEEDED","failureCode":null,"internalFailureCode":null}' + ) + + +@pytest.fixture +def secret(): + return "my-callback-secret" + + +def test_valid_signature_header_expects_successful_validation(secret, string_to_sign): + headers = { + "X-Sinch-Signature": "d2107528d5d52897a97dc6e24e09a208036ccd83" + } + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is True + + +def test_missing_signature_expects_no_validation(secret, string_to_sign): + headers = {} + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is False + + +def test_invalid_signature_expects_no_validation(secret, string_to_sign): + headers = { + "X-Sinch-Signature": "invalid-signature" + } + validated = validate_signature_header(secret, headers, string_to_sign) + assert validated is False diff --git a/tests/unit/domains/numbers/v1/test_numbers_callback.py b/tests/unit/domains/numbers/v1/test_numbers_callback.py index 610d705b..cf818696 100644 --- a/tests/unit/domains/numbers/v1/test_numbers_callback.py +++ b/tests/unit/domains/numbers/v1/test_numbers_callback.py @@ -9,7 +9,7 @@ @pytest.mark.parametrize( - "test_name,config_kwargs,expected_request_data", + "test_name, config_kwargs, expected_request_data", [ ( "without_extra_params", {}, None diff --git a/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py b/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py new file mode 100644 index 00000000..8ec761ef --- /dev/null +++ b/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py @@ -0,0 +1,79 @@ +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError +from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent + + +@pytest.fixture +def valid_data(): + return { + "eventId": "event-123", + "timestamp": "2025-04-08T09:38:04.854087+00:00", + "projectId": "project-456", + "resourceId": "+1234567890", + "resourceType": "ACTIVE_NUMBER", + "eventType": "PROVISIONING_TO_VOICE_PLATFORM", + "status": "SUCCEEDED", + "failureCode": None, + "internalFailureCode": None, + "extraField": "extra_value" + } + + +@pytest.fixture +def invalid_data(): + return { + "eventId": 123, + "timestamp": "invalid-timestamp", + "projectId": "project-456", + "resourceId": "+1234567890" + } + + +def test_numbers_webhooks_response_expects_parsed_data(valid_data): + """ + Expects all fields to map correctly from camelCase input + and handle valid data appropriately. + """ + response = NumbersWebhooksEvent(**valid_data) + + assert response.event_id == "event-123" + assert response.timestamp == datetime( + 2025, 4, 8, 9, 38, 4, 854087, tzinfo=timezone.utc + ) + assert response.project_id == "project-456" + assert response.resource_id == "+1234567890" + assert response.resource_type == "ACTIVE_NUMBER" + assert response.event_type == "PROVISIONING_TO_VOICE_PLATFORM" + assert response.status == "SUCCEEDED" + assert response.failure_code is None + assert response.internal_failure_code is None + assert response.extra_field == "extra_value" + + +def test_numbers_webhooks_response_missing_optional_fields_expects_parsed_data(): + """ + Expects the model to handle missing optional fields. + """ + data = { + "eventId": "event-123", + "projectId": "project-456" + } + response = NumbersWebhooksEvent(**data) + + assert response.event_id == "event-123" + assert response.project_id == "project-456" + assert response.timestamp is None + assert response.resource_id is None + assert response.resource_type is None + assert response.event_type is None + assert response.status is None + assert response.failure_code is None + + +def test_numbers_webhooks_response_invalid_data_expects_validation_error(invalid_data): + """ + Expects the model to raise a validation error for invalid data. + """ + with pytest.raises(ValidationError): + NumbersWebhooksEvent(**invalid_data) diff --git a/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py b/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py new file mode 100644 index 00000000..171a22fc --- /dev/null +++ b/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py @@ -0,0 +1,82 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks +from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent + + +@pytest.fixture +def string_to_sign(): + return ( + '{"eventId":"01jr7stexp0znky34pj07dwp41","timestamp":"2025-04-07T09:38:04.85408760",' + '"projectId":"project-id","resourceId":"+1234567890",' + '"resourceType":"ACTIVE_NUMBER","eventType":"PROVISIONING_TO_VOICE_PLATFORM",' + '"status":"SUCCEEDED","failureCode":null,"internalFailureCode":null}' + ) + + +@pytest.fixture +def numbers_webhooks(): + return NumbersWebhooks('my-callback-secret') + + +@pytest.fixture +def base_payload_parse_event(): + return { + "eventId": "01jr7stexp0znky34pj07dwp41", + "projectId": "project-id", + "resourceId": "+1234567890", + "resourceType": "ACTIVE_NUMBER", + "eventType": "PROVISIONING_TO_VOICE_PLATFORM", + "status": "SUCCEEDED", + "failureCode": None, + "internalFailureCode": None, + "timestamp": "2025-04-07T09:38:04.854087603" + } + + +def test_valid_signature_header_expects_successful_validation(numbers_webhooks, string_to_sign): + headers = { + "X-Sinch-Signature": "8e58baa351ffa5e0d7eaef3c739d0d7aa6093da3" + } + response = numbers_webhooks.validate_authentication_header(headers, string_to_sign) + assert response is True + + +@pytest.mark.parametrize( + "test_name, timestamp_str", + [ + ( + "parse_without_timezone_suffix", "2025-04-06T08:45:27.565347" + ), + ( + "parse_with_zulu_timezone_suffix", "2025-04-06T08:45:27.565347Z" + ), + ( + "parse_with_extra_digits", "2025-04-06T08:45:27.56534760" + ) + ] +) +def test_parse_event_expects_timestamp_as_utc(numbers_webhooks, test_name, timestamp_str): + payload = {"timestamp": timestamp_str} + parsed = numbers_webhooks.parse_event(payload) + expected = datetime( + 2025, 4, 6, 8, 45, 27, 565347, tzinfo=timezone.utc + ) + assert parsed.timestamp == expected + + +def test_parse_event_expects_parsed_response(numbers_webhooks, base_payload_parse_event): + response = numbers_webhooks.parse_event(base_payload_parse_event) + assert isinstance(response, NumbersWebhooksEvent) + assert response.event_id == "01jr7stexp0znky34pj07dwp41" + assert response.project_id == "project-id" + assert response.resource_id == "+1234567890" + assert response.resource_type == "ACTIVE_NUMBER" + assert response.event_type == "PROVISIONING_TO_VOICE_PLATFORM" + assert response.status == "SUCCEEDED" + assert response.failure_code is None + assert response.internal_failure_code is None + expected_timestamp = datetime( + 2025, 4, 7, 9, 38, 4, 854087, tzinfo=timezone.utc + ) + assert response.timestamp == expected_timestamp diff --git a/tests/unit/test_token_manager.py b/tests/unit/test_token_manager.py index 2eb24802..502d5ad1 100644 --- a/tests/unit/test_token_manager.py +++ b/tests/unit/test_token_manager.py @@ -2,7 +2,7 @@ from unittest.mock import Mock from sinch.core.token_manager import TokenManager -from sinch.domains.authentication.models.authentication import OAuthToken +from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.core.exceptions import ValidationException From 4f580904296248a723905e25dfdedc031425e8a1 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Mon, 14 Apr 2025 13:23:34 +0200 Subject: [PATCH 040/106] chore: rename Numbers class (#60) Rename class to avoid conflict with built-in Python package numbers Signed-off-by: Jessica Matsuoka --- sinch/core/clients/sinch_client_sync.py | 4 ++-- sinch/domains/numbers/__init__.py | 4 ++-- sinch/domains/numbers/{numbers.py => virtual_numbers.py} | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename sinch/domains/numbers/{numbers.py => virtual_numbers.py} (99%) diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index c288b8c0..a15459c4 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -3,7 +3,7 @@ from sinch.core.token_manager import TokenManager from sinch.core.adapters.requests_http_transport import HTTPTransportRequests from sinch.domains.authentication import Authentication -from sinch.domains.numbers import Numbers +from sinch.domains.numbers import VirtualNumbers from sinch.domains.conversation import Conversation from sinch.domains.sms import SMS from sinch.domains.verification import Verification @@ -43,7 +43,7 @@ def __init__( ) self.authentication = Authentication(self) - self.numbers = Numbers(self) + self.numbers = VirtualNumbers(self) self.conversation = Conversation(self) self.sms = SMS(self) self.verification = Verification(self) diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index ab4d778c..cabff49c 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,3 +1,3 @@ -from sinch.domains.numbers.numbers import Numbers +from sinch.domains.numbers.virtual_numbers import VirtualNumbers -__all__ = ['Numbers'] +__all__ = ['VirtualNumbers'] diff --git a/sinch/domains/numbers/numbers.py b/sinch/domains/numbers/virtual_numbers.py similarity index 99% rename from sinch/domains/numbers/numbers.py rename to sinch/domains/numbers/virtual_numbers.py index b4f3a42e..890b469c 100644 --- a/sinch/domains/numbers/numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -15,7 +15,7 @@ from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks -class Numbers: +class VirtualNumbers: """ Synchronous version of the Numbers domain. From d28d5a7315852f040325e31225e78fda69b1f2a9 Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:07:58 +0200 Subject: [PATCH 041/106] DEVEXP-868: Validate secret for validate_signature_header() method (#61) --- .../webhooks/v1/authentication_validation.py | 2 ++ .../authentication/test_authentication_validation.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 94135cab..0997bf73 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -23,6 +23,8 @@ def validate_signature_header( :rtype: bool """ + if callback_secret is None: + return False normalized_headers = normalize_headers(headers) signature = get_header(normalized_headers.get('x-sinch-signature')) if signature is None: diff --git a/tests/unit/domains/authentication/test_authentication_validation.py b/tests/unit/domains/authentication/test_authentication_validation.py index 53b6c569..b8f11c86 100644 --- a/tests/unit/domains/authentication/test_authentication_validation.py +++ b/tests/unit/domains/authentication/test_authentication_validation.py @@ -37,3 +37,11 @@ def test_invalid_signature_expects_no_validation(secret, string_to_sign): } validated = validate_signature_header(secret, headers, string_to_sign) assert validated is False + + +def test_None_secret_expects_no_validation(string_to_sign): + headers = { + "X-Sinch-Signature": "d2107528d5d52897a97dc6e24e09a208036ccd83" + } + validated = validate_signature_header(None, headers, string_to_sign) + assert validated is False From f36cda56c3b767d56563f945cf611490e96b4996 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Tue, 6 May 2025 20:20:40 +0200 Subject: [PATCH 042/106] DEVEXP-854: Shared model for rent/rentAny endpoints (#62) --- .../numbers/api/v1/available_numbers_apis.py | 4 +- .../internal/available_numbers_endpoints.py | 6 +- .../numbers/models/v1/response/__init__.py | 2 - .../v1/response/rent_any_number_response.py | 23 --- sinch/domains/numbers/virtual_numbers.py | 10 +- .../numbers/features/steps/numbers.steps.py | 4 +- .../test_rent_any_number_endpoint.py | 6 +- .../test_rent_any_number_response_model.py | 160 ------------------ 8 files changed, 15 insertions(+), 200 deletions(-) delete mode 100644 sinch/domains/numbers/models/v1/response/rent_any_number_response.py delete mode 100644 tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 68e6a4a1..dd0a8bff 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -3,7 +3,7 @@ from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse + ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse ) from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( @@ -77,7 +77,7 @@ def rent_any( voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, **kwargs - ) -> RentAnyNumberResponse: + ) -> ActiveNumber: request_data = RentAnyNumberRequest( region_code=region_code, type_=type_, diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 004cf25c..ad3bdf5e 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -7,7 +7,7 @@ NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse + ActiveNumber, CheckNumberAvailabilityResponse ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint @@ -73,11 +73,11 @@ def request_body(self) -> str: request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) return json.dumps(request_data) - def handle_response(self, response: HTTPResponse) -> RentAnyNumberResponse: + def handle_response(self, response: HTTPResponse) -> ActiveNumber: error = super(RentAnyNumberEndpoint, self).handle_response(response) if error: return error - return self.process_response_model(response.body, RentAnyNumberResponse) + return self.process_response_model(response.body, ActiveNumber) class SearchForNumberEndpoint(NumbersEndpoint): diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index f111e358..f4e1f98e 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -3,7 +3,6 @@ from sinch.domains.numbers.models.v1.response.available_region import AvailableRegion from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse from sinch.domains.numbers.models.v1.response.numbers_callback import CallbackConfigurationResponse -from sinch.domains.numbers.models.v1.response.rent_any_number_response import RentAnyNumberResponse __all__ = [ "ActiveNumber", @@ -11,5 +10,4 @@ "AvailableRegion", "CallbackConfigurationResponse", "CheckNumberAvailabilityResponse", - "RentAnyNumberResponse" ] diff --git a/sinch/domains/numbers/models/v1/response/rent_any_number_response.py b/sinch/domains/numbers/models/v1/response/rent_any_number_response.py deleted file mode 100644 index 0755e3ba..00000000 --- a/sinch/domains/numbers/models/v1/response/rent_any_number_response.py +++ /dev/null @@ -1,23 +0,0 @@ -from datetime import datetime -from typing import Optional -from pydantic import Field, StrictStr, StrictInt -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.shared import ( - Money, SmsConfigurationResponse, VoiceConfigurationResponse -) -from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberTypeValues - - -class RentAnyNumberResponse(BaseModelConfigurationResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - project_id: Optional[StrictStr] = Field(default=None, alias="projectId") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberTypeValues] = Field(default=None) - capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) - money: Optional[Money] = Field(default=None) - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") - expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 890b469c..a30f1ec3 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -5,7 +5,7 @@ ) from sinch.core.pagination import Paginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse, RentAnyNumberResponse + ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, @@ -319,7 +319,7 @@ def rent_any( number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: + ) -> ActiveNumber: pass @overload @@ -332,7 +332,7 @@ def rent_any( number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: + ) -> ActiveNumber: pass @overload @@ -345,7 +345,7 @@ def rent_any( number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValuesList] = None, callback_url: Optional[StrictStr] = None - ) -> RentAnyNumberResponse: + ) -> ActiveNumber: pass def rent_any( @@ -358,7 +358,7 @@ def rent_any( voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, **kwargs - ) -> RentAnyNumberResponse: + ) -> ActiveNumber: """ Search for and activate an available Sinch virtual number all in one API call. Currently, the ``rent_any`` operation works only for US 10DLC numbers. diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 11027280..655279ed 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -3,7 +3,7 @@ from decimal import Decimal from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1.errors import NotFoundError -from sinch.domains.numbers.models.v1.response import ActiveNumber, RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response import ActiveNumber @given('the Numbers service is available') @@ -86,7 +86,7 @@ def step_rent_any_number(context): @then('the response contains a rented phone number') def step_validate_rented_number(context): - data: RentAnyNumberResponse = context.response + data: ActiveNumber = context.response assert data.phone_number == '+12017654321' assert data.project_id == '123c0ffee-dada-beef-cafe-baadc0de5678' assert data.display_name == '' diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 82aa73ab..1c3e6107 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -4,7 +4,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.numbers.api.v1.internal import RentAnyNumberEndpoint from sinch.domains.numbers.models.v1.internal import RentAnyNumberRequest -from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse +from sinch.domains.numbers.models.v1.response import ActiveNumber @pytest.fixture @@ -26,7 +26,7 @@ def valid_request_data(): @pytest.fixture def valid_response_data(): """ - Provides valid mock response data for RentAnyNumberResponse. + Provides valid mock response data for ActiveNumer. """ return { "phoneNumber": "+12025550134", @@ -105,7 +105,7 @@ def test_handle_response_expects_valid_mapping(valid_response_data): response = endpoint.handle_response(mock_response) # Validate response fields - assert isinstance(response, RentAnyNumberResponse) + assert isinstance(response, ActiveNumber) assert response.phone_number == "+12025550134" assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" assert response.region_code == "US" diff --git a/tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py deleted file mode 100644 index cad02e4c..00000000 --- a/tests/unit/domains/numbers/v1/models/response/test_rent_any_number_response_model.py +++ /dev/null @@ -1,160 +0,0 @@ -import pytest -from datetime import datetime, timezone -from pydantic import ValidationError -from sinch.domains.numbers.models.v1.response import RentAnyNumberResponse - - -@pytest.fixture -def valid_data(): - """ - Provides valid test data for RentAnyNumberResponse. - """ - return { - "phoneNumber": "+12025550134", - "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", - "displayName": "string", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "money": {"currencyCode": "USD", "amount": "2.00"}, - "paymentIntervalMonths": 0, - "nextChargeDate": "2025-01-24T09:32:27.437Z", - "expireAt": "2025-01-25T09:32:27.437Z", - "smsConfiguration": { - "servicePlanId": "string", - "campaignId": "string", - "scheduledProvisioning": { - "servicePlanId": "string", - "campaignId": "string", - "status": "PROVISIONING_STATUS_UNSPECIFIED", - "lastUpdatedTime": "2025-01-24T09:32:27.437Z", - "errorCodes": ["ERROR_CODE_UNSPECIFIED"], - }, - }, - "voiceConfiguration": { - "type": "RTC", - "lastUpdatedTime": "2025-01-24T09:32:27.437Z", - "scheduledVoiceProvisioning": { - "type": "RTC", - "lastUpdatedTime": "2025-01-24T09:32:27.437Z", - "status": "PROVISIONING_STATUS_UNSPECIFIED", - "appId": "string", - }, - "appId": "string", - }, - "callbackUrl": "https://www.your-callback-server.com/callback", - } - - -def test_rent_any_number_response_expects_valid_data(valid_data): - """ - Test that RentAnyNumberResponse correctly parses valid data. - """ - response = RentAnyNumberResponse(**valid_data) - - assert response.phone_number == "+12025550134" - assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" - assert response.region_code == "US" - assert response.type == "MOBILE" - assert response.capability == ["SMS"] - assert response.money.currency_code == "USD" - assert response.money.amount == 2.00 - assert response.payment_interval_months == 0 - expected_next_charge_date = ( - datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc) - ) - assert response.next_charge_date == expected_next_charge_date - expected_expire_at = ( - datetime(2025, 1, 25, 9, 32, 27, 437000, tzinfo=timezone.utc) - ) - assert response.expire_at == expected_expire_at - - sms_config = response.sms_configuration - assert sms_config.service_plan_id == "string" - assert sms_config.campaign_id == "string" - assert sms_config.scheduled_provisioning.service_plan_id == "string" - assert sms_config.scheduled_provisioning.campaign_id == "string" - assert sms_config.scheduled_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" - expected_last_updated_time = ( - datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) - assert sms_config.scheduled_provisioning.last_updated_time == expected_last_updated_time - assert sms_config.scheduled_provisioning.error_codes == ["ERROR_CODE_UNSPECIFIED"] - - voice_config = response.voice_configuration - assert voice_config.type == "RTC" - expected_last_updated_time = ( - datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) - assert voice_config.last_updated_time == expected_last_updated_time - scheduled_voice_provisioning = voice_config.scheduled_voice_provisioning - assert scheduled_voice_provisioning.type == "RTC" - assert scheduled_voice_provisioning.last_updated_time == expected_last_updated_time - assert scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" - assert scheduled_voice_provisioning.app_id == "string" - assert voice_config.app_id == "string" - assert response.callback_url == "https://www.your-callback-server.com/callback" - - -def test_rent_any_number_response_expects_missing_optional_fields(): - """ - Test that RentAnyNumberResponse handles missing optional fields correctly. - """ - data = { - "phoneNumber": "+12025550134", - "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "money": {"currencyCode": "USD", "amount": "2.00"}, - "paymentIntervalMonths": 0, - } - - response = RentAnyNumberResponse(**data) - - assert response.next_charge_date is None - assert response.expire_at is None - assert response.sms_configuration is None - assert response.voice_configuration is None - assert response.callback_url is None - - -def test_rent_any_number_response_expects_validation_error_for_missing_required_fields(): - """ - Test that RentAnyNumberResponse raises a validation error for missing required fields. - """ - data = { - "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", - "regionCode": "US", - "smsConfiguration": { - # Missing required field "service_plan_id" - "campaignId": "string" - } - } - - with pytest.raises(ValidationError) as exc_info: - RentAnyNumberResponse(**data) - # Assert the validation error mentions missing fields - assert "smsConfiguration.servicePlanId" in str(exc_info.value) - - -def test_rent_any_number_response_expects_ignore_extra_fields(): - """ - Test that RentAnyNumberResponse ignores extra fields. - """ - data = { - "phoneNumber": "+12025550134", - "projectId": "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a", - "regionCode": "US", - "type": "MOBILE", - "capability": ["SMS"], - "money": {"currency_code": "USD", "amount": "2.00"}, - "paymentIntervalMonths": 0, - "extraField": "unexpected", - } - - response = RentAnyNumberResponse(**data) - - # Assert valid fields are parsed correctly - assert response.phone_number == "+12025550134" - assert response.project_id == "51bc3f40-f266-4ca8-8938-a1ed0ff32b9a" - assert response.region_code == "US" - assert response.extra_field == "unexpected" From 1a40545a5e8263643572d305df25bbb8efbddf93 Mon Sep 17 00:00:00 2001 From: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> Date: Mon, 12 May 2025 10:51:16 +0200 Subject: [PATCH 043/106] Numbers Events - Remove pre-processing of event body (#63) --- tests/e2e/numbers/features/steps/webhooks.steps.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py index ac871587..b0caae0b 100644 --- a/tests/e2e/numbers/features/steps/webhooks.steps.py +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -1,4 +1,3 @@ -import re import json import requests from behave import given, when, then @@ -8,10 +7,8 @@ def parse_event(context, response): context.headers = response.headers - # Strip all whitespace characters from the raw event text. - raw_event = re.sub(r'\s+', '', response.text) - context.raw_event = raw_event - return json.loads(raw_event) + context.raw_event = response.text + return json.loads(context.raw_event) @given('the Numbers Webhooks handler is available') From 7d0c6515cf1f4cdde27be836765b8806fb16ac4d Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Tue, 13 May 2025 10:41:12 +0200 Subject: [PATCH 044/106] chore: update docstring to reST format (#64) --- sinch/domains/numbers/virtual_numbers.py | 36 +++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index a30f1ec3..6c5d9179 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -282,22 +282,26 @@ def rent( """ Rent a virtual number to use with SMS, Voice, or both products. - Args: - phone_number (StrictStr): The phone number in E.164 format with leading +. - sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. - Including fields such as: - - service_plan_id (str): The service plan ID. - - campaign_id (Optional[str]): The campaign ID. - voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. - Supported types include: - - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. - - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. - - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. - callback_url (Optional[StrictStr]): The callback URL to be called. - **kwargs: Additional parameters for the request. - - Returns: - ActiveNumber: A response object with the rented number and its details. + :param phone_number: The phone number in E.164 format with leading ``+``. + :type phone_number: StrictStr + :param sms_configuration: A dictionary defining the SMS configuration. + Include the following fields: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + :param voice_configuration: A dictionary defining the Voice configuration. + Supported types include: + - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + :param callback_url: The callback URL to be called. + :type callback_url: Optional[StrictStr] + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the rented number and its details. + :rtype: ActiveNumber For detailed documentation, visit https://developers.sinch.com """ From 768b2ed4b09d4970cefec4085c35746a90578b4a Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Wed, 14 May 2025 09:44:04 +0200 Subject: [PATCH 045/106] Revert "chore: update docstring to reST format (#64)" (#65) This reverts commit 7d0c6515cf1f4cdde27be836765b8806fb16ac4d. --- sinch/domains/numbers/virtual_numbers.py | 36 +++++++++++------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 6c5d9179..a30f1ec3 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -282,26 +282,22 @@ def rent( """ Rent a virtual number to use with SMS, Voice, or both products. - :param phone_number: The phone number in E.164 format with leading ``+``. - :type phone_number: StrictStr - :param sms_configuration: A dictionary defining the SMS configuration. - Include the following fields: - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. - :type sms_configuration: Optional[SmsConfigurationDict] - :param voice_configuration: A dictionary defining the Voice configuration. - Supported types include: - - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] - :param callback_url: The callback URL to be called. - :type callback_url: Optional[StrictStr] - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: A response object with the rented number and its details. - :rtype: ActiveNumber + Args: + phone_number (StrictStr): The phone number in E.164 format with leading +. + sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. + Including fields such as: + - service_plan_id (str): The service plan ID. + - campaign_id (Optional[str]): The campaign ID. + voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. + Supported types include: + - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. + - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. + - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. + callback_url (Optional[StrictStr]): The callback URL to be called. + **kwargs: Additional parameters for the request. + + Returns: + ActiveNumber: A response object with the rented number and its details. For detailed documentation, visit https://developers.sinch.com """ From f06acfcc1a5a7ba1000279ce5245af13c5ead0ba Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Fri, 16 May 2025 12:21:40 +0200 Subject: [PATCH 046/106] chore: update e2e tests (#66) --- .github/workflows/run-tests.yml | 5 ++- .../numbers/features/steps/webhooks.steps.py | 45 ++++++++----------- 2 files changed, 22 insertions(+), 28 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e5758410..e089a633 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -69,7 +69,10 @@ jobs: - name: Copy feature files run: | - cp sinch-sdk-mockserver/features/numbers/*.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/available-regions.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py index b0caae0b..a681fd96 100644 --- a/tests/e2e/numbers/features/steps/webhooks.steps.py +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -16,36 +16,27 @@ def step_webhook_handler_is_available(context): context.numbers_webhook = context.sinch.numbers.webhooks(SINCH_NUMBERS_CALLBACK_SECRET) -@when('I send a request to trigger the success to provision to voice platform event') -def step_send_trigger_success_event(context): - response = requests.get('http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/succeeded') +@when('I send a request to trigger the "{status}" for "{event_type}" event') +def step_send_trigger_event(context, status, event_type): + endpoint = 'succeeded' if status == 'success' else 'failed' + response = requests.get(f'http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/{endpoint}') event_json = parse_event(context, response) context.event = context.numbers_webhook.parse_event(event_json) -@then('the event header contains a valid signature') -def step_check_valid_signature(context): +@then('the header of the "{status}" for "{event_type}" event contains a valid signature') +def step_check_valid_signature(context, status, event_type): assert context.numbers_webhook.validate_authentication_header( context.headers, context.raw_event - ), "Signature validation failed" - - -@then('the event describes a success to provision to voice platform event') -def step_check_success_event_details(context): - assert context.event.event_type == 'PROVISIONING_TO_VOICE_PLATFORM' - assert context.event.status == 'SUCCEEDED' - assert context.event.failure_code is None - - -@when('I send a request to trigger the failure to provision to voice platform event') -def step_send_trigger_failure_event(context): - response = requests.get('http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/failed') - event_json = parse_event(context, response) - context.event = context.numbers_webhook.parse_event(event_json) - - -@then('the event describes a failure to provision to voice platform event') -def step_check_failure_event_details(context): - assert context.event.event_type == 'PROVISIONING_TO_VOICE_PLATFORM' - assert context.event.status == 'FAILED' - assert context.event.failure_code == 'PROVISIONING_TO_VOICE_PLATFORM_FAILED' + ), 'Signature validation failed' + + +@then('the event describes a "{status}" for "{event_type}" event') +def step_check_event_details(context, status, event_type): + assert context.event.event_type == event_type + if status == 'success': + assert context.event.status == 'SUCCEEDED' + assert context.event.failure_code is None + else: + assert context.event.status == 'FAILED' + assert context.event.failure_code == 'PROVISIONING_TO_VOICE_PLATFORM_FAILED' From f94a7451b161a8ed0edbd12eaaa956631a4a6bcd Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Mon, 19 May 2025 18:35:42 +0200 Subject: [PATCH 047/106] chore: update docstring (#67) --- sinch/domains/numbers/virtual_numbers.py | 36 +++++++++++++----------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index a30f1ec3..1ccd1e87 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -282,22 +282,26 @@ def rent( """ Rent a virtual number to use with SMS, Voice, or both products. - Args: - phone_number (StrictStr): The phone number in E.164 format with leading +. - sms_configuration (Optional[SmsConfigurationDict]): A dictionary defining the SMS configuration. - Including fields such as: - - service_plan_id (str): The service plan ID. - - campaign_id (Optional[str]): The campaign ID. - voice_configuration (Optional[VoiceConfigurationDictType]): A dictionary defining the Voice configuration. - Supported types include: - - `VoiceConfigurationDictRTC`: type 'RTC' with an `app_id` field. - - `VoiceConfigurationDictEST`: type 'EST' with a `trunk_id` field. - - `VoiceConfigurationDictFAX`: type 'FAX' with a `service_id` field. - callback_url (Optional[StrictStr]): The callback URL to be called. - **kwargs: Additional parameters for the request. - - Returns: - ActiveNumber: A response object with the rented number and its details. + :param phone_number: The phone number in E.164 format with leading ``+``. + :type phone_number: StrictStr + :param sms_configuration: A dictionary defining the SMS configuration. + Include the following fields: + - ``service_plan_id`` (str): The service plan ID. + - ``campaign_id`` (Optional[str]): The campaign ID. + :type sms_configuration: Optional[SmsConfigurationDict] + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + + - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDictType] + :param callback_url: The callback URL to be called. + :type callback_url: Optional[StrictStr] + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: A response object with the rented number and its details. + :rtype: ActiveNumber For detailed documentation, visit https://developers.sinch.com """ From a614bdc2fad92b768bbca950398100ca07b4c170 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Wed, 21 May 2025 09:24:57 +0200 Subject: [PATCH 048/106] DEVEXP-903: (Numbers) Sync OAS - Week 20 (#68) --- .../v1/internal/rent_any_number_request.py | 6 ++-- .../v1/shared/scheduled_sms_provisioning.py | 31 +++++++++++++++++-- .../v1/types/status_scheduled_provisioning.py | 2 +- sinch/domains/numbers/virtual_numbers.py | 23 ++++++++------ .../v1/events/numbers_webhooks_event.py | 5 ++- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 1cbee690..2939ccb7 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,14 +1,14 @@ from typing import Optional, Dict from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.shared import NumberPattern -from sinch.domains.numbers.models.v1.types import CapabilityType +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest class RentAnyNumberRequest(BaseModelConfigurationRequest): - region_code: StrictStr = Field(default=None, alias="regionCode") - type_: StrictStr = Field(default=None, alias="type") + region_code: StrictStr = Field(alias="regionCode") + type_: NumberType = Field(default=None, alias="type") number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") capabilities: Optional[CapabilityType] = Field(default=None) sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index ebd60cad..642efbd5 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional +from typing import Optional, Union, Literal from pydantic import StrictStr, Field, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning @@ -10,4 +10,31 @@ class ScheduledSmsProvisioning(BaseModelConfigurationResponse): campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") status: Optional[StatusScheduledProvisioning] = None last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - error_codes: Optional[conlist(StrictStr, min_length=0)] = Field(default=None, alias="errorCodes") + error_codes: Optional[ + conlist( + Union[ + Literal[ + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_REVIEW", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_NOT_AVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "CAMPAIGN_PROVISIONING_FAILED", + "ERROR_CODE_UNSPECIFIED", + "EXCEEDED_10DLC_LIMIT", + "INSUFFICIENT_BALANCE", + "INTERNAL_ERROR", + "INVALID_NNID", + "MNO_SHARING_ERROR", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "SMS_PROVISIONING_FAILED", + "TFN_NOT_ALLOWED", + ], + StrictStr, + ], + min_length=0, + ) + ] = Field(default=None, alias="errorCodes") diff --git a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py index 4ffc0d01..284ae130 100644 --- a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py +++ b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py @@ -2,6 +2,6 @@ from pydantic import StrictStr, Field StatusScheduledProvisioning = Annotated[ - Union[Literal["WAITING", "IN_PROGRESS", "FAILED"], StrictStr], + Union[Literal["WAITING", "IN_PROGRESS", "FAILED", "PROVISIONING_STATUS_UNSPECIFIED"], StrictStr], Field(default=None) ] diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 1ccd1e87..8e48ac74 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -155,12 +155,14 @@ def update( :param display_name: The display name for the virtual number. :type display_name: Optional[str] - :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as: + :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as:: + - ``service_plan_id`` (str): The service plan ID. - ``campaign_id`` (Optional[str]): The campaign ID. :type sms_configuration: Optional[SmsConfigurationDict] - :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + - ``VoiceConfigurationDictRTC``: type 'RTC' with an ``app_id`` field. - ``VoiceConfigurationDictEST``: type 'EST' with a ``trunk_id`` field. - ``VoiceConfigurationDictFAX``: type 'FAX' with a ``service_id`` field. @@ -285,7 +287,8 @@ def rent( :param phone_number: The phone number in E.164 format with leading ``+``. :type phone_number: StrictStr :param sms_configuration: A dictionary defining the SMS configuration. - Include the following fields: + Include the following fields:: + - ``service_plan_id`` (str): The service plan ID. - ``campaign_id`` (Optional[str]): The campaign ID. :type sms_configuration: Optional[SmsConfigurationDict] @@ -370,7 +373,7 @@ def rent_any( :param region_code: ISO 3166-1 alpha-2 country code of the phone number. :type region_code: str - :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). + :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). Defaults to ``"MOBILE"``. :type type_: NumberType :param number_pattern: Specific sequence of digits to search for. @@ -379,15 +382,17 @@ def rent_any( :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). :type capabilities: Optional[CapabilityType] - :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as: + :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as:: + - ``service_plan_id`` (str): The service plan ID. - ``campaign_id`` (Optional[str]): The campaign ID. :type sms_configuration: Optional[SmsConfigurationDict] - :param voice_configuration: A dictionary defining the Voice configuration. Supported types include: - - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. + :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: + + - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDictType] :param callback_url: The callback URL to receive notifications. diff --git a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py index 523d2589..08e221ce 100644 --- a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py +++ b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py @@ -18,7 +18,10 @@ class NumbersWebhooksEvent(WebhookEvent): "PROVISIONING_TO_VOICE_PLATFORM", "DEPROVISIONING_TO_VOICE_PLATFORM" ], StrictStr]] = Field(default=None, alias="eventType") - status: Optional[StrictStr] = None + status: Optional[Union[Literal[ + "SUCCEEDED", + "FAILED" + ], StrictStr]] = None failure_code: Optional[Union[Literal[ "CAMPAIGN_EXPIRED", "CAMPAIGN_MNO_REJECTED", From f515ccfdf073cac167d9116c455551592186804e Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Tue, 27 May 2025 14:13:51 +0200 Subject: [PATCH 049/106] chore: rename error model (#70) --- sinch/domains/numbers/models/v1/errors/__init__.py | 2 +- .../models/v1/errors/{not_found.py => not_found_error.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename sinch/domains/numbers/models/v1/errors/{not_found.py => not_found_error.py} (100%) diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index 1f135a55..aac4e58a 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,4 +1,4 @@ -from sinch.domains.numbers.models.v1.errors.not_found import NotFoundError +from sinch.domains.numbers.models.v1.errors.not_found_error import NotFoundError from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails __all__ = [ diff --git a/sinch/domains/numbers/models/v1/errors/not_found.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py similarity index 100% rename from sinch/domains/numbers/models/v1/errors/not_found.py rename to sinch/domains/numbers/models/v1/errors/not_found_error.py From 3985ab5efbf8cee056177e0748843ce172a3e6cb Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Mon, 2 Jun 2025 18:19:49 +0200 Subject: [PATCH 050/106] refactor(Numbers): Match expected generated structure (#71) * fix: Optional conlist * chore: - rename error details - remove duplicate model - rename model filename --- .../numbers/api/v1/available_numbers_apis.py | 4 ++-- .../internal/available_numbers_endpoints.py | 6 ++--- .../numbers/models/v1/errors/__init__.py | 4 ++-- .../models/v1/errors/not_found_error.py | 4 ++-- ..._details.py => not_found_error_details.py} | 2 +- .../numbers/models/v1/response/__init__.py | 4 +--- .../models/v1/response/available_region.py | 2 +- ....py => callback_configuration_response.py} | 0 .../check_number_availability_response.py | 17 ------------- sinch/domains/numbers/virtual_numbers.py | 6 ++--- .../test_search_for_number_endpoint.py | 4 ++-- ...odel.py => test_available_number_model.py} | 24 +++++++++---------- .../response/test_available_region_model.py | 10 ++++---- .../numbers/v1/test_available_numbers.py | 4 ++-- 14 files changed, 36 insertions(+), 55 deletions(-) rename sinch/domains/numbers/models/v1/errors/{error_details.py => not_found_error_details.py} (89%) rename sinch/domains/numbers/models/v1/response/{numbers_callback.py => callback_configuration_response.py} (100%) delete mode 100644 sinch/domains/numbers/models/v1/response/check_number_availability_response.py rename tests/unit/domains/numbers/v1/models/response/{test_search_for_number_response_model.py => test_available_number_model.py} (70%) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index dd0a8bff..ae827ddf 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -3,7 +3,7 @@ from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse + ActiveNumber, AvailableNumber ) from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( @@ -20,7 +20,7 @@ class AvailableNumbers(BaseNumbers): - def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumber: request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(SearchForNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index ad3bdf5e..920b3e6b 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -7,7 +7,7 @@ NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, CheckNumberAvailabilityResponse + ActiveNumber, AvailableNumber ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint @@ -91,9 +91,9 @@ class SearchForNumberEndpoint(NumbersEndpoint): def __init__(self, project_id: str, request_data: NumberRequest): super(SearchForNumberEndpoint, self).__init__(project_id, request_data) - def handle_response(self, response: HTTPResponse) -> CheckNumberAvailabilityResponse: + def handle_response(self, response: HTTPResponse) -> AvailableNumber: try: super(SearchForNumberEndpoint, self).handle_response(response) except NumbersException as e: raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) - return self.process_response_model(response.body, CheckNumberAvailabilityResponse) + return self.process_response_model(response.body, AvailableNumber) diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index aac4e58a..5543bd1c 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,7 +1,7 @@ from sinch.domains.numbers.models.v1.errors.not_found_error import NotFoundError -from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails +from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails __all__ = [ "NotFoundError", - "ErrorDetails", + "NotFoundErrorDetails", ] diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index 1573ba80..0281b5d4 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -1,5 +1,5 @@ from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.errors.error_details import ErrorDetails +from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails from typing import Optional from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr @@ -8,6 +8,6 @@ class NotFoundError(BaseModelConfigurationResponse): code: Optional[StrictInt] = Field(default=None, alias="code") message: Optional[StrictStr] = Field(default=None, alias="message") status: Optional[StrictStr] = Field(default=None, alias="status") - details: Optional[conlist(ErrorDetails, min_length=1)] = Field(default=None, alias="details") + details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None, alias="details") model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigurationResponse._to_snake_case) diff --git a/sinch/domains/numbers/models/v1/errors/error_details.py b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py similarity index 89% rename from sinch/domains/numbers/models/v1/errors/error_details.py rename to sinch/domains/numbers/models/v1/errors/not_found_error_details.py index b0c40047..85d3f5e6 100644 --- a/sinch/domains/numbers/models/v1/errors/error_details.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py @@ -3,7 +3,7 @@ from pydantic import Field, StrictStr -class ErrorDetails(BaseModelConfigurationResponse): +class NotFoundErrorDetails(BaseModelConfigurationResponse): type: Optional[StrictStr] = Field(default=None, alias="type") resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index f4e1f98e..4d116d1a 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -1,13 +1,11 @@ from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber from sinch.domains.numbers.models.v1.response.available_region import AvailableRegion -from sinch.domains.numbers.models.v1.response.check_number_availability_response import CheckNumberAvailabilityResponse -from sinch.domains.numbers.models.v1.response.numbers_callback import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.response.callback_configuration_response import CallbackConfigurationResponse __all__ = [ "ActiveNumber", "AvailableNumber", "AvailableRegion", "CallbackConfigurationResponse", - "CheckNumberAvailabilityResponse", ] diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py index f284d2cf..073fd789 100644 --- a/sinch/domains/numbers/models/v1/response/available_region.py +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -7,4 +7,4 @@ class AvailableRegion(BaseModelConfigurationResponse): region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") region_name: Optional[StrictStr] = Field(default=None, alias="regionName") - types: Optional[conlist(NumberType, min_length=1)] = Field(default=None) + types: Optional[conlist(NumberType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/response/numbers_callback.py b/sinch/domains/numbers/models/v1/response/callback_configuration_response.py similarity index 100% rename from sinch/domains/numbers/models/v1/response/numbers_callback.py rename to sinch/domains/numbers/models/v1/response/callback_configuration_response.py diff --git a/sinch/domains/numbers/models/v1/response/check_number_availability_response.py b/sinch/domains/numbers/models/v1/response/check_number_availability_response.py deleted file mode 100644 index 03767ec8..00000000 --- a/sinch/domains/numbers/models/v1/response/check_number_availability_response.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Optional -from pydantic import Field, StrictInt, StrictStr, StrictBool -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.shared import Money -from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType - - -class CheckNumberAvailabilityResponse(BaseModelConfigurationResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") - type: Optional[NumberType] = None - capabilities: Optional[CapabilityType] = None - setup_price: Optional[Money] = Field(default=None, alias="setupPrice") - monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - supporting_documentation_required: Optional[StrictBool] = ( - Field(default=None, alias="supportingDocumentationRequired")) diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 8e48ac74..1a3adc08 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -5,7 +5,7 @@ ) from sinch.core.pagination import Paginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber, CheckNumberAvailabilityResponse + ActiveNumber, AvailableNumber ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, @@ -226,7 +226,7 @@ def release( """ return self._active.release(phone_number=phone_number, **kwargs) - def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAvailabilityResponse: + def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumber: """ Enter a specific phone number to check availability. @@ -237,7 +237,7 @@ def check_availability(self, phone_number: StrictStr, **kwargs) -> CheckNumberAv :type kwargs: dict :returns: A response object with the availability status of the number. - :rtype: CheckNumberAvailabilityResponse + :rtype: AvailableNumber For detailed documentation, visit: https://developers.sinch.com """ diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py index 06eaea58..8856e76a 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_search_for_number_endpoint.py @@ -1,7 +1,7 @@ import pytest from sinch.domains.numbers.api.v1.internal import SearchForNumberEndpoint from sinch.domains.numbers.models.v1.internal import NumberRequest -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response import AvailableNumber from sinch.core.models.http_response import HTTPResponse @@ -59,7 +59,7 @@ def test_handle_response_expects_correct_mapping(mock_request_data, mock_respons endpoint = SearchForNumberEndpoint(project_id="test_project", request_data=mock_request_data) response = endpoint.handle_response(mock_response) - assert isinstance(response, CheckNumberAvailabilityResponse) + assert isinstance(response, AvailableNumber) assert response.phone_number == "+1234567890" assert response.region_code == "US" assert response.type == "MOBILE" diff --git a/tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py b/tests/unit/domains/numbers/v1/models/response/test_available_number_model.py similarity index 70% rename from tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py rename to tests/unit/domains/numbers/v1/models/response/test_available_number_model.py index dd3428c9..8011092b 100644 --- a/tests/unit/domains/numbers/v1/models/response/test_search_for_number_response_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_available_number_model.py @@ -1,11 +1,11 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1.response import CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response import AvailableNumber -def test_check_number_availability_response_expects_valid_data(): +def test_available_number_expects_valid_data(): """ - Expects CheckNumberAvailabilityResponse to be created with valid data. + Expects AvailableNumber to be created with valid data. """ data = { "phoneNumber": "+1234567890", @@ -18,7 +18,7 @@ def test_check_number_availability_response_expects_valid_data(): "supportingDocumentationRequired": True } - response = CheckNumberAvailabilityResponse(**data) + response = AvailableNumber(**data) assert response.phone_number == "+1234567890" assert response.region_code == "US" @@ -32,9 +32,9 @@ def test_check_number_availability_response_expects_valid_data(): assert response.supporting_documentation_required is True -def test_check_number_availability_response_missing_optional_fields_expects_valid_data(): +def test_available_number_missing_optional_fields_expects_valid_data(): """ - Verifies CheckNumberAvailabilityResponse can be created with missing optional fields, + Verifies AvailableNumber can be created with missing optional fields, and doesn't include them in the response. """ data = { @@ -46,15 +46,15 @@ def test_check_number_availability_response_missing_optional_fields_expects_vali "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} } - response = CheckNumberAvailabilityResponse(**data) + response = AvailableNumber(**data) assert response.payment_interval_months is None assert response.supporting_documentation_required is None -def test_check_number_availability_response_expects_parsed_new_type(): +def test_available_number_expects_parsed_new_type(): """ - Test CheckNumberAvailabilityResponse with invalid data. + Test AvailableNumber with invalid data. """ data = { "phoneNumber": "+1234567890", @@ -65,11 +65,11 @@ def test_check_number_availability_response_expects_parsed_new_type(): "monthlyPrice": {"amount": "5.00", "currencyCode": "USD"} } - response = CheckNumberAvailabilityResponse(**data) + response = AvailableNumber(**data) assert response.type == "NEW_TYPE" -def test_check_number_availability_response_expects_validation_error_for_missing_required_fields(): +def test_available_number_expects_validation_error_for_missing_required_fields(): """ Check if validation fails when required fields are missing. """ @@ -82,4 +82,4 @@ def test_check_number_availability_response_expects_validation_error_for_missing } with pytest.raises(ValidationError): - CheckNumberAvailabilityResponse.model_validate(data, strict=True) + AvailableNumber.model_validate(data, strict=True) diff --git a/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py index 7fd76d4f..50918116 100644 --- a/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_available_region_model.py @@ -24,17 +24,17 @@ def test_available_region_expects_all_fields_mapped_correctly(test_data): assert len(response.types) == 3 -def test_available_region_expects_validation_error_on_empty_types_list(): +def test_available_region_expects_empty_types_list(): """ - Expects validation error when types list is empty due to min_length=1 constraint + Expects no error when types list is empty """ - invalid_data = { + empty_types_list = { "regionCode": "US", "regionName": "United States", "types": [] } - with pytest.raises(ValidationError): - AvailableRegion(**invalid_data) + response = AvailableRegion(**empty_types_list) + assert len(response.types) == 0 def test_available_region_expects_validation_error_on_non_string_fields(): diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 3d8432e9..8112259c 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -6,7 +6,7 @@ from sinch.domains.numbers.models.v1.internal import ( ListAvailableNumbersRequest, ListAvailableNumbersResponse, NumberRequest, RentNumberRequest ) -from sinch.domains.numbers.models.v1.response import ActiveNumber, CheckNumberAvailabilityResponse +from sinch.domains.numbers.models.v1.response import ActiveNumber, AvailableNumber def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): @@ -76,7 +76,7 @@ def test_check_availability_expects_correct_request(mock_sinch_client_numbers, m Test that the AvailableNumbers.check_availability method sends the correct request and handles the response properly. """ - mock_response = CheckNumberAvailabilityResponse.model_construct() + mock_response = AvailableNumber.model_construct() mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response spy_endpoint = mocker.spy(SearchForNumberEndpoint, "__init__") From 7ee8b98deebb84f82703dfc3aa15bbe1f335f1f1 Mon Sep 17 00:00:00 2001 From: matsk-sinch Date: Fri, 13 Jun 2025 17:03:42 +0200 Subject: [PATCH 051/106] refactor(Numbers): Match expected generated model (#73) --- sinch/domains/numbers/models/v1/shared/money.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sinch/domains/numbers/models/v1/shared/money.py b/sinch/domains/numbers/models/v1/shared/money.py index f81d0ab2..a389811a 100644 --- a/sinch/domains/numbers/models/v1/shared/money.py +++ b/sinch/domains/numbers/models/v1/shared/money.py @@ -1,8 +1,8 @@ -from decimal import Decimal -from pydantic import StrictStr, Field +from typing import Optional +from pydantic import StrictStr, Field, condecimal from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse class Money(BaseModelConfigurationResponse): - currency_code: StrictStr = Field(alias="currencyCode") - amount: Decimal + currency_code: Optional[StrictStr] = Field(alias="currencyCode") + amount: Optional[condecimal()] = None From 1b32534a2e6c960253249a4b737e538306f2c1a9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 23 Jun 2025 11:45:51 +0200 Subject: [PATCH 052/106] DEVEXP-1006: (Numbers) Sync OAS - Week 25 (#75) * Add missing implementations for async authentication (#74) * Bump SDK version to v1.1.2 * DEVEXP-1006: (Numbers) Sync OAS - Week 25 * Bump Python SDK version to v2.0.0 --------- Co-authored-by: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> --- pyproject.toml | 2 +- sinch/__init__.py | 2 +- .../numbers/api/v1/available_regions_apis.py | 7 +- .../list_available_regions_request.py | 6 +- .../numbers/models/v1/types/__init__.py | 4 - .../models/v1/types/number_types_regions.py | 6 - tests/unit/http_transport_tests.py | 112 ++++++++++++++++++ 7 files changed, 121 insertions(+), 18 deletions(-) delete mode 100644 sinch/domains/numbers/models/v1/types/number_types_regions.py create mode 100644 tests/unit/http_transport_tests.py diff --git a/pyproject.toml b/pyproject.toml index e912a815..1eb439f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch" description = "Sinch SDK for Python programming language" -version = "1.1.1" +version = "2.0.0" license = "Apache 2.0" readme = "README.md" authors = [ diff --git a/sinch/__init__.py b/sinch/__init__.py index 9212e61c..b643433f 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -1,5 +1,5 @@ """ Sinch Python SDK""" -__version__ = "1.1.1" +__version__ = "2.0.0" from sinch.core.clients.sinch_client_sync import SinchClient diff --git a/sinch/domains/numbers/api/v1/available_regions_apis.py b/sinch/domains/numbers/api/v1/available_regions_apis.py index 308cbb88..a5dd7426 100644 --- a/sinch/domains/numbers/api/v1/available_regions_apis.py +++ b/sinch/domains/numbers/api/v1/available_regions_apis.py @@ -1,9 +1,10 @@ from typing import Optional +from pydantic import conlist from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest from sinch.domains.numbers.models.v1.response import AvailableRegion -from sinch.domains.numbers.models.v1.types import NumberTypesRegionsValuesList +from sinch.domains.numbers.models.v1.types import NumberType class AvailableRegions: @@ -12,7 +13,7 @@ def __init__(self, sinch): def list( self, - types: Optional[NumberTypesRegionsValuesList] = None, + types: Optional[conlist(NumberType)] = None, **kwargs ) -> Paginator[AvailableRegion]: """ @@ -21,7 +22,7 @@ def list( See which regions apply to your virtual number. :param types: List of number types to filter the regions. - :type types: Optional[NumberTypesRegionsValuesList] + :type types: Optional[conlist(NumberType)] :param kwargs: Additional parameters for the request. :type kwargs: Optional[dict] diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py index dd1a4bbd..2fc14c77 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -1,8 +1,8 @@ from typing import Optional -from pydantic import Field +from pydantic import Field, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.types import NumberTypesRegionsValuesList +from sinch.domains.numbers.models.v1.types import NumberTypeValues class ListAvailableRegionsRequest(BaseModelConfigurationRequest): - types: Optional[NumberTypesRegionsValuesList] = Field(default=None) + types: Optional[conlist(NumberTypeValues)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index c5dd2e40..0e01b321 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -5,9 +5,6 @@ NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues ) from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues -from sinch.domains.numbers.models.v1.types.number_types_regions import ( - NumberTypesRegionsValuesList -) from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning @@ -24,7 +21,6 @@ "NumberSearchPatternTypeValues", "NumberType", "NumberTypeValues", - "NumberTypesRegionsValuesList", "OrderByValues", "SmsConfigurationDict", "StatusScheduledProvisioning", diff --git a/sinch/domains/numbers/models/v1/types/number_types_regions.py b/sinch/domains/numbers/models/v1/types/number_types_regions.py deleted file mode 100644 index b37be594..00000000 --- a/sinch/domains/numbers/models/v1/types/number_types_regions.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import conlist, StrictStr -from typing import Literal, Union - -NumberTypesRegionsValuesList = conlist( - Union[Literal["MOBILE", "LOCAL", "TOLL_FREE", "NUMBER_TYPE_UNSPECIFIED"], StrictStr], min_length=1 -) diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py new file mode 100644 index 00000000..1d2ebf97 --- /dev/null +++ b/tests/unit/http_transport_tests.py @@ -0,0 +1,112 @@ +import pytest +from unittest.mock import Mock +from sinch.core.enums import HTTPAuthentication +from sinch.core.exceptions import ValidationException +from sinch.core.models.http_request import HttpRequest +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_response import HTTPResponse +from sinch.core.ports.http_transport import HTTPTransport + + +# Mock classes and fixtures +class MockEndpoint(HTTPEndpoint): + def __init__(self, auth_type): + self.HTTP_AUTHENTICATION = auth_type + self.HTTP_METHOD = "GET" + + def build_url(self, sinch): + return "api.sinch.com/test" + + def get_url_without_origin(self, sinch): + return "/test" + + def request_body(self): + return {} + + def build_query_params(self): + return {} + + def handle_response(self, response: HTTPResponse): + return response + + +@pytest.fixture +def mock_sinch(): + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.key_id = "test_key_id" + sinch.configuration.key_secret = "test_key_secret" + sinch.configuration.project_id = "test_project_id" + sinch.configuration.application_key = "test_app_key" + sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" + sinch.configuration.sms_api_token = "test_sms_token" + sinch.configuration.service_plan_id = "test_service_plan" + return sinch + + +@pytest.fixture +def base_request(): + return HttpRequest( + headers={}, + protocol="https://", + url="https://api.sinch.com/test", + http_method="GET", + request_body={}, + query_params={}, + auth=() + ) + + +class MockHTTPTransport(HTTPTransport): + def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + # Simple mock implementation that just returns a dummy response + return HTTPResponse(status_code=200, body={}, headers={}) + + +# Synchronous Transport Tests +class TestHTTPTransport: + @pytest.mark.parametrize("auth_type", [ + HTTPAuthentication.BASIC.value, + HTTPAuthentication.OAUTH.value, + HTTPAuthentication.SIGNED.value, + HTTPAuthentication.SMS_TOKEN.value + ]) + def test_authenticate(self, mock_sinch, base_request, auth_type): + transport = MockHTTPTransport(mock_sinch) + endpoint = MockEndpoint(auth_type) + + if auth_type == HTTPAuthentication.BASIC.value: + result = transport.authenticate(endpoint, base_request) + assert result.auth == ("test_key_id", "test_key_secret") + + elif auth_type == HTTPAuthentication.OAUTH.value: + mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_token" + assert result.headers["Content-Type"] == "application/json" + + elif auth_type == HTTPAuthentication.SIGNED.value: + result = transport.authenticate(endpoint, base_request) + assert "x-timestamp" in result.headers + assert "Authorization" in result.headers + + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_sms_token" + assert result.headers["Content-Type"] == "application/json" + + @pytest.mark.parametrize("auth_type,missing_creds", [ + (HTTPAuthentication.BASIC.value, {"key_id": None}), + (HTTPAuthentication.OAUTH.value, {"key_secret": None}), + (HTTPAuthentication.SIGNED.value, {"application_key": None}), + (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) + ]) + def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): + transport = MockHTTPTransport(mock_sinch) + endpoint = MockEndpoint(auth_type) + + for cred, value in missing_creds.items(): + setattr(mock_sinch.configuration, cred, value) + + with pytest.raises(ValidationException): + transport.authenticate(endpoint, base_request) From b1dbb7c5068d386d7a4645471765a9870b53231c Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Jun 2025 14:19:51 +0200 Subject: [PATCH 053/106] refactor(Numbers): Match expected generated model (#76) --- sinch/domains/numbers/models/v1/response/active_number.py | 4 ++-- sinch/domains/numbers/models/v1/shared/__init__.py | 4 ++-- .../{sms_configuration_response.py => sms_configuration.py} | 2 +- tests/unit/domains/numbers/v1/models/shared/test_numbers.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename sinch/domains/numbers/models/v1/shared/{sms_configuration_response.py => sms_configuration.py} (88%) diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index a8ce9252..aeffdd73 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -3,7 +3,7 @@ from pydantic import StrictStr, Field, StrictInt from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import ( - Money, SmsConfigurationResponse, VoiceConfigurationResponse + Money, SmsConfiguration, VoiceConfigurationResponse ) from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType @@ -19,6 +19,6 @@ class ActiveNumber(BaseModelConfigurationResponse): payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfigurationResponse] = Field(default=None, alias="smsConfiguration") + sms_configuration: Optional[SmsConfiguration] = Field(default=None, alias="smsConfiguration") voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index a3ec7b7b..e3371b17 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -8,7 +8,7 @@ from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC -from sinch.domains.numbers.models.v1.shared.sms_configuration_response import SmsConfigurationResponse +from sinch.domains.numbers.models.v1.shared.sms_configuration import SmsConfiguration from sinch.domains.numbers.models.v1.shared.voice_configuration_response import VoiceConfigurationResponse __all__ = [ @@ -20,6 +20,6 @@ "ScheduledVoiceProvisioningEST", "ScheduledVoiceProvisioningFAX", "ScheduledVoiceProvisioningRTC", - "SmsConfigurationResponse", + "SmsConfiguration", "VoiceConfigurationResponse", ] diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py b/sinch/domains/numbers/models/v1/shared/sms_configuration.py similarity index 88% rename from sinch/domains/numbers/models/v1/shared/sms_configuration_response.py rename to sinch/domains/numbers/models/v1/shared/sms_configuration.py index 1daad846..70dd8ab2 100644 --- a/sinch/domains/numbers/models/v1/shared/sms_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration.py @@ -4,7 +4,7 @@ from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning -class SmsConfigurationResponse(BaseModelConfigurationResponse): +class SmsConfiguration(BaseModelConfigurationResponse): service_plan_id: StrictStr = Field(alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") scheduled_provisioning: Optional[ScheduledSmsProvisioning] = ( diff --git a/tests/unit/domains/numbers/v1/models/shared/test_numbers.py b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py index 77f01ee4..0b763d86 100644 --- a/tests/unit/domains/numbers/v1/models/shared/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from sinch.domains.numbers.models.v1.shared import ( - ScheduledSmsProvisioning, SmsConfigurationResponse, VoiceConfigurationResponse + ScheduledSmsProvisioning, SmsConfiguration, VoiceConfigurationResponse ) @@ -54,7 +54,7 @@ def test_sms_configuration_valid_expects_parsed_data(): "status": "ACTIVE" } } - config = SmsConfigurationResponse.model_validate(data) + config = SmsConfiguration.model_validate(data) assert config.service_plan_id == "test_plan" assert config.campaign_id == "test_campaign" From f99142d5a80bb0ee513f036abc248b1a8cb9c845 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 17 Jul 2025 15:21:21 +0200 Subject: [PATCH 054/106] DEVEXP-928: Match Generated Code V (#77) --- .../numbers/api/v1/active_numbers_apis.py | 6 +- .../numbers/api/v1/available_numbers_apis.py | 8 +-- .../internal/list_active_numbers_request.py | 4 +- .../list_available_numbers_request.py | 6 +- .../v1/internal/rent_any_number_request.py | 12 ++-- .../models/v1/response/active_number.py | 12 ++-- .../models/v1/response/available_number.py | 4 +- .../numbers/models/v1/shared/__init__.py | 8 ++- .../v1/shared/scheduled_sms_provisioning.py | 33 +-------- .../v1/shared/scheduled_voice_provisioning.py | 6 +- ...ponse.py => voice_configuration_common.py} | 5 +- .../v1/shared/voice_configuration_est.py | 6 ++ .../v1/shared/voice_configuration_fax.py | 6 ++ .../v1/shared/voice_configuration_rtc.py | 6 ++ .../numbers/models/v1/types/__init__.py | 14 +++- .../models/v1/types/capability_type.py | 6 +- .../numbers/models/v1/types/sms_error_code.py | 29 ++++++++ .../models/v1/types/voice_application_type.py | 14 ++++ .../models/v1/types/voice_configuration.py | 6 ++ sinch/domains/numbers/virtual_numbers.py | 18 ++--- .../numbers/v1/models/shared/test_numbers.py | 70 ++++++++++++++++--- 21 files changed, 191 insertions(+), 88 deletions(-) rename sinch/domains/numbers/models/v1/shared/{voice_configuration_response.py => voice_configuration_common.py} (82%) create mode 100644 sinch/domains/numbers/models/v1/shared/voice_configuration_est.py create mode 100644 sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py create mode 100644 sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py create mode 100644 sinch/domains/numbers/models/v1/types/sms_error_code.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_application_type.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_configuration.py diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 18197e66..94e87930 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import StrictStr, StrictInt +from pydantic import StrictStr, StrictInt, conlist from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( @@ -12,7 +12,7 @@ ListActiveNumbersRequest, NumberRequest, UpdateNumberConfigurationRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, SmsConfigurationDict, VoiceConfigurationDictType ) @@ -25,7 +25,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, page_size: Optional[StrictInt] = None, page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index ae827ddf..0b9f4889 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import StrictInt, StrictStr +from pydantic import StrictInt, StrictStr, conlist from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( @@ -13,7 +13,7 @@ ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberPatternDict, NumberSearchPatternTypeValues, + CapabilityTypeValues, NumberPatternDict, NumberSearchPatternTypeValues, NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDictType ) @@ -30,7 +30,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, page_size: Optional[StrictInt] = None, **kwargs ) -> Paginator[AvailableNumber]: @@ -72,7 +72,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index aec42c55..0aa334c9 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import Field, StrictInt, StrictStr, field_validator +from pydantic import Field, StrictInt, StrictStr, field_validator, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import ( CapabilityType, OrderByValues, NumberSearchPatternTypeValues, NumberTypeValues, @@ -11,7 +11,7 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberTypeValues = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") - capabilities: Optional[CapabilityType] = Field(default=None) + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( Field(default=None, alias="numberPattern.searchPattern") ) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 725d44d2..1ef3c8cf 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,14 +1,14 @@ from typing import Optional -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt, StrictStr, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.types import CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberType +from sinch.domains.numbers.models.v1.types import CapabilityTypeValues, NumberSearchPatternTypeValues, NumberType class ListAvailableNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") - capabilities: Optional[CapabilityTypeValuesList] = Field(default=None) + capabilities: Optional[conlist(CapabilityTypeValues)] = Field(default=None) number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( Field(default=None, alias="numberPattern.searchPattern")) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 2939ccb7..4ec6113a 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,5 +1,5 @@ -from typing import Optional, Dict -from pydantic import Field, StrictStr +from typing import Optional, Dict, Any +from pydantic import Field, StrictStr, conlist from sinch.domains.numbers.models.v1.shared import NumberPattern from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration @@ -8,11 +8,11 @@ class RentAnyNumberRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") - type_: NumberType = Field(default=None, alias="type") + type_: NumberType = Field(alias="type") number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") - capabilities: Optional[CapabilityType] = Field(default=None) - sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + sms_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="smsConfiguration") + voice_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="voiceConfiguration") callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") def __init__(self, **data): diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index aeffdd73..d717cca3 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -1,11 +1,9 @@ from datetime import datetime from typing import Optional -from pydantic import StrictStr, Field, StrictInt +from pydantic import StrictStr, Field, StrictInt, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.shared import ( - Money, SmsConfiguration, VoiceConfigurationResponse -) -from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType +from sinch.domains.numbers.models.v1.shared import Money, SmsConfiguration +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType, VoiceConfiguration class ActiveNumber(BaseModelConfigurationResponse): @@ -14,11 +12,11 @@ class ActiveNumber(BaseModelConfigurationResponse): display_name: Optional[StrictStr] = Field(default=None, alias="displayName") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) - capabilities: Optional[CapabilityType] = Field(default=None) + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) money: Optional[Money] = Field(default=None) payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") expire_at: Optional[datetime] = Field(default=None, alias="expireAt") sms_configuration: Optional[SmsConfiguration] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfigurationResponse] = Field(default=None, alias="voiceConfiguration") + voice_configuration: Optional[VoiceConfiguration] = Field(default=None, alias="voiceConfiguration") callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") diff --git a/sinch/domains/numbers/models/v1/response/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py index 2c1d2b8c..3bdfbae3 100644 --- a/sinch/domains/numbers/models/v1/response/available_number.py +++ b/sinch/domains/numbers/models/v1/response/available_number.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import Field, StrictBool, StrictInt, StrictStr +from pydantic import Field, StrictBool, StrictInt, StrictStr, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse from sinch.domains.numbers.models.v1.shared import Money from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType @@ -9,7 +9,7 @@ class AvailableNumber(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) - capability: Optional[CapabilityType] = Field(default=None) + capability: Optional[conlist(CapabilityType)] = Field(default=None) setup_price: Optional[Money] = Field(default=None, alias="setupPrice") monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index e3371b17..4ca47c10 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -9,7 +9,9 @@ from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC from sinch.domains.numbers.models.v1.shared.sms_configuration import SmsConfiguration -from sinch.domains.numbers.models.v1.shared.voice_configuration_response import VoiceConfigurationResponse +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import VoiceConfigurationEST +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import VoiceConfigurationRTC +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import VoiceConfigurationFAX __all__ = [ "Money", @@ -21,5 +23,7 @@ "ScheduledVoiceProvisioningFAX", "ScheduledVoiceProvisioningRTC", "SmsConfiguration", - "VoiceConfigurationResponse", + "VoiceConfigurationEST", + "VoiceConfigurationRTC", + "VoiceConfigurationFAX" ] diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index 642efbd5..7cce8874 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -1,8 +1,8 @@ from datetime import datetime -from typing import Optional, Union, Literal +from typing import Optional from pydantic import StrictStr, Field, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning, SmsErrorCode class ScheduledSmsProvisioning(BaseModelConfigurationResponse): @@ -10,31 +10,4 @@ class ScheduledSmsProvisioning(BaseModelConfigurationResponse): campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") status: Optional[StatusScheduledProvisioning] = None last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - error_codes: Optional[ - conlist( - Union[ - Literal[ - "CAMPAIGN_EXPIRED", - "CAMPAIGN_MNO_REJECTED", - "CAMPAIGN_MNO_REVIEW", - "CAMPAIGN_MNO_SUSPENDED", - "CAMPAIGN_NOT_AVAILABLE", - "CAMPAIGN_PENDING_ACCEPTANCE", - "CAMPAIGN_PROVISIONING_FAILED", - "ERROR_CODE_UNSPECIFIED", - "EXCEEDED_10DLC_LIMIT", - "INSUFFICIENT_BALANCE", - "INTERNAL_ERROR", - "INVALID_NNID", - "MNO_SHARING_ERROR", - "MOCK_CAMPAIGN_NOT_ALLOWED", - "NUMBER_PROVISIONING_FAILED", - "PARTNER_SERVICE_UNAVAILABLE", - "SMS_PROVISIONING_FAILED", - "TFN_NOT_ALLOWED", - ], - StrictStr, - ], - min_length=0, - ) - ] = Field(default=None, alias="errorCodes") + error_codes: Optional[conlist(SmsErrorCode)] = Field(default=None, alias="errorCodes") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py index 4bd1adca..87f1503a 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py @@ -1,11 +1,11 @@ from datetime import datetime -from typing import Literal, Optional +from typing import Optional from pydantic import Field from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning, VoiceApplicationType class ScheduledVoiceProvisioning(BaseModelConfigurationResponse): - type: Literal["FAX", "EST", "RTC"] + type: VoiceApplicationType last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py similarity index 82% rename from sinch/domains/numbers/models/v1/shared/voice_configuration_response.py rename to sinch/domains/numbers/models/v1/shared/voice_configuration_common.py index 4e5c6263..136d3557 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_response.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py @@ -8,8 +8,8 @@ ) -class VoiceConfigurationResponse(BaseModelConfigurationResponse): - type: Union[Literal["RTC", "EST", "FAX"], StrictStr] +class VoiceConfigurationCommon(BaseModelConfigurationResponse): + type: Optional[Union[Literal["RTC", "EST", "FAX"], StrictStr]] last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningRTC, ScheduledVoiceProvisioningEST, @@ -18,4 +18,3 @@ class VoiceConfigurationResponse(BaseModelConfigurationResponse): None] = Field( default=None, alias="scheduledVoiceProvisioning" ) - app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py new file mode 100644 index 00000000..7c7b438a --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py @@ -0,0 +1,6 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon + + +class VoiceConfigurationEST(VoiceConfigurationCommon): + trunk_id: StrictStr = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py new file mode 100644 index 00000000..ad3789e1 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py @@ -0,0 +1,6 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon + + +class VoiceConfigurationFAX(VoiceConfigurationCommon): + service_id: StrictStr = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py new file mode 100644 index 00000000..f6862916 --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py @@ -0,0 +1,6 @@ +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon + + +class VoiceConfigurationRTC(VoiceConfigurationCommon): + app_id: StrictStr = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 0e01b321..99490517 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,5 +1,5 @@ from sinch.domains.numbers.models.v1.types.capability_type import ( - CapabilityType, CapabilityTypeValuesList + CapabilityType, CapabilityTypeValues ) from sinch.domains.numbers.models.v1.types.number_pattern import ( NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues @@ -7,7 +7,12 @@ from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict +from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode, SmsErrorCodeValues from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types.voice_application_type import ( + VoiceApplicationType, VoiceApplicationTypeValues +) +from sinch.domains.numbers.models.v1.types.voice_configuration import VoiceConfiguration from sinch.domains.numbers.models.v1.types.voice_configuration_dict import ( VoiceConfigurationDictCustom, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictType @@ -15,7 +20,7 @@ __all__ = [ "CapabilityType", - "CapabilityTypeValuesList", + "CapabilityTypeValues", "NumberPatternDict", "NumberSearchPatternType", "NumberSearchPatternTypeValues", @@ -23,7 +28,12 @@ "NumberTypeValues", "OrderByValues", "SmsConfigurationDict", + "SmsErrorCode", + "SmsErrorCodeValues", "StatusScheduledProvisioning", + "VoiceApplicationType", + "VoiceApplicationTypeValues", + "VoiceConfiguration", "VoiceConfigurationDictCustom", "VoiceConfigurationDictEST", "VoiceConfigurationDictFAX", diff --git a/sinch/domains/numbers/models/v1/types/capability_type.py b/sinch/domains/numbers/models/v1/types/capability_type.py index 1d6831e9..4a5e4592 100644 --- a/sinch/domains/numbers/models/v1/types/capability_type.py +++ b/sinch/domains/numbers/models/v1/types/capability_type.py @@ -1,9 +1,9 @@ -from pydantic import conlist, Field, StrictStr +from pydantic import Field, StrictStr from typing import Annotated, Literal, Union -CapabilityTypeValuesList = conlist(Union[Literal["SMS", "VOICE"], StrictStr], min_length=1) +CapabilityTypeValues = Union[Literal["SMS", "VOICE"], StrictStr] CapabilityType = Annotated[ - CapabilityTypeValuesList, + CapabilityTypeValues, Field(default=None) ] diff --git a/sinch/domains/numbers/models/v1/types/sms_error_code.py b/sinch/domains/numbers/models/v1/types/sms_error_code.py new file mode 100644 index 00000000..353ea444 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/sms_error_code.py @@ -0,0 +1,29 @@ +from typing import Annotated, Literal, Union +from pydantic import Field, StrictStr + + +SmsErrorCodeValues = Union[Literal[ + "ERROR_CODE_UNSPECIFIED", + "INTERNAL_ERROR", + "SMS_PROVISIONING_FAILED", + "CAMPAIGN_PROVISIONING_FAILED", + "CAMPAIGN_NOT_AVAILABLE", + "EXCEEDED_10DLC_LIMIT", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "MNO_SHARING_ERROR", + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_MNO_REVIEW", + "INSUFFICIENT_BALANCE", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "TFN_NOT_ALLOWED", + "INVALID_NNID" +], StrictStr] + +SmsErrorCode = Annotated[ + SmsErrorCodeValues, + Field(default=None) +] diff --git a/sinch/domains/numbers/models/v1/types/voice_application_type.py b/sinch/domains/numbers/models/v1/types/voice_application_type.py new file mode 100644 index 00000000..8ab8a714 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_application_type.py @@ -0,0 +1,14 @@ +from typing import Annotated, Literal, Union +from pydantic import Field, StrictStr + + +VoiceApplicationTypeValues = Union[Literal[ + "RTC", + "EST", + "FAX" +], StrictStr] + +VoiceApplicationType = Annotated[ + VoiceApplicationTypeValues, + Field(default=None) +] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration.py b/sinch/domains/numbers/models/v1/types/voice_configuration.py new file mode 100644 index 00000000..470067cd --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration.py @@ -0,0 +1,6 @@ +from typing import Union +from sinch.domains.numbers.models.v1.shared import ( + VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX +) + +VoiceConfiguration = Union[VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX] diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 1a3adc08..5d6a09b5 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -1,5 +1,5 @@ from typing import Optional, overload -from pydantic import StrictStr, StrictInt +from pydantic import StrictStr, StrictInt, conlist from sinch.domains.numbers.api.v1 import ( ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration ) @@ -8,7 +8,7 @@ ActiveNumber, AvailableNumber ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValuesList, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, VoiceConfigurationDictEST, NumberPatternDict ) @@ -50,7 +50,7 @@ def list( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, page_size: Optional[StrictInt] = None, page_token: Optional[StrictStr] = None, order_by: Optional[OrderByValues] = None, @@ -72,7 +72,7 @@ def list( :type number_search_pattern: Optional[NumberSearchPatternTypeValues] :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). - :type capabilities: Optional[CapabilityTypeValuesList] + :type capabilities: Optional[conlist(CapabilityTypeValues)] :param page_size: Maximum number of items to return. :type page_size: StrictInt @@ -324,7 +324,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictRTC, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -337,7 +337,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictFAX, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -350,7 +350,7 @@ def rent_any( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationDictEST, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -360,7 +360,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDictType] = None, callback_url: Optional[StrictStr] = None, @@ -423,7 +423,7 @@ def search_for_available_numbers( number_type: NumberTypeValues, number_pattern: Optional[StrictStr] = None, number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[CapabilityTypeValuesList] = None, + capabilities: Optional[conlist(CapabilityTypeValues)] = None, page_size: Optional[StrictInt] = None, **kwargs ) -> Paginator[AvailableNumber]: diff --git a/tests/unit/domains/numbers/v1/models/shared/test_numbers.py b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py index 0b763d86..1a0c5d63 100644 --- a/tests/unit/domains/numbers/v1/models/shared/test_numbers.py +++ b/tests/unit/domains/numbers/v1/models/shared/test_numbers.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone -from sinch.domains.numbers.models.v1.shared import ( - ScheduledSmsProvisioning, SmsConfiguration, VoiceConfigurationResponse -) +from pydantic import TypeAdapter +from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning, SmsConfiguration +from sinch.domains.numbers.models.v1.types import VoiceConfiguration def test_scheduled_provisioning_sms_configuration_valid_expects_parsed_data(): @@ -70,15 +70,21 @@ def test_voice_configuration_rtc_valid_expects_parsed_data(): data = { "type": "RTC", "appId": "test_app", + "trunkId": "", + "serviceId": "", "lastUpdatedTime": "2025-01-24T09:32:27.437Z", "scheduledVoiceProvisioning": { - "type": "RTC", + "type": "EST", "lastUpdatedTime": "2025-01-24T09:32:27.437Z", - "status": "ACTIVE", - "appId": "test_app" + "status": "WAITING", + "appId": "", + "trunkId": "test_app_est", + "serviceId": "" } } - config = VoiceConfigurationResponse.model_validate(data) + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) assert config.type == "RTC" assert config.app_id == "test_app" @@ -86,7 +92,42 @@ def test_voice_configuration_rtc_valid_expects_parsed_data(): datetime(2025, 1, 24, 9, 32, 27, 437000, tzinfo=timezone.utc)) assert config.scheduled_voice_provisioning is not None - assert config.scheduled_voice_provisioning.type == "RTC" + assert config.scheduled_voice_provisioning.type == "EST" + assert config.scheduled_voice_provisioning.status == "WAITING" + assert config.scheduled_voice_provisioning.trunk_id == "test_app_est" + + +def test_voice_configuration_est_valid_expects_parsed_data(): + """ + Test a valid EST voice configuration + """ + data = { + "type": "EST", + "appId": "", + "trunkId": "test_trunk", + "serviceId": "", + "lastUpdatedTime": "2025-02-25T09:32:27.437Z", + "scheduledVoiceProvisioning": { + "type": "EST", + "lastUpdatedTime": "2025-02-25T09:32:27.437Z", + "status": "ACTIVE", + "appId": "", + "trunkId": "test_trunk", + "serviceId": "" + } + } + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) + + assert config.type == "EST" + assert config.trunk_id == "test_trunk" + assert (config.last_updated_time == + datetime(2025, 2, 25, 9, 32, 27, 437000, + tzinfo=timezone.utc)) + assert config.scheduled_voice_provisioning is not None + assert config.scheduled_voice_provisioning.type == "EST" + assert config.scheduled_voice_provisioning.trunk_id == "test_trunk" assert config.scheduled_voice_provisioning.status == "ACTIVE" @@ -96,17 +137,28 @@ def test_voice_configuration_fax_valid_expects_parsed_data(): """ data = { "type": "FAX", + "appId": "", + "trunkId": "", + "serviceId": "test_service", "lastUpdatedTime": "2025-01-24T09:32:27.437Z", "scheduledVoiceProvisioning": { "type": "FAX", "lastUpdatedTime": "2025-01-24T09:32:27.437Z", "status": "ACTIVE", + "appId": "", + "trunkId": "", "serviceId": "test_service" } } - config = VoiceConfigurationResponse.model_validate(data) + + voice_configuration_adapter = TypeAdapter(VoiceConfiguration) + config = voice_configuration_adapter.validate_python(data) assert config.type == "FAX" + assert config.service_id == "test_service" + assert (config.last_updated_time == + datetime(2025, 1, 24, 9, 32, 27, 437000, + tzinfo=timezone.utc)) assert config.scheduled_voice_provisioning is not None assert config.scheduled_voice_provisioning.type == "FAX" assert config.scheduled_voice_provisioning.status == "ACTIVE" From a9369157540341a5b4007a3ca193cac3e26086d1 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 8 Aug 2025 09:37:15 +0200 Subject: [PATCH 055/106] DEVEXP-928: Match Generated Code VI (#78) --- .../numbers/api/v1/active_numbers_apis.py | 4 +- .../numbers/api/v1/available_numbers_apis.py | 8 +-- .../models/v1/errors/not_found_error.py | 12 ++--- .../numbers/models/v1/internal/__init__.py | 5 +- .../internal/list_active_numbers_response.py | 6 +-- .../list_available_numbers_response.py | 6 +-- .../list_available_regions_response.py | 6 +-- .../v1/internal/rent_any_number_request.py | 6 +-- .../voice_configuration_custom_request.py | 6 +++ .../voice_configuration_est_request.py | 8 +++ .../voice_configuration_fax_request.py | 8 +++ .../internal/voice_configuration_request.py | 8 +-- .../voice_configuration_rtc_request.py | 8 +++ .../numbers/models/v1/shared/__init__.py | 4 +- .../v1/shared/scheduled_sms_provisioning.py | 3 +- ...=> scheduled_voice_provisioning_common.py} | 5 +- .../scheduled_voice_provisioning_est.py | 4 +- .../scheduled_voice_provisioning_fax.py | 4 +- .../scheduled_voice_provisioning_rtc.py | 4 +- .../v1/shared/voice_configuration_common.py | 11 +--- .../numbers/models/v1/types/__init__.py | 26 +++++---- .../models/v1/types/number_pattern_dict.py | 8 +++ ...ttern.py => number_search_pattern_type.py} | 7 --- .../v1/types/scheduled_voice_provisioning.py | 10 ++++ .../models/v1/types/voice_configuration.py | 6 +-- .../types/voice_configuration_custom_dict.py | 5 ++ .../v1/types/voice_configuration_dict.py | 32 +++-------- .../v1/types/voice_configuration_est_dict.py | 7 +++ .../v1/types/voice_configuration_fax_dict.py | 7 +++ .../v1/types/voice_configuration_rtc_dict.py | 7 +++ .../numbers/models/v1/utils/validators.py | 16 ++++++ sinch/domains/numbers/virtual_numbers.py | 54 +++++++++---------- .../test_rent_any_number_request_model.py | 6 ++- .../numbers/v1/test_available_numbers.py | 4 +- 34 files changed, 191 insertions(+), 130 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py create mode 100644 sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py create mode 100644 sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py create mode 100644 sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py rename sinch/domains/numbers/models/v1/shared/{scheduled_voice_provisioning.py => scheduled_voice_provisioning_common.py} (56%) create mode 100644 sinch/domains/numbers/models/v1/types/number_pattern_dict.py rename sinch/domains/numbers/models/v1/types/{number_pattern.py => number_search_pattern_type.py} (57%) create mode 100644 sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py create mode 100644 sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 94e87930..4cd3cb92 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -13,7 +13,7 @@ ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, - SmsConfigurationDict, VoiceConfigurationDictType + SmsConfigurationDict, VoiceConfigurationDict ) @@ -54,7 +54,7 @@ def update( phone_number: StrictStr, display_name: Optional[StrictStr] = None, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index 0b9f4889..ab15de34 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -14,7 +14,7 @@ ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValues, NumberPatternDict, NumberSearchPatternTypeValues, - NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDictType + NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDict ) @@ -24,7 +24,7 @@ def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumb request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(SearchForNumberEndpoint, request_data) - def list( + def search_for_available_numbers( self, region_code: StrictStr, number_type: NumberTypeValues, @@ -54,7 +54,7 @@ def rent( self, phone_number: StrictStr, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: @@ -74,7 +74,7 @@ def rent_any( number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[conlist(CapabilityTypeValues)] = None, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index 0281b5d4..6f3a2591 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -1,13 +1,13 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails from typing import Optional from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails class NotFoundError(BaseModelConfigurationResponse): - code: Optional[StrictInt] = Field(default=None, alias="code") - message: Optional[StrictStr] = Field(default=None, alias="message") - status: Optional[StrictStr] = Field(default=None, alias="status") - details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None, alias="details") + code: Optional[StrictInt] = Field(default=None) + message: Optional[StrictStr] = Field(default=None) + status: Optional[StrictStr] = Field(default=None) + details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigurationResponse._to_snake_case) diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index a0ba87a8..aa2f72c7 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -20,7 +20,7 @@ ) from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, - VoiceConfigurationRTC, VoiceConfigurationType + VoiceConfigurationRTC ) __all__ = [ @@ -39,6 +39,5 @@ "VoiceConfigurationCustom", "VoiceConfigurationEST", "VoiceConfigurationFAX", - "VoiceConfigurationRTC", - "VoiceConfigurationType", + "VoiceConfigurationRTC" ] diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index 95f73f60..9f0d9789 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,10 +1,10 @@ -from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt, conlist from sinch.domains.numbers.models.v1.response import ActiveNumber class ListActiveNumbersResponse(BaseModel): - active_numbers: Optional[List[ActiveNumber]] = Field(default=None, alias="activeNumbers") + active_numbers: Optional[conlist(ActiveNumber)] = Field(default=None, alias="activeNumbers") next_page_token: Optional[StrictStr] = Field(default=None, alias="nextPageToken") total_size: Optional[StrictInt] = Field(default=None, alias="totalSize") diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index a06e648f..8f5e92d5 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -1,10 +1,10 @@ -from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, conlist from sinch.domains.numbers.models.v1.response import AvailableNumber class ListAvailableNumbersResponse(BaseModel): - available_numbers: Optional[List[AvailableNumber]] = Field(default=None, alias="availableNumbers") + available_numbers: Optional[conlist(AvailableNumber)] = Field(default=None, alias="availableNumbers") model_config = ConfigDict( populate_by_name=True diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py index c6c61ef0..658a9c49 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py @@ -1,10 +1,10 @@ -from typing import List, Optional -from pydantic import BaseModel, ConfigDict, Field +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, conlist from sinch.domains.numbers.models.v1.response import AvailableRegion class ListAvailableRegionsResponse(BaseModel): - available_regions: Optional[List[AvailableRegion]] = Field(default=None, alias="availableRegions") + available_regions: Optional[conlist(AvailableRegion)] = Field(default=None, alias="availableRegions") model_config = ConfigDict( populate_by_name=True diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 4ec6113a..70793048 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,15 +1,14 @@ from typing import Optional, Dict, Any from pydantic import Field, StrictStr, conlist -from sinch.domains.numbers.models.v1.shared import NumberPattern from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType -from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration, validate_number_pattern from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest class RentAnyNumberRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") type_: NumberType = Field(alias="type") - number_pattern: Optional[NumberPattern] = Field(default=None, alias="numberPattern") + number_pattern: Optional[Dict[str, Any]] = Field(default=None, alias="numberPattern") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) sms_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="smsConfiguration") voice_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="voiceConfiguration") @@ -20,4 +19,5 @@ def __init__(self, **data): Custom initializer to validate nested dictionaries. """ validate_sms_voice_configuration(data) + validate_number_pattern(data) super().__init__(**data) diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py new file mode 100644 index 00000000..19886fa3 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py @@ -0,0 +1,6 @@ +from pydantic import StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +class VoiceConfigurationCustom(BaseModelConfigurationRequest): + type: StrictStr diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py new file mode 100644 index 00000000..722d4fa9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py @@ -0,0 +1,8 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +class VoiceConfigurationEST(BaseModelConfigurationRequest): + type: Literal["EST"] = "EST" + trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py new file mode 100644 index 00000000..d206b541 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py @@ -0,0 +1,8 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +class VoiceConfigurationFAX(BaseModelConfigurationRequest): + type: Literal["FAX"] = "FAX" + service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py index f01685fb..3d69661a 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, Annotated, Literal +from typing import Optional, Literal from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest @@ -20,9 +20,3 @@ class VoiceConfigurationRTC(BaseModelConfigurationRequest): class VoiceConfigurationCustom(BaseModelConfigurationRequest): type: StrictStr - - -VoiceConfigurationType = Annotated[ - Union[VoiceConfigurationFAX, VoiceConfigurationEST, VoiceConfigurationRTC], - Field(discriminator="type") -] diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py new file mode 100644 index 00000000..e8f6bd66 --- /dev/null +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py @@ -0,0 +1,8 @@ +from typing import Optional, Literal +from pydantic import Field, StrictStr +from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest + + +class VoiceConfigurationRTC(BaseModelConfigurationRequest): + type: Literal["RTC"] = "RTC" + app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index 4ca47c10..587ab850 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -1,7 +1,7 @@ from sinch.domains.numbers.models.v1.shared.money import Money from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( ScheduledVoiceProvisioningCustom ) @@ -17,7 +17,7 @@ "Money", "NumberPattern", "ScheduledSmsProvisioning", - "ScheduledVoiceProvisioning", + "ScheduledVoiceProvisioningCommon", "ScheduledVoiceProvisioningCustom", "ScheduledVoiceProvisioningEST", "ScheduledVoiceProvisioningFAX", diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index 7cce8874..2bf381a3 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -2,7 +2,8 @@ from typing import Optional from pydantic import StrictStr, Field, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning, SmsErrorCode +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode class ScheduledSmsProvisioning(BaseModelConfigurationResponse): diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py similarity index 56% rename from sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py rename to sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py index 87f1503a..067036b7 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py @@ -2,10 +2,11 @@ from typing import Optional from pydantic import Field from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types import StatusScheduledProvisioning, VoiceApplicationType +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.types.voice_application_type import VoiceApplicationType -class ScheduledVoiceProvisioning(BaseModelConfigurationResponse): +class ScheduledVoiceProvisioningCommon(BaseModelConfigurationResponse): type: VoiceApplicationType last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py index d0d9ac4c..5a6f3b89 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon -class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioning): +class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioningCommon): trunk_id: Optional[StrictStr] = Field(default=None, alias="trunkId") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py index 6dfb1bda..7ab08d9f 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon -class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioning): +class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioningCommon): service_id: Optional[StrictStr] = Field(default=None, alias="serviceId") diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py index b2c1b6b5..4efa25ba 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared import ScheduledVoiceProvisioning +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon -class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioning): +class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioningCommon): app_id: Optional[StrictStr] = Field(default=None, alias="appId") diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py index 136d3557..40ff1e46 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py @@ -2,19 +2,12 @@ from typing import Literal, Optional, Union from pydantic import Field, StrictStr from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.shared import ( - ScheduledVoiceProvisioningCustom, ScheduledVoiceProvisioningEST, ScheduledVoiceProvisioningFAX, - ScheduledVoiceProvisioningRTC -) +from sinch.domains.numbers.models.v1.types import ScheduledVoiceProvisioning class VoiceConfigurationCommon(BaseModelConfigurationResponse): type: Optional[Union[Literal["RTC", "EST", "FAX"], StrictStr]] last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - scheduled_voice_provisioning: Union[ScheduledVoiceProvisioningRTC, - ScheduledVoiceProvisioningEST, - ScheduledVoiceProvisioningFAX, - ScheduledVoiceProvisioningCustom, - None] = Field( + scheduled_voice_provisioning: Optional[ScheduledVoiceProvisioning] = Field( default=None, alias="scheduledVoiceProvisioning" ) diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 99490517..1139bd6f 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,11 +1,13 @@ from sinch.domains.numbers.models.v1.types.capability_type import ( CapabilityType, CapabilityTypeValues ) -from sinch.domains.numbers.models.v1.types.number_pattern import ( - NumberPatternDict, NumberSearchPatternType, NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.types.number_search_pattern_type import ( + NumberSearchPatternType, NumberSearchPatternTypeValues ) +from sinch.domains.numbers.models.v1.types.number_pattern_dict import NumberPatternDict from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode, SmsErrorCodeValues from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning @@ -13,10 +15,11 @@ VoiceApplicationType, VoiceApplicationTypeValues ) from sinch.domains.numbers.models.v1.types.voice_configuration import VoiceConfiguration -from sinch.domains.numbers.models.v1.types.voice_configuration_dict import ( - VoiceConfigurationDictCustom, VoiceConfigurationDictEST, VoiceConfigurationDictFAX, - VoiceConfigurationDictRTC, VoiceConfigurationDictType -) +from sinch.domains.numbers.models.v1.types.voice_configuration_dict import VoiceConfigurationDict +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import VoiceConfigurationESTDict +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import VoiceConfigurationFAXDict +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import VoiceConfigurationRTCDict +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import VoiceConfigurationCustomDict __all__ = [ "CapabilityType", @@ -27,6 +30,7 @@ "NumberType", "NumberTypeValues", "OrderByValues", + "ScheduledVoiceProvisioning", "SmsConfigurationDict", "SmsErrorCode", "SmsErrorCodeValues", @@ -34,9 +38,9 @@ "VoiceApplicationType", "VoiceApplicationTypeValues", "VoiceConfiguration", - "VoiceConfigurationDictCustom", - "VoiceConfigurationDictEST", - "VoiceConfigurationDictFAX", - "VoiceConfigurationDictRTC", - "VoiceConfigurationDictType", + "VoiceConfigurationCustomDict", + "VoiceConfigurationESTDict", + "VoiceConfigurationFAXDict", + "VoiceConfigurationRTCDict", + "VoiceConfigurationDict", ] diff --git a/sinch/domains/numbers/models/v1/types/number_pattern_dict.py b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py new file mode 100644 index 00000000..9d6e5a08 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired +from sinch.domains.numbers.models.v1.types import NumberSearchPatternTypeValues + + +class NumberPatternDict(TypedDict): + pattern: NotRequired[str] + search_pattern: NotRequired[NumberSearchPatternTypeValues] diff --git a/sinch/domains/numbers/models/v1/types/number_pattern.py b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py similarity index 57% rename from sinch/domains/numbers/models/v1/types/number_pattern.py rename to sinch/domains/numbers/models/v1/types/number_search_pattern_type.py index 7883bc85..e44c85da 100644 --- a/sinch/domains/numbers/models/v1/types/number_pattern.py +++ b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py @@ -1,5 +1,3 @@ -from typing import TypedDict -from typing_extensions import NotRequired from typing import Union, Literal, Annotated from pydantic import StrictStr, Field @@ -10,8 +8,3 @@ NumberSearchPatternTypeValues, Field(default=None) ] - - -class NumberPatternDict(TypedDict): - pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternTypeValues] diff --git a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py new file mode 100644 index 00000000..260b973d --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py @@ -0,0 +1,10 @@ +from typing import Union +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ScheduledVoiceProvisioningCustom + +ScheduledVoiceProvisioning = Union[ScheduledVoiceProvisioningEST, + ScheduledVoiceProvisioningFAX, + ScheduledVoiceProvisioningRTC, + ScheduledVoiceProvisioningCustom] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration.py b/sinch/domains/numbers/models/v1/types/voice_configuration.py index 470067cd..f13585c4 100644 --- a/sinch/domains/numbers/models/v1/types/voice_configuration.py +++ b/sinch/domains/numbers/models/v1/types/voice_configuration.py @@ -1,6 +1,6 @@ from typing import Union -from sinch.domains.numbers.models.v1.shared import ( - VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX -) +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import VoiceConfigurationEST +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import VoiceConfigurationRTC +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import VoiceConfigurationFAX VoiceConfiguration = Union[VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py new file mode 100644 index 00000000..acaa0bdd --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_custom_dict.py @@ -0,0 +1,5 @@ +from typing_extensions import TypedDict + + +class VoiceConfigurationCustomDict(TypedDict): + type: str diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py index c6f23885..4d83ef0f 100644 --- a/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py @@ -1,29 +1,13 @@ -from typing import TypedDict, Literal, Union, Annotated -from typing_extensions import NotRequired +from typing import Union, Annotated from pydantic import Field +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import VoiceConfigurationESTDict +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import VoiceConfigurationRTCDict +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import VoiceConfigurationFAXDict +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import VoiceConfigurationCustomDict -class VoiceConfigurationDictRTC(TypedDict): - type: Literal["RTC"] - app_id: NotRequired[str] - - -class VoiceConfigurationDictEST(TypedDict): - type: Literal["EST"] - trunk_id: NotRequired[str] - - -class VoiceConfigurationDictFAX(TypedDict): - type: Literal["FAX"] - service_id: NotRequired[str] - - -class VoiceConfigurationDictCustom(TypedDict): - type: str - - -VoiceConfigurationDictType = Annotated[ - Union[VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, - VoiceConfigurationDictEST, VoiceConfigurationDictCustom], +VoiceConfigurationDict = Annotated[ + Union[VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, VoiceConfigurationCustomDict], Field(discriminator="type") ] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py new file mode 100644 index 00000000..a7c87495 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_est_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationESTDict(TypedDict): + type: Literal["EST"] + trunk_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py new file mode 100644 index 00000000..5b00a9a9 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_fax_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationFAXDict(TypedDict): + type: Literal["FAX"] + service_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py new file mode 100644 index 00000000..3e9e41ed --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_rtc_dict.py @@ -0,0 +1,7 @@ +from typing import TypedDict, Literal +from typing_extensions import NotRequired + + +class VoiceConfigurationRTCDict(TypedDict): + type: Literal["RTC"] + app_id: NotRequired[str] diff --git a/sinch/domains/numbers/models/v1/utils/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py index dbaccdef..5fa84da5 100644 --- a/sinch/domains/numbers/models/v1/utils/validators.py +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -6,6 +6,22 @@ VoiceConfigurationFAX, VoiceConfigurationCustom, ) +from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern + + +def validate_number_pattern(data: Dict[str, Any]) -> None: + """ + Validates `number_pattern` field in request data. + + Args: + data (dict): The request payload. + + Raises: + ValidationError: If validation fails for the number pattern. + """ + for key in ("numberPattern", "number_pattern"): + if key in data and data[key] is not None: + NumberPattern(**data[key]) def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 5d6a09b5..e1bbcdd2 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -9,8 +9,8 @@ ) from sinch.domains.numbers.models.v1.types import ( CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, - SmsConfigurationDict, VoiceConfigurationDictType, VoiceConfigurationDictFAX, VoiceConfigurationDictRTC, - VoiceConfigurationDictEST, NumberPatternDict + SmsConfigurationDict, VoiceConfigurationDict, VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, NumberPatternDict ) from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks @@ -108,7 +108,7 @@ def update( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, + voice_configuration: VoiceConfigurationESTDict, display_name: Optional[StrictStr] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: @@ -119,7 +119,7 @@ def update( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, + voice_configuration: VoiceConfigurationFAXDict, display_name: Optional[StrictStr] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: @@ -130,7 +130,7 @@ def update( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, + voice_configuration: VoiceConfigurationRTCDict, display_name: Optional[StrictStr] = None, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: @@ -141,7 +141,7 @@ def update( phone_number: StrictStr, display_name: Optional[StrictStr] = None, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: @@ -163,10 +163,10 @@ def update( :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - ``VoiceConfigurationDictRTC``: type 'RTC' with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type 'EST' with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type 'FAX' with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] + - ``VoiceConfigurationRTCDict``: type 'RTC' with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type 'EST' with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type 'FAX' with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL for the virtual number. :type callback_url: Optional[str] @@ -248,7 +248,7 @@ def rent( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, + voice_configuration: VoiceConfigurationESTDict, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -258,7 +258,7 @@ def rent( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, + voice_configuration: VoiceConfigurationFAXDict, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -268,7 +268,7 @@ def rent( self, phone_number: StrictStr, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, + voice_configuration: VoiceConfigurationRTCDict, callback_url: Optional[StrictStr] = None ) -> ActiveNumber: pass @@ -277,7 +277,7 @@ def rent( self, phone_number: StrictStr, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: @@ -294,10 +294,10 @@ def rent( :type sms_configuration: Optional[SmsConfigurationDict] :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL to be called. :type callback_url: Optional[StrictStr] :param kwargs: Additional parameters for the request. @@ -322,7 +322,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictRTC, + voice_configuration: VoiceConfigurationRTCDict, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[CapabilityTypeValues] = None, callback_url: Optional[StrictStr] = None @@ -335,7 +335,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictFAX, + voice_configuration: VoiceConfigurationFAXDict, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[conlist(CapabilityTypeValues)] = None, callback_url: Optional[StrictStr] = None @@ -348,7 +348,7 @@ def rent_any( region_code: StrictStr, type_: NumberTypeValues, sms_configuration: SmsConfigurationDict, - voice_configuration: VoiceConfigurationDictEST, + voice_configuration: VoiceConfigurationESTDict, number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[conlist(CapabilityTypeValues)] = None, callback_url: Optional[StrictStr] = None @@ -362,7 +362,7 @@ def rent_any( number_pattern: Optional[NumberPatternDict] = None, capabilities: Optional[conlist(CapabilityTypeValues)] = None, sms_configuration: Optional[SmsConfigurationDict] = None, - voice_configuration: Optional[VoiceConfigurationDictType] = None, + voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[StrictStr] = None, **kwargs ) -> ActiveNumber: @@ -390,10 +390,10 @@ def rent_any( :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - ``VoiceConfigurationDictRTC``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationDictEST``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationDictFAX``: type ``'FAX'`` with a ``service_id`` field. - :type voice_configuration: Optional[VoiceConfigurationDictType] + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL to receive notifications. :type callback_url: StrictStr @@ -456,7 +456,7 @@ def search_for_available_numbers( For detailed documentation, visit: https://developers.sinch.com """ - return self._available.list( + return self._available.search_for_available_numbers( region_code=region_code, number_type=number_type, page_size=page_size, diff --git a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py index 0ccc4580..e0bac382 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py @@ -26,8 +26,10 @@ def test_rent_any_number_request_expects_valid_data(): request = RentAnyNumberRequest(**data) - assert request.number_pattern.pattern == "string" - assert request.number_pattern.search_pattern == "START" + assert request.number_pattern == { + "pattern": "string", + "searchPattern": "START" + } assert request.region_code == "string" assert request.type_ == "MOBILE" assert request.capabilities == ["SMS"] diff --git a/tests/unit/domains/numbers/v1/test_available_numbers.py b/tests/unit/domains/numbers/v1/test_available_numbers.py index 8112259c..775c282b 100644 --- a/tests/unit/domains/numbers/v1/test_available_numbers.py +++ b/tests/unit/domains/numbers/v1/test_available_numbers.py @@ -11,7 +11,7 @@ def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, mocker): """ - Test that the AvailableNumbers.list method sends the correct request + Test that the AvailableNumbers.search_for_available_numbers method sends the correct request and handles the response properly. """ mock_response = ListAvailableNumbersResponse(availableNumbers=[]) @@ -21,7 +21,7 @@ def test_list_available_numbers_expects_valid_request(mock_sinch_client_numbers, spy_endpoint = mocker.spy(AvailableNumbersEndpoint, "__init__") available_numbers = AvailableNumbers(mock_sinch_client_numbers) - response = available_numbers.list( + response = available_numbers.search_for_available_numbers( region_code="US", number_type="LOCAL", capabilities=["SMS", "VOICE"], From 124ff02325d79a1cbd1ec933f4c6ba20c70ef58a Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 30 Sep 2025 15:20:08 +0200 Subject: [PATCH 056/106] DEVEXP-928: Match generated code (#81) --- .../numbers/api/v1/active_numbers_apis.py | 31 ++-- .../numbers/api/v1/available_numbers_apis.py | 35 ++-- .../numbers/api/v1/available_regions_apis.py | 7 +- sinch/domains/numbers/api/v1/base/__init__.py | 2 +- .../api/v1/callback_configuration_apis.py | 2 +- .../internal/list_active_numbers_request.py | 9 +- .../list_available_numbers_request.py | 6 +- .../list_available_regions_request.py | 4 +- .../v1/internal/rent_any_number_request.py | 2 +- .../numbers/models/v1/types/__init__.py | 25 +-- .../models/v1/types/capability_type.py | 10 +- .../models/v1/types/number_pattern_dict.py | 4 +- .../v1/types/number_search_pattern_type.py | 11 +- .../numbers/models/v1/types/number_type.py | 11 +- .../numbers/models/v1/types/order_by.py | 5 + .../models/v1/types/order_by_values.py | 4 - .../v1/types/scheduled_voice_provisioning.py | 1 + .../numbers/models/v1/types/sms_error_code.py | 11 +- .../v1/types/status_scheduled_provisioning.py | 15 +- .../models/v1/types/voice_application_type.py | 11 +- sinch/domains/numbers/virtual_numbers.py | 166 +++++++++--------- .../voice/endpoints/callouts/callout.py | 6 +- .../numbers/features/steps/numbers.steps.py | 2 +- .../test_rent_any_number_endpoint.py | 2 +- ...st_list_available_numbers_request_model.py | 8 +- .../test_rent_any_number_request_model.py | 4 +- .../domains/voice/test_callout_conference.py | 80 +++++++++ 27 files changed, 262 insertions(+), 212 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/types/order_by.py delete mode 100644 sinch/domains/numbers/models/v1/types/order_by_values.py create mode 100644 tests/unit/domains/voice/test_callout_conference.py diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 4cd3cb92..17d31d8a 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -1,5 +1,4 @@ -from typing import Optional -from pydantic import StrictStr, StrictInt, conlist +from typing import Optional, List from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( @@ -12,7 +11,7 @@ ListActiveNumbersRequest, NumberRequest, UpdateNumberConfigurationRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + CapabilityType, NumberSearchPatternType, NumberType, OrderBy, SmsConfigurationDict, VoiceConfigurationDict ) @@ -21,14 +20,14 @@ class ActiveNumbers(BaseNumbers): def list( self, - region_code: StrictStr, - number_type: NumberTypeValues, - number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - page_size: Optional[StrictInt] = None, - page_token: Optional[StrictStr] = None, - order_by: Optional[OrderByValues] = None, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + order_by: Optional[OrderBy] = None, **kwargs ) -> Paginator[ActiveNumber]: return TokenBasedPaginator( @@ -51,11 +50,11 @@ def list( def update( self, - phone_number: StrictStr, - display_name: Optional[StrictStr] = None, + phone_number: str, + display_name: Optional[str] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: request_data = UpdateNumberConfigurationRequest( @@ -70,7 +69,7 @@ def update( def get( self, - phone_number: StrictStr, + phone_number: str, **kwargs ) -> ActiveNumber: request_data = NumberRequest( @@ -81,7 +80,7 @@ def get( def release( self, - phone_number: StrictStr, + phone_number: str, **kwargs ) -> ActiveNumber: request_data = NumberRequest( diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index ab15de34..b3d058e9 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -1,5 +1,4 @@ -from typing import Optional -from pydantic import StrictInt, StrictStr, conlist +from typing import Optional, List from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( @@ -13,25 +12,25 @@ ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest, RentNumberRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValues, NumberPatternDict, NumberSearchPatternTypeValues, - NumberTypeValues, SmsConfigurationDict, VoiceConfigurationDict + CapabilityType, NumberPatternDict, NumberSearchPatternType, + NumberType, SmsConfigurationDict, VoiceConfigurationDict ) class AvailableNumbers(BaseNumbers): - def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumber: + def check_availability(self, phone_number: str, **kwargs) -> AvailableNumber: request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(SearchForNumberEndpoint, request_data) def search_for_available_numbers( self, - region_code: StrictStr, - number_type: NumberTypeValues, - number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - page_size: Optional[StrictInt] = None, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, **kwargs ) -> Paginator[AvailableNumber]: return TokenBasedPaginator( @@ -52,10 +51,10 @@ def search_for_available_numbers( def rent( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: request_data = RentNumberRequest( @@ -69,18 +68,18 @@ def rent( def rent_any( self, - region_code: StrictStr, - type_: NumberTypeValues, + region_code: str, + number_type: NumberType, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, + capabilities: Optional[List[CapabilityType]] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: request_data = RentAnyNumberRequest( region_code=region_code, - type_=type_, + number_type=number_type, number_pattern=number_pattern, capabilities=capabilities, sms_configuration=sms_configuration, diff --git a/sinch/domains/numbers/api/v1/available_regions_apis.py b/sinch/domains/numbers/api/v1/available_regions_apis.py index a5dd7426..36c28b47 100644 --- a/sinch/domains/numbers/api/v1/available_regions_apis.py +++ b/sinch/domains/numbers/api/v1/available_regions_apis.py @@ -1,5 +1,4 @@ -from typing import Optional -from pydantic import conlist +from typing import Optional, List from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest @@ -13,7 +12,7 @@ def __init__(self, sinch): def list( self, - types: Optional[conlist(NumberType)] = None, + types: Optional[List[NumberType]] = None, **kwargs ) -> Paginator[AvailableRegion]: """ @@ -22,7 +21,7 @@ def list( See which regions apply to your virtual number. :param types: List of number types to filter the regions. - :type types: Optional[conlist(NumberType)] + :type types: Optional[List[NumberType]] :param kwargs: Additional parameters for the request. :type kwargs: Optional[dict] diff --git a/sinch/domains/numbers/api/v1/base/__init__.py b/sinch/domains/numbers/api/v1/base/__init__.py index 296c8791..67baaa66 100644 --- a/sinch/domains/numbers/api/v1/base/__init__.py +++ b/sinch/domains/numbers/api/v1/base/__init__.py @@ -1,3 +1,3 @@ -from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers as BaseNumbers +from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers __all__ = ['BaseNumbers'] diff --git a/sinch/domains/numbers/api/v1/callback_configuration_apis.py b/sinch/domains/numbers/api/v1/callback_configuration_apis.py index 57c5a549..43dc854d 100644 --- a/sinch/domains/numbers/api/v1/callback_configuration_apis.py +++ b/sinch/domains/numbers/api/v1/callback_configuration_apis.py @@ -31,7 +31,7 @@ def get( def update( self, - hmac_secret, + hmac_secret: str, **kwargs ) -> CallbackConfigurationResponse: """ diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 0aa334c9..5d223a7b 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -2,22 +2,21 @@ from pydantic import Field, StrictInt, StrictStr, field_validator, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import ( - CapabilityType, OrderByValues, NumberSearchPatternTypeValues, NumberTypeValues, - + CapabilityType, OrderBy, NumberSearchPatternType, NumberType ) class ListActiveNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") - number_type: NumberTypeValues = Field(alias="type") + number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( + number_search_pattern: Optional[NumberSearchPatternType] = ( Field(default=None, alias="numberPattern.searchPattern") ) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") - order_by: Optional[OrderByValues] = Field(default=None, alias="orderBy") + order_by: Optional[OrderBy] = Field(default=None, alias="orderBy") @field_validator("order_by", mode="before") @classmethod diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 1ef3c8cf..ea8fa5e1 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,14 +1,14 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.types import CapabilityTypeValues, NumberSearchPatternTypeValues, NumberType +from sinch.domains.numbers.models.v1.types import CapabilityType, NumberSearchPatternType, NumberType class ListAvailableNumbersRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") - capabilities: Optional[conlist(CapabilityTypeValues)] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternTypeValues] = ( + capabilities: Optional[conlist(CapabilityType)] = Field(default=None) + number_search_pattern: Optional[NumberSearchPatternType] = ( Field(default=None, alias="numberPattern.searchPattern")) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py index 2fc14c77..c5e2dd6a 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -1,8 +1,8 @@ from typing import Optional from pydantic import Field, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.types import NumberTypeValues +from sinch.domains.numbers.models.v1.types import NumberType class ListAvailableRegionsRequest(BaseModelConfigurationRequest): - types: Optional[conlist(NumberTypeValues)] = Field(default=None) + types: Optional[conlist(NumberType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 70793048..3017af96 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -7,7 +7,7 @@ class RentAnyNumberRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") - type_: NumberType = Field(alias="type") + number_type: NumberType = Field(alias="type") number_pattern: Optional[Dict[str, Any]] = Field(default=None, alias="numberPattern") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) sms_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="smsConfiguration") diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 1139bd6f..9526a09a 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,19 +1,13 @@ -from sinch.domains.numbers.models.v1.types.capability_type import ( - CapabilityType, CapabilityTypeValues -) -from sinch.domains.numbers.models.v1.types.number_search_pattern_type import ( - NumberSearchPatternType, NumberSearchPatternTypeValues -) +from sinch.domains.numbers.models.v1.types.capability_type import CapabilityType +from sinch.domains.numbers.models.v1.types.number_search_pattern_type import NumberSearchPatternType from sinch.domains.numbers.models.v1.types.number_pattern_dict import NumberPatternDict -from sinch.domains.numbers.models.v1.types.number_type import NumberType, NumberTypeValues -from sinch.domains.numbers.models.v1.types.order_by_values import OrderByValues +from sinch.domains.numbers.models.v1.types.number_type import NumberType +from sinch.domains.numbers.models.v1.types.order_by import OrderBy from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict -from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode, SmsErrorCodeValues +from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning -from sinch.domains.numbers.models.v1.types.voice_application_type import ( - VoiceApplicationType, VoiceApplicationTypeValues -) +from sinch.domains.numbers.models.v1.types.voice_application_type import VoiceApplicationType from sinch.domains.numbers.models.v1.types.voice_configuration import VoiceConfiguration from sinch.domains.numbers.models.v1.types.voice_configuration_dict import VoiceConfigurationDict from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import VoiceConfigurationESTDict @@ -23,20 +17,15 @@ __all__ = [ "CapabilityType", - "CapabilityTypeValues", "NumberPatternDict", "NumberSearchPatternType", - "NumberSearchPatternTypeValues", "NumberType", - "NumberTypeValues", - "OrderByValues", + "OrderBy", "ScheduledVoiceProvisioning", "SmsConfigurationDict", "SmsErrorCode", - "SmsErrorCodeValues", "StatusScheduledProvisioning", "VoiceApplicationType", - "VoiceApplicationTypeValues", "VoiceConfiguration", "VoiceConfigurationCustomDict", "VoiceConfigurationESTDict", diff --git a/sinch/domains/numbers/models/v1/types/capability_type.py b/sinch/domains/numbers/models/v1/types/capability_type.py index 4a5e4592..ab5dab80 100644 --- a/sinch/domains/numbers/models/v1/types/capability_type.py +++ b/sinch/domains/numbers/models/v1/types/capability_type.py @@ -1,9 +1,5 @@ -from pydantic import Field, StrictStr -from typing import Annotated, Literal, Union +from pydantic import StrictStr +from typing import Literal, Union -CapabilityTypeValues = Union[Literal["SMS", "VOICE"], StrictStr] -CapabilityType = Annotated[ - CapabilityTypeValues, - Field(default=None) -] +CapabilityType = Union[Literal["SMS", "VOICE"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/number_pattern_dict.py b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py index 9d6e5a08..2e674f7f 100644 --- a/sinch/domains/numbers/models/v1/types/number_pattern_dict.py +++ b/sinch/domains/numbers/models/v1/types/number_pattern_dict.py @@ -1,8 +1,8 @@ from typing import TypedDict from typing_extensions import NotRequired -from sinch.domains.numbers.models.v1.types import NumberSearchPatternTypeValues +from sinch.domains.numbers.models.v1.types import NumberSearchPatternType class NumberPatternDict(TypedDict): pattern: NotRequired[str] - search_pattern: NotRequired[NumberSearchPatternTypeValues] + search_pattern: NotRequired[NumberSearchPatternType] diff --git a/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py index e44c85da..23208cbf 100644 --- a/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py +++ b/sinch/domains/numbers/models/v1/types/number_search_pattern_type.py @@ -1,10 +1,5 @@ -from typing import Union, Literal, Annotated -from pydantic import StrictStr, Field +from typing import Union, Literal +from pydantic import StrictStr -NumberSearchPatternTypeValues = Union[Literal["START", "CONTAINS", "END"], StrictStr] - -NumberSearchPatternType = Annotated[ - NumberSearchPatternTypeValues, - Field(default=None) -] +NumberSearchPatternType = Union[Literal["START", "CONTAINS", "END"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/number_type.py b/sinch/domains/numbers/models/v1/types/number_type.py index f1205dc4..fcefdf3d 100644 --- a/sinch/domains/numbers/models/v1/types/number_type.py +++ b/sinch/domains/numbers/models/v1/types/number_type.py @@ -1,10 +1,5 @@ -from typing import Union, Literal, Annotated -from pydantic import StrictStr, Field +from typing import Union, Literal +from pydantic import StrictStr -NumberTypeValues = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] - -NumberType = Annotated[ - NumberTypeValues, - Field(default=None) -] +NumberType = Union[Literal["MOBILE", "LOCAL", "TOLL_FREE"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/order_by.py b/sinch/domains/numbers/models/v1/types/order_by.py new file mode 100644 index 00000000..22f6610f --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/order_by.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +OrderBy = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/order_by_values.py b/sinch/domains/numbers/models/v1/types/order_by_values.py deleted file mode 100644 index 3135e106..00000000 --- a/sinch/domains/numbers/models/v1/types/order_by_values.py +++ /dev/null @@ -1,4 +0,0 @@ -from typing import Literal, Union -from pydantic import StrictStr - -OrderByValues = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py index 260b973d..0cabd963 100644 --- a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py @@ -4,6 +4,7 @@ from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ScheduledVoiceProvisioningCustom + ScheduledVoiceProvisioning = Union[ScheduledVoiceProvisioningEST, ScheduledVoiceProvisioningFAX, ScheduledVoiceProvisioningRTC, diff --git a/sinch/domains/numbers/models/v1/types/sms_error_code.py b/sinch/domains/numbers/models/v1/types/sms_error_code.py index 353ea444..defee394 100644 --- a/sinch/domains/numbers/models/v1/types/sms_error_code.py +++ b/sinch/domains/numbers/models/v1/types/sms_error_code.py @@ -1,8 +1,8 @@ -from typing import Annotated, Literal, Union -from pydantic import Field, StrictStr +from typing import Literal, Union +from pydantic import StrictStr -SmsErrorCodeValues = Union[Literal[ +SmsErrorCode = Union[Literal[ "ERROR_CODE_UNSPECIFIED", "INTERNAL_ERROR", "SMS_PROVISIONING_FAILED", @@ -22,8 +22,3 @@ "TFN_NOT_ALLOWED", "INVALID_NNID" ], StrictStr] - -SmsErrorCode = Annotated[ - SmsErrorCodeValues, - Field(default=None) -] diff --git a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py index 284ae130..9bae5fd3 100644 --- a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py +++ b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py @@ -1,7 +1,10 @@ -from typing import Annotated, Union, Literal -from pydantic import StrictStr, Field +from typing import Union, Literal +from pydantic import StrictStr -StatusScheduledProvisioning = Annotated[ - Union[Literal["WAITING", "IN_PROGRESS", "FAILED", "PROVISIONING_STATUS_UNSPECIFIED"], StrictStr], - Field(default=None) -] + +StatusScheduledProvisioning = Union[Literal[ + "WAITING", + "IN_PROGRESS", + "FAILED", + "PROVISIONING_STATUS_UNSPECIFIED" +], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/voice_application_type.py b/sinch/domains/numbers/models/v1/types/voice_application_type.py index 8ab8a714..64773ffd 100644 --- a/sinch/domains/numbers/models/v1/types/voice_application_type.py +++ b/sinch/domains/numbers/models/v1/types/voice_application_type.py @@ -1,14 +1,9 @@ -from typing import Annotated, Literal, Union -from pydantic import Field, StrictStr +from typing import Literal, Union +from pydantic import StrictStr -VoiceApplicationTypeValues = Union[Literal[ +VoiceApplicationType = Union[Literal[ "RTC", "EST", "FAX" ], StrictStr] - -VoiceApplicationType = Annotated[ - VoiceApplicationTypeValues, - Field(default=None) -] diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index e1bbcdd2..09018672 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -1,5 +1,4 @@ -from typing import Optional, overload -from pydantic import StrictStr, StrictInt, conlist +from typing import Optional, overload, List from sinch.domains.numbers.api.v1 import ( ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration ) @@ -8,7 +7,7 @@ ActiveNumber, AvailableNumber ) from sinch.domains.numbers.models.v1.types import ( - CapabilityTypeValues, NumberSearchPatternTypeValues, NumberTypeValues, OrderByValues, + CapabilityType, NumberSearchPatternType, NumberType, OrderBy, SmsConfigurationDict, VoiceConfigurationDict, VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, VoiceConfigurationESTDict, NumberPatternDict ) @@ -31,12 +30,12 @@ def __init__(self, sinch): self._active = ActiveNumbers(self._sinch) self._available = AvailableNumbers(self._sinch) - def webhooks(self, callback_secret: StrictStr) -> NumbersWebhooks: + def webhooks(self, callback_secret: str) -> NumbersWebhooks: """ Create a Numbers webhooks handler with the specified callback secret. :param callback_secret: Secret used for webhook validation. - :type callback_secret: StrictStr + :type callback_secret: str :returns: A configured webhooks handler :rtype: NumbersWebhooks """ @@ -46,42 +45,42 @@ def webhooks(self, callback_secret: StrictStr) -> NumbersWebhooks: def list( self, - region_code: StrictStr, - number_type: NumberTypeValues, - number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - page_size: Optional[StrictInt] = None, - page_token: Optional[StrictStr] = None, - order_by: Optional[OrderByValues] = None, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + order_by: Optional[OrderBy] = None, **kwargs ) -> Paginator[ActiveNumber]: """ Search for all active virtual numbers associated with a certain project. :param region_code: ISO 3166-1 alpha-2 country code of the phone number. - :type region_code: StrictStr + :type region_code: str :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - :type number_type: NumberTypeValues + :type number_type: NumberType :param number_pattern: Specific sequence of digits to search for. - :type number_pattern: Optional[StrictStr] + :type number_pattern: Optional[str] :param number_search_pattern: Pattern to apply (e.g., "START", "CONTAINS", "END"). - :type number_search_pattern: Optional[NumberSearchPatternTypeValues] + :type number_search_pattern: Optional[NumberSearchPatternType] :param capabilities: Capabilities required for the number (e.g., ["SMS", "VOICE"]). - :type capabilities: Optional[conlist(CapabilityTypeValues)] + :type capabilities: Optional[List[CapabilityType]] :param page_size: Maximum number of items to return. - :type page_size: StrictInt + :type page_size: int :param page_token: Token for the next page of results. - :type page_token: Optional[StrictStr] + :type page_token: Optional[str] :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). - :type order_by: Optional[OrderByValues] + :type order_by: Optional[OrderBy] :param kwargs: Additional filters for the request. :type kwargs: dict @@ -106,43 +105,43 @@ def list( @overload def update( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None + display_name: Optional[str] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def update( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None + display_name: Optional[str] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def update( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, - display_name: Optional[StrictStr] = None, - callback_url: Optional[StrictStr] = None + display_name: Optional[str] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass def update( self, - phone_number: StrictStr, - display_name: Optional[StrictStr] = None, + phone_number: str, + display_name: Optional[str] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: """ @@ -181,12 +180,13 @@ def update( display_name=display_name, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, **kwargs + callback_url=callback_url, + **kwargs ) def get( self, - phone_number: StrictStr, + phone_number: str, **kwargs ) -> ActiveNumber: """ @@ -207,7 +207,7 @@ def get( def release( self, - phone_number: StrictStr, + phone_number: str, **kwargs ) -> ActiveNumber: """ @@ -226,7 +226,7 @@ def release( """ return self._active.release(phone_number=phone_number, **kwargs) - def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumber: + def check_availability(self, phone_number: str, **kwargs) -> AvailableNumber: """ Enter a specific phone number to check availability. @@ -246,46 +246,46 @@ def check_availability(self, phone_number: StrictStr, **kwargs) -> AvailableNumb @overload def rent( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, - callback_url: Optional[StrictStr] = None + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def rent( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, - callback_url: Optional[StrictStr] = None + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def rent( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, - callback_url: Optional[StrictStr] = None + callback_url: Optional[str] = None ) -> ActiveNumber: pass def rent( self, - phone_number: StrictStr, + phone_number: str, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: """ Rent a virtual number to use with SMS, Voice, or both products. :param phone_number: The phone number in E.164 format with leading ``+``. - :type phone_number: StrictStr + :type phone_number: str :param sms_configuration: A dictionary defining the SMS configuration. Include the following fields:: @@ -299,7 +299,7 @@ def rent( - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL to be called. - :type callback_url: Optional[StrictStr] + :type callback_url: Optional[str] :param kwargs: Additional parameters for the request. :type kwargs: dict @@ -319,51 +319,51 @@ def rent( @overload def rent_any( self, - region_code: StrictStr, - type_: NumberTypeValues, + region_code: str, + number_type: NumberType, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[CapabilityTypeValues] = None, - callback_url: Optional[StrictStr] = None + number_pattern: NumberPatternDict, + capabilities: Optional[CapabilityType] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def rent_any( self, - region_code: StrictStr, - type_: NumberTypeValues, + region_code: str, + number_type: NumberType, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - callback_url: Optional[StrictStr] = None + number_pattern: NumberPatternDict, + capabilities: Optional[List[CapabilityType]] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass @overload def rent_any( self, - region_code: StrictStr, - type_: NumberTypeValues, + region_code: str, + number_type: NumberType, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, - number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - callback_url: Optional[StrictStr] = None + number_pattern: NumberPatternDict, + capabilities: Optional[List[CapabilityType]] = None, + callback_url: Optional[str] = None ) -> ActiveNumber: pass def rent_any( self, - region_code: StrictStr, - type_: NumberTypeValues, + region_code: str, + number_type: NumberType, number_pattern: Optional[NumberPatternDict] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, + capabilities: Optional[List[CapabilityType]] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[StrictStr] = None, + callback_url: Optional[str] = None, **kwargs ) -> ActiveNumber: """ @@ -373,10 +373,14 @@ def rent_any( :param region_code: ISO 3166-1 alpha-2 country code of the phone number. :type region_code: str - :param type_: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). Defaults to ``"MOBILE"``. - :type type_: NumberType + :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). Defaults to ``"MOBILE"``. + :type number_type: NumberType - :param number_pattern: Specific sequence of digits to search for. + :param number_pattern: A dictionary defining the specific sequence of digits to search for. + Include fields such as:: + - ``pattern`` (str): The specific sequence of digits. + - ``search_pattern`` (str): + The pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). :type number_pattern: Optional[NumberPatternDict] :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). @@ -396,7 +400,7 @@ def rent_any( :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL to receive notifications. - :type callback_url: StrictStr + :type callback_url: str :param kwargs: Additional parameters for the request. :type kwargs: dict @@ -408,7 +412,7 @@ def rent_any( """ return self._available.rent_any( region_code=region_code, - type_=type_, + number_type=number_type, number_pattern=number_pattern, capabilities=capabilities, sms_configuration=sms_configuration, @@ -419,34 +423,34 @@ def rent_any( def search_for_available_numbers( self, - region_code: StrictStr, - number_type: NumberTypeValues, - number_pattern: Optional[StrictStr] = None, - number_search_pattern: Optional[NumberSearchPatternTypeValues] = None, - capabilities: Optional[conlist(CapabilityTypeValues)] = None, - page_size: Optional[StrictInt] = None, + region_code: str, + number_type: NumberType, + number_pattern: Optional[str] = None, + number_search_pattern: Optional[NumberSearchPatternType] = None, + capabilities: Optional[List[CapabilityType]] = None, + page_size: Optional[int] = None, **kwargs ) -> Paginator[AvailableNumber]: """ Search for available virtual numbers for you to rent using a variety of parameters to filter results. :param region_code: ISO 3166-1 alpha-2 country code of the phone number. - :type region_code: StrictStr + :type region_code: str :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). :type number_type: NumberType :param number_pattern: Specific sequence of digits to search for. - :type number_pattern: Optional[StrictStr] + :type number_pattern: Optional[str] :param number_search_pattern: Pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). :type number_search_pattern: Optional[NumberSearchPatternType] :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). - :type capabilities: Optional[CapabilityType] + :type capabilities: Optional[List[CapabilityType]] :param page_size: Maximum number of items to return. - :type page_size: StrictInt + :type page_size: int :param kwargs: Additional filters for the request. :type kwargs: dict diff --git a/sinch/domains/voice/endpoints/callouts/callout.py b/sinch/domains/voice/endpoints/callouts/callout.py index 324e7698..ef889a05 100644 --- a/sinch/domains/voice/endpoints/callouts/callout.py +++ b/sinch/domains/voice/endpoints/callouts/callout.py @@ -36,13 +36,13 @@ def request_body(self): dtmf_options = {} if self.request_data.conferenceDtmfOptions["mode"]: - dtmf_options["mode"] = self.request_data.get["conferenceDtmfOptions"]["mode"] + dtmf_options["mode"] = self.request_data.conferenceDtmfOptions["mode"] if self.request_data.conferenceDtmfOptions["timeout_mills"]: - dtmf_options["timeoutMills"] = self.request_data.get["conferenceDtmfOptions"]["timeout_mills"] + dtmf_options["timeoutMills"] = self.request_data.conferenceDtmfOptions["timeout_mills"] if self.request_data.conferenceDtmfOptions["max_digits"]: - dtmf_options["maxDigits"] = self.request_data.get["conferenceDtmfOptions"]["max_digits"] + dtmf_options["maxDigits"] = self.request_data.conferenceDtmfOptions["max_digits"] self.request_data.conferenceDtmfOptions = dtmf_options diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 655279ed..1e5e895a 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -68,7 +68,7 @@ def step_check_unavailable_number(context, phone_number): def step_rent_any_number(context): context.response = context.sinch.numbers.rent_any( region_code='US', - type_='LOCAL', + number_type='LOCAL', capabilities=['SMS', 'VOICE'], sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index 1c3e6107..db09d00a 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -14,7 +14,7 @@ def valid_request_data(): """ return RentAnyNumberRequest( region_code="US", - type_="MOBILE", + number_type="MOBILE", number_pattern={"pattern": "string", "searchPattern": "START"}, capabilities=["SMS"], sms_configuration={"servicePlanId": "string", "campaignId": "string"}, diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py index fa78f7f4..31ebe255 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py @@ -108,7 +108,7 @@ def test_list_available_numbers_expects_parsed_extra_field_snake_case(): } response = ListAvailableNumbersRequest(**data) - # Assert known fields + # Assert unknown fields assert response.extraField == "Extra Value" @@ -125,13 +125,13 @@ def test_list_available_numbers_expects_snake_case_to_parsed_extra_field_snake_c } response = ListAvailableNumbersRequest(**data) - # Assert known fields + # Assert unknown fields assert response.extra_field == "Extra Value" def test_list_available_numbers_expects_extra_capability(): """ - Expects unrecognized fields to be dynamically added as snake_case attributes. + Expects unrecognized value to be added. """ data = { "number_type": "MOBILE", @@ -142,5 +142,5 @@ def test_list_available_numbers_expects_extra_capability(): } response = ListAvailableNumbersRequest(**data) - # Assert known fields + # Assert extra fields assert response.capabilities == ["SMS", "VOICE", "EXTRA"] diff --git a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py index e0bac382..22f1f66f 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py @@ -31,7 +31,7 @@ def test_rent_any_number_request_expects_valid_data(): "searchPattern": "START" } assert request.region_code == "string" - assert request.type_ == "MOBILE" + assert request.number_type == "MOBILE" assert request.capabilities == ["SMS"] assert request.sms_configuration == { "servicePlanId": "string", @@ -56,7 +56,7 @@ def test_rent_any_number_request_expects_missing_optional_fields(): request = RentAnyNumberRequest(**data) assert request.region_code == "string" - assert request.type_ == "MOBILE" + assert request.number_type == "MOBILE" assert request.number_pattern is None assert request.capabilities is None diff --git a/tests/unit/domains/voice/test_callout_conference.py b/tests/unit/domains/voice/test_callout_conference.py new file mode 100644 index 00000000..83581547 --- /dev/null +++ b/tests/unit/domains/voice/test_callout_conference.py @@ -0,0 +1,80 @@ +import json +import pytest +from sinch.domains.voice.enums import CalloutMethod + +from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint + +from sinch.domains.voice.models.callouts.requests import ConferenceVoiceCalloutRequest + + +@pytest.fixture +def request_data(): + return ConferenceVoiceCalloutRequest( + destination={ + "type": "number", + "endpoint": "+33612345678", + }, + cli="", + greeting='Welcome', + conferenceId="123456", + conferenceDtmfOptions={ + "mode": "forward", + "max_digits": 2, + "timeout_mills": 2500 + }, + dtmf="dtmf", + conference="conference", + maxDuration=10, + enableAce=True, + enableDice=True, + enablePie=True, + locale="locale", + mohClass="moh_class", + custom="custom", + domain="pstn" + ) + + +@pytest.fixture +def endpoint(request_data): + return CalloutEndpoint(request_data, CalloutMethod.CONFERENCE.value) + + +@pytest.fixture +def mock_response_body(): + expected_body = { + "method": "conferenceCallout", + "conferenceCallout": { + "destination": { + "type": "number", + "endpoint": "+33612345678" + }, + "conferenceId": "123456", + "cli": "", + "conferenceDtmfOptions": { + "mode": "forward", + "timeoutMills": 2500, + "maxDigits": 2 + }, + "dtmf": "dtmf", + "conference": "conference", + "maxDuration": 10, + "enableAce": True, + "enableDice": True, + "enablePie": True, + "locale": "locale", + "greeting": "Welcome", + "mohClass": "moh_class", + "custom": "custom", + "domain": "pstn" + } + } + return json.dumps(expected_body) + + +def test_handle_response(endpoint, mock_response_body): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + request_body = endpoint.request_body() + assert request_body == mock_response_body From 502b4cffd14fd488c148b3d0f7d4bf7e854148da Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 7 Oct 2025 09:05:06 +0200 Subject: [PATCH 057/106] chore: rename types (#82) --- sinch/domains/numbers/api/v1/active_numbers_apis.py | 4 ++-- .../models/v1/internal/list_active_numbers_request.py | 4 ++-- sinch/domains/numbers/models/v1/types/__init__.py | 4 ++-- sinch/domains/numbers/models/v1/types/order_by.py | 5 ----- sinch/domains/numbers/models/v1/types/order_by_type.py | 5 +++++ sinch/domains/numbers/virtual_numbers.py | 6 +++--- 6 files changed, 14 insertions(+), 14 deletions(-) delete mode 100644 sinch/domains/numbers/models/v1/types/order_by.py create mode 100644 sinch/domains/numbers/models/v1/types/order_by_type.py diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 17d31d8a..4faa1c7f 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -11,7 +11,7 @@ ListActiveNumbersRequest, NumberRequest, UpdateNumberConfigurationRequest ) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, NumberSearchPatternType, NumberType, OrderBy, + CapabilityType, NumberSearchPatternType, NumberType, OrderByType, SmsConfigurationDict, VoiceConfigurationDict ) @@ -27,7 +27,7 @@ def list( capabilities: Optional[List[CapabilityType]] = None, page_size: Optional[int] = None, page_token: Optional[str] = None, - order_by: Optional[OrderBy] = None, + order_by: Optional[OrderByType] = None, **kwargs ) -> Paginator[ActiveNumber]: return TokenBasedPaginator( diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 5d223a7b..5612c1c0 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -2,7 +2,7 @@ from pydantic import Field, StrictInt, StrictStr, field_validator, conlist from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest from sinch.domains.numbers.models.v1.types import ( - CapabilityType, OrderBy, NumberSearchPatternType, NumberType + CapabilityType, OrderByType, NumberSearchPatternType, NumberType ) @@ -16,7 +16,7 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): ) number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") - order_by: Optional[OrderBy] = Field(default=None, alias="orderBy") + order_by: Optional[OrderByType] = Field(default=None, alias="orderBy") @field_validator("order_by", mode="before") @classmethod diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 9526a09a..41a2c94e 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -2,7 +2,7 @@ from sinch.domains.numbers.models.v1.types.number_search_pattern_type import NumberSearchPatternType from sinch.domains.numbers.models.v1.types.number_pattern_dict import NumberPatternDict from sinch.domains.numbers.models.v1.types.number_type import NumberType -from sinch.domains.numbers.models.v1.types.order_by import OrderBy +from sinch.domains.numbers.models.v1.types.order_by_type import OrderByType from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ScheduledVoiceProvisioning from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode @@ -20,7 +20,7 @@ "NumberPatternDict", "NumberSearchPatternType", "NumberType", - "OrderBy", + "OrderByType", "ScheduledVoiceProvisioning", "SmsConfigurationDict", "SmsErrorCode", diff --git a/sinch/domains/numbers/models/v1/types/order_by.py b/sinch/domains/numbers/models/v1/types/order_by.py deleted file mode 100644 index 22f6610f..00000000 --- a/sinch/domains/numbers/models/v1/types/order_by.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Literal, Union -from pydantic import StrictStr - - -OrderBy = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/order_by_type.py b/sinch/domains/numbers/models/v1/types/order_by_type.py new file mode 100644 index 00000000..ed46cdc5 --- /dev/null +++ b/sinch/domains/numbers/models/v1/types/order_by_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +OrderByType = Union[Literal["PHONE_NUMBER", "DISPLAY_NAME"], StrictStr] diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 09018672..850b6356 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -7,7 +7,7 @@ ActiveNumber, AvailableNumber ) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, NumberSearchPatternType, NumberType, OrderBy, + CapabilityType, NumberSearchPatternType, NumberType, OrderByType, SmsConfigurationDict, VoiceConfigurationDict, VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, VoiceConfigurationESTDict, NumberPatternDict ) @@ -52,7 +52,7 @@ def list( capabilities: Optional[List[CapabilityType]] = None, page_size: Optional[int] = None, page_token: Optional[str] = None, - order_by: Optional[OrderBy] = None, + order_by: Optional[OrderByType] = None, **kwargs ) -> Paginator[ActiveNumber]: """ @@ -80,7 +80,7 @@ def list( :type page_token: Optional[str] :param order_by: Field to order the results by (e.g., "phoneNumber", "displayName"). - :type order_by: Optional[OrderBy] + :type order_by: Optional[OrderByType] :param kwargs: Additional filters for the request. :type kwargs: dict From 85de97488b3c1ddf4dc7839e826c406512f24274 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 15 Oct 2025 15:15:20 +0200 Subject: [PATCH 058/106] chore: add ruff (#84) --- .github/workflows/{run-tests.yml => ci.yml} | 5 +- pyproject.toml | 12 +++ requirements-dev.txt | 2 +- sinch/domains/numbers/__init__.py | 2 +- sinch/domains/numbers/api/v1/__init__.py | 14 ++- .../numbers/api/v1/active_numbers_apis.py | 53 +++++------ .../numbers/api/v1/available_numbers_apis.py | 42 +++++---- .../numbers/api/v1/available_regions_apis.py | 15 ++-- sinch/domains/numbers/api/v1/base/__init__.py | 2 +- .../numbers/api/v1/base/base_numbers.py | 2 +- .../api/v1/callback_configuration_apis.py | 28 +++--- .../numbers/api/v1/internal/__init__.py | 20 +++-- .../v1/internal/active_numbers_endpoints.py | 87 +++++++++++++++---- .../internal/available_numbers_endpoints.py | 60 ++++++++++--- .../internal/available_regions_endpoints.py | 38 ++++++-- .../numbers/api/v1/internal/base/__init__.py | 4 +- .../api/v1/internal/base/numbers_endpoint.py | 19 ++-- .../callback_configuration_endpoints.py | 73 ++++++++++++---- .../numbers/models/v1/errors/__init__.py | 8 +- .../models/v1/errors/not_found_error.py | 13 ++- .../v1/errors/not_found_error_details.py | 12 ++- .../numbers/models/v1/internal/__init__.py | 48 ++++++---- .../models/v1/internal/base/__init__.py | 3 +- .../internal/base/base_model_configuration.py | 22 +++-- .../internal/list_active_numbers_request.py | 17 ++-- .../internal/list_active_numbers_response.py | 17 +++- .../list_available_numbers_request.py | 19 ++-- .../list_available_numbers_response.py | 8 +- .../list_available_regions_request.py | 4 +- .../list_available_regions_response.py | 8 +- .../models/v1/internal/number_request.py | 4 +- .../v1/internal/rent_any_number_request.py | 25 ++++-- .../models/v1/internal/rent_number_request.py | 20 +++-- .../v1/internal/sms_configuration_request.py | 4 +- .../update_callback_configuration_request.py | 4 +- .../update_number_configuration_request.py | 24 +++-- .../voice_configuration_custom_request.py | 4 +- .../voice_configuration_est_request.py | 4 +- .../voice_configuration_fax_request.py | 4 +- .../internal/voice_configuration_request.py | 4 +- .../voice_configuration_rtc_request.py | 4 +- .../numbers/models/v1/response/__init__.py | 12 ++- .../models/v1/response/active_number.py | 38 ++++++-- .../models/v1/response/available_number.py | 17 ++-- .../models/v1/response/available_region.py | 4 +- .../callback_configuration_response.py | 4 +- .../numbers/models/v1/shared/__init__.py | 42 ++++++--- .../domains/numbers/models/v1/shared/money.py | 4 +- .../models/v1/shared/number_pattern.py | 8 +- .../v1/shared/scheduled_sms_provisioning.py | 20 +++-- .../scheduled_voice_provisioning_common.py | 16 +++- .../scheduled_voice_provisioning_custom.py | 4 +- .../scheduled_voice_provisioning_est.py | 4 +- .../scheduled_voice_provisioning_fax.py | 4 +- .../scheduled_voice_provisioning_rtc.py | 4 +- .../models/v1/shared/sms_configuration.py | 9 +- .../v1/shared/voice_configuration_common.py | 8 +- .../v1/shared/voice_configuration_est.py | 4 +- .../v1/shared/voice_configuration_fax.py | 4 +- .../v1/shared/voice_configuration_rtc.py | 4 +- .../numbers/models/v1/types/__init__.py | 52 ++++++++--- .../v1/types/scheduled_voice_provisioning.py | 26 ++++-- .../numbers/models/v1/types/sms_error_code.py | 43 ++++----- .../v1/types/status_scheduled_provisioning.py | 12 +-- .../models/v1/types/voice_application_type.py | 6 +- .../models/v1/types/voice_configuration.py | 16 +++- .../v1/types/voice_configuration_dict.py | 26 ++++-- .../numbers/models/v1/utils/validators.py | 8 +- sinch/domains/numbers/virtual_numbers.py | 79 +++++++++-------- .../numbers/webhooks/v1/events/__init__.py | 4 +- .../v1/events/numbers_webhooks_event.py | 69 ++++++++------- .../numbers/webhooks/v1/internal/__init__.py | 4 +- .../webhooks/v1/internal/webhook_event.py | 4 +- .../numbers/webhooks/v1/numbers_webhooks.py | 22 ++--- 74 files changed, 910 insertions(+), 429 deletions(-) rename .github/workflows/{run-tests.yml => ci.yml} (94%) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/ci.yml similarity index 94% rename from .github/workflows/run-tests.yml rename to .github/workflows/ci.yml index e089a633..cbb3233d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,10 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: Lint with flake8 + - name: Lint and format check with Ruff run: | - flake8 sinch tests --count --max-complexity=10 --max-line-length=120 --statistics + ruff check sinch/domains/numbers --statistics + ruff format sinch/domains/numbers --check --diff - name: Test with Pytest run: | diff --git a/pyproject.toml b/pyproject.toml index 1eb439f1..88e7c51f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,15 @@ pydantic = ">=2.0.0" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 79 +target-version = "py39" +extend-exclude = [ + "sinch/core", + "tests", + "venv", + "__pycache__", + "dist", + "build" +] diff --git a/requirements-dev.txt b/requirements-dev.txt index 467451a6..384715db 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,7 +5,7 @@ coverage behave # Code Quality -flake8 +ruff # HTTP Libraries requests diff --git a/sinch/domains/numbers/__init__.py b/sinch/domains/numbers/__init__.py index cabff49c..0411ea02 100644 --- a/sinch/domains/numbers/__init__.py +++ b/sinch/domains/numbers/__init__.py @@ -1,3 +1,3 @@ from sinch.domains.numbers.virtual_numbers import VirtualNumbers -__all__ = ['VirtualNumbers'] +__all__ = ["VirtualNumbers"] diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index 1ad0cf75..15b4bde9 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -1,12 +1,18 @@ from sinch.domains.numbers.api.v1.active_numbers_apis import ActiveNumbers -from sinch.domains.numbers.api.v1.available_numbers_apis import AvailableNumbers -from sinch.domains.numbers.api.v1.available_regions_apis import AvailableRegions -from sinch.domains.numbers.api.v1.callback_configuration_apis import CallbackConfiguration +from sinch.domains.numbers.api.v1.available_numbers_apis import ( + AvailableNumbers, +) +from sinch.domains.numbers.api.v1.available_regions_apis import ( + AvailableRegions, +) +from sinch.domains.numbers.api.v1.callback_configuration_apis import ( + CallbackConfiguration, +) __all__ = [ "ActiveNumbers", "AvailableNumbers", "AvailableRegions", - "CallbackConfiguration" + "CallbackConfiguration", ] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 4faa1c7f..9cf6dbd3 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -2,22 +2,29 @@ from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( - GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, - UpdateNumberConfigurationEndpoint + GetNumberConfigurationEndpoint, + ListActiveNumbersEndpoint, + ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint, ) from sinch.domains.numbers.models.v1.response import ActiveNumber from sinch.domains.numbers.models.v1.internal import ( - ListActiveNumbersRequest, NumberRequest, UpdateNumberConfigurationRequest + ListActiveNumbersRequest, + NumberRequest, + UpdateNumberConfigurationRequest, ) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, NumberSearchPatternType, NumberType, OrderByType, - SmsConfigurationDict, VoiceConfigurationDict + CapabilityType, + NumberSearchPatternType, + NumberType, + OrderByType, + SmsConfigurationDict, + VoiceConfigurationDict, ) class ActiveNumbers(BaseNumbers): - def list( self, region_code: str, @@ -28,7 +35,7 @@ def list( page_size: Optional[int] = None, page_token: Optional[str] = None, order_by: Optional[OrderByType] = None, - **kwargs + **kwargs, ) -> Paginator[ActiveNumber]: return TokenBasedPaginator( sinch=self._sinch, @@ -43,9 +50,9 @@ def list( number_search_pattern=number_search_pattern, page_token=page_token, order_by=order_by, - **kwargs - ) - ) + **kwargs, + ), + ), ) def update( @@ -55,7 +62,7 @@ def update( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: request_data = UpdateNumberConfigurationRequest( phone_number=phone_number, @@ -63,28 +70,14 @@ def update( sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) return self._request(UpdateNumberConfigurationEndpoint, request_data) - def get( - self, - phone_number: str, - **kwargs - ) -> ActiveNumber: - request_data = NumberRequest( - phone_number=phone_number, - **kwargs - ) + def get(self, phone_number: str, **kwargs) -> ActiveNumber: + request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(GetNumberConfigurationEndpoint, request_data) - def release( - self, - phone_number: str, - **kwargs - ) -> ActiveNumber: - request_data = NumberRequest( - phone_number=phone_number, - **kwargs - ) + def release(self, phone_number: str, **kwargs) -> ActiveNumber: + request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(ReleaseNumberFromProjectEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index b3d058e9..cf415f61 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -2,24 +2,36 @@ from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber + ActiveNumber, + AvailableNumber, ) from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( - AvailableNumbersEndpoint, RentAnyNumberEndpoint, RentNumberEndpoint, SearchForNumberEndpoint + AvailableNumbersEndpoint, + RentAnyNumberEndpoint, + RentNumberEndpoint, + SearchForNumberEndpoint, ) from sinch.domains.numbers.models.v1.internal import ( - ListAvailableNumbersRequest, NumberRequest, RentAnyNumberRequest, RentNumberRequest + ListAvailableNumbersRequest, + NumberRequest, + RentAnyNumberRequest, + RentNumberRequest, ) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, NumberPatternDict, NumberSearchPatternType, - NumberType, SmsConfigurationDict, VoiceConfigurationDict + CapabilityType, + NumberPatternDict, + NumberSearchPatternType, + NumberType, + SmsConfigurationDict, + VoiceConfigurationDict, ) class AvailableNumbers(BaseNumbers): - - def check_availability(self, phone_number: str, **kwargs) -> AvailableNumber: + def check_availability( + self, phone_number: str, **kwargs + ) -> AvailableNumber: request_data = NumberRequest(phone_number=phone_number, **kwargs) return self._request(SearchForNumberEndpoint, request_data) @@ -31,7 +43,7 @@ def search_for_available_numbers( number_search_pattern: Optional[NumberSearchPatternType] = None, capabilities: Optional[List[CapabilityType]] = None, page_size: Optional[int] = None, - **kwargs + **kwargs, ) -> Paginator[AvailableNumber]: return TokenBasedPaginator( sinch=self._sinch, @@ -44,9 +56,9 @@ def search_for_available_numbers( capabilities=capabilities, number_pattern=number_pattern, number_search_pattern=number_search_pattern, - **kwargs - ) - ) + **kwargs, + ), + ), ) def rent( @@ -55,14 +67,14 @@ def rent( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: request_data = RentNumberRequest( phone_number=phone_number, sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) return self._request(RentNumberEndpoint, request_data) @@ -75,7 +87,7 @@ def rent_any( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: request_data = RentAnyNumberRequest( region_code=region_code, @@ -85,6 +97,6 @@ def rent_any( sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) return self._request(RentAnyNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/available_regions_apis.py b/sinch/domains/numbers/api/v1/available_regions_apis.py index 36c28b47..5ba441e7 100644 --- a/sinch/domains/numbers/api/v1/available_regions_apis.py +++ b/sinch/domains/numbers/api/v1/available_regions_apis.py @@ -1,7 +1,9 @@ from typing import Optional, List from sinch.core.pagination import TokenBasedPaginator, Paginator from sinch.domains.numbers.api.v1.internal import ListAvailableRegionsEndpoint -from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, +) from sinch.domains.numbers.models.v1.response import AvailableRegion from sinch.domains.numbers.models.v1.types import NumberType @@ -11,9 +13,7 @@ def __init__(self, sinch): self._sinch = sinch def list( - self, - types: Optional[List[NumberType]] = None, - **kwargs + self, types: Optional[List[NumberType]] = None, **kwargs ) -> Paginator[AvailableRegion]: """ Lists all regions for numbers provided using the project ID. @@ -36,8 +36,7 @@ def list( endpoint=ListAvailableRegionsEndpoint( project_id=self._sinch.configuration.project_id, request_data=ListAvailableRegionsRequest( - types=types, - **kwargs - ) - ) + types=types, **kwargs + ), + ), ) diff --git a/sinch/domains/numbers/api/v1/base/__init__.py b/sinch/domains/numbers/api/v1/base/__init__.py index 67baaa66..94295842 100644 --- a/sinch/domains/numbers/api/v1/base/__init__.py +++ b/sinch/domains/numbers/api/v1/base/__init__.py @@ -1,3 +1,3 @@ from sinch.domains.numbers.api.v1.base.base_numbers import BaseNumbers -__all__ = ['BaseNumbers'] +__all__ = ["BaseNumbers"] diff --git a/sinch/domains/numbers/api/v1/base/base_numbers.py b/sinch/domains/numbers/api/v1/base/base_numbers.py index 1b6bc1b5..af60fd32 100644 --- a/sinch/domains/numbers/api/v1/base/base_numbers.py +++ b/sinch/domains/numbers/api/v1/base/base_numbers.py @@ -18,6 +18,6 @@ def _request(self, endpoint_class, request_data): return self._sinch.configuration.transport.request( endpoint_class( project_id=self._sinch.configuration.project_id, - request_data=request_data + request_data=request_data, ) ) diff --git a/sinch/domains/numbers/api/v1/callback_configuration_apis.py b/sinch/domains/numbers/api/v1/callback_configuration_apis.py index 43dc854d..f4d38c67 100644 --- a/sinch/domains/numbers/api/v1/callback_configuration_apis.py +++ b/sinch/domains/numbers/api/v1/callback_configuration_apis.py @@ -1,18 +1,21 @@ from sinch.domains.numbers.api.v1.base import BaseNumbers from sinch.domains.numbers.api.v1.internal import ( - GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint + GetCallbackConfigurationEndpoint, + UpdateCallbackConfigurationEndpoint, +) +from sinch.domains.numbers.models.v1.internal import ( + UpdateCallbackConfigurationRequest, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.response import ( + CallbackConfigurationResponse, ) -from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse class CallbackConfiguration(BaseNumbers): - - def get( - self, - **kwargs - ) -> CallbackConfigurationResponse: + def get(self, **kwargs) -> CallbackConfigurationResponse: """ Returns the callback configuration for the specified project @@ -30,9 +33,7 @@ def get( return self._request(GetCallbackConfigurationEndpoint, request_data) def update( - self, - hmac_secret: str, - **kwargs + self, hmac_secret: str, **kwargs ) -> CallbackConfigurationResponse: """ Updates the callback configuration for the specified project @@ -49,7 +50,6 @@ def update( For detailed documentation, visit https://developers.sinch.com """ request_data = UpdateCallbackConfigurationRequest( - hmac_secret=hmac_secret, - **kwargs + hmac_secret=hmac_secret, **kwargs ) return self._request(UpdateCallbackConfigurationEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 98d24236..083ffb0a 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -1,13 +1,21 @@ from sinch.domains.numbers.api.v1.internal.active_numbers_endpoints import ( - GetNumberConfigurationEndpoint, ListActiveNumbersEndpoint, ReleaseNumberFromProjectEndpoint, - UpdateNumberConfigurationEndpoint + GetNumberConfigurationEndpoint, + ListActiveNumbersEndpoint, + ReleaseNumberFromProjectEndpoint, + UpdateNumberConfigurationEndpoint, ) from sinch.domains.numbers.api.v1.internal.available_numbers_endpoints import ( - AvailableNumbersEndpoint, RentAnyNumberEndpoint, RentNumberEndpoint, SearchForNumberEndpoint + AvailableNumbersEndpoint, + RentAnyNumberEndpoint, + RentNumberEndpoint, + SearchForNumberEndpoint, +) +from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ( + ListAvailableRegionsEndpoint, ) -from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ListAvailableRegionsEndpoint from sinch.domains.numbers.api.v1.internal.callback_configuration_endpoints import ( - GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint + GetCallbackConfigurationEndpoint, + UpdateCallbackConfigurationEndpoint, ) __all__ = [ @@ -21,5 +29,5 @@ "RentAnyNumberEndpoint", "SearchForNumberEndpoint", "UpdateCallbackConfigurationEndpoint", - "UpdateNumberConfigurationEndpoint" + "UpdateNumberConfigurationEndpoint", ] diff --git a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py index 35f070b2..9b31a217 100644 --- a/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/active_numbers_endpoints.py @@ -1,10 +1,16 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint from sinch.domains.numbers.models.v1.internal import ( - ListActiveNumbersRequest, ListActiveNumbersResponse, NumberRequest, UpdateNumberConfigurationRequest + ListActiveNumbersRequest, + ListActiveNumbersResponse, + NumberRequest, + UpdateNumberConfigurationRequest, ) from sinch.domains.numbers.models.v1.response import ActiveNumber @@ -13,20 +19,31 @@ class GetNumberConfigurationEndpoint(NumbersEndpoint): """ Endpoint to get the configuration of a specific number """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + ) HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data: NumberRequest): - super(GetNumberConfigurationEndpoint, self).__init__(project_id, request_data) + super(GetNumberConfigurationEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: - super(GetNumberConfigurationEndpoint, self).handle_response(response) + super(GetNumberConfigurationEndpoint, self).handle_response( + response + ) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) return self.process_response_model(response.body, ActiveNumber) @@ -34,21 +51,30 @@ class ListActiveNumbersEndpoint(NumbersEndpoint): """ Endpoint to list all active numbers for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: ListActiveNumbersRequest): - super(ListActiveNumbersEndpoint, self).__init__(project_id, request_data) + def __init__( + self, project_id: str, request_data: ListActiveNumbersRequest + ): + super(ListActiveNumbersEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def handle_response(self, response: HTTPResponse) -> ListActiveNumbersResponse: + def handle_response( + self, response: HTTPResponse + ) -> ListActiveNumbersResponse: super(ListActiveNumbersEndpoint, self).handle_response(response) - return self.process_response_model(response.body, ListActiveNumbersResponse) + return self.process_response_model( + response.body, ListActiveNumbersResponse + ) class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): @@ -57,15 +83,23 @@ class ReleaseNumberFromProjectEndpoint(NumbersEndpoint): HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id, request_data: NumberRequest): - super(ReleaseNumberFromProjectEndpoint, self).__init__(project_id, request_data) + super(ReleaseNumberFromProjectEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: - super(ReleaseNumberFromProjectEndpoint, self).handle_response(response) + super(ReleaseNumberFromProjectEndpoint, self).handle_response( + response + ) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) return self.process_response_model(response.body, ActiveNumber) @@ -73,22 +107,37 @@ class UpdateNumberConfigurationEndpoint(NumbersEndpoint): """ Endpoint to update the configuration of a specific number """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/activeNumbers/{phone_number}" + ) HTTP_METHOD = HTTPMethods.PATCH.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: UpdateNumberConfigurationRequest): - super(UpdateNumberConfigurationEndpoint, self).__init__(project_id, request_data) + def __init__( + self, project_id: str, request_data: UpdateNumberConfigurationRequest + ): + super(UpdateNumberConfigurationEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def request_body(self): - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) return json.dumps(request_data) def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: - super(UpdateNumberConfigurationEndpoint, self).handle_response(response) + super(UpdateNumberConfigurationEndpoint, self).handle_response( + response + ) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) return self.process_response_model(response.body, ActiveNumber) diff --git a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py index 920b3e6b..b9321256 100644 --- a/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_numbers_endpoints.py @@ -1,13 +1,20 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException, NumbersException +from sinch.domains.numbers.api.v1.exceptions import ( + NumberNotFoundException, + NumbersException, +) from sinch.domains.numbers.models.v1.internal import ( - ListAvailableNumbersRequest, ListAvailableNumbersResponse, - NumberRequest, RentAnyNumberRequest, RentNumberRequest + ListAvailableNumbersRequest, + ListAvailableNumbersResponse, + NumberRequest, + RentAnyNumberRequest, + RentNumberRequest, ) from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber + ActiveNumber, + AvailableNumber, ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint @@ -16,6 +23,7 @@ class RentNumberEndpoint(NumbersEndpoint): """ Endpoint to rent a virtual number for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}:rent" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -25,15 +33,20 @@ def __init__(self, project_id: str, request_data: RentNumberRequest): def request_body(self) -> str: # Convert the request data to a dictionary and remove None values - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) return json.dumps(request_data) def handle_response(self, response: HTTPResponse) -> ActiveNumber: try: super(RentNumberEndpoint, self).handle_response(response) except NumbersException as ex: - raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, - is_from_server=ex.is_from_server) + raise NumberNotFoundException( + message=ex.args[0], + response=ex.http_response, + is_from_server=ex.is_from_server, + ) return self.process_response_model(response.body, ActiveNumber) @@ -41,26 +54,36 @@ class AvailableNumbersEndpoint(NumbersEndpoint): """ Endpoint to list available virtual numbers for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: ListAvailableNumbersRequest): - super(AvailableNumbersEndpoint, self).__init__(project_id, request_data) + def __init__( + self, project_id: str, request_data: ListAvailableNumbersRequest + ): + super(AvailableNumbersEndpoint, self).__init__( + project_id, request_data + ) self.request_data = request_data def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def handle_response(self, response: HTTPResponse) -> ListAvailableNumbersResponse: + def handle_response( + self, response: HTTPResponse + ) -> ListAvailableNumbersResponse: super(AvailableNumbersEndpoint, self).handle_response(response) - return self.process_response_model(response.body, ListAvailableNumbersResponse) + return self.process_response_model( + response.body, ListAvailableNumbersResponse + ) class RentAnyNumberEndpoint(NumbersEndpoint): """ Endpoint to rent an available virtual number for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers:rentAny" HTTP_METHOD = HTTPMethods.POST.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -70,7 +93,9 @@ def __init__(self, project_id: str, request_data: RentAnyNumberRequest): self.request_data = request_data def request_body(self) -> str: - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) return json.dumps(request_data) def handle_response(self, response: HTTPResponse) -> ActiveNumber: @@ -84,7 +109,10 @@ class SearchForNumberEndpoint(NumbersEndpoint): """ Endpoint to check the availability of a virtual number for a project. """ - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" + + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/availableNumbers/{phone_number}" + ) HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -95,5 +123,9 @@ def handle_response(self, response: HTTPResponse) -> AvailableNumber: try: super(SearchForNumberEndpoint, self).handle_response(response) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) return self.process_response_model(response.body, AvailableNumber) diff --git a/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py index 55bf86a1..4f957921 100644 --- a/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/available_regions_endpoints.py @@ -1,30 +1,50 @@ from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import ( + NumbersEndpoint, +) from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.numbers.models.v1.internal import ListAvailableRegionsRequest, ListAvailableRegionsResponse +from sinch.domains.numbers.models.v1.internal import ( + ListAvailableRegionsRequest, + ListAvailableRegionsResponse, +) class ListAvailableRegionsEndpoint(NumbersEndpoint): """ Endpoint to list all the regions that have numbers assigned to a project """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/availableRegions" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: ListAvailableRegionsRequest): - super(ListAvailableRegionsEndpoint, self).__init__(project_id, request_data) + def __init__( + self, project_id: str, request_data: ListAvailableRegionsRequest + ): + super(ListAvailableRegionsEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def build_query_params(self) -> dict: return self.request_data.model_dump(exclude_none=True, by_alias=True) - def handle_response(self, response: HTTPResponse) -> ListAvailableRegionsResponse: + def handle_response( + self, response: HTTPResponse + ) -> ListAvailableRegionsResponse: try: super(ListAvailableRegionsEndpoint, self).handle_response(response) except NumbersException as ex: - raise NumberNotFoundException(message=ex.args[0], response=ex.http_response, - is_from_server=ex.is_from_server) - return self.process_response_model(response.body, ListAvailableRegionsResponse) + raise NumberNotFoundException( + message=ex.args[0], + response=ex.http_response, + is_from_server=ex.is_from_server, + ) + return self.process_response_model( + response.body, ListAvailableRegionsResponse + ) diff --git a/sinch/domains/numbers/api/v1/internal/base/__init__.py b/sinch/domains/numbers/api/v1/internal/base/__init__.py index 38ca27d6..8c71684d 100644 --- a/sinch/domains/numbers/api/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/base/__init__.py @@ -1,3 +1,5 @@ -from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import NumbersEndpoint +from sinch.domains.numbers.api.v1.internal.base.numbers_endpoint import ( + NumbersEndpoint, +) __all__ = ["NumbersEndpoint"] diff --git a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py index 992274df..66d6be7c 100644 --- a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py +++ b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py @@ -8,18 +8,19 @@ class NumbersEndpoint(HTTPEndpoint, ABC): - def __init__(self, project_id: str, request_data: BM): super().__init__(project_id, request_data) def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: - raise NotImplementedError("ENDPOINT_URL must be defined in the subclass.") + raise NotImplementedError( + "ENDPOINT_URL must be defined in the subclass." + ) return self.ENDPOINT_URL.format( origin=sinch.configuration.numbers_origin, project_id=self.project_id, - **vars(self.request_data) + **vars(self.request_data), ) def build_query_params(self) -> dict: @@ -40,7 +41,9 @@ def request_body(self) -> str: """ return "" - def process_response_model(self, response_body: dict, response_model: Type[BM]) -> BM: + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: """ Processes the response body and maps it to a response model. @@ -58,12 +61,14 @@ def process_response_model(self, response_body: dict, response_model: Type[BM]) def handle_response(self, response: HTTPResponse): if response.status_code == 404: - error = NotFoundError(**response.body['error']) - raise NumbersException(message=error, response=response, is_from_server=True) + error = NotFoundError(**response.body["error"]) + raise NumbersException( + message=error, response=response, is_from_server=True + ) if response.status_code >= 400: raise NumbersException( message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", response=response, - is_from_server=True + is_from_server=True, ) diff --git a/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py b/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py index 1ab8d6e5..e50dac4f 100644 --- a/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py @@ -1,22 +1,32 @@ import json from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.exceptions import NumbersException, NumberNotFoundException +from sinch.domains.numbers.api.v1.exceptions import ( + NumbersException, + NumberNotFoundException, +) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint -from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.internal import ( + UpdateCallbackConfigurationRequest, +) +from sinch.domains.numbers.models.v1.response import ( + CallbackConfigurationResponse, +) class GetCallbackConfigurationEndpoint(NumbersEndpoint): """ Endpoint to get the callbacks configuration for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data=None): - super(GetCallbackConfigurationEndpoint, self).__init__(project_id, request_data) + super(GetCallbackConfigurationEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data @@ -25,42 +35,71 @@ def build_url(self, sinch) -> str: super(GetCallbackConfigurationEndpoint, self).build_url(sinch) return self.ENDPOINT_URL.format( origin=sinch.configuration.numbers_origin, - project_id=self.project_id + project_id=self.project_id, ) def build_query_params(self) -> dict: if self.request_data: - return self.request_data.model_dump(exclude_none=True, by_alias=True) + return self.request_data.model_dump( + exclude_none=True, by_alias=True + ) return {} - def handle_response(self, response: HTTPResponse) -> CallbackConfigurationResponse: + def handle_response( + self, response: HTTPResponse + ) -> CallbackConfigurationResponse: try: - super(GetCallbackConfigurationEndpoint, self).handle_response(response) + super(GetCallbackConfigurationEndpoint, self).handle_response( + response + ) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) - return self.process_response_model(response.body, CallbackConfigurationResponse) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, CallbackConfigurationResponse + ) class UpdateCallbackConfigurationEndpoint(NumbersEndpoint): """ Endpoint to update the callbacks configuration for a project. """ + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" HTTP_METHOD = HTTPMethods.PATCH.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - def __init__(self, project_id: str, request_data: UpdateCallbackConfigurationRequest): - super(UpdateCallbackConfigurationEndpoint, self).__init__(project_id, request_data) + def __init__( + self, project_id: str, request_data: UpdateCallbackConfigurationRequest + ): + super(UpdateCallbackConfigurationEndpoint, self).__init__( + project_id, request_data + ) self.project_id = project_id self.request_data = request_data def request_body(self): - request_data = self.request_data.model_dump(by_alias=True, exclude_none=True) + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) return json.dumps(request_data) - def handle_response(self, response: HTTPResponse) -> CallbackConfigurationResponse: + def handle_response( + self, response: HTTPResponse + ) -> CallbackConfigurationResponse: try: - super(UpdateCallbackConfigurationEndpoint, self).handle_response(response) + super(UpdateCallbackConfigurationEndpoint, self).handle_response( + response + ) except NumbersException as e: - raise NumberNotFoundException(message=e.args[0], response=e.http_response, is_from_server=e.is_from_server) - return self.process_response_model(response.body, CallbackConfigurationResponse) + raise NumberNotFoundException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, CallbackConfigurationResponse + ) diff --git a/sinch/domains/numbers/models/v1/errors/__init__.py b/sinch/domains/numbers/models/v1/errors/__init__.py index 5543bd1c..885a36a3 100644 --- a/sinch/domains/numbers/models/v1/errors/__init__.py +++ b/sinch/domains/numbers/models/v1/errors/__init__.py @@ -1,5 +1,9 @@ -from sinch.domains.numbers.models.v1.errors.not_found_error import NotFoundError -from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails +from sinch.domains.numbers.models.v1.errors.not_found_error import ( + NotFoundError, +) +from sinch.domains.numbers.models.v1.errors.not_found_error_details import ( + NotFoundErrorDetails, +) __all__ = [ "NotFoundError", diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py index 6f3a2591..da52f032 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py @@ -1,7 +1,11 @@ from typing import Optional from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.errors.not_found_error_details import NotFoundErrorDetails +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.errors.not_found_error_details import ( + NotFoundErrorDetails, +) class NotFoundError(BaseModelConfigurationResponse): @@ -10,4 +14,7 @@ class NotFoundError(BaseModelConfigurationResponse): status: Optional[StrictStr] = Field(default=None) details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None) - model_config = ConfigDict(populate_by_name=True, alias_generator=BaseModelConfigurationResponse._to_snake_case) + model_config = ConfigDict( + populate_by_name=True, + alias_generator=BaseModelConfigurationResponse._to_snake_case, + ) diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error_details.py b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py index 85d3f5e6..3c511cbf 100644 --- a/sinch/domains/numbers/models/v1/errors/not_found_error_details.py +++ b/sinch/domains/numbers/models/v1/errors/not_found_error_details.py @@ -1,11 +1,17 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from typing import Optional from pydantic import Field, StrictStr class NotFoundErrorDetails(BaseModelConfigurationResponse): type: Optional[StrictStr] = Field(default=None, alias="type") - resource_type: Optional[StrictStr] = Field(default=None, alias="resourceType") - resource_name: Optional[StrictStr] = Field(default=None, alias="resourceName") + resource_type: Optional[StrictStr] = Field( + default=None, alias="resourceType" + ) + resource_name: Optional[StrictStr] = Field( + default=None, alias="resourceName" + ) owner: Optional[StrictStr] = Field(default=None, alias="owner") description: Optional[StrictStr] = Field(default=None, alias="description") diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index aa2f72c7..a73547d5 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -1,26 +1,44 @@ -from sinch.domains.numbers.models.v1.internal.rent_number_request import RentNumberRequest -from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ListActiveNumbersRequest -from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ListActiveNumbersResponse -from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ListAvailableNumbersRequest -from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ListAvailableNumbersResponse +from sinch.domains.numbers.models.v1.internal.rent_number_request import ( + RentNumberRequest, +) +from sinch.domains.numbers.models.v1.internal.list_active_numbers_request import ( + ListActiveNumbersRequest, +) +from sinch.domains.numbers.models.v1.internal.list_active_numbers_response import ( + ListActiveNumbersResponse, +) +from sinch.domains.numbers.models.v1.internal.list_available_numbers_request import ( + ListAvailableNumbersRequest, +) +from sinch.domains.numbers.models.v1.internal.list_available_numbers_response import ( + ListAvailableNumbersResponse, +) from sinch.domains.numbers.models.v1.internal.list_available_regions_request import ( - ListAvailableRegionsRequest + ListAvailableRegionsRequest, ) from sinch.domains.numbers.models.v1.internal.list_available_regions_response import ( - ListAvailableRegionsResponse + ListAvailableRegionsResponse, +) +from sinch.domains.numbers.models.v1.internal.number_request import ( + NumberRequest, +) +from sinch.domains.numbers.models.v1.internal.rent_any_number_request import ( + RentAnyNumberRequest, +) +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import ( + SmsConfigurationRequest, ) -from sinch.domains.numbers.models.v1.internal.number_request import NumberRequest -from sinch.domains.numbers.models.v1.internal.rent_any_number_request import RentAnyNumberRequest -from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest from sinch.domains.numbers.models.v1.internal.update_callback_configuration_request import ( - UpdateCallbackConfigurationRequest + UpdateCallbackConfigurationRequest, ) from sinch.domains.numbers.models.v1.internal.update_number_configuration_request import ( - UpdateNumberConfigurationRequest + UpdateNumberConfigurationRequest, ) from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( - VoiceConfigurationCustom, VoiceConfigurationEST, VoiceConfigurationFAX, - VoiceConfigurationRTC + VoiceConfigurationCustom, + VoiceConfigurationEST, + VoiceConfigurationFAX, + VoiceConfigurationRTC, ) __all__ = [ @@ -39,5 +57,5 @@ "VoiceConfigurationCustom", "VoiceConfigurationEST", "VoiceConfigurationFAX", - "VoiceConfigurationRTC" + "VoiceConfigurationRTC", ] diff --git a/sinch/domains/numbers/models/v1/internal/base/__init__.py b/sinch/domains/numbers/models/v1/internal/base/__init__.py index 19c2b9d0..51cffe56 100644 --- a/sinch/domains/numbers/models/v1/internal/base/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/base/__init__.py @@ -1,5 +1,6 @@ from sinch.domains.numbers.models.v1.internal.base.base_model_configuration import ( - BaseModelConfigurationRequest, BaseModelConfigurationResponse + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, ) __all__ = [ diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py index c22c3107..8e9d179b 100644 --- a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py +++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py @@ -13,9 +13,9 @@ def _to_camel_case(snake_str: str) -> str: """Converts snake_case to camelCase while preserving multiple underscores.""" if not snake_str or "_" not in snake_str: return snake_str - components = snake_str.split('_') - return components[0].lower() + ''.join( - (x.capitalize() if x else '_') for x in components[1:] + components = snake_str.split("_") + return components[0].lower() + "".join( + (x.capitalize() if x else "_") for x in components[1:] ) @classmethod @@ -39,12 +39,15 @@ def _convert_dict_keys(cls, obj): # Allows using both alias (camelCase) and field name (snake_case) populate_by_name=True, # Allows extra values in input - extra="allow" + extra="allow", ) def _convert_dict_to_camel_case(self, data): if isinstance(data, dict): - return {self._to_camel_case(k): self._convert_dict_to_camel_case(v) for k, v in data.items()} + return { + self._to_camel_case(k): self._convert_dict_to_camel_case(v) + for k, v in data.items() + } elif isinstance(data, list): return [self._convert_dict_to_camel_case(i) for i in data] return data @@ -53,7 +56,7 @@ def model_dump(self, **kwargs) -> dict: """Converts extra fields from snake_case to camelCase when dumping the model in endpoint.""" # Get the standard model dump. data = super().model_dump(**kwargs) - if not kwargs or kwargs['by_alias']: + if not kwargs or kwargs["by_alias"]: data = self._convert_dict_to_camel_case(data) # Get extra fields @@ -89,7 +92,7 @@ class BaseModelConfigurationResponse(BaseModel): @staticmethod def _to_snake_case(camel_str: str) -> str: """Helper to convert camelCase string to snake_case.""" - return re.sub(r'(? str: ) def model_post_init(self, __context: Any) -> None: - """ Converts unknown fields from camelCase to snake_case.""" + """Converts unknown fields from camelCase to snake_case.""" if self.__pydantic_extra__: converted_extra = { - self._to_snake_case(key): value for key, value in self.__pydantic_extra__.items() + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() } self.__pydantic_extra__.clear() self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 5612c1c0..3c6a5ca6 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -1,8 +1,13 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, field_validator, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, OrderByType, NumberSearchPatternType, NumberType + CapabilityType, + OrderByType, + NumberSearchPatternType, + NumberType, ) @@ -11,10 +16,12 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternType] = ( - Field(default=None, alias="numberPattern.searchPattern") + number_search_pattern: Optional[NumberSearchPatternType] = Field( + default=None, alias="numberPattern.searchPattern" + ) + number_pattern: Optional[StrictStr] = Field( + default=None, alias="numberPattern.pattern" ) - number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") page_token: Optional[StrictStr] = Field(default=None, alias="pageToken") order_by: Optional[OrderByType] = Field(default=None, alias="orderBy") diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py index 9f0d9789..e93f6aff 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_response.py @@ -1,11 +1,22 @@ from typing import Optional -from pydantic import BaseModel, ConfigDict, Field, StrictStr, StrictInt, conlist +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictStr, + StrictInt, + conlist, +) from sinch.domains.numbers.models.v1.response import ActiveNumber class ListActiveNumbersResponse(BaseModel): - active_numbers: Optional[conlist(ActiveNumber)] = Field(default=None, alias="activeNumbers") - next_page_token: Optional[StrictStr] = Field(default=None, alias="nextPageToken") + active_numbers: Optional[conlist(ActiveNumber)] = Field( + default=None, alias="activeNumbers" + ) + next_page_token: Optional[StrictStr] = Field( + default=None, alias="nextPageToken" + ) total_size: Optional[StrictInt] = Field(default=None, alias="totalSize") model_config = ConfigDict( diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index ea8fa5e1..4f255ca6 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -1,7 +1,13 @@ from typing import Optional from pydantic import Field, StrictInt, StrictStr, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.types import CapabilityType, NumberSearchPatternType, NumberType +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberSearchPatternType, + NumberType, +) class ListAvailableNumbersRequest(BaseModelConfigurationRequest): @@ -9,6 +15,9 @@ class ListAvailableNumbersRequest(BaseModelConfigurationRequest): number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) - number_search_pattern: Optional[NumberSearchPatternType] = ( - Field(default=None, alias="numberPattern.searchPattern")) - number_pattern: Optional[StrictStr] = Field(default=None, alias="numberPattern.pattern") + number_search_pattern: Optional[NumberSearchPatternType] = Field( + default=None, alias="numberPattern.searchPattern" + ) + number_pattern: Optional[StrictStr] = Field( + default=None, alias="numberPattern.pattern" + ) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py index 8f5e92d5..d4aa6e51 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_response.py @@ -4,12 +4,12 @@ class ListAvailableNumbersResponse(BaseModel): - available_numbers: Optional[conlist(AvailableNumber)] = Field(default=None, alias="availableNumbers") - - model_config = ConfigDict( - populate_by_name=True + available_numbers: Optional[conlist(AvailableNumber)] = Field( + default=None, alias="availableNumbers" ) + model_config = ConfigDict(populate_by_name=True) + @property def content(self): """Returns the available numbers as part of the response object to be used in the pagination.""" diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py index c5e2dd6a..5e99dc57 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_request.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import Field, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) from sinch.domains.numbers.models.v1.types import NumberType diff --git a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py index 658a9c49..430c0a4b 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_regions_response.py @@ -4,12 +4,12 @@ class ListAvailableRegionsResponse(BaseModel): - available_regions: Optional[conlist(AvailableRegion)] = Field(default=None, alias="availableRegions") - - model_config = ConfigDict( - populate_by_name=True + available_regions: Optional[conlist(AvailableRegion)] = Field( + default=None, alias="availableRegions" ) + model_config = ConfigDict(populate_by_name=True) + @property def content(self): """Returns the available regions as part of the response object to be used in the pagination.""" diff --git a/sinch/domains/numbers/models/v1/internal/number_request.py b/sinch/domains/numbers/models/v1/internal/number_request.py index f7d2e19c..8204f73e 100644 --- a/sinch/domains/numbers/models/v1/internal/number_request.py +++ b/sinch/domains/numbers/models/v1/internal/number_request.py @@ -1,5 +1,7 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class NumberRequest(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 3017af96..4cf721b9 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -1,18 +1,31 @@ from typing import Optional, Dict, Any from pydantic import Field, StrictStr, conlist from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType -from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration, validate_number_pattern -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, + validate_number_pattern, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class RentAnyNumberRequest(BaseModelConfigurationRequest): region_code: StrictStr = Field(alias="regionCode") number_type: NumberType = Field(alias="type") - number_pattern: Optional[Dict[str, Any]] = Field(default=None, alias="numberPattern") + number_pattern: Optional[Dict[str, Any]] = Field( + default=None, alias="numberPattern" + ) capabilities: Optional[conlist(CapabilityType)] = Field(default=None) - sms_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[Dict[str, Any]] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + sms_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict[str, Any]] = Field( + default=None, alias="voiceConfiguration" + ) + callback_url: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) def __init__(self, **data): """ diff --git a/sinch/domains/numbers/models/v1/internal/rent_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_number_request.py index 0f20a890..24e75b3d 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_number_request.py @@ -1,15 +1,25 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class RentNumberRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") # Accepts only dictionary input, not Pydantic models - sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + sms_configuration: Optional[Dict] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict] = Field( + default=None, alias="voiceConfiguration" + ) + callback_url: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) def __init__(self, **data): """ diff --git a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py index 63072233..5e1f82fa 100644 --- a/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/sms_configuration_request.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class SmsConfigurationRequest(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py index 6d6e14cf..33b52ae7 100644 --- a/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class UpdateCallbackConfigurationRequest(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py index d741834b..456a39f7 100644 --- a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -1,15 +1,27 @@ from typing import Optional, Dict from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.utils.validators import validate_sms_voice_configuration +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.utils.validators import ( + validate_sms_voice_configuration, +) class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest): phone_number: StrictStr = Field(alias="phoneNumber") - display_name: Optional[StrictStr] = Field(default=None, alias="displayName") - sms_configuration: Optional[Dict] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[Dict] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + display_name: Optional[StrictStr] = Field( + default=None, alias="displayName" + ) + sms_configuration: Optional[Dict] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[Dict] = Field( + default=None, alias="voiceConfiguration" + ) + callback_url: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) def __init__(self, **data): """ diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py index 19886fa3..785515c7 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_custom_request.py @@ -1,5 +1,7 @@ from pydantic import StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class VoiceConfigurationCustom(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py index 722d4fa9..10d85df0 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_est_request.py @@ -1,6 +1,8 @@ from typing import Optional, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class VoiceConfigurationEST(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py index d206b541..798615f8 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_fax_request.py @@ -1,6 +1,8 @@ from typing import Optional, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class VoiceConfigurationFAX(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py index 3d69661a..39f0398d 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_request.py @@ -1,6 +1,8 @@ from typing import Optional, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class VoiceConfigurationFAX(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py index e8f6bd66..ada4c448 100644 --- a/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py +++ b/sinch/domains/numbers/models/v1/internal/voice_configuration_rtc_request.py @@ -1,6 +1,8 @@ from typing import Optional, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) class VoiceConfigurationRTC(BaseModelConfigurationRequest): diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index 4d116d1a..5852d55f 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -1,7 +1,13 @@ from sinch.domains.numbers.models.v1.response.active_number import ActiveNumber -from sinch.domains.numbers.models.v1.response.available_number import AvailableNumber -from sinch.domains.numbers.models.v1.response.available_region import AvailableRegion -from sinch.domains.numbers.models.v1.response.callback_configuration_response import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.response.available_number import ( + AvailableNumber, +) +from sinch.domains.numbers.models.v1.response.available_region import ( + AvailableRegion, +) +from sinch.domains.numbers.models.v1.response.callback_configuration_response import ( + CallbackConfigurationResponse, +) __all__ = [ "ActiveNumber", diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index d717cca3..ce27b44f 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -1,22 +1,42 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, StrictInt, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from sinch.domains.numbers.models.v1.shared import Money, SmsConfiguration -from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType, VoiceConfiguration +from sinch.domains.numbers.models.v1.types import ( + CapabilityType, + NumberType, + VoiceConfiguration, +) class ActiveNumber(BaseModelConfigurationResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + phone_number: Optional[StrictStr] = Field( + default=None, alias="phoneNumber" + ) project_id: Optional[StrictStr] = Field(default=None, alias="projectId") - display_name: Optional[StrictStr] = Field(default=None, alias="displayName") + display_name: Optional[StrictStr] = Field( + default=None, alias="displayName" + ) region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) capabilities: Optional[conlist(CapabilityType)] = Field(default=None) money: Optional[Money] = Field(default=None) - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - next_charge_date: Optional[datetime] = Field(default=None, alias="nextChargeDate") + payment_interval_months: Optional[StrictInt] = Field( + default=None, alias="paymentIntervalMonths" + ) + next_charge_date: Optional[datetime] = Field( + default=None, alias="nextChargeDate" + ) expire_at: Optional[datetime] = Field(default=None, alias="expireAt") - sms_configuration: Optional[SmsConfiguration] = Field(default=None, alias="smsConfiguration") - voice_configuration: Optional[VoiceConfiguration] = Field(default=None, alias="voiceConfiguration") - callback_url: Optional[StrictStr] = Field(default=None, alias="callbackUrl") + sms_configuration: Optional[SmsConfiguration] = Field( + default=None, alias="smsConfiguration" + ) + voice_configuration: Optional[VoiceConfiguration] = Field( + default=None, alias="voiceConfiguration" + ) + callback_url: Optional[StrictStr] = Field( + default=None, alias="callbackUrl" + ) diff --git a/sinch/domains/numbers/models/v1/response/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py index 3bdfbae3..0aa128ea 100644 --- a/sinch/domains/numbers/models/v1/response/available_number.py +++ b/sinch/domains/numbers/models/v1/response/available_number.py @@ -1,17 +1,24 @@ from typing import Optional from pydantic import Field, StrictBool, StrictInt, StrictStr, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from sinch.domains.numbers.models.v1.shared import Money from sinch.domains.numbers.models.v1.types import CapabilityType, NumberType class AvailableNumber(BaseModelConfigurationResponse): - phone_number: Optional[StrictStr] = Field(default=None, alias="phoneNumber") + phone_number: Optional[StrictStr] = Field( + default=None, alias="phoneNumber" + ) region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) capability: Optional[conlist(CapabilityType)] = Field(default=None) setup_price: Optional[Money] = Field(default=None, alias="setupPrice") monthly_price: Optional[Money] = Field(default=None, alias="monthlyPrice") - payment_interval_months: Optional[StrictInt] = Field(default=None, alias="paymentIntervalMonths") - supporting_documentation_required: Optional[StrictBool] = ( - Field(default=None, alias="supportingDocumentationRequired")) + payment_interval_months: Optional[StrictInt] = Field( + default=None, alias="paymentIntervalMonths" + ) + supporting_documentation_required: Optional[StrictBool] = Field( + default=None, alias="supportingDocumentationRequired" + ) diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py index 073fd789..0e418a78 100644 --- a/sinch/domains/numbers/models/v1/response/available_region.py +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from sinch.domains.numbers.models.v1.types import NumberType diff --git a/sinch/domains/numbers/models/v1/response/callback_configuration_response.py b/sinch/domains/numbers/models/v1/response/callback_configuration_response.py index c9921b8b..b1a26be6 100644 --- a/sinch/domains/numbers/models/v1/response/callback_configuration_response.py +++ b/sinch/domains/numbers/models/v1/response/callback_configuration_response.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) class CallbackConfigurationResponse(BaseModelConfigurationResponse): diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index 587ab850..fcd463aa 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -1,17 +1,35 @@ from sinch.domains.numbers.models.v1.shared.money import Money from sinch.domains.numbers.models.v1.shared.number_pattern import NumberPattern -from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ScheduledSmsProvisioning -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ( + ScheduledSmsProvisioning, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( - ScheduledVoiceProvisioningCustom -) -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC -from sinch.domains.numbers.models.v1.shared.sms_configuration import SmsConfiguration -from sinch.domains.numbers.models.v1.shared.voice_configuration_est import VoiceConfigurationEST -from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import VoiceConfigurationRTC -from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import VoiceConfigurationFAX + ScheduledVoiceProvisioningCustom, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ( + ScheduledVoiceProvisioningEST, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ( + ScheduledVoiceProvisioningFAX, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ( + ScheduledVoiceProvisioningRTC, +) +from sinch.domains.numbers.models.v1.shared.sms_configuration import ( + SmsConfiguration, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import ( + VoiceConfigurationEST, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import ( + VoiceConfigurationRTC, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import ( + VoiceConfigurationFAX, +) __all__ = [ "Money", @@ -25,5 +43,5 @@ "SmsConfiguration", "VoiceConfigurationEST", "VoiceConfigurationRTC", - "VoiceConfigurationFAX" + "VoiceConfigurationFAX", ] diff --git a/sinch/domains/numbers/models/v1/shared/money.py b/sinch/domains/numbers/models/v1/shared/money.py index a389811a..d55d9264 100644 --- a/sinch/domains/numbers/models/v1/shared/money.py +++ b/sinch/domains/numbers/models/v1/shared/money.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field, condecimal -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) class Money(BaseModelConfigurationResponse): diff --git a/sinch/domains/numbers/models/v1/shared/number_pattern.py b/sinch/domains/numbers/models/v1/shared/number_pattern.py index e5d47d4d..b2ee848f 100644 --- a/sinch/domains/numbers/models/v1/shared/number_pattern.py +++ b/sinch/domains/numbers/models/v1/shared/number_pattern.py @@ -1,9 +1,13 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) from sinch.domains.numbers.models.v1.types import NumberSearchPatternType class NumberPattern(BaseModelConfigurationRequest): pattern: Optional[StrictStr] - search_pattern: Optional[NumberSearchPatternType] = Field(alias="searchPattern") + search_pattern: Optional[NumberSearchPatternType] = Field( + alias="searchPattern" + ) diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py index 2bf381a3..172740ea 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_sms_provisioning.py @@ -1,14 +1,24 @@ from datetime import datetime from typing import Optional from pydantic import StrictStr, Field, conlist -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode class ScheduledSmsProvisioning(BaseModelConfigurationResponse): - service_plan_id: Optional[StrictStr] = Field(default=None, alias="servicePlanId") + service_plan_id: Optional[StrictStr] = Field( + default=None, alias="servicePlanId" + ) campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") status: Optional[StatusScheduledProvisioning] = None - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") - error_codes: Optional[conlist(SmsErrorCode)] = Field(default=None, alias="errorCodes") + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) + error_codes: Optional[conlist(SmsErrorCode)] = Field( + default=None, alias="errorCodes" + ) diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py index 067036b7..549bd015 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_common.py @@ -1,12 +1,20 @@ from datetime import datetime from typing import Optional from pydantic import Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse -from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning -from sinch.domains.numbers.models.v1.types.voice_application_type import VoiceApplicationType +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) +from sinch.domains.numbers.models.v1.types.voice_application_type import ( + VoiceApplicationType, +) class ScheduledVoiceProvisioningCommon(BaseModelConfigurationResponse): type: VoiceApplicationType - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) status: Optional[StatusScheduledProvisioning] = None diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py index 580ee7ae..508f560a 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_custom.py @@ -1,5 +1,7 @@ from pydantic import StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) class ScheduledVoiceProvisioningCustom(BaseModelConfigurationResponse): diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py index 5a6f3b89..0a3018f3 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_est.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) class ScheduledVoiceProvisioningEST(ScheduledVoiceProvisioningCommon): diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py index 7ab08d9f..d5ab4f44 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_fax.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) class ScheduledVoiceProvisioningFAX(ScheduledVoiceProvisioningCommon): diff --git a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py index 4efa25ba..65127b74 100644 --- a/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py +++ b/sinch/domains/numbers/models/v1/shared/scheduled_voice_provisioning_rtc.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ScheduledVoiceProvisioningCommon +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_common import ( + ScheduledVoiceProvisioningCommon, +) class ScheduledVoiceProvisioningRTC(ScheduledVoiceProvisioningCommon): diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration.py b/sinch/domains/numbers/models/v1/shared/sms_configuration.py index 70dd8ab2..d197b304 100644 --- a/sinch/domains/numbers/models/v1/shared/sms_configuration.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration.py @@ -1,11 +1,14 @@ from typing import Optional from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning class SmsConfiguration(BaseModelConfigurationResponse): service_plan_id: StrictStr = Field(alias="servicePlanId") campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") - scheduled_provisioning: Optional[ScheduledSmsProvisioning] = ( - Field(default=None, alias="scheduledProvisioning")) + scheduled_provisioning: Optional[ScheduledSmsProvisioning] = Field( + default=None, alias="scheduledProvisioning" + ) diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py index 40ff1e46..153e6065 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_common.py @@ -1,13 +1,17 @@ from datetime import datetime from typing import Literal, Optional, Union from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) from sinch.domains.numbers.models.v1.types import ScheduledVoiceProvisioning class VoiceConfigurationCommon(BaseModelConfigurationResponse): type: Optional[Union[Literal["RTC", "EST", "FAX"], StrictStr]] - last_updated_time: Optional[datetime] = Field(default=None, alias="lastUpdatedTime") + last_updated_time: Optional[datetime] = Field( + default=None, alias="lastUpdatedTime" + ) scheduled_voice_provisioning: Optional[ScheduledVoiceProvisioning] = Field( default=None, alias="scheduledVoiceProvisioning" ) diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py index 7c7b438a..62cb1cd5 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_est.py @@ -1,5 +1,7 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) class VoiceConfigurationEST(VoiceConfigurationCommon): diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py index ad3789e1..34f09374 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_fax.py @@ -1,5 +1,7 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) class VoiceConfigurationFAX(VoiceConfigurationCommon): diff --git a/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py index f6862916..66d8dd9c 100644 --- a/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py +++ b/sinch/domains/numbers/models/v1/shared/voice_configuration_rtc.py @@ -1,5 +1,7 @@ from pydantic import Field, StrictStr -from sinch.domains.numbers.models.v1.shared.voice_configuration_common import VoiceConfigurationCommon +from sinch.domains.numbers.models.v1.shared.voice_configuration_common import ( + VoiceConfigurationCommon, +) class VoiceConfigurationRTC(VoiceConfigurationCommon): diff --git a/sinch/domains/numbers/models/v1/types/__init__.py b/sinch/domains/numbers/models/v1/types/__init__.py index 41a2c94e..0d4d9f6c 100644 --- a/sinch/domains/numbers/models/v1/types/__init__.py +++ b/sinch/domains/numbers/models/v1/types/__init__.py @@ -1,19 +1,45 @@ -from sinch.domains.numbers.models.v1.types.capability_type import CapabilityType -from sinch.domains.numbers.models.v1.types.number_search_pattern_type import NumberSearchPatternType -from sinch.domains.numbers.models.v1.types.number_pattern_dict import NumberPatternDict +from sinch.domains.numbers.models.v1.types.capability_type import ( + CapabilityType, +) +from sinch.domains.numbers.models.v1.types.number_search_pattern_type import ( + NumberSearchPatternType, +) +from sinch.domains.numbers.models.v1.types.number_pattern_dict import ( + NumberPatternDict, +) from sinch.domains.numbers.models.v1.types.number_type import NumberType from sinch.domains.numbers.models.v1.types.order_by_type import OrderByType -from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ScheduledVoiceProvisioning -from sinch.domains.numbers.models.v1.types.sms_configuration_dict import SmsConfigurationDict +from sinch.domains.numbers.models.v1.types.scheduled_voice_provisioning import ( + ScheduledVoiceProvisioning, +) +from sinch.domains.numbers.models.v1.types.sms_configuration_dict import ( + SmsConfigurationDict, +) from sinch.domains.numbers.models.v1.types.sms_error_code import SmsErrorCode -from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import StatusScheduledProvisioning -from sinch.domains.numbers.models.v1.types.voice_application_type import VoiceApplicationType -from sinch.domains.numbers.models.v1.types.voice_configuration import VoiceConfiguration -from sinch.domains.numbers.models.v1.types.voice_configuration_dict import VoiceConfigurationDict -from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import VoiceConfigurationESTDict -from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import VoiceConfigurationFAXDict -from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import VoiceConfigurationRTCDict -from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import VoiceConfigurationCustomDict +from sinch.domains.numbers.models.v1.types.status_scheduled_provisioning import ( + StatusScheduledProvisioning, +) +from sinch.domains.numbers.models.v1.types.voice_application_type import ( + VoiceApplicationType, +) +from sinch.domains.numbers.models.v1.types.voice_configuration import ( + VoiceConfiguration, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_dict import ( + VoiceConfigurationDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import ( + VoiceConfigurationESTDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import ( + VoiceConfigurationFAXDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import ( + VoiceConfigurationRTCDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import ( + VoiceConfigurationCustomDict, +) __all__ = [ "CapabilityType", diff --git a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py index 0cabd963..d002d638 100644 --- a/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py +++ b/sinch/domains/numbers/models/v1/types/scheduled_voice_provisioning.py @@ -1,11 +1,21 @@ from typing import Union -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ScheduledVoiceProvisioningEST -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ScheduledVoiceProvisioningFAX -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ScheduledVoiceProvisioningRTC -from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ScheduledVoiceProvisioningCustom +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_est import ( + ScheduledVoiceProvisioningEST, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_fax import ( + ScheduledVoiceProvisioningFAX, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_rtc import ( + ScheduledVoiceProvisioningRTC, +) +from sinch.domains.numbers.models.v1.shared.scheduled_voice_provisioning_custom import ( + ScheduledVoiceProvisioningCustom, +) -ScheduledVoiceProvisioning = Union[ScheduledVoiceProvisioningEST, - ScheduledVoiceProvisioningFAX, - ScheduledVoiceProvisioningRTC, - ScheduledVoiceProvisioningCustom] +ScheduledVoiceProvisioning = Union[ + ScheduledVoiceProvisioningEST, + ScheduledVoiceProvisioningFAX, + ScheduledVoiceProvisioningRTC, + ScheduledVoiceProvisioningCustom, +] diff --git a/sinch/domains/numbers/models/v1/types/sms_error_code.py b/sinch/domains/numbers/models/v1/types/sms_error_code.py index defee394..3847e7ec 100644 --- a/sinch/domains/numbers/models/v1/types/sms_error_code.py +++ b/sinch/domains/numbers/models/v1/types/sms_error_code.py @@ -2,23 +2,26 @@ from pydantic import StrictStr -SmsErrorCode = Union[Literal[ - "ERROR_CODE_UNSPECIFIED", - "INTERNAL_ERROR", - "SMS_PROVISIONING_FAILED", - "CAMPAIGN_PROVISIONING_FAILED", - "CAMPAIGN_NOT_AVAILABLE", - "EXCEEDED_10DLC_LIMIT", - "NUMBER_PROVISIONING_FAILED", - "PARTNER_SERVICE_UNAVAILABLE", - "CAMPAIGN_PENDING_ACCEPTANCE", - "MNO_SHARING_ERROR", - "CAMPAIGN_EXPIRED", - "CAMPAIGN_MNO_REJECTED", - "CAMPAIGN_MNO_SUSPENDED", - "CAMPAIGN_MNO_REVIEW", - "INSUFFICIENT_BALANCE", - "MOCK_CAMPAIGN_NOT_ALLOWED", - "TFN_NOT_ALLOWED", - "INVALID_NNID" -], StrictStr] +SmsErrorCode = Union[ + Literal[ + "ERROR_CODE_UNSPECIFIED", + "INTERNAL_ERROR", + "SMS_PROVISIONING_FAILED", + "CAMPAIGN_PROVISIONING_FAILED", + "CAMPAIGN_NOT_AVAILABLE", + "EXCEEDED_10DLC_LIMIT", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "MNO_SHARING_ERROR", + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_MNO_REVIEW", + "INSUFFICIENT_BALANCE", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "TFN_NOT_ALLOWED", + "INVALID_NNID", + ], + StrictStr, +] diff --git a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py index 9bae5fd3..3103f0d4 100644 --- a/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py +++ b/sinch/domains/numbers/models/v1/types/status_scheduled_provisioning.py @@ -2,9 +2,9 @@ from pydantic import StrictStr -StatusScheduledProvisioning = Union[Literal[ - "WAITING", - "IN_PROGRESS", - "FAILED", - "PROVISIONING_STATUS_UNSPECIFIED" -], StrictStr] +StatusScheduledProvisioning = Union[ + Literal[ + "WAITING", "IN_PROGRESS", "FAILED", "PROVISIONING_STATUS_UNSPECIFIED" + ], + StrictStr, +] diff --git a/sinch/domains/numbers/models/v1/types/voice_application_type.py b/sinch/domains/numbers/models/v1/types/voice_application_type.py index 64773ffd..6c9d4f83 100644 --- a/sinch/domains/numbers/models/v1/types/voice_application_type.py +++ b/sinch/domains/numbers/models/v1/types/voice_application_type.py @@ -2,8 +2,4 @@ from pydantic import StrictStr -VoiceApplicationType = Union[Literal[ - "RTC", - "EST", - "FAX" -], StrictStr] +VoiceApplicationType = Union[Literal["RTC", "EST", "FAX"], StrictStr] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration.py b/sinch/domains/numbers/models/v1/types/voice_configuration.py index f13585c4..24aa27c4 100644 --- a/sinch/domains/numbers/models/v1/types/voice_configuration.py +++ b/sinch/domains/numbers/models/v1/types/voice_configuration.py @@ -1,6 +1,14 @@ from typing import Union -from sinch.domains.numbers.models.v1.shared.voice_configuration_est import VoiceConfigurationEST -from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import VoiceConfigurationRTC -from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import VoiceConfigurationFAX +from sinch.domains.numbers.models.v1.shared.voice_configuration_est import ( + VoiceConfigurationEST, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_rtc import ( + VoiceConfigurationRTC, +) +from sinch.domains.numbers.models.v1.shared.voice_configuration_fax import ( + VoiceConfigurationFAX, +) -VoiceConfiguration = Union[VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX] +VoiceConfiguration = Union[ + VoiceConfigurationEST, VoiceConfigurationRTC, VoiceConfigurationFAX +] diff --git a/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py index 4d83ef0f..9655d736 100644 --- a/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py +++ b/sinch/domains/numbers/models/v1/types/voice_configuration_dict.py @@ -1,13 +1,25 @@ from typing import Union, Annotated from pydantic import Field -from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import VoiceConfigurationESTDict -from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import VoiceConfigurationRTCDict -from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import VoiceConfigurationFAXDict -from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import VoiceConfigurationCustomDict +from sinch.domains.numbers.models.v1.types.voice_configuration_est_dict import ( + VoiceConfigurationESTDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_rtc_dict import ( + VoiceConfigurationRTCDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_fax_dict import ( + VoiceConfigurationFAXDict, +) +from sinch.domains.numbers.models.v1.types.voice_configuration_custom_dict import ( + VoiceConfigurationCustomDict, +) VoiceConfigurationDict = Annotated[ - Union[VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, - VoiceConfigurationESTDict, VoiceConfigurationCustomDict], - Field(discriminator="type") + Union[ + VoiceConfigurationFAXDict, + VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, + VoiceConfigurationCustomDict, + ], + Field(discriminator="type"), ] diff --git a/sinch/domains/numbers/models/v1/utils/validators.py b/sinch/domains/numbers/models/v1/utils/validators.py index 5fa84da5..27729926 100644 --- a/sinch/domains/numbers/models/v1/utils/validators.py +++ b/sinch/domains/numbers/models/v1/utils/validators.py @@ -1,5 +1,7 @@ from typing import Dict, Any -from sinch.domains.numbers.models.v1.internal.sms_configuration_request import SmsConfigurationRequest +from sinch.domains.numbers.models.v1.internal.sms_configuration_request import ( + SmsConfigurationRequest, +) from sinch.domains.numbers.models.v1.internal.voice_configuration_request import ( VoiceConfigurationRTC, VoiceConfigurationEST, @@ -50,5 +52,7 @@ def validate_sms_voice_configuration(data: Dict[str, Any]) -> None: if key in data and data[key] is not None: # Handle legacy requests voice_type = data[key].get("type") or "RTC" - voice_config_class = voice_config_map.get(voice_type, VoiceConfigurationCustom) + voice_config_class = voice_config_map.get( + voice_type, VoiceConfigurationCustom + ) voice_config_class(**data[key]) diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 850b6356..8bf39526 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -1,15 +1,26 @@ from typing import Optional, overload, List from sinch.domains.numbers.api.v1 import ( - ActiveNumbers, AvailableNumbers, AvailableRegions, CallbackConfiguration + ActiveNumbers, + AvailableNumbers, + AvailableRegions, + CallbackConfiguration, ) from sinch.core.pagination import Paginator from sinch.domains.numbers.models.v1.response import ( - ActiveNumber, AvailableNumber + ActiveNumber, + AvailableNumber, ) from sinch.domains.numbers.models.v1.types import ( - CapabilityType, NumberSearchPatternType, NumberType, OrderByType, - SmsConfigurationDict, VoiceConfigurationDict, VoiceConfigurationFAXDict, VoiceConfigurationRTCDict, - VoiceConfigurationESTDict, NumberPatternDict + CapabilityType, + NumberSearchPatternType, + NumberType, + OrderByType, + SmsConfigurationDict, + VoiceConfigurationDict, + VoiceConfigurationFAXDict, + VoiceConfigurationRTCDict, + VoiceConfigurationESTDict, + NumberPatternDict, ) from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks @@ -53,7 +64,7 @@ def list( page_size: Optional[int] = None, page_token: Optional[str] = None, order_by: Optional[OrderByType] = None, - **kwargs + **kwargs, ) -> Paginator[ActiveNumber]: """ Search for all active virtual numbers associated with a certain project. @@ -99,7 +110,7 @@ def list( number_search_pattern=number_search_pattern, page_token=page_token, order_by=order_by, - **kwargs + **kwargs, ) @overload @@ -109,7 +120,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -120,7 +131,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -131,7 +142,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -142,7 +153,7 @@ def update( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: """ Make updates to the configuration of your virtual number. @@ -181,14 +192,10 @@ def update( sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) - def get( - self, - phone_number: str, - **kwargs - ) -> ActiveNumber: + def get(self, phone_number: str, **kwargs) -> ActiveNumber: """ List of configuration settings for your virtual number. @@ -205,11 +212,7 @@ def get( """ return self._active.get(phone_number=phone_number, **kwargs) - def release( - self, - phone_number: str, - **kwargs - ) -> ActiveNumber: + def release(self, phone_number: str, **kwargs) -> ActiveNumber: """ Release virtual numbers you no longer need from your project. @@ -226,7 +229,9 @@ def release( """ return self._active.release(phone_number=phone_number, **kwargs) - def check_availability(self, phone_number: str, **kwargs) -> AvailableNumber: + def check_availability( + self, phone_number: str, **kwargs + ) -> AvailableNumber: """ Enter a specific phone number to check availability. @@ -241,7 +246,9 @@ def check_availability(self, phone_number: str, **kwargs) -> AvailableNumber: For detailed documentation, visit: https://developers.sinch.com """ - return self._available.check_availability(phone_number=phone_number, **kwargs) + return self._available.check_availability( + phone_number=phone_number, **kwargs + ) @overload def rent( @@ -249,7 +256,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -259,7 +266,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -269,7 +276,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -279,7 +286,7 @@ def rent( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: """ Rent a virtual number to use with SMS, Voice, or both products. @@ -313,7 +320,7 @@ def rent( sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) @overload @@ -325,7 +332,7 @@ def rent_any( voice_configuration: VoiceConfigurationRTCDict, number_pattern: NumberPatternDict, capabilities: Optional[CapabilityType] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -338,7 +345,7 @@ def rent_any( voice_configuration: VoiceConfigurationFAXDict, number_pattern: NumberPatternDict, capabilities: Optional[List[CapabilityType]] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -351,7 +358,7 @@ def rent_any( voice_configuration: VoiceConfigurationESTDict, number_pattern: NumberPatternDict, capabilities: Optional[List[CapabilityType]] = None, - callback_url: Optional[str] = None + callback_url: Optional[str] = None, ) -> ActiveNumber: pass @@ -364,7 +371,7 @@ def rent_any( sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, callback_url: Optional[str] = None, - **kwargs + **kwargs, ) -> ActiveNumber: """ Search for and activate an available Sinch virtual number all in one API call. @@ -418,7 +425,7 @@ def rent_any( sms_configuration=sms_configuration, voice_configuration=voice_configuration, callback_url=callback_url, - **kwargs + **kwargs, ) def search_for_available_numbers( @@ -429,7 +436,7 @@ def search_for_available_numbers( number_search_pattern: Optional[NumberSearchPatternType] = None, capabilities: Optional[List[CapabilityType]] = None, page_size: Optional[int] = None, - **kwargs + **kwargs, ) -> Paginator[AvailableNumber]: """ Search for available virtual numbers for you to rent using a variety of parameters to filter results. @@ -467,5 +474,5 @@ def search_for_available_numbers( capabilities=capabilities, number_pattern=number_pattern, number_search_pattern=number_search_pattern, - **kwargs + **kwargs, ) diff --git a/sinch/domains/numbers/webhooks/v1/events/__init__.py b/sinch/domains/numbers/webhooks/v1/events/__init__.py index e1263943..b5fb44cc 100644 --- a/sinch/domains/numbers/webhooks/v1/events/__init__.py +++ b/sinch/domains/numbers/webhooks/v1/events/__init__.py @@ -1,3 +1,5 @@ -from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import NumbersWebhooksEvent +from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import ( + NumbersWebhooksEvent, +) __all__ = ["NumbersWebhooksEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py index 08e221ce..2070b4f3 100644 --- a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py +++ b/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py @@ -9,33 +9,42 @@ class NumbersWebhooksEvent(WebhookEvent): timestamp: Optional[datetime] = Field(default=None) project_id: Optional[StrictStr] = Field(default=None, alias="projectId") resource_id: Optional[StrictStr] = Field(default=None, alias="resourceId") - resource_type: Optional[Union[Literal["ACTIVE_NUMBER"], StrictStr]] = Field(default=None, alias="resourceType") - event_type: Optional[Union[Literal[ - "PROVISIONING_TO_CAMPAIGN", - "DEPROVISIONING_FROM_CAMPAIGN", - "PROVISIONING_TO_SMS_PLATFORM", - "DEPROVISIONING_FROM_SMS_PLATFORM", - "PROVISIONING_TO_VOICE_PLATFORM", - "DEPROVISIONING_TO_VOICE_PLATFORM" - ], StrictStr]] = Field(default=None, alias="eventType") - status: Optional[Union[Literal[ - "SUCCEEDED", - "FAILED" - ], StrictStr]] = None - failure_code: Optional[Union[Literal[ - "CAMPAIGN_EXPIRED", - "CAMPAIGN_MNO_REJECTED", - "CAMPAIGN_MNO_REVIEW", - "CAMPAIGN_MNO_SUSPENDED", - "CAMPAIGN_NOT_AVAILABLE", - "CAMPAIGN_PENDING_ACCEPTANCE", - "CAMPAIGN_PROVISIONING_FAILED", - "EXCEEDED_10DLC_LIMIT", - "INSUFFICIENT_BALANCE", - "INVALID_NNID", - "MNO_SHARING_ERROR", - "MOCK_CAMPAIGN_NOT_ALLOWED", - "NUMBER_PROVISIONING_FAILED", - "PARTNER_SERVICE_UNAVAILABLE", - "TFN_NOT_ALLOWED" - ], StrictStr]] = Field(default=None, alias="failureCode") + resource_type: Optional[Union[Literal["ACTIVE_NUMBER"], StrictStr]] = ( + Field(default=None, alias="resourceType") + ) + event_type: Optional[ + Union[ + Literal[ + "PROVISIONING_TO_CAMPAIGN", + "DEPROVISIONING_FROM_CAMPAIGN", + "PROVISIONING_TO_SMS_PLATFORM", + "DEPROVISIONING_FROM_SMS_PLATFORM", + "PROVISIONING_TO_VOICE_PLATFORM", + "DEPROVISIONING_TO_VOICE_PLATFORM", + ], + StrictStr, + ] + ] = Field(default=None, alias="eventType") + status: Optional[Union[Literal["SUCCEEDED", "FAILED"], StrictStr]] = None + failure_code: Optional[ + Union[ + Literal[ + "CAMPAIGN_EXPIRED", + "CAMPAIGN_MNO_REJECTED", + "CAMPAIGN_MNO_REVIEW", + "CAMPAIGN_MNO_SUSPENDED", + "CAMPAIGN_NOT_AVAILABLE", + "CAMPAIGN_PENDING_ACCEPTANCE", + "CAMPAIGN_PROVISIONING_FAILED", + "EXCEEDED_10DLC_LIMIT", + "INSUFFICIENT_BALANCE", + "INVALID_NNID", + "MNO_SHARING_ERROR", + "MOCK_CAMPAIGN_NOT_ALLOWED", + "NUMBER_PROVISIONING_FAILED", + "PARTNER_SERVICE_UNAVAILABLE", + "TFN_NOT_ALLOWED", + ], + StrictStr, + ] + ] = Field(default=None, alias="failureCode") diff --git a/sinch/domains/numbers/webhooks/v1/internal/__init__.py b/sinch/domains/numbers/webhooks/v1/internal/__init__.py index 8b88aa81..892d0749 100644 --- a/sinch/domains/numbers/webhooks/v1/internal/__init__.py +++ b/sinch/domains/numbers/webhooks/v1/internal/__init__.py @@ -1,3 +1,5 @@ -from sinch.domains.numbers.webhooks.v1.internal.webhook_event import WebhookEvent +from sinch.domains.numbers.webhooks.v1.internal.webhook_event import ( + WebhookEvent, +) __all__ = ["WebhookEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py b/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py index 9e03f150..6c9cf47f 100644 --- a/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py +++ b/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py @@ -1,4 +1,6 @@ -from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) # Alias for NumbersWebhooksEvent used for request modeling. diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py index 4359a69c..2645879a 100644 --- a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py +++ b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py @@ -3,7 +3,9 @@ from datetime import datetime import re from pydantic import StrictBool, StrictStr -from sinch.domains.authentication.webhooks.v1.authentication_validation import validate_signature_header +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + validate_signature_header, +) from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent @@ -12,9 +14,7 @@ def __init__(self, callback_secret: StrictStr): self.callback_secret = callback_secret def validate_authentication_header( - self, - headers: Dict[StrictStr, StrictStr], - json_payload: StrictStr + self, headers: Dict[StrictStr, StrictStr], json_payload: StrictStr ) -> StrictBool: """ Validate the authorization header for a callback request @@ -27,12 +27,12 @@ def validate_authentication_header( :rtype: bool """ return validate_signature_header( - self.callback_secret, - headers, - json_payload + self.callback_secret, headers, json_payload ) - def parse_event(self, event_body: Union[StrictStr, Dict[StrictStr, Any]]) -> NumbersWebhooksEvent: + def parse_event( + self, event_body: Union[StrictStr, Dict[StrictStr, Any]] + ) -> NumbersWebhooksEvent: """ Parses the event payload into a NumbersWebhooksEvent object. @@ -47,7 +47,7 @@ def parse_event(self, event_body: Union[StrictStr, Dict[StrictStr, Any]]) -> Num """ if isinstance(event_body, str): event_body = self._parse_json(event_body) - timestamp = event_body.get('timestamp') + timestamp = event_body.get("timestamp") if timestamp: event_body["timestamp"] = self._normalize_iso_timestamp(timestamp) try: @@ -78,7 +78,9 @@ def _normalize_iso_timestamp(self, timestamp: StrictStr) -> datetime: match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) if match_ms: micro_trimmed = match_ms.group(1)[:6] - timestamp = re.sub(r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp) + timestamp = re.sub( + r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp + ) try: return datetime.fromisoformat(timestamp) except ValueError as e: From 48d38bea0f0beb35c537e842788c47c1d960232d Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 23 Oct 2025 09:29:45 +0200 Subject: [PATCH 059/106] DEVEXP-1117: Add sms delivery reports models and pagination (#87) Co-authored-by: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + sinch/core/pagination.py | 44 +- sinch/domains/sms/__init__.py | 563 +----------------- sinch/domains/sms/api/__init__.py | 1 + sinch/domains/sms/api/v1/__init__.py | 13 + sinch/domains/sms/api/v1/base/__init__.py | 3 + sinch/domains/sms/api/v1/base/base_sms.py | 24 + .../sms/api/v1/delivery_reports_apis.py | 80 +++ sinch/domains/sms/api/v1/exceptions.py | 5 + sinch/domains/sms/api/v1/internal/__init__.py | 12 + .../sms/api/v1/internal/base/__init__.py | 3 + .../sms/api/v1/internal/base/sms_endpoint.py | 68 +++ .../v1/internal/delivery_reports_endpoints.py | 114 ++++ .../domains/sms/endpoints/batches/__init__.py | 0 .../sms/endpoints/batches/cancel_batch.py | 38 -- .../sms/endpoints/batches/get_batch.py | 41 -- .../sms/endpoints/batches/list_batches.py | 48 -- .../sms/endpoints/batches/replace_batch.py | 42 -- .../sms/endpoints/batches/send_batch.py | 41 -- .../endpoints/batches/send_batch_dry_run.py | 39 -- .../batches/send_delivery_feedback.py | 29 - .../sms/endpoints/batches/update_batch.py | 43 -- .../endpoints/delivery_reports/__init__.py | 0 .../get_all_delivery_reports_for_project.py | 48 -- .../get_delivery_report_for_batch.py | 44 -- .../get_delivery_report_for_number.py | 38 -- .../domains/sms/endpoints/groups/__init__.py | 0 .../sms/endpoints/groups/create_group.py | 35 -- .../sms/endpoints/groups/delete_group.py | 25 - .../domains/sms/endpoints/groups/get_group.py | 33 - .../groups/get_phone_numbers_for_group.py | 27 - .../sms/endpoints/groups/list_groups.py | 43 -- .../sms/endpoints/groups/replace_group.py | 37 -- .../sms/endpoints/groups/update_group.py | 37 -- .../sms/endpoints/inbounds/__init__.py | 0 .../inbounds/get_incoming_message.py | 34 -- .../inbounds/list_incoming_messages.py | 45 -- sinch/domains/sms/endpoints/sms_endpoint.py | 27 - .../sms/{endpoints => models/v1}/__init__.py | 0 .../sms/models/v1/internal/__init__.py | 19 + .../sms/models/v1/internal/base/__init__.py | 9 + .../internal/base/base_model_configuration.py | 44 ++ .../get_batch_delivery_report_request.py | 30 + .../get_recipient_delivery_report_request.py | 14 + .../internal/list_delivery_reports_request.py | 20 + .../list_delivery_reports_response.py | 29 + .../sms/models/v1/response/__init__.py | 11 + .../v1/response/batch_delivery_report.py | 27 + .../v1/response/recipient_delivery_report.py | 60 ++ .../domains/sms/models/v1/shared/__init__.py | 7 + .../v1/shared/message_delivery_status.py | 19 + sinch/domains/sms/models/v1/types/__init__.py | 21 + .../delivery_receipt_status_code_type.py | 27 + .../models/v1/types/delivery_report_type.py | 5 + .../models/v1/types/delivery_status_type.py | 19 + .../sms/models/v1/types/encoding_type.py | 5 + .../types/recipient_delivery_report_type.py | 8 + tests/conftest.py | 115 ++-- ...get_batch_delivery_report_request_model.py | 109 ++++ ...recipient_delivery_report_request_model.py | 70 +++ ...est_list_delivery_reports_request_model.py | 67 +++ ...st_list_delivery_reports_response_model.py | 99 +++ .../test_batch_delivery_report_model.py | 254 ++++++++ .../test_recipient_delivery_report_model.py | 197 ++++++ tests/unit/test_pagination.py | 116 ++-- 65 files changed, 1682 insertions(+), 1445 deletions(-) create mode 100644 sinch/domains/sms/api/__init__.py create mode 100644 sinch/domains/sms/api/v1/__init__.py create mode 100644 sinch/domains/sms/api/v1/base/__init__.py create mode 100644 sinch/domains/sms/api/v1/base/base_sms.py create mode 100644 sinch/domains/sms/api/v1/delivery_reports_apis.py create mode 100644 sinch/domains/sms/api/v1/exceptions.py create mode 100644 sinch/domains/sms/api/v1/internal/__init__.py create mode 100644 sinch/domains/sms/api/v1/internal/base/__init__.py create mode 100644 sinch/domains/sms/api/v1/internal/base/sms_endpoint.py create mode 100644 sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py delete mode 100644 sinch/domains/sms/endpoints/batches/__init__.py delete mode 100644 sinch/domains/sms/endpoints/batches/cancel_batch.py delete mode 100644 sinch/domains/sms/endpoints/batches/get_batch.py delete mode 100644 sinch/domains/sms/endpoints/batches/list_batches.py delete mode 100644 sinch/domains/sms/endpoints/batches/replace_batch.py delete mode 100644 sinch/domains/sms/endpoints/batches/send_batch.py delete mode 100644 sinch/domains/sms/endpoints/batches/send_batch_dry_run.py delete mode 100644 sinch/domains/sms/endpoints/batches/send_delivery_feedback.py delete mode 100644 sinch/domains/sms/endpoints/batches/update_batch.py delete mode 100644 sinch/domains/sms/endpoints/delivery_reports/__init__.py delete mode 100644 sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py delete mode 100644 sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py delete mode 100644 sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py delete mode 100644 sinch/domains/sms/endpoints/groups/__init__.py delete mode 100644 sinch/domains/sms/endpoints/groups/create_group.py delete mode 100644 sinch/domains/sms/endpoints/groups/delete_group.py delete mode 100644 sinch/domains/sms/endpoints/groups/get_group.py delete mode 100644 sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py delete mode 100644 sinch/domains/sms/endpoints/groups/list_groups.py delete mode 100644 sinch/domains/sms/endpoints/groups/replace_group.py delete mode 100644 sinch/domains/sms/endpoints/groups/update_group.py delete mode 100644 sinch/domains/sms/endpoints/inbounds/__init__.py delete mode 100644 sinch/domains/sms/endpoints/inbounds/get_incoming_message.py delete mode 100644 sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py delete mode 100644 sinch/domains/sms/endpoints/sms_endpoint.py rename sinch/domains/sms/{endpoints => models/v1}/__init__.py (100%) create mode 100644 sinch/domains/sms/models/v1/internal/__init__.py create mode 100644 sinch/domains/sms/models/v1/internal/base/__init__.py create mode 100644 sinch/domains/sms/models/v1/internal/base/base_model_configuration.py create mode 100644 sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py create mode 100644 sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py create mode 100644 sinch/domains/sms/models/v1/response/__init__.py create mode 100644 sinch/domains/sms/models/v1/response/batch_delivery_report.py create mode 100644 sinch/domains/sms/models/v1/response/recipient_delivery_report.py create mode 100644 sinch/domains/sms/models/v1/shared/__init__.py create mode 100644 sinch/domains/sms/models/v1/shared/message_delivery_status.py create mode 100644 sinch/domains/sms/models/v1/types/__init__.py create mode 100644 sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py create mode 100644 sinch/domains/sms/models/v1/types/delivery_report_type.py create mode 100644 sinch/domains/sms/models/v1/types/delivery_status_type.py create mode 100644 sinch/domains/sms/models/v1/types/encoding_type.py create mode 100644 sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cbb3233d..56e8acf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,8 @@ jobs: run: | ruff check sinch/domains/numbers --statistics ruff format sinch/domains/numbers --check --diff + ruff check sinch/domains/sms --statistics + ruff format sinch/domains/sms --check --diff - name: Test with Pytest run: | diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index 47cab729..a54e9f52 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -62,28 +62,52 @@ def _initialize(cls, sinch, endpoint): pass -class IntBasedPaginator(Paginator): - __doc__ = Paginator.__doc__ +class SMSPaginator(Paginator[BM]): + """Base paginator for integer-based pagination with explicit page navigation and metadata.""" - def _calculate_next_page(self): - if self.result.page_size: - self.has_next_page = True - else: - self.has_next_page = False + def __init__(self, sinch, endpoint, result=None): + super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint)) + + def content(self) -> list[BM]: + """Returns the content list from the result.""" + return getattr(self.result, "content", []) def next_page(self): + """Returns a new paginator instance for the next page.""" + if not self.has_next_page: + return None + self.endpoint.request_data.page += 1 self.result = self._sinch.configuration.transport.request(self.endpoint) self._calculate_next_page() return self - def auto_paging_iter(self): - return PageIterator(self) + def iterator(self): + """Iterates over individual items across all pages.""" + paginator = self + while paginator: + yield from paginator.content() + + next_page_instance = paginator.next_page() + if not next_page_instance: + break + paginator = next_page_instance + + def _calculate_next_page(self): + """Calculates if there's a next page based on count, page, and page_size.""" + if hasattr(self.result, 'count') and hasattr(self.result, 'page') and hasattr(self.result, 'page_size'): + # Calculate total pages needed + total_pages = (self.result.count + self.result.page_size - 1) // self.result.page_size + # Check if current page is less than total pages - 1 (0-indexed) + self.has_next_page = self.result.page < (total_pages - 1) + else: + self.has_next_page = False @classmethod def _initialize(cls, sinch, endpoint): + """Creates an instance of the paginator skipping first page.""" result = sinch.configuration.transport.request(endpoint) - return cls(sinch, endpoint, result) + return cls(sinch, endpoint, result=result) class TokenBasedPaginator(Paginator[BM]): diff --git a/sinch/domains/sms/__init__.py b/sinch/domains/sms/__init__.py index a1ebb8f8..7bd3068b 100644 --- a/sinch/domains/sms/__init__.py +++ b/sinch/domains/sms/__init__.py @@ -1,556 +1,15 @@ -from sinch.core.pagination import IntBasedPaginator +from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +# from sinch.domains.sms.api.v1.groups_apis import Groups +# from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +# from sinch.domains.sms.api.v1.webhooks_apis import Webhooks +# from sinch.domains.sms.api.v1.batches_apis import Batches -from sinch.domains.sms.endpoints.batches.send_batch import SendBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.list_batches import ListSMSBatchesEndpoint -from sinch.domains.sms.endpoints.batches.get_batch import GetSMSEndpoint -from sinch.domains.sms.endpoints.batches.cancel_batch import CancelBatchEndpoint -from sinch.domains.sms.endpoints.batches.update_batch import UpdateBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.replace_batch import ReplaceBatchSMSEndpoint -from sinch.domains.sms.endpoints.batches.send_delivery_feedback import SendDeliveryReportEndpoint -from sinch.domains.sms.endpoints.batches.send_batch_dry_run import SendBatchSMSDryRunEndpoint -from sinch.domains.sms.endpoints.groups.create_group import CreateSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.list_groups import ListSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.delete_group import DeleteSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.get_group import GetSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.update_group import UpdateSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.replace_group import ReplaceSMSGroupEndpoint -from sinch.domains.sms.endpoints.groups.get_phone_numbers_for_group import GetSMSGroupPhoneNumbersEndpoint - -from sinch.domains.sms.endpoints.inbounds.list_incoming_messages import ListInboundMessagesEndpoint -from sinch.domains.sms.endpoints.inbounds.get_incoming_message import GetInboundMessagesEndpoint - -from sinch.domains.sms.endpoints.delivery_reports.get_delivery_report_for_number import ( - GetDeliveryReportForNumberEndpoint -) -from sinch.domains.sms.endpoints.delivery_reports.get_delivery_report_for_batch import GetDeliveryReportForBatchEndpoint -from sinch.domains.sms.endpoints.delivery_reports.get_all_delivery_reports_for_project import ( - ListDeliveryReportsEndpoint -) - -from sinch.domains.sms.models.batches.requests import ( - SendBatchRequest, - ListBatchesRequest, - GetBatchRequest, - BatchDryRunRequest, - CancelBatchRequest, - UpdateBatchRequest, - ReplaceBatchRequest, - SendDeliveryFeedbackRequest -) - -from sinch.domains.sms.models.batches.responses import ( - SendSMSBatchResponse, - GetSMSBatchResponse, - CancelSMSBatchResponse, - SendSMSDeliveryFeedbackResponse, - ListSMSBatchesResponse, - UpdateSMSBatchResponse, - ReplaceSMSBatchResponse, - SendSMSBatchDryRunResponse -) - -from sinch.domains.sms.models.groups.requests import ( - CreateSMSGroupRequest, - ListSMSGroupRequest, - DeleteSMSGroupRequest, - GetSMSGroupRequest, - GetSMSGroupPhoneNumbersRequest, - UpdateSMSGroupRequest, - ReplaceSMSGroupPhoneNumbersRequest -) - -from sinch.domains.sms.models.groups.responses import ( - CreateSMSGroupResponse, - SinchDeleteSMSGroupResponse, - UpdateSMSGroupResponse, - SinchListSMSGroupResponse, - ReplaceSMSGroupResponse, - GetSMSGroupResponse, - SinchGetSMSGroupPhoneNumbersResponse -) - -from sinch.domains.sms.models.inbounds.requests import ( - ListSMSInboundMessageRequest, - GetSMSInboundMessageRequest -) - -from sinch.domains.sms.models.inbounds.responses import ( - SinchListInboundMessagesResponse, - GetInboundMessagesResponse -) - -from sinch.domains.sms.models.delivery_reports.requests import ( - ListSMSDeliveryReportsRequest, - GetSMSDeliveryReportForBatchRequest, - GetSMSDeliveryReportForNumberRequest -) - -from sinch.domains.sms.models.delivery_reports.responses import ( - ListSMSDeliveryReportsResponse, - GetSMSDeliveryReportForBatchResponse, - GetSMSDeliveryReportForNumberResponse -) - - -class SMSDeliveryReports: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - page: int = 0, - start_date: str = None, - end_date: str = None, - status: str = None, - code: str = None, - page_size: int = None, - client_reference: str = None - ) -> ListSMSDeliveryReportsResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListDeliveryReportsEndpoint( - sinch=self._sinch, - request_data=ListSMSDeliveryReportsRequest( - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - status=status, - code=code, - client_reference=client_reference - ) - ) - ) - - def get_for_batch( - self, - batch_id: str, - type_: str = None, - code: list = None, - status: list = None - ) -> GetSMSDeliveryReportForBatchResponse: - return self._sinch.configuration.transport.request( - GetDeliveryReportForBatchEndpoint( - sinch=self._sinch, - request_data=GetSMSDeliveryReportForBatchRequest( - batch_id=batch_id, - type_=type_, - code=code, - status=status - ) - ) - ) - - def get_for_number( - self, - batch_id: str, - recipient_number: str - ) -> GetSMSDeliveryReportForNumberResponse: - return self._sinch.configuration.transport.request( - GetDeliveryReportForNumberEndpoint( - sinch=self._sinch, - request_data=GetSMSDeliveryReportForNumberRequest( - batch_id=batch_id, - recipient_number=recipient_number - ) - ) - ) - - -class SMSInbounds: - def __init__(self, sinch): - self._sinch = sinch - - def list( - self, - page: int = 0, - start_date: str = None, - to: str = None, - end_date: str = None, - page_size: int = None, - client_reference: str = None - ) -> SinchListInboundMessagesResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListInboundMessagesEndpoint( - sinch=self._sinch, - request_data=ListSMSInboundMessageRequest( - page=page, - page_size=page_size, - to=to, - end_date=end_date, - start_date=start_date, - client_reference=client_reference - ) - ) - ) - - def get(self, inbound_id: str) -> GetInboundMessagesResponse: - return self._sinch.configuration.transport.request( - GetInboundMessagesEndpoint( - sinch=self._sinch, - request_data=GetSMSInboundMessageRequest( - inbound_id=inbound_id - ) - ) - ) - - -class SMSBatches: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - body: str, - delivery_report: str, - to: list, - from_: str = None, - parameters: dict = None, - type_: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - client_reference: str = None, - feedback_enabled: bool = None, - flash_message: bool = None, - truncate_concat: bool = None, - max_number_of_message_parts: int = None, - from_ton: int = None, - from_npi: int = None - ) -> SendSMSBatchResponse: - return self._sinch.configuration.transport.request( - SendBatchSMSEndpoint( - sinch=self._sinch, - request_data=SendBatchRequest( - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - feedback_enabled=feedback_enabled, - parameters=parameters, - type_=type_, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - client_reference=client_reference, - flash_message=flash_message, - truncate_concat=truncate_concat, - max_number_of_message_parts=max_number_of_message_parts, - from_npi=from_npi, - from_ton=from_ton - ) - ) - ) - - def list( - self, - page: int = 0, - page_size: int = None, - from_s: str = None, - start_date: str = None, - end_date: str = None, - client_reference: str = None - ) -> ListSMSBatchesResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSBatchesEndpoint( - sinch=self._sinch, - request_data=ListBatchesRequest( - page=page, - page_size=page_size, - from_s=from_s, - start_date=start_date, - end_date=end_date, - client_reference=client_reference - ) - ) - ) - - def get(self, batch_id: str) -> GetSMSBatchResponse: - return self._sinch.configuration.transport.request( - GetSMSEndpoint( - sinch=self._sinch, - request_data=GetBatchRequest( - batch_id=batch_id - ) - ) - ) - - def send_dry_run( - self, - to: str, - body: str, - per_recipient: bool = None, - number_of_recipients: int = None, - from_: str = None, - type_: str = None, - udh: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - flash_message: bool = None, - parameters: dict = None, - client_reference: str = None, - max_number_of_message_parts: int = None - ) -> SendSMSBatchDryRunResponse: - return self._sinch.configuration.transport.request( - SendBatchSMSDryRunEndpoint( - sinch=self._sinch, - request_data=BatchDryRunRequest( - per_recipient=per_recipient, - number_of_recipients=number_of_recipients, - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - type_=type_, - udh=udh, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - flash_message=flash_message, - parameters=parameters, - client_reference=client_reference, - max_number_of_message_parts=max_number_of_message_parts - ) - ) - ) - - def cancel(self, batch_id: str) -> CancelSMSBatchResponse: - return self._sinch.configuration.transport.request( - CancelBatchEndpoint( - sinch=self._sinch, - request_data=CancelBatchRequest( - batch_id=batch_id - ) - ) - ) - - def update( - self, - batch_id: str, - to_add: list = None, - to_remove: list = None, - from_: str = None, - body: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - ) -> UpdateSMSBatchResponse: - return self._sinch.configuration.transport.request( - UpdateBatchSMSEndpoint( - sinch=self._sinch, - request_data=UpdateBatchRequest( - batch_id=batch_id, - to_add=to_add, - to_remove=to_remove, - from_=from_, - body=body, - delivery_report=delivery_report, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url - ) - ) - ) - - def replace( - self, - batch_id: str, - to: str, - body: str, - from_: str = None, - type_: str = None, - udh: str = None, - delivery_report: str = None, - send_at: str = None, - expire_at: str = None, - callback_url: str = None, - flash_message: bool = None, - parameters: dict = None, - client_reference: str = None, - max_number_of_message_parts: int = None - ) -> ReplaceSMSBatchResponse: - return self._sinch.configuration.transport.request( - ReplaceBatchSMSEndpoint( - sinch=self._sinch, - request_data=ReplaceBatchRequest( - batch_id=batch_id, - to=to, - body=body, - from_=from_, - delivery_report=delivery_report, - type_=type_, - udh=udh, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - flash_message=flash_message, - client_reference=client_reference, - max_number_of_message_parts=max_number_of_message_parts, - parameters=parameters - ) - ) - ) - - def send_delivery_feedback( - self, - batch_id: str, - recipients: list - ) -> SendSMSDeliveryFeedbackResponse: - return self._sinch.configuration.transport.request( - SendDeliveryReportEndpoint( - sinch=self._sinch, - request_data=SendDeliveryFeedbackRequest( - batch_id=batch_id, - recipients=recipients - ) - ) - ) - - -class SMSGroups: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - name: str, - members: list = None, - child_groups: list = None, - auto_update: dict = None - ) -> CreateSMSGroupResponse: - return self._sinch.configuration.transport.request( - CreateSMSGroupEndpoint( - sinch=self._sinch, - request_data=CreateSMSGroupRequest( - name=name, - members=members, - child_groups=child_groups, - auto_update=auto_update - ) - ) - ) - - def list( - self, - page=0, - page_size=None - ) -> SinchListSMSGroupResponse: - return IntBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListSMSGroupEndpoint( - sinch=self._sinch, - request_data=ListSMSGroupRequest( - page=page, - page_size=page_size - ) - ) - ) - - def delete( - self, - group_id: str - ) -> SinchDeleteSMSGroupResponse: - return self._sinch.configuration.transport.request( - DeleteSMSGroupEndpoint( - sinch=self._sinch, - request_data=DeleteSMSGroupRequest( - group_id=group_id - ) - ) - ) - - def get( - self, - group_id: str - ) -> GetSMSGroupResponse: - return self._sinch.configuration.transport.request( - GetSMSGroupEndpoint( - sinch=self._sinch, - request_data=GetSMSGroupRequest( - group_id=group_id - ) - ) - ) - - def get_group_phone_numbers( - self, - group_id: str - ) -> SinchGetSMSGroupPhoneNumbersResponse: - return self._sinch.configuration.transport.request( - GetSMSGroupPhoneNumbersEndpoint( - sinch=self._sinch, - request_data=GetSMSGroupPhoneNumbersRequest( - group_id=group_id - ) - ) - ) - - def update( - self, - group_id: str, - name: str = None, - add: list = None, - remove: list = None, - add_from_group: str = None, - remove_from_group: str = None, - auto_update: dict = None - ) -> UpdateSMSGroupResponse: - return self._sinch.configuration.transport.request( - UpdateSMSGroupEndpoint( - sinch=self._sinch, - request_data=UpdateSMSGroupRequest( - group_id=group_id, - name=name, - add=add, - remove=remove, - add_from_group=add_from_group, - remove_from_group=remove_from_group, - auto_update=auto_update - ) - ) - ) - - def replace( - self, - group_id: str, - members: list, - name: str = None - ) -> ReplaceSMSGroupResponse: - return self._sinch.configuration.transport.request( - ReplaceSMSGroupEndpoint( - sinch=self._sinch, - request_data=ReplaceSMSGroupPhoneNumbersRequest( - group_id=group_id, - members=members, - name=name - ) - ) - ) - - -class SMSBase: - """ - Documentation for the SMS API: https://developers.sinch.com/docs/sms/ - """ +class SMS: def __init__(self, sinch): self._sinch = sinch - - -class SMS(SMSBase): - """ - Synchronous version of the SMS Domain - """ - __doc__ += SMSBase.__doc__ - - def __init__(self, sinch): - super(SMS, self).__init__(sinch) - self.groups = SMSGroups(self._sinch) - self.batches = SMSBatches(self._sinch) - self.inbounds = SMSInbounds(self._sinch) - self.delivery_reports = SMSDeliveryReports(self._sinch) + self.delivery_reports = DeliveryReports(sinch) + # self.groups = Groups(sinch) + # self.inbounds = Inbounds(sinch) + # self.webhooks = Webhooks(sinch) + # self.batches = Batches(sinch) diff --git a/sinch/domains/sms/api/__init__.py b/sinch/domains/sms/api/__init__.py new file mode 100644 index 00000000..932b7982 --- /dev/null +++ b/sinch/domains/sms/api/__init__.py @@ -0,0 +1 @@ +# Empty file diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py new file mode 100644 index 00000000..b77f5c3e --- /dev/null +++ b/sinch/domains/sms/api/v1/__init__.py @@ -0,0 +1,13 @@ +from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +# from sinch.domains.sms.api.v1.groups_apis import Groups +# from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +# from sinch.domains.sms.api.v1.webhooks_apis import Webhooks +# from sinch.domains.sms.api.v1.batches_apis import Batches + +__all__ = [ + "DeliveryReports", + # "Groups", + # "Inbounds", + # "Webhooks", + # "Batches", +] diff --git a/sinch/domains/sms/api/v1/base/__init__.py b/sinch/domains/sms/api/v1/base/__init__.py new file mode 100644 index 00000000..e4cdc085 --- /dev/null +++ b/sinch/domains/sms/api/v1/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.sms.api.v1.base.base_sms import BaseSms + +__all__ = ["BaseSms"] diff --git a/sinch/domains/sms/api/v1/base/base_sms.py b/sinch/domains/sms/api/v1/base/base_sms.py new file mode 100644 index 00000000..85694e88 --- /dev/null +++ b/sinch/domains/sms/api/v1/base/base_sms.py @@ -0,0 +1,24 @@ +class BaseSms: + """Base class for handling Sinch Sms operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + # TODO: Refactor project_id to service_plan_id + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py new file mode 100644 index 00000000..cf2167ff --- /dev/null +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -0,0 +1,80 @@ +from datetime import datetime +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base import BaseSms +from sinch.domains.sms.api.v1.internal import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + GetBatchDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types import ( + DeliveryStatusType, + DeliveryReceiptStatusCodeType, +) + + +class DeliveryReports(BaseSms): + def get( + self, + batch_id: str, + report_type: Optional[str] = None, + status: Optional[List[DeliveryStatusType]] = None, + code: Optional[List[DeliveryReceiptStatusCodeType]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> BatchDeliveryReport: + request_data = GetBatchDeliveryReportRequest( + batch_id=batch_id, + type=report_type, + status=status, + code=code, + client_reference=client_reference, + **kwargs, + ) + return self._request(GetBatchDeliveryReportEndpoint, request_data) + + def get_for_number( + self, batch_id: str, recipient: str, **kwargs + ) -> RecipientDeliveryReport: + request_data = GetRecipientDeliveryReportRequest( + batch_id=batch_id, recipient_msisdn=recipient, **kwargs + ) + return self._request(GetRecipientDeliveryReportEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + status: Optional[List[DeliveryStatusType]] = None, + code: Optional[List[DeliveryReceiptStatusCodeType]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[RecipientDeliveryReport]: + return SMSPaginator( + sinch=self._sinch, + endpoint=ListDeliveryReportsEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListDeliveryReportsRequest( + page=page, + page_size=page_size, + start_date=start_date, + end_date=end_date, + status=status, + code=code, + client_reference=client_reference, + **kwargs, + ), + ), + ) diff --git a/sinch/domains/sms/api/v1/exceptions.py b/sinch/domains/sms/api/v1/exceptions.py new file mode 100644 index 00000000..3165b7e9 --- /dev/null +++ b/sinch/domains/sms/api/v1/exceptions.py @@ -0,0 +1,5 @@ +from sinch.core.exceptions import SinchException + + +class SmsException(SinchException): + pass diff --git a/sinch/domains/sms/api/v1/internal/__init__.py b/sinch/domains/sms/api/v1/internal/__init__.py new file mode 100644 index 00000000..f8fbf9f2 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/__init__.py @@ -0,0 +1,12 @@ +from sinch.domains.sms.api.v1.internal.delivery_reports_endpoints import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) + + +__all__ = [ + "GetBatchDeliveryReportEndpoint", + "GetRecipientDeliveryReportEndpoint", + "ListDeliveryReportsEndpoint", +] diff --git a/sinch/domains/sms/api/v1/internal/base/__init__.py b/sinch/domains/sms/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..b5bce8aa --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint + +__all__ = ["SmsEndpoint"] diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py new file mode 100644 index 00000000..3e8c1991 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -0,0 +1,68 @@ +from abc import ABC +from typing import Type +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM +from sinch.domains.sms.api.v1.exceptions import SmsException + + +class SmsEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data: BM): + # TODO: Refactor HTTPEndpoint and endpoints for service_id + super().__init__(project_id, request_data) + + def build_url(self, sinch) -> str: + if not self.ENDPOINT_URL: + raise NotImplementedError( + "ENDPOINT_URL must be defined in the subclass." + ) + + return self.ENDPOINT_URL.format( + origin=sinch.configuration.sms_origin, + project_id=self.project_id, + **vars(self.request_data), + ) + + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + return {} + + def request_body(self) -> str: + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + return "" + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class to map the response. + + Returns: + Parsed response object. + """ + try: + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + raise SmsException( + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py new file mode 100644 index 00000000..83a92ee4 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py @@ -0,0 +1,114 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.models.v1.internal import ( + GetBatchDeliveryReportRequest, + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) +from sinch.domains.sms.api.v1.internal.base import SmsEndpoint +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + + +class GetBatchDeliveryReportEndpoint(SmsEndpoint): + ENDPOINT_URL = ( + "{origin}/xms/v1/{service_plan_id}/batches/{batch_id}/delivery_report" + ) + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: GetBatchDeliveryReportRequest + ): + super(GetBatchDeliveryReportEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport: + try: + super(GetBatchDeliveryReportEndpoint, self).handle_response( + response + ) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchDeliveryReport) + + +class GetRecipientDeliveryReportEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{service_plan_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, + project_id: str, + request_data: GetRecipientDeliveryReportRequest, + ): + super(GetRecipientDeliveryReportEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response( + self, response: HTTPResponse + ) -> RecipientDeliveryReport: + try: + super(GetRecipientDeliveryReportEndpoint, self).handle_response( + response + ) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, RecipientDeliveryReport + ) + + +class ListDeliveryReportsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{service_plan_id}/delivery_reports" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: ListDeliveryReportsRequest + ): + super(ListDeliveryReportsEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response( + self, response: HTTPResponse + ) -> ListDeliveryReportsResponse: + try: + super(ListDeliveryReportsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ListDeliveryReportsResponse + ) diff --git a/sinch/domains/sms/endpoints/batches/__init__.py b/sinch/domains/sms/endpoints/batches/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/batches/cancel_batch.py b/sinch/domains/sms/endpoints/batches/cancel_batch.py deleted file mode 100644 index 091a966d..00000000 --- a/sinch/domains/sms/endpoints/batches/cancel_batch.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.requests import CancelBatchRequest -from sinch.domains.sms.models.batches.responses import CancelSMSBatchResponse - - -class CancelBatchEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: CancelBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def handle_response(self, response: HTTPResponse): - super(CancelBatchEndpoint, self).handle_response(response) - return CancelSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/get_batch.py b/sinch/domains/sms/endpoints/batches/get_batch.py deleted file mode 100644 index 555ab5d6..00000000 --- a/sinch/domains/sms/endpoints/batches/get_batch.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.requests import GetBatchRequest -from sinch.domains.sms.models.batches.responses import GetSMSBatchResponse - - -class GetSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(GetSMSEndpoint, self).handle_response(response) - return GetSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/list_batches.py b/sinch/domains/sms/endpoints/batches/list_batches.py deleted file mode 100644 index b770efbf..00000000 --- a/sinch/domains/sms/endpoints/batches/list_batches.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches import Batch -from sinch.domains.sms.models.batches.requests import ListBatchesRequest -from sinch.domains.sms.models.batches.responses import ListSMSBatchesResponse - - -class ListSMSBatchesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListBatchesRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListSMSBatchesEndpoint, self).handle_response(response) - return ListSMSBatchesResponse( - batches=[ - Batch( - id=batch.get("id"), - to=batch.get("to"), - from_=batch.get("from"), - body=batch.get("body"), - delivery_report=batch.get("delivery_report"), - cancelled=batch.get("cancelled"), - type=batch.get("type"), - campaign_id=batch.get("campaign_id"), - created_at=batch.get("created_at"), - modified_at=batch.get("modified_at"), - send_at=batch.get("send_at"), - expire_at=batch.get("expire_at") - ) for batch in response.body["batches"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/batches/replace_batch.py b/sinch/domains/sms/endpoints/batches/replace_batch.py deleted file mode 100644 index 15fdd838..00000000 --- a/sinch/domains/sms/endpoints/batches/replace_batch.py +++ /dev/null @@ -1,42 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import ReplaceSMSBatchResponse -from sinch.domains.sms.models.batches.requests import ReplaceBatchRequest - - -class ReplaceBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ReplaceBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(ReplaceBatchSMSEndpoint, self).handle_response(response) - return ReplaceSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_batch.py b/sinch/domains/sms/endpoints/batches/send_batch.py deleted file mode 100644 index 4bb94f1a..00000000 --- a/sinch/domains/sms/endpoints/batches/send_batch.py +++ /dev/null @@ -1,41 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSBatchResponse -from sinch.domains.sms.models.batches.requests import SendBatchRequest - - -class SendBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: SendBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=self.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendBatchSMSEndpoint, self).handle_response(response) - return SendSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py b/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py deleted file mode 100644 index b9c64f2a..00000000 --- a/sinch/domains/sms/endpoints/batches/send_batch_dry_run.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSBatchDryRunResponse -from sinch.domains.sms.models.batches.requests import BatchDryRunRequest - - -class SendBatchSMSDryRunEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/dry_run" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: BatchDryRunRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return { - "per_recipient": str(self.request_data.per_recipient).lower(), - "number_of_recipients": self.request_data.number_of_recipients - } - - def request_body(self): - self.request_data.per_recipient = None - self.request_data.number_of_recipients = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendBatchSMSDryRunEndpoint, self).handle_response(response) - return SendSMSBatchDryRunResponse( - number_of_messages=response.body.get("number_of_messages"), - number_of_recipients=response.body.get("number_of_recipients"), - per_recipient=response.body.get("per_recipient") - ) diff --git a/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py b/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py deleted file mode 100644 index 3c3f7933..00000000 --- a/sinch/domains/sms/endpoints/batches/send_delivery_feedback.py +++ /dev/null @@ -1,29 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import SendSMSDeliveryFeedbackResponse -from sinch.domains.sms.models.batches.requests import SendDeliveryFeedbackRequest - - -class SendDeliveryReportEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_feedback" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: SendDeliveryFeedbackRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(SendDeliveryReportEndpoint, self).handle_response(response) - return SendSMSDeliveryFeedbackResponse() diff --git a/sinch/domains/sms/endpoints/batches/update_batch.py b/sinch/domains/sms/endpoints/batches/update_batch.py deleted file mode 100644 index 28a88056..00000000 --- a/sinch/domains/sms/endpoints/batches/update_batch.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.batches.responses import UpdateSMSBatchResponse -from sinch.domains.sms.models.batches.requests import UpdateBatchRequest - - -class UpdateBatchSMSEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: UpdateBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def request_body(self): - self.request_data.batch_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(UpdateBatchSMSEndpoint, self).handle_response(response) - return UpdateSMSBatchResponse( - id=response.body.get("id"), - to=response.body.get("to"), - from_=response.body.get("from"), - body=response.body.get("body"), - delivery_report=response.body.get("delivery_report"), - cancelled=response.body.get("cancelled"), - type=response.body.get("type"), - campaign_id=response.body.get("campaign_id"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - send_at=response.body.get("send_at"), - expire_at=response.body.get("expire_at"), - callback_url=response.body.get("callback_url") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/__init__.py b/sinch/domains/sms/endpoints/delivery_reports/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py b/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py deleted file mode 100644 index dc1b55d8..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_all_delivery_reports_for_project.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports import DeliveryReport -from sinch.domains.sms.models.delivery_reports.requests import ListSMSDeliveryReportsRequest -from sinch.domains.sms.models.delivery_reports.responses import ListSMSDeliveryReportsResponse - - -class ListDeliveryReportsEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/delivery_reports" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSDeliveryReportsRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListDeliveryReportsEndpoint, self).handle_response(response) - return ListSMSDeliveryReportsResponse( - delivery_reports=[ - DeliveryReport( - at=delivery_report.get("at"), - batch_id=delivery_report.get("batch_id"), - code=delivery_report.get("code"), - recipient=delivery_report.get("recipient"), - status=delivery_report.get("status"), - applied_originator=delivery_report.get("applied_originator"), - client_reference=delivery_report.get("client_reference"), - encoding=delivery_report.get("encoding"), - number_of_message_parts=delivery_report.get("number_of_message_parts"), - operator=delivery_report.get("operator"), - operator_status_at=delivery_report.get("operator_status_at"), - type=delivery_report.get("type") - ) for delivery_report in response.body["delivery_reports"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py b/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py deleted file mode 100644 index 4018d3bf..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_batch.py +++ /dev/null @@ -1,44 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports.requests import GetSMSDeliveryReportForBatchRequest -from sinch.domains.sms.models.delivery_reports.responses import GetSMSDeliveryReportForBatchResponse - - -class GetDeliveryReportForBatchEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_report" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSDeliveryReportForBatchRequest, sinch): - super().__init__(request_data, sinch) - - def build_query_params(self): - params = {} - if self.request_data.type_: - params["type"] = self.request_data.type_ - - if self.request_data.status: - params["status"] = self.request_data.status - - if self.request_data.code: - params["code"] = list(map(str, self.request_data.code)) - - return params - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetDeliveryReportForBatchEndpoint, self).handle_response(response) - return GetSMSDeliveryReportForBatchResponse( - type=response.body.get("type"), - batch_id=response.body.get("batch_id"), - total_message_count=response.body.get("total_message_count"), - statuses=response.body.get("statuses"), - client_reference=response.body.get("client_reference") - ) diff --git a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py b/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py deleted file mode 100644 index 97dcdf9d..00000000 --- a/sinch/domains/sms/endpoints/delivery_reports/get_delivery_report_for_number.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.delivery_reports.requests import GetSMSDeliveryReportForNumberRequest -from sinch.domains.sms.models.delivery_reports.responses import GetSMSDeliveryReportForNumberResponse - - -class GetDeliveryReportForNumberEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSDeliveryReportForNumberRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - batch_id=self.request_data.batch_id, - recipient_msisdn=self.request_data.recipient_number - ) - - def handle_response(self, response: HTTPResponse): - super(GetDeliveryReportForNumberEndpoint, self).handle_response(response) - return GetSMSDeliveryReportForNumberResponse( - at=response.body.get("at"), - batch_id=response.body.get("batch_id"), - code=response.body.get("code"), - recipient=response.body.get("recipient"), - status=response.body.get("status"), - applied_originator=response.body.get("applied_originator"), - client_reference=response.body.get("client_reference"), - number_of_message_parts=response.body.get("number_of_message_parts"), - operator=response.body.get("operator"), - operator_status_at=response.body.get("operator_status_at"), - type=response.body.get("type") - ) diff --git a/sinch/domains/sms/endpoints/groups/__init__.py b/sinch/domains/sms/endpoints/groups/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/groups/create_group.py b/sinch/domains/sms/endpoints/groups/create_group.py deleted file mode 100644 index 3d57eb91..00000000 --- a/sinch/domains/sms/endpoints/groups/create_group.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import CreateSMSGroupRequest -from sinch.domains.sms.models.groups.responses import CreateSMSGroupResponse - - -class CreateSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: CreateSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(CreateSMSGroupEndpoint, self).handle_response(response) - return CreateSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/delete_group.py b/sinch/domains/sms/endpoints/groups/delete_group.py deleted file mode 100644 index e4790c3c..00000000 --- a/sinch/domains/sms/endpoints/groups/delete_group.py +++ /dev/null @@ -1,25 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import DeleteSMSGroupRequest -from sinch.domains.sms.models.groups.responses import SinchDeleteSMSGroupResponse - - -class DeleteSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: DeleteSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(DeleteSMSGroupEndpoint, self).handle_response(response) - return SinchDeleteSMSGroupResponse() diff --git a/sinch/domains/sms/endpoints/groups/get_group.py b/sinch/domains/sms/endpoints/groups/get_group.py deleted file mode 100644 index 23c98848..00000000 --- a/sinch/domains/sms/endpoints/groups/get_group.py +++ /dev/null @@ -1,33 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import GetSMSGroupRequest -from sinch.domains.sms.models.groups.responses import GetSMSGroupResponse - - -class GetSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetSMSGroupEndpoint, self).handle_response(response) - return GetSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py b/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py deleted file mode 100644 index 7e1e81ac..00000000 --- a/sinch/domains/sms/endpoints/groups/get_phone_numbers_for_group.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import GetSMSGroupPhoneNumbersRequest -from sinch.domains.sms.models.groups.responses import SinchGetSMSGroupPhoneNumbersResponse - - -class GetSMSGroupPhoneNumbersEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}/members" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSGroupPhoneNumbersRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetSMSGroupPhoneNumbersEndpoint, self).handle_response(response) - return SinchGetSMSGroupPhoneNumbersResponse( - phone_numbers=response.body - ) diff --git a/sinch/domains/sms/endpoints/groups/list_groups.py b/sinch/domains/sms/endpoints/groups/list_groups.py deleted file mode 100644 index 758f3f71..00000000 --- a/sinch/domains/sms/endpoints/groups/list_groups.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import ListSMSGroupRequest -from sinch.domains.sms.models.groups.responses import SinchListSMSGroupResponse -from sinch.domains.sms.models.groups import SMSGroup - - -class ListSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListSMSGroupEndpoint, self).handle_response(response) - return SinchListSMSGroupResponse( - groups=[ - SMSGroup( - id=group.get("id"), - size=group.get("size"), - created_at=group.get("created_at"), - modified_at=group.get("modified_at"), - name=group.get("name"), - child_groups=group.get("child_groups"), - auto_update=response.body.get("auto_update") - ) for group in response.body["groups"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/groups/replace_group.py b/sinch/domains/sms/endpoints/groups/replace_group.py deleted file mode 100644 index 07234168..00000000 --- a/sinch/domains/sms/endpoints/groups/replace_group.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import ReplaceSMSGroupPhoneNumbersRequest -from sinch.domains.sms.models.groups.responses import ReplaceSMSGroupResponse - - -class ReplaceSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ReplaceSMSGroupPhoneNumbersRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def request_body(self): - self.request_data.group_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(ReplaceSMSGroupEndpoint, self).handle_response(response) - return ReplaceSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/groups/update_group.py b/sinch/domains/sms/endpoints/groups/update_group.py deleted file mode 100644 index ccc4d0c2..00000000 --- a/sinch/domains/sms/endpoints/groups/update_group.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.groups.requests import UpdateSMSGroupRequest -from sinch.domains.sms.models.groups.responses import UpdateSMSGroupResponse - - -class UpdateSMSGroupEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/groups/{group_id}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: UpdateSMSGroupRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - group_id=self.request_data.group_id - ) - - def request_body(self): - self.request_data.group_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse): - super(UpdateSMSGroupEndpoint, self).handle_response(response) - return UpdateSMSGroupResponse( - id=response.body.get("id"), - size=response.body.get("size"), - created_at=response.body.get("created_at"), - modified_at=response.body.get("modified_at"), - name=response.body.get("name"), - child_groups=response.body.get("child_groups"), - auto_update=response.body.get("auto_update") - ) diff --git a/sinch/domains/sms/endpoints/inbounds/__init__.py b/sinch/domains/sms/endpoints/inbounds/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py b/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py deleted file mode 100644 index 94f7d052..00000000 --- a/sinch/domains/sms/endpoints/inbounds/get_incoming_message.py +++ /dev/null @@ -1,34 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.inbounds.requests import GetSMSInboundMessageRequest -from sinch.domains.sms.models.inbounds.responses import GetInboundMessagesResponse - - -class GetInboundMessagesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/inbounds/{inbound_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: GetSMSInboundMessageRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id, - inbound_id=self.request_data.inbound_id - ) - - def handle_response(self, response: HTTPResponse): - super(GetInboundMessagesEndpoint, self).handle_response(response) - return GetInboundMessagesResponse( - type=response.body.get("type"), - id=response.body.get("id"), - origin_number=response.body.get("from"), - destination_number=response.body.get("to"), - body=response.body.get("body"), - operator_id=response.body.get("operator_id"), - send_at=response.body.get("send_at"), - received_at=response.body.get("received_at") - ) diff --git a/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py b/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py deleted file mode 100644 index 4eb2d2d4..00000000 --- a/sinch/domains/sms/endpoints/inbounds/list_incoming_messages.py +++ /dev/null @@ -1,45 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.sms.endpoints.sms_endpoint import SMSEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.sms.models.inbounds import InboundMessage -from sinch.domains.sms.models.inbounds.requests import ListSMSInboundMessageRequest -from sinch.domains.sms.models.inbounds.responses import SinchListInboundMessagesResponse - - -class ListInboundMessagesEndpoint(SMSEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{project_or_service_id}/inbounds" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, request_data: ListSMSInboundMessageRequest, sinch): - super().__init__(request_data, sinch) - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, - project_or_service_id=self.project_or_service_id - ) - - def build_query_params(self): - return self.request_data.as_dict() - - def handle_response(self, response: HTTPResponse): - super(ListInboundMessagesEndpoint, self).handle_response(response) - return SinchListInboundMessagesResponse( - inbounds=[ - InboundMessage( - type=inbound.get("type"), - id=inbound.get("id"), - from_=inbound.get("from"), - to=inbound.get("to"), - body=inbound.get("body"), - operator_id=inbound.get("operator_id"), - send_at=inbound.get("send_at"), - received_at=inbound.get("received_at"), - client_reference=inbound.get("client_reference") - ) for inbound in response.body["inbounds"] - ], - page=response.body.get("page"), - page_size=response.body.get("page_size"), - count=response.body.get("count") - ) diff --git a/sinch/domains/sms/endpoints/sms_endpoint.py b/sinch/domains/sms/endpoints/sms_endpoint.py deleted file mode 100644 index 89bcb830..00000000 --- a/sinch/domains/sms/endpoints/sms_endpoint.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.sms.exceptions import SMSException -from sinch.core.enums import HTTPAuthentication - - -class SMSEndpoint(HTTPEndpoint): - def __init__(self, request_data, sinch): - self.request_data = request_data - self.sinch = sinch - - if sinch.configuration.service_plan_id: - self.project_or_service_id = sinch.configuration.service_plan_id - self.HTTP_AUTHENTICATION = HTTPAuthentication.SMS_TOKEN.value - self.sms_origin = self.sinch.configuration.sms_origin_with_service_plan_id - - else: - self.project_or_service_id = sinch.configuration.project_id - self.sms_origin = self.sinch.configuration.sms_origin - - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise SMSException( - message=response.body["text"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/sms/endpoints/__init__.py b/sinch/domains/sms/models/v1/__init__.py similarity index 100% rename from sinch/domains/sms/endpoints/__init__.py rename to sinch/domains/sms/models/v1/__init__.py diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py new file mode 100644 index 00000000..39f6d856 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -0,0 +1,19 @@ +from sinch.domains.sms.models.v1.internal.list_delivery_reports_response import ( + ListDeliveryReportsResponse, +) +from sinch.domains.sms.models.v1.internal.get_recipient_delivery_report_request import ( + GetRecipientDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.internal.get_batch_delivery_report_request import ( + GetBatchDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.internal.list_delivery_reports_request import ( + ListDeliveryReportsRequest, +) + +__all__ = [ + "ListDeliveryReportsResponse", + "GetRecipientDeliveryReportRequest", + "ListDeliveryReportsRequest", + "GetBatchDeliveryReportRequest", +] diff --git a/sinch/domains/sms/models/v1/internal/base/__init__.py b/sinch/domains/sms/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..33b1664b --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.sms.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py new file mode 100644 index 00000000..204ea49d --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py @@ -0,0 +1,44 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py new file mode 100644 index 00000000..e6f951e7 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py @@ -0,0 +1,30 @@ +from typing import Optional, List +from pydantic import StrictStr, Field +from sinch.domains.sms.models.v1.types import ( + DeliveryReceiptStatusCodeType, + DeliveryReportType, + DeliveryStatusType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GetBatchDeliveryReportRequest(BaseModelConfigurationRequest): + batch_id: StrictStr + type: Optional[DeliveryReportType] = Field( + default=None, + description="The type of delivery report.", + ) + status: Optional[List[DeliveryStatusType]] = Field( + default=None, + description="Comma separated list of delivery_report_statuses to include", + ) + code: Optional[List[DeliveryReceiptStatusCodeType]] = Field( + default=None, + description="Comma separated list of delivery receipt error codes to include", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) diff --git a/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py new file mode 100644 index 00000000..b4a52f38 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py @@ -0,0 +1,14 @@ +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GetRecipientDeliveryReportRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The batch ID you received from sending a message.", + ) + recipient_msisdn: StrictStr = Field( + default=..., description="The recipient phone number in E.164 format." + ) diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py new file mode 100644 index 00000000..3c818c04 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py @@ -0,0 +1,20 @@ +from typing import Optional +from datetime import datetime +from pydantic import conlist, conint, constr +from sinch.domains.sms.models.v1.types import DeliveryReceiptStatusCodeType +from sinch.domains.sms.models.v1.types import DeliveryStatusType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListDeliveryReportsRequest(BaseModelConfigurationRequest): + page: Optional[conint(strict=True, ge=0)] = 0 + page_size: Optional[conint(strict=True, le=100, ge=1)] = 30 + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + status: Optional[conlist(DeliveryStatusType)] = None + code: Optional[conlist(DeliveryReceiptStatusCodeType)] = None + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = None diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py new file mode 100644 index 00000000..efc85cb7 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.response import RecipientDeliveryReport +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListDeliveryReportsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + delivery_reports: Optional[conlist(RecipientDeliveryReport)] = Field( + default=None, + description="The page of delivery reports matching the given filters.", + ) + + @property + def content(self): + """Returns the content of the delivery report list.""" + return self.delivery_reports or [] diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py new file mode 100644 index 00000000..954a9fa9 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.sms.models.v1.response.batch_delivery_report import ( + BatchDeliveryReport, +) +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) + +__all__ = [ + "BatchDeliveryReport", + "RecipientDeliveryReport", +] diff --git a/sinch/domains/sms/models/v1/response/batch_delivery_report.py b/sinch/domains/sms/models/v1/response/batch_delivery_report.py new file mode 100644 index 00000000..22c8f0e5 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/batch_delivery_report.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist, conint +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BatchDeliveryReport(BaseModelConfigurationResponse): + batch_id: StrictStr = Field( + default=..., + description="The ID of the batch this delivery report belongs to.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) + statuses: conlist(MessageDeliveryStatus) = Field( + default=..., + description="Array with status objects. Only status codes with at least one recipient will be listed.", + ) + total_message_count: conint(strict=True, ge=0) = Field( + default=..., description="The total number of messages in the batch." + ) + type: StrictStr = Field( + default=..., description="The delivery report type." + ) diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py new file mode 100644 index 00000000..c0440950 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -0,0 +1,60 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.sms.models.v1.types import ( + DeliveryReceiptStatusCodeType, + DeliveryStatusType, + EncodingType, + RecipientDeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class RecipientDeliveryReport(BaseModelConfigurationResponse): + applied_originator: Optional[StrictStr] = Field( + default=None, + description="The default originator used for the recipient this delivery report belongs to, if default originator pool configured and no originator set when submitting batch.", + ) + at: datetime = Field( + default=..., + description="A timestamp of when the Delivery Report was created in the Sinch service. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + batch_id: StrictStr = Field( + default=..., + description="The ID of the batch this delivery report belongs to", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="The client identifier of the batch this delivery report belongs to, if set when submitting batch.", + ) + code: DeliveryReceiptStatusCodeType = Field( + default=..., + description="The detailed [status code](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Delivery-reports/#tag/Delivery-reports/section/Delivery-report-error-codes).", + ) + encoding: Optional[EncodingType] = Field( + default=None, + description="Applied encoding for message. Present only if smart encoding is enabled.", + ) + number_of_message_parts: Optional[StrictInt] = Field( + default=None, + description="The number of parts the message was split into. Present only if `max_number_of_message_parts` parameter was set.", + ) + operator: Optional[StrictStr] = Field( + default=None, + description="The operator that was used for delivering the message to this recipient, if enabled on the account by Sinch.", + ) + operator_status_at: Optional[datetime] = Field( + default=None, + description="A timestamp extracted from the Delivery Receipt from the originating SMSC. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + recipient: StrictStr = Field( + default=..., description="Phone number that was queried." + ) + status: DeliveryStatusType = Field( + default=..., description="The delivery status." + ) + type: RecipientDeliveryReportType = Field( + default=..., description="The recipient delivery report type." + ) diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py new file mode 100644 index 00000000..0cca0879 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.sms.models.v1.shared.message_delivery_status import ( + MessageDeliveryStatus, +) + +__all__ = [ + "MessageDeliveryStatus", +] diff --git a/sinch/domains/sms/models/v1/shared/message_delivery_status.py b/sinch/domains/sms/models/v1/shared/message_delivery_status.py new file mode 100644 index 00000000..d172856e --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/message_delivery_status.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MessageDeliveryStatus(BaseModelConfigurationResponse): + code: StrictInt = Field( + default=..., description="The delivery receipt error code." + ) + count: StrictInt = Field( + default=..., description="The number of messages with this status." + ) + recipients: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers (MSISDNs) with this status. Only present in full reports.", + ) + status: StrictStr = Field(default=..., description="The delivery status.") diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py new file mode 100644 index 00000000..bed362c9 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -0,0 +1,21 @@ +from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( + DeliveryReceiptStatusCodeType, +) +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.types.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.sms.models.v1.types.encoding_type import EncodingType +from sinch.domains.sms.models.v1.types.recipient_delivery_report_type import ( + RecipientDeliveryReportType, +) + +__all__ = [ + "DeliveryReceiptStatusCodeType", + "DeliveryReportType", + "DeliveryStatusType", + "EncodingType", + "RecipientDeliveryReportType", +] diff --git a/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py new file mode 100644 index 00000000..36e07deb --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py @@ -0,0 +1,27 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryReceiptStatusCodeType = Union[ + Literal[ + 400, + 401, + 402, + 403, + 404, + 405, + 406, + 407, + 408, + 410, + 411, + 412, + 413, + 414, + 415, + 416, + 417, + 418, + ], + StrictStr, +] diff --git a/sinch/domains/sms/models/v1/types/delivery_report_type.py b/sinch/domains/sms/models/v1/types/delivery_report_type.py new file mode 100644 index 00000000..c7eb56c9 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_report_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryReportType = Union[Literal["summary", "full"], StrictStr] diff --git a/sinch/domains/sms/models/v1/types/delivery_status_type.py b/sinch/domains/sms/models/v1/types/delivery_status_type.py new file mode 100644 index 00000000..6fd26822 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/delivery_status_type.py @@ -0,0 +1,19 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryStatusType = Union[ + Literal[ + "QUEUED", + "DISPATCHED", + "ABORTED", + "CANCELLED", + "FAILED", + "DELIVERED", + "EXPIRED", + "REJECTED", + "DELETED", + "UNKNOWN", + ], + StrictStr, +] diff --git a/sinch/domains/sms/models/v1/types/encoding_type.py b/sinch/domains/sms/models/v1/types/encoding_type.py new file mode 100644 index 00000000..eeb752cb --- /dev/null +++ b/sinch/domains/sms/models/v1/types/encoding_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +EncodingType = Union[Literal["GSM", "UNICODE"], StrictStr] diff --git a/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py b/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py new file mode 100644 index 00000000..7952aee9 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/recipient_delivery_report_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +RecipientDeliveryReportType = Union[ + Literal["recipient_delivery_report_sms", "recipient_delivery_report_mms"], + StrictStr, +] diff --git a/tests/conftest.py b/tests/conftest.py index 90284021..9484a478 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,11 @@ import os from dataclasses import dataclass from unittest.mock import Mock, MagicMock +from sinch.domains.sms.models.v1.internal import ( + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) +from sinch.domains.sms.models.v1.response import RecipientDeliveryReport import pytest @@ -12,12 +17,15 @@ from sinch.domains.numbers.models.v1.response import ActiveNumber -@dataclass -class IntBasedPaginationResponse(SinchBaseModel): - count: int - page: int - page_size: int - pig_dogs: list +def parse_iso_datetime(iso_string): + """ + Parse ISO datetime string that may end with 'Z' (UTC indicator). + Compatible with Python 3.9+ by replacing 'Z' with '+00:00'. + """ + from datetime import datetime + if iso_string.endswith('Z'): + iso_string = iso_string[:-1] + '+00:00' + return datetime.fromisoformat(iso_string) @dataclass @@ -26,12 +34,6 @@ class IntBasedPaginationRequest(SinchRequestBaseModel): page_size: int = 0 -@dataclass -class TokenBasedPaginationResponse(SinchBaseModel): - pig_dogs: list - next_page_token: str = None - - @dataclass class TokenBasedPaginationRequest(SinchRequestBaseModel): page_size: int @@ -187,40 +189,20 @@ def token_based_pagination_request_data(): @pytest.fixture -def int_based_pagination_request_data(): - return IntBasedPaginationRequest( +def sms_pagination_request_data(): + return ListDeliveryReportsRequest( page=0, page_size=2 ) -@pytest.fixture -def first_int_based_pagination_response(): - return IntBasedPaginationResponse( - count=4, - page=0, - page_size=2, - pig_dogs=["Bartosz", "Piotr"] - ) - - -@pytest.fixture -def second_int_based_pagination_response(): - return IntBasedPaginationResponse( - count=4, - page=1, - page_size=2, - pig_dogs=["Walaszek", "Połać"] - ) - - @pytest.fixture def third_int_based_pagination_response(): - return IntBasedPaginationResponse( + return ListDeliveryReportsResponse( count=4, page=2, - page_size=0, - pig_dogs=[] + page_size=2, + delivery_reports=[] ) @@ -290,3 +272,62 @@ def mock_pagination_expected_phone_numbers_response(): return [ "+12345678901", "+12345678902", "+12345678903", "+12345678904", "+12345678905" ] + + +@pytest.fixture +def mock_sms_pagination_responses(): + from datetime import datetime + from sinch.domains.sms.models.v1.response import RecipientDeliveryReport + + return [ + Mock(content=[ + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:45:31.935Z"), + batch_id="01K7YNS82JMYGAKAATHFP0QTB5", + code=400, + recipient="12346836075", + status="DELIVERED", + type="recipient_delivery_report_sms" + ), + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:40:26.855Z"), + batch_id="01K7YNFY30DS2KKVQZVBFANHMR", + code=400, + recipient="12346836075", + status="DELIVERED", + type="recipient_delivery_report_sms" + ) + ], + count=4, page=0, page_size=2), + Mock(content=[ + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:35:15.123Z"), + batch_id="01K7YNGZ45XW8KKPQRSTUVWXYZ", + code=401, + recipient="34683607595", + status="DISPATCHED", + type="recipient_delivery_report_sms" + ), + RecipientDeliveryReport( + at=parse_iso_datetime("2025-10-19T16:30:10.456Z"), + batch_id="01K7YNHM67YZ3LMNOPQRSTUVWX", + code=402, + recipient="34683607596", + status="FAILED", + type="recipient_delivery_report_sms" + ) + ], + count=4, page=1, page_size=2), + Mock(content=[], + count=4, page=2, page_size=2) + ] + + +@pytest.fixture +def mock_int_pagination_expected_delivery_reports(): + return [ + "01K7YNS82JMYGAKAATHFP0QTB5", + "01K7YNFY30DS2KKVQZVBFANHMR", + "01K7YNGZ45XW8KKPQRSTUVWXYZ", + "01K7YNHM67YZ3LMNOPQRSTUVWX" + ] diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py new file mode 100644 index 00000000..b8cac734 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py @@ -0,0 +1,109 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import GetBatchDeliveryReportRequest + + +@pytest.mark.parametrize( + "batch_id, report_type, status, code, expected_report_type", + [ + ("batch123", "summary", ["Queued", "Dispatched"], [400, 401], "summary"), + ("batch456", "full", ["Dispatched", "Delivered"], [401, 400], "full"), + ("batch789", None, ["Failed", "Cancelled"], [402, 403], None), + ] +) +def test_get_batch_delivery_report_request_expects_valid_input( + batch_id, report_type, status, code, expected_report_type +): + """ + Test that the model correctly parses valid inputs. + """ + data = { + "batch_id": batch_id, + "type": report_type, + "status": status, + "code": code + } + + # Remove None values + data = {k: v for k, v in data.items() if v is not None} + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == batch_id + assert request.type == expected_report_type + assert request.status == status + assert request.code == code + + +def test_get_batch_delivery_report_request_expects_status_list(): + """ + Test that the model correctly handles status list input. + """ + data = { + "batch_id": "batch123", + "status": ["QUEUED", "DELIVERED", "FAILED"] + } + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == "batch123" + assert request.status == ["QUEUED", "DELIVERED", "FAILED"] + assert request.type is None + assert request.code is None + + +def test_get_batch_delivery_report_request_expects_code_list(): + """ + Test that the model correctly handles code list input. + """ + data = { + "batch_id": "batch123", + "code": [400, 401, 402] + } + + request = GetBatchDeliveryReportRequest(**data) + + assert request.batch_id == "batch123" + assert request.code == [400, 401, 402] + assert request.type is None + assert request.status is None + + +def test_get_batch_delivery_report_request_expects_validation_error_for_missing_batch_id(): + """ + Test that missing required batch_id field raises a ValidationError. + """ + data = { + "type": "summary" + } + + with pytest.raises(ValidationError) as exc_info: + GetBatchDeliveryReportRequest(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_get_batch_delivery_report_request_expects_delivery_report_type_validation(): + """ + Test that the model correctly handles DeliveryReportType enum values. + """ + # Test with valid enum values + valid_types = ["summary", "full"] + + for report_type in valid_types: + data = { + "batch_id": "batch123", + "type": report_type + } + + request = GetBatchDeliveryReportRequest(**data) + assert request.type == report_type + assert request.batch_id == "batch123" + + data = { + "batch_id": "batch123", + "type": "custom_type" + } + + request = GetBatchDeliveryReportRequest(**data) + assert request.type == "custom_type" diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py new file mode 100644 index 00000000..4291f26b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py @@ -0,0 +1,70 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import GetRecipientDeliveryReportRequest + + +@pytest.mark.parametrize( + "batch_id, recipient_msisdn", + [ + ("01FC66621XXXXX119Z8PMV1QPQ", "+44231235674"), + ("batch123", "+15551234567"), + ("test-batch-456", "+1234567890"), + ] +) +def test_get_recipient_delivery_report_request_expects_valid_inputs(batch_id, recipient_msisdn): + """ + Test that the model correctly parses valid inputs. + """ + data = { + "batch_id": batch_id, + "recipient_msisdn": recipient_msisdn + } + + request = GetRecipientDeliveryReportRequest(**data) + + assert request.batch_id == batch_id + assert request.recipient_msisdn == recipient_msisdn + + +def test_get_recipient_delivery_report_request_expects_validation_error_for_missing_batch_id(): + """ + Test that missing batch_id raises a ValidationError. + """ + data = { + "recipient_msisdn": "+44231235674" + } + + with pytest.raises(ValidationError) as exc_info: + GetRecipientDeliveryReportRequest(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_get_recipient_delivery_report_request_expects_validation_error_for_missing_recipient_msisdn(): + """ + Test that missing recipient_msisdn raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ" + } + + with pytest.raises(ValidationError) as exc_info: + GetRecipientDeliveryReportRequest(**data) + + assert "recipient_msisdn" in str(exc_info.value) + + +def test_get_recipient_delivery_report_request_with_additional_kwargs(): + """ + Test that additional kwargs are handled properly. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "recipient_msisdn": "+44231235674", + "extra_field": "extra_value" + } + + request = GetRecipientDeliveryReportRequest(**data) + + assert request.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert request.recipient_msisdn == "+44231235674" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py new file mode 100644 index 00000000..e38afdc1 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py @@ -0,0 +1,67 @@ +from datetime import datetime, timedelta, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import ListDeliveryReportsRequest + + +def test_list_delivery_reports_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListDeliveryReportsRequest() + assert model.page == 0 + assert model.page_size == 30 + assert model.start_date is None + assert model.end_date is None + assert model.status is None + assert model.code is None + assert model.client_reference is None + + +def test_list_delivery_reports_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListDeliveryReportsRequest( + page=1, + page_size=50, + start_date=start, + end_date=end, + status=["DELIVERED", "FAILED"], + code=[401, 402], + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.start_date == start + assert model.end_date == end + assert model.status == ["DELIVERED", "FAILED"] + assert model.code == [401, 402] + assert model.client_reference == "my-client-ref" + + +@pytest.mark.parametrize( + "page, expected_error", + [ + (-1, ValidationError), + (-10, ValidationError), + ] +) +def test_list_delivery_reports_request_expects_validation_error_for_invalid_page(page, expected_error): + """Test that invalid page values raise ValidationError.""" + with pytest.raises(expected_error): + ListDeliveryReportsRequest(page=page) + + +@pytest.mark.parametrize( + "page_size, expected_error", + [ + (0, ValidationError), + (101, ValidationError), + (-1, ValidationError), + ] +) +def test_list_delivery_reports_request_expects_validation_error_for_invalid_page_size(page_size, expected_error): + """Test that invalid page_size values raise ValidationError.""" + with pytest.raises(expected_error): + ListDeliveryReportsRequest(page_size=page_size) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py new file mode 100644 index 00000000..d2154aff --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py @@ -0,0 +1,99 @@ +from datetime import datetime, timezone +import pytest +from sinch.domains.sms.models.v1.internal import ListDeliveryReportsResponse + + +@pytest.fixture +def test_data(): + return { + "count": 2, + "page": 0, + "page_size": 2, + "delivery_reports": [ + { + "at": "2025-01-19T16:45:31.935Z", + "batch_id": "01K7YNS82JMYGAKAATHFP0QTB5", + "code": 401, + "operator_status_at": "2025-01-19T16:45:00Z", + "recipient": "34683607594", + "status": "Delivered", + "type": "recipient_delivery_report_sms", + }, + { + "at": "2025-01-19T16:40:26.855Z", + "batch_id": "01K7YNFY30DS2KKVQZVBFANHMR", + "code": 402, + "operator_status_at": "2025-01-19T16:40:00Z", + "recipient": "34683607595", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + }, + ], + } + + +def assert_delivery_report_fields(delivery_report, expected_batch_id, expected_recipient, expected_code, expected_status): + """Helper function to assert delivery report fields.""" + assert delivery_report.batch_id == expected_batch_id + assert delivery_report.recipient == expected_recipient + assert delivery_report.code == expected_code + assert delivery_report.status == expected_status + assert delivery_report.type == "recipient_delivery_report_sms" + + +def test_list_delivery_reports_response_empty_content_expects_empty_list(): + """Test that empty delivery reports list returns empty content.""" + model = ListDeliveryReportsResponse(count=0, page=0, page_size=30, delivery_reports=None) + assert model.count == 0 + assert model.page == 0 + assert model.page_size == 30 + assert model.content == [] + + +def test_list_delivery_reports_response_expects_correct_mapping(test_data): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + response = ListDeliveryReportsResponse(**test_data) + assert hasattr(response, "content") + assert response.content == response.delivery_reports + + # Test top-level fields + assert response.count == 2 + assert response.page == 0 + assert response.page_size == 2 + + # Test content property + content = response.content + assert isinstance(content, list) + assert len(content) == 2 + + # Test first delivery report + first_report = content[0] + expected_first_at = datetime(2025, 1, 19, 16, 45, 31, 935000, tzinfo=timezone.utc) + expected_first_operator_at = datetime(2025, 1, 19, 16, 45, 0, tzinfo=timezone.utc) + assert first_report.at == expected_first_at + assert first_report.operator_status_at == expected_first_operator_at + assert_delivery_report_fields( + first_report, + "01K7YNS82JMYGAKAATHFP0QTB5", + "34683607594", + 401, + "Delivered" + ) + + # Test second delivery report + second_report = content[1] + expected_second_at = datetime(2025, 1, 19, 16, 40, 26, 855000, tzinfo=timezone.utc) + expected_second_operator_at = datetime(2025, 1, 19, 16, 40, 0, tzinfo=timezone.utc) + assert second_report.at == expected_second_at + assert second_report.operator_status_at == expected_second_operator_at + assert_delivery_report_fields( + second_report, + "01K7YNFY30DS2KKVQZVBFANHMR", + "34683607595", + 402, + "Dispatched" + ) + + diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py new file mode 100644 index 00000000..fdd4338d --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py @@ -0,0 +1,254 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.response.batch_delivery_report import BatchDeliveryReport + +@pytest.fixture +def sample_message_delivery_status(): + """ + Sample MessageDeliveryStatus for testing. + """ + return { + "code": 401, + "count": 1, + "recipients": ["+1234567890"], + "status": "Dispatched" + } + + +@pytest.fixture +def sample_batch_delivery_report_data(sample_message_delivery_status): + """ + Sample BatchDeliveryReport data for testing. + """ + return { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "my_client_reference", + "statuses": [sample_message_delivery_status], + "total_message_count": 1, + "type": "delivery_report_sms" + } + + +def test_batch_delivery_report_expects_valid_input(sample_batch_delivery_report_data): + """ + Test that the model correctly parses valid input. + """ + report = BatchDeliveryReport(**sample_batch_delivery_report_data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.client_reference == "my_client_reference" + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" + assert len(report.statuses) == 1 + + status = report.statuses[0] + assert status.code == 401 + assert status.count == 1 + assert status.recipients == ["+1234567890"] + assert status.status == "Dispatched" + + +def test_batch_delivery_report_expects_without_client_reference(): + """ + Test that the model works without optional client_reference. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{ + "code": 401, + "count": 1, + "recipients": ["+44231235674"], + "status": "Dispatched" + }], + "total_message_count": 1, + "type": "delivery_report_sms" + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.client_reference is None + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" + + +def test_batch_delivery_report_expects_with_multiple_statuses(): + """ + Test that the model works with multiple statuses. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [ + { + "code": 401, + "count": 1, + "recipients": ["+44231235674"], + "status": "Dispatched" + }, + { + "code": 0, + "count": 1, + "recipients": ["+44231235675"], + "status": "Delivered" + } + ], + "total_message_count": 2, + "type": "delivery_report_sms" + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.total_message_count == 2 + assert len(report.statuses) == 2 + + # Check first status + assert report.statuses[0].code == 401 + assert report.statuses[0].status == "Dispatched" + + # Check second status + assert report.statuses[1].code == 0 + assert report.statuses[1].status == "Delivered" + + +def test_batch_delivery_report_expects_no_recipients(): + """ + Test that the model works when recipients are not provided. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{ + "code": 401, + "count": 1, + "status": "Dispatched" + }], + "total_message_count": 1, + "type": "delivery_report_sms" + } + + report = BatchDeliveryReport(**data) + + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.total_message_count == 1 + assert len(report.statuses) == 1 + + status = report.statuses[0] + assert status.code == 401 + assert status.count == 1 + assert status.recipients is None + assert status.status == "Dispatched" + + +def test_batch_delivery_report_expects_validation_error_for_missing_batch_id(): + """ + Test that missing required batch_id field raises a ValidationError. + """ + data = { + "statuses": [{ + "code": 401, + "count": 1, + "status": "Dispatched" + }], + "total_message_count": 1, + "type": "delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_statuses(): + """ + Test that missing required statuses field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "total_message_count": 1, + "type": "delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "statuses" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_total_message_count(): + """ + Test that missing required total_message_count field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{ + "code": 401, + "count": 1, + "status": "Dispatched" + }], + "type": "delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "total_message_count" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_missing_type(): + """ + Test that missing required type field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{ + "code": 401, + "count": 1, + "status": "Dispatched" + }], + "total_message_count": 1 + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "type" in str(exc_info.value) + + +def test_batch_delivery_report_expects_validation_error_for_negative_total_message_count(): + """ + Test that negative total_message_count raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [{ + "code": 401, + "count": 1, + "status": "Dispatched" + }], + "total_message_count": -1, + "type": "delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + BatchDeliveryReport(**data) + + assert "total_message_count" in str(exc_info.value) + + +def test_batch_delivery_report_expects_empty_statuses(): + """ + Test that empty statuses list is allowed. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "statuses": [], + "total_message_count": 1, + "type": "delivery_report_sms" + } + + report = BatchDeliveryReport(**data) + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.statuses == [] + assert report.total_message_count == 1 + assert report.type == "delivery_report_sms" diff --git a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py new file mode 100644 index 00000000..1eb1b826 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py @@ -0,0 +1,197 @@ +import pytest +from datetime import datetime, timezone +from pydantic import ValidationError +from sinch.domains.sms.models.v1.response.recipient_delivery_report import RecipientDeliveryReport +from tests.conftest import parse_iso_datetime + + +@pytest.fixture +def sample_recipient_delivery_report_data(): + """ + Sample data for RecipientDeliveryReport testing. + """ + return { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms" + } + + +@pytest.mark.parametrize( + "status, code, report_type", + [ + ("Delivered", 401, "recipient_delivery_report_sms"), + ("Failed", 402, "recipient_delivery_report_sms"), + ("Queued", 400, "recipient_delivery_report_mms"), + ("Dispatched", 401, "recipient_delivery_report_mms"), + ] +) +def test_recipient_delivery_report_expects_valid_inputs(status, code, report_type): + """ + Test that the model correctly parses valid inputs with different statuses and codes. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": code, + "recipient": "+44231235674", + "status": status, + "type": report_type + } + + report = RecipientDeliveryReport(**data) + + assert report.at == parse_iso_datetime("2022-08-30T08:16:08.930Z") + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert report.code == code + assert report.recipient == "+44231235674" + assert report.status == status + assert report.type == report_type + + +def test_recipient_delivery_report_expects_with_optional_fields(sample_recipient_delivery_report_data): + """ + Test that the model works with all optional fields provided. + """ + data = sample_recipient_delivery_report_data.copy() + data.update({ + "applied_originator": "My Originator", + "client_reference": "my_client_reference", + "encoding": "GSM", + "number_of_message_parts": 1, + "operator": "35000", + "operator_status_at": parse_iso_datetime("2019-08-24T14:15:22Z") + }) + + report = RecipientDeliveryReport(**data) + + assert report.applied_originator == "My Originator" + assert report.client_reference == "my_client_reference" + assert report.encoding == "GSM" + assert report.number_of_message_parts == 1 + assert report.operator == "35000" + assert report.operator_status_at == parse_iso_datetime("2019-08-24T14:15:22Z") + + +def test_recipient_delivery_report_expects_without_optional_fields(sample_recipient_delivery_report_data): + """ + Test that the model works without optional fields. + """ + report = RecipientDeliveryReport(**sample_recipient_delivery_report_data) + + assert report.applied_originator is None + assert report.client_reference is None + assert report.encoding is None + assert report.number_of_message_parts is None + assert report.operator is None + assert report.operator_status_at is None + + +def test_recipient_delivery_report_expects_validation_error_for_missing_at(): + """ + Test that missing 'at' field raises a ValidationError. + """ + data = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "at" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_validation_error_for_missing_batch_id(): + """ + Test that missing 'batch_id' field raises a ValidationError. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "batch_id" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_invalid_datetime_format(): + """ + Test that invalid datetime format raises a ValidationError. + """ + data = { + "at": "invalid-datetime", + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms" + } + + with pytest.raises(ValidationError) as exc_info: + RecipientDeliveryReport(**data) + + assert "at" in str(exc_info.value) + + +def test_recipient_delivery_report_expects_custom_encoding(): + """ + Test that the model accepts custom encoding values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "recipient_delivery_report_sms", + "encoding": "CUSTOM_ENCODING" + } + + report = RecipientDeliveryReport(**data) + assert report.encoding == "CUSTOM_ENCODING" + + +def test_recipient_delivery_report_expects_custom_status(): + """ + Test that the model accepts custom status values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "CUSTOM_STATUS", + "type": "recipient_delivery_report_sms" + } + + report = RecipientDeliveryReport(**data) + assert report.status == "CUSTOM_STATUS" + + +def test_recipient_delivery_report_expects_custom_type(): + """ + Test that the model accepts custom type values due to Union + StrictStr. + """ + data = { + "at": parse_iso_datetime("2022-08-30T08:16:08.930Z"), + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "code": 401, + "recipient": "+44231235674", + "status": "Dispatched", + "type": "custom_delivery_report_type" + } + + report = RecipientDeliveryReport(**data) + assert report.type == "custom_delivery_report_type" diff --git a/tests/unit/test_pagination.py b/tests/unit/test_pagination.py index 6d846ce0..2dfa938b 100644 --- a/tests/unit/test_pagination.py +++ b/tests/unit/test_pagination.py @@ -1,73 +1,85 @@ from unittest.mock import Mock +import pytest from sinch.core.pagination import ( - IntBasedPaginator, + SMSPaginator, TokenBasedPaginator ) -def test_page_int_iterator_sync_using_manual_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data +# Helper function to initialize SMS paginator +def initialize_sms_paginator(endpoint_mock, request_data, responses): + client = Mock() + + # Create a mock that returns different responses based on page number + def mock_request(endpoint): + page = endpoint.request_data.page + if page == 0: + return responses[0] + elif page == 1: + return responses[1] + else: + return responses[2] + + client.configuration.transport.request.side_effect = mock_request + endpoint_mock.request_data = request_data + + return SMSPaginator(sinch=client, endpoint=endpoint_mock) + + +def test_page_sms_iterator_sync_using_manual_pagination( + sms_pagination_request_data, + mock_sms_pagination_responses, + mock_int_pagination_expected_delivery_reports ): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - int_based_paginator = IntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the pagination iterates correctly through multiple items.""" + sms_paginator = initialize_sms_paginator( + endpoint_mock=Mock(), + request_data=sms_pagination_request_data, + responses=mock_sms_pagination_responses ) - assert int_based_paginator + assert sms_paginator is not None page_counter = 0 - assert int_based_paginator.result.page == page_counter + assert sms_paginator.result.page == page_counter - while int_based_paginator.has_next_page: - int_based_paginator = int_based_paginator.next_page() - page_counter += 1 - assert int_based_paginator.result.page == page_counter + delivery_reports_list = [] + reached_last_page = False + while not reached_last_page: + delivery_reports_list.extend([report.batch_id for report in sms_paginator.content()]) + if sms_paginator.has_next_page: + sms_paginator = sms_paginator.next_page() + page_counter += 1 + assert isinstance(sms_paginator, SMSPaginator) + else: + reached_last_page = True - assert page_counter == 2 + assert page_counter == 1 + assert delivery_reports_list == mock_int_pagination_expected_delivery_reports -def test_page_int_iterator_sync_using_auto_pagination( - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response, - int_based_pagination_request_data +def test_page_sms_iterator_sync_using_auto_pagination( + sms_pagination_request_data, + mock_sms_pagination_responses, + mock_int_pagination_expected_delivery_reports ): - endpoint = Mock() - endpoint.request_data = int_based_pagination_request_data - sinch_client = Mock() - - sinch_client.configuration.transport.request.side_effect = [ - first_int_based_pagination_response, - second_int_based_pagination_response, - third_int_based_pagination_response - ] - - int_based_paginator = IntBasedPaginator._initialize( - sinch=sinch_client, - endpoint=endpoint + """Test that the pagination iterates correctly through multiple items.""" + sms_paginator = initialize_sms_paginator( + endpoint_mock=Mock(), + request_data=sms_pagination_request_data, + responses=mock_sms_pagination_responses ) - assert int_based_paginator + assert sms_paginator is not None page_counter = 0 - assert int_based_paginator.result.page == page_counter - - for page in int_based_paginator.auto_paging_iter(): - page_counter += 1 - assert page.result.page == page_counter - assert isinstance(page, IntBasedPaginator) - - assert page_counter == 2 + assert sms_paginator.result.page == page_counter + + all_delivery_reports = [] + for delivery_report in sms_paginator.iterator(): + all_delivery_reports.append(delivery_report.batch_id) + + # Should have 4 delivery reports total (2 from page 0, 2 from page 1, 0 from page 2) + assert len(all_delivery_reports) == 4 + assert all_delivery_reports == mock_int_pagination_expected_delivery_reports # Helper function to initialize token paginator From 7cb66e2098b48f8ca9716fbc6f2eed5762690972 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 29 Oct 2025 14:52:22 +0100 Subject: [PATCH 060/106] DEVEXP-1118: Refactor SMS auth (#90) --- .github/workflows/ci.yml | 2 + .../clients/sinch_client_configuration.py | 94 +++++++- sinch/core/clients/sinch_client_sync.py | 6 +- sinch/core/models/__init__.py | 4 + sinch/core/models/utils.py | 30 +++ sinch/core/pagination.py | 17 +- sinch/domains/sms/api/v1/base/base_sms.py | 22 +- .../sms/api/v1/delivery_reports_apis.py | 35 +-- .../sms/api/v1/internal/base/sms_endpoint.py | 20 +- .../v1/internal/delivery_reports_endpoints.py | 11 +- .../delivery_receipt_status_code_type.py | 4 +- tests/conftest.py | 30 ++- tests/e2e/numbers/features/environment.py | 11 +- .../features/steps/delivery_reports.steps.py | 166 +++++++++++++ tests/e2e/shared_config.py | 15 ++ tests/e2e/sms/features/environment.py | 6 + .../features/steps/delivery_reports.steps.py | 166 +++++++++++++ ...test_get_batch_delivery_report_endpoint.py | 114 +++++++++ ..._get_recipient_delivery_report_endpoint.py | 75 ++++++ .../base/test_base_model_configuration.py | 57 +++++ ...get_batch_delivery_report_request_model.py | 57 +++-- ...recipient_delivery_report_request_model.py | 37 ++- ...est_list_delivery_reports_request_model.py | 14 +- ...st_list_delivery_reports_response_model.py | 58 +++-- .../test_batch_delivery_report_model.py | 123 +++++----- .../test_recipient_delivery_report_model.py | 68 +++--- .../domains/sms/v1/test_delivery_reports.py | 218 ++++++++++++++++++ tests/unit/test_client.py | 35 ++- tests/unit/test_configuration.py | 134 ++++++++++- 29 files changed, 1382 insertions(+), 247 deletions(-) create mode 100644 sinch/core/models/utils.py create mode 100644 tests/e2e/numbers/features/steps/delivery_reports.steps.py create mode 100644 tests/e2e/shared_config.py create mode 100644 tests/e2e/sms/features/environment.py create mode 100644 tests/e2e/sms/features/steps/delivery_reports.steps.py create mode 100644 tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py create mode 100644 tests/unit/domains/sms/v1/test_delivery_reports.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56e8acf6..db41ce66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ + cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index d969f7fd..28472608 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -12,18 +12,19 @@ class Configuration: """ def __init__( self, - key_id: str, - key_secret: str, - project_id: str, transport: HTTPTransport, token_manager: TokenManager, + connection_timeout=10, + key_id: str = None, + key_secret: str = None, + project_id: str = None, logger: Logger = None, logger_name: str = None, - connection_timeout=10, application_key: str = None, application_secret: str = None, service_plan_id: str = None, - sms_api_token: str = None + sms_api_token: str = None, + sms_region: str = None, ): self.key_id = key_id self.key_secret = key_secret @@ -33,6 +34,9 @@ def __init__( self.connection_timeout = connection_timeout self.sms_api_token = sms_api_token self.service_plan_id = service_plan_id + + # Determine authentication method based on provided parameters + self._authentication_method = self._determine_authentication_method() self.auth_origin = "https://auth.sinch.com" self.numbers_origin = "https://numbers.api.sinch.com" self.verification_origin = "https://verification.api.sinch.com" @@ -41,11 +45,10 @@ def __init__( self._voice_region = None self._conversation_region = "eu" self._conversation_domain = ".conversation.api.sinch.com" - self._sms_region = "us" - self._sms_region_with_service_plan_id = "us" + self._sms_region = sms_region + self._sms_region_with_service_plan_id = sms_region self._sms_domain = "https://zt.{}.sms.api.sinch.com" self._sms_domain_with_service_plan_id = "https://{}.sms.api.sinch.com" - self._sms_authentication = HTTPAuthentication.OAUTH.value self._templates_region = "eu" self._templates_domain = ".template.api.sinch.com" self.token_manager = token_manager @@ -65,9 +68,12 @@ def __init__( self.logger = logging.getLogger("Sinch") def _set_sms_origin_with_service_plan_id(self): - self.sms_origin_with_service_plan_id = self._sms_domain_with_service_plan_id.format( - self._sms_region_with_service_plan_id - ) + if self._sms_region_with_service_plan_id: + self.sms_origin_with_service_plan_id = self._sms_domain_with_service_plan_id.format( + self._sms_region_with_service_plan_id + ) + else: + self.sms_origin_with_service_plan_id = None def _set_sms_region_with_service_plan_id(self, region): self._sms_region_with_service_plan_id = region @@ -96,7 +102,10 @@ def _get_sms_domain_with_service_plan_id(self): ) def _set_sms_origin(self): - self.sms_origin = self._sms_domain.format(self._sms_region) + if self._sms_region: + self.sms_origin = self._sms_domain.format(self._sms_region) + else: + self.sms_origin = None def _set_sms_region(self, region): self._sms_region = region @@ -200,3 +209,64 @@ def _get_voice_region(self): _set_voice_region, doc="Voice Region" ) + + def _determine_authentication_method(self): + """ + Determines the authentication method based on provided parameters. + Priority: SMS authentication (service_plan_id + sms_api_token) over project authentication (project_id). + """ + if self.service_plan_id and self.sms_api_token: + return "sms_auth" + elif self.project_id: + return "project_auth" + else: + # No authentication parameters provided - will be validated later + return None + + @property + def authentication_method(self): + """Returns the determined authentication method""" + return self._authentication_method + + def validate_authentication_parameters(self): + """ + Validates that sufficient authentication parameters are provided. + Recalculates the authentication method based on current credentials before validating. + This should be called before making actual API requests. + """ + + self._authentication_method = self._determine_authentication_method() + + # Check for incomplete SMS auth only if not using project auth + # This prevents false positives when both service_plan_id and project_id are provided + has_project_auth = self.project_id and self.key_id and self.key_secret + if self.service_plan_id and not self.sms_api_token and not has_project_auth: + raise ValueError( + "The sms_api_token is required when using service_plan_id" + ) + if self._authentication_method is None or self._authentication_method == "project_auth": + # Default to project_auth and validate parameters + if not self.project_id: + raise ValueError( + "The project_id is required" + ) + if not self.key_id or not self.key_secret: + raise ValueError( + "The key_id and key_secret are required" + ) + elif self._authentication_method == "sms_auth": + if not self.service_plan_id or not self.sms_api_token: + raise ValueError( + "The service_plan_id and sms_api_token are required" + ) + + def get_sms_origin_for_auth(self): + """ + Returns the appropriate SMS origin based on the authentication method. + - SMS auth (service_plan_id + sms_api_token): uses sms_origin_with_service_plan_id + - Project auth (project_id): uses regular sms_origin + """ + if self._authentication_method == "sms_auth": + return self.sms_origin_with_service_plan_id + else: + return self.sms_origin diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index a15459c4..96a45d1d 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -26,7 +26,8 @@ def __init__( application_key: str = None, application_secret: str = None, service_plan_id: str = None, - sms_api_token: str = None + sms_api_token: str = None, + sms_region: str = None, ): self.configuration = Configuration( key_id=key_id, @@ -39,7 +40,8 @@ def __init__( application_key=application_key, application_secret=application_secret, service_plan_id=service_plan_id, - sms_api_token=sms_api_token + sms_api_token=sms_api_token, + sms_region=sms_region, ) self.authentication = Authentication(self) diff --git a/sinch/core/models/__init__.py b/sinch/core/models/__init__.py index e69de29b..914e728c 100644 --- a/sinch/core/models/__init__.py +++ b/sinch/core/models/__init__.py @@ -0,0 +1,4 @@ +from sinch.core.models.utils import model_dump_for_query_params + +__all__ = ["model_dump_for_query_params"] + diff --git a/sinch/core/models/utils.py b/sinch/core/models/utils.py new file mode 100644 index 00000000..2f5a340b --- /dev/null +++ b/sinch/core/models/utils.py @@ -0,0 +1,30 @@ +def model_dump_for_query_params(model, exclude_none=True, by_alias=True): + """ + Serializes a Pydantic model for use as query parameters. + Converts list values to comma-separated strings for APIs that expect this format. + Filters out empty values (empty strings and empty lists). + + Args: + model: A Pydantic BaseModel instance + exclude_none: Whether to exclude None values (default: True) + by_alias: Whether to use field aliases (default: True) + + Returns: + dict: Serialized model data with lists converted to comma-separated strings + """ + data = model.model_dump(exclude_none=exclude_none, by_alias=by_alias) + filtered_data = {} + for key, value in data.items(): + # Filter out empty strings + if value == "": + continue + # Filter out empty lists + if isinstance(value, list) and len(value) == 0: + continue + # Convert lists to comma-separated strings + if isinstance(value, list): + filtered_data[key] = ",".join(str(item) for item in value) + else: + filtered_data[key] = value + return filtered_data + diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py index a54e9f52..abc17741 100644 --- a/sinch/core/pagination.py +++ b/sinch/core/pagination.py @@ -77,6 +77,8 @@ def next_page(self): if not self.has_next_page: return None + if self.endpoint.request_data.page is None: + self.endpoint.request_data.page = 0 self.endpoint.request_data.page += 1 self.result = self._sinch.configuration.transport.request(self.endpoint) self._calculate_next_page() @@ -95,11 +97,16 @@ def iterator(self): def _calculate_next_page(self): """Calculates if there's a next page based on count, page, and page_size.""" - if hasattr(self.result, 'count') and hasattr(self.result, 'page') and hasattr(self.result, 'page_size'): - # Calculate total pages needed - total_pages = (self.result.count + self.result.page_size - 1) // self.result.page_size - # Check if current page is less than total pages - 1 (0-indexed) - self.has_next_page = self.result.page < (total_pages - 1) + if hasattr(self.result, 'count') and hasattr(self.result, 'page'): + # Use the requested page_size from the endpoint + request_page_size = self.endpoint.request_data.page_size or 1 + if request_page_size > 0 and hasattr(self.result, 'page_size'): + # Calculate total pages needed using the request page_size + total_pages = (self.result.count + request_page_size - 1) // request_page_size + # Check if current page is less than total pages - 1 (0-indexed) + self.has_next_page = self.result.page < (total_pages - 1) + else: + self.has_next_page = False else: self.has_next_page = False diff --git a/sinch/domains/sms/api/v1/base/base_sms.py b/sinch/domains/sms/api/v1/base/base_sms.py index 85694e88..a4898aa8 100644 --- a/sinch/domains/sms/api/v1/base/base_sms.py +++ b/sinch/domains/sms/api/v1/base/base_sms.py @@ -15,10 +15,20 @@ def _request(self, endpoint_class, request_data): Returns: The response from the Sinch transport request. """ - return self._sinch.configuration.transport.request( - endpoint_class( - # TODO: Refactor project_id to service_plan_id - project_id=self._sinch.configuration.project_id, - request_data=request_data, - ) + self._sinch.configuration.validate_authentication_parameters() + + # Use service_plan_id for SMS auth, project_id for project auth + if self._sinch.configuration.authentication_method == "sms_auth": + path_identifier = self._sinch.configuration.service_plan_id + else: + path_identifier = self._sinch.configuration.project_id + + endpoint = endpoint_class( + project_id=path_identifier, + request_data=request_data, ) + + # Set the authentication method based on configuration + endpoint.set_authentication_method(self._sinch) + + return self._sinch.configuration.transport.request(endpoint) diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py index cf2167ff..455284b5 100644 --- a/sinch/domains/sms/api/v1/delivery_reports_apis.py +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -62,19 +62,26 @@ def list( client_reference: Optional[str] = None, **kwargs, ) -> Paginator[RecipientDeliveryReport]: - return SMSPaginator( - sinch=self._sinch, - endpoint=ListDeliveryReportsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListDeliveryReportsRequest( - page=page, - page_size=page_size, - start_date=start_date, - end_date=end_date, - status=status, - code=code, - client_reference=client_reference, - **kwargs, - ), + # Use service_plan_id for SMS auth, project_id for project auth + if self._sinch.configuration.authentication_method == "sms_auth": + path_identifier = self._sinch.configuration.service_plan_id + else: + path_identifier = self._sinch.configuration.project_id + + endpoint = ListDeliveryReportsEndpoint( + project_id=path_identifier, + request_data=ListDeliveryReportsRequest( + page=page, + page_size=page_size, + start_date=start_date, + end_date=end_date, + status=status, + code=code, + client_reference=client_reference, + **kwargs, ), ) + # Set the authentication method based on configuration + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py index 3e8c1991..037e891d 100644 --- a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -3,22 +3,34 @@ from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.core.types import BM +from sinch.core.enums import HTTPAuthentication from sinch.domains.sms.api.v1.exceptions import SmsException class SmsEndpoint(HTTPEndpoint, ABC): def __init__(self, project_id: str, request_data: BM): - # TODO: Refactor HTTPEndpoint and endpoints for service_id super().__init__(project_id, request_data) + def set_authentication_method(self, sinch): + """ + Sets the authentication method based on the sinch client configuration. + """ + if sinch.configuration.authentication_method == "sms_auth": + self.HTTP_AUTHENTICATION = HTTPAuthentication.SMS_TOKEN.value + else: + self.HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: raise NotImplementedError( "ENDPOINT_URL must be defined in the subclass." ) + # Use the appropriate SMS origin based on authentication method + origin = sinch.configuration.get_sms_origin_for_auth() + return self.ENDPOINT_URL.format( - origin=sinch.configuration.sms_origin, + origin=origin, project_id=self.project_id, **vars(self.request_data), ) @@ -61,8 +73,10 @@ def process_response_model( def handle_response(self, response: HTTPResponse): if response.status_code >= 400: + error_message = f"Error {response.status_code}" + raise SmsException( - message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + message=error_message, response=response, is_from_server=True, ) diff --git a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py index 83a92ee4..ca4aa6e5 100644 --- a/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/delivery_reports_endpoints.py @@ -1,5 +1,6 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params from sinch.domains.sms.api.v1.exceptions import SmsException from sinch.domains.sms.models.v1.internal import ( GetBatchDeliveryReportRequest, @@ -16,7 +17,7 @@ class GetBatchDeliveryReportEndpoint(SmsEndpoint): ENDPOINT_URL = ( - "{origin}/xms/v1/{service_plan_id}/batches/{batch_id}/delivery_report" + "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_report" ) HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -31,7 +32,7 @@ def __init__( self.request_data = request_data def build_query_params(self) -> dict: - return self.request_data.model_dump(exclude_none=True, by_alias=True) + return model_dump_for_query_params(self.request_data) def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport: try: @@ -48,7 +49,7 @@ def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport: class GetRecipientDeliveryReportEndpoint(SmsEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{service_plan_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_report/{recipient_msisdn}" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -82,7 +83,7 @@ def handle_response( class ListDeliveryReportsEndpoint(SmsEndpoint): - ENDPOINT_URL = "{origin}/xms/v1/{service_plan_id}/delivery_reports" + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/delivery_reports" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -96,7 +97,7 @@ def __init__( self.request_data = request_data def build_query_params(self) -> dict: - return self.request_data.model_dump(exclude_none=True, by_alias=True) + return model_dump_for_query_params(self.request_data) def handle_response( self, response: HTTPResponse diff --git a/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py index 36e07deb..fcff3ed0 100644 --- a/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py +++ b/sinch/domains/sms/models/v1/types/delivery_receipt_status_code_type.py @@ -1,5 +1,5 @@ from typing import Literal, Union -from pydantic import StrictStr +from pydantic import StrictInt DeliveryReceiptStatusCodeType = Union[ @@ -23,5 +23,5 @@ 417, 418, ], - StrictStr, + StrictInt, ] diff --git a/tests/conftest.py b/tests/conftest.py index 9484a478..1fc00a59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -145,7 +145,6 @@ def application_secret(): def service_plan_id(): return os.getenv("SERVICE_PLAN_ID") - @pytest.fixture def http_response(): return HTTPResponse( @@ -253,6 +252,35 @@ class MockSinchClient: return MockSinchClient() +@pytest.fixture +def mock_sinch_client_sms(): + from sinch.core.clients.sinch_client_configuration import Configuration + from sinch.core.ports.http_transport import HTTPTransport + from sinch.core.token_manager import TokenManager + + mock_transport = MagicMock(spec=HTTPTransport) + mock_transport.request = MagicMock() + + mock_token_manager = MagicMock(spec=TokenManager) + + config = Configuration( + transport=mock_transport, + token_manager=mock_token_manager, + project_id="test_project_id", + key_id="test_key_id", + key_secret="test_key_secret", + service_plan_id="test_service_plan_id", + sms_region="eu" + ) + + config._authentication_method = "project_auth" + + class MockSinchClient: + configuration = config + + return MockSinchClient() + + @pytest.fixture def mock_pagination_active_number_responses(): return [ diff --git a/tests/e2e/numbers/features/environment.py b/tests/e2e/numbers/features/environment.py index 75842367..db663960 100644 --- a/tests/e2e/numbers/features/environment.py +++ b/tests/e2e/numbers/features/environment.py @@ -1,13 +1,6 @@ -from sinch import SinchClient +from tests.e2e.shared_config import create_test_client def before_all(context): """Initializes the Sinch client""" - client_params = { - 'project_id': 'tinyfrog-jump-high-over-lilypadbasin', - 'key_id': 'keyId', - 'key_secret': 'keySecret', - } - context.sinch = SinchClient(**client_params) - context.sinch.configuration.auth_origin = 'http://localhost:3011' - context.sinch.configuration.numbers_origin = 'http://localhost:3013' + context.sinch = create_test_client() diff --git a/tests/e2e/numbers/features/steps/delivery_reports.steps.py b/tests/e2e/numbers/features/steps/delivery_reports.steps.py new file mode 100644 index 00000000..69fb0cc2 --- /dev/null +++ b/tests/e2e/numbers/features/steps/delivery_reports.steps.py @@ -0,0 +1,166 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport + + +@given('the SMS service "{service_name}" is available') +def step_sms_service_available(context, service_name): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + + +@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') +def step_sms_service_available_with_service_plan(context, service_name): + """Ensures the Sinch client is initialized with service_plan_id authentication""" + from sinch import SinchClient + + # Create a new client with service_plan_id authentication + context.sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.sms_origin = 'http://localhost:3017' + context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + + +@when('I send a request to retrieve a summary SMS delivery report') +def step_retrieve_summary_delivery_report(context): + """Retrieve a summary SMS delivery report""" + context.response = context.sinch.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + status=['DELIVERED', 'FAILED'], + code=[15, 0] + ) + + +@then('the response contains a summary SMS delivery report') +def step_validate_summary_delivery_report(context): + """Validate summary SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.client_reference == 'reference_e2e' + assert data.statuses is not None + assert len(data.statuses) >= 2 + + status = data.statuses[0] + assert status.code == 15 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Failed' + + status = data.statuses[1] + assert status.code == 0 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Delivered' + + assert data.total_message_count == 2 + assert data.type == 'delivery_report_sms' + + +@when('I send a request to retrieve a full SMS delivery report') +def step_retrieve_full_delivery_report(context): + """Retrieve a full SMS delivery report""" + context.response = context.sinch.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + report_type='full' + ) + + +@then('the response contains a full SMS delivery report') +def step_validate_full_delivery_report(context): + """Validate full SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.statuses is not None + status = data.statuses[0] + assert status.recipients is not None + assert status.code == 0 + assert status.count == 1 + assert status.recipients[0] == '12017777777' + assert status.status == 'Delivered' + + +@when('I send a request to retrieve a recipient\'s delivery report') +def step_retrieve_recipient_delivery_report(context): + """Retrieve a recipient's delivery report""" + context.response = context.sinch.sms.delivery_reports.get_for_number( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + recipient='12017777777' + ) + + +@then('the response contains the recipient\'s delivery report details') +def step_validate_recipient_delivery_report(context): + """Validate recipient delivery report response""" + data: RecipientDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.recipient == '12017777777' + assert data.client_reference == 'reference_e2e' + assert data.status == 'Delivered' + assert data.type == 'recipient_delivery_report_sms' + assert data.code == 0 + assert data.at == datetime(2024, 6, 6, 13, 6, 27, 833000, tzinfo=timezone.utc) + assert data.operator_status_at == datetime(2024, 6, 6, 13, 6, 0, tzinfo=timezone.utc) + + +@when('I send a request to list the SMS delivery reports') +def step_list_delivery_reports(context): + """List a page of SMS delivery reports""" + context.response = context.sinch.sms.delivery_reports.list() + + +@then('the response contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_count(context, count): + """Validate the count of SMS delivery reports in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the SMS delivery reports') +def step_list_all_delivery_reports(context): + """List all SMS delivery reports using iterator""" + response = context.sinch.sms.delivery_reports.list(page_size=2) + delivery_reports_list = [] + + for delivery_report in response.iterator(): + delivery_reports_list.append(delivery_report) + + context.delivery_reports_list = delivery_reports_list + + +@then('the SMS delivery reports list contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_list_count(context, count): + """Validate the count of SMS delivery reports in the full list""" + expected_count = int(count) + assert len(context.delivery_reports_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.delivery_reports_list)}' + + +@when('I iterate manually over the SMS delivery reports pages') +def step_iterate_manually_delivery_reports(context): + """Manually iterate over SMS delivery reports pages""" + context.list_response = context.sinch.sms.delivery_reports.list(page_size=2) + + # Iterate through all pages + context.delivery_reports_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.delivery_reports_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the SMS delivery reports iteration result contains the data from "{count}" pages') +def step_validate_delivery_reports_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/shared_config.py b/tests/e2e/shared_config.py new file mode 100644 index 00000000..6940b7ef --- /dev/null +++ b/tests/e2e/shared_config.py @@ -0,0 +1,15 @@ +from sinch import SinchClient + + +def create_test_client(): + """Creates a Sinch client with test configuration for all domains""" + client_params = { + 'project_id': 'tinyfrog-jump-high-over-lilypadbasin', + 'key_id': 'keyId', + 'key_secret': 'keySecret', + } + client = SinchClient(**client_params) + client.configuration.auth_origin = 'http://localhost:3011' + client.configuration.numbers_origin = 'http://localhost:3013' + client.configuration.sms_origin = 'http://localhost:3017' + return client diff --git a/tests/e2e/sms/features/environment.py b/tests/e2e/sms/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/sms/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/sms/features/steps/delivery_reports.steps.py b/tests/e2e/sms/features/steps/delivery_reports.steps.py new file mode 100644 index 00000000..69fb0cc2 --- /dev/null +++ b/tests/e2e/sms/features/steps/delivery_reports.steps.py @@ -0,0 +1,166 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport + + +@given('the SMS service "{service_name}" is available') +def step_sms_service_available(context, service_name): + """Ensures the Sinch client is initialized""" + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + + +@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') +def step_sms_service_available_with_service_plan(context, service_name): + """Ensures the Sinch client is initialized with service_plan_id authentication""" + from sinch import SinchClient + + # Create a new client with service_plan_id authentication + context.sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.sms_origin = 'http://localhost:3017' + context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + + +@when('I send a request to retrieve a summary SMS delivery report') +def step_retrieve_summary_delivery_report(context): + """Retrieve a summary SMS delivery report""" + context.response = context.sinch.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + status=['DELIVERED', 'FAILED'], + code=[15, 0] + ) + + +@then('the response contains a summary SMS delivery report') +def step_validate_summary_delivery_report(context): + """Validate summary SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.client_reference == 'reference_e2e' + assert data.statuses is not None + assert len(data.statuses) >= 2 + + status = data.statuses[0] + assert status.code == 15 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Failed' + + status = data.statuses[1] + assert status.code == 0 + assert status.count == 1 + assert status.recipients is None + assert status.status == 'Delivered' + + assert data.total_message_count == 2 + assert data.type == 'delivery_report_sms' + + +@when('I send a request to retrieve a full SMS delivery report') +def step_retrieve_full_delivery_report(context): + """Retrieve a full SMS delivery report""" + context.response = context.sinch.sms.delivery_reports.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + report_type='full' + ) + + +@then('the response contains a full SMS delivery report') +def step_validate_full_delivery_report(context): + """Validate full SMS delivery report response""" + data: BatchDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.statuses is not None + status = data.statuses[0] + assert status.recipients is not None + assert status.code == 0 + assert status.count == 1 + assert status.recipients[0] == '12017777777' + assert status.status == 'Delivered' + + +@when('I send a request to retrieve a recipient\'s delivery report') +def step_retrieve_recipient_delivery_report(context): + """Retrieve a recipient's delivery report""" + context.response = context.sinch.sms.delivery_reports.get_for_number( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + recipient='12017777777' + ) + + +@then('the response contains the recipient\'s delivery report details') +def step_validate_recipient_delivery_report(context): + """Validate recipient delivery report response""" + data: RecipientDeliveryReport = context.response + assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.recipient == '12017777777' + assert data.client_reference == 'reference_e2e' + assert data.status == 'Delivered' + assert data.type == 'recipient_delivery_report_sms' + assert data.code == 0 + assert data.at == datetime(2024, 6, 6, 13, 6, 27, 833000, tzinfo=timezone.utc) + assert data.operator_status_at == datetime(2024, 6, 6, 13, 6, 0, tzinfo=timezone.utc) + + +@when('I send a request to list the SMS delivery reports') +def step_list_delivery_reports(context): + """List a page of SMS delivery reports""" + context.response = context.sinch.sms.delivery_reports.list() + + +@then('the response contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_count(context, count): + """Validate the count of SMS delivery reports in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the SMS delivery reports') +def step_list_all_delivery_reports(context): + """List all SMS delivery reports using iterator""" + response = context.sinch.sms.delivery_reports.list(page_size=2) + delivery_reports_list = [] + + for delivery_report in response.iterator(): + delivery_reports_list.append(delivery_report) + + context.delivery_reports_list = delivery_reports_list + + +@then('the SMS delivery reports list contains "{count}" SMS delivery reports') +def step_validate_delivery_reports_list_count(context, count): + """Validate the count of SMS delivery reports in the full list""" + expected_count = int(count) + assert len(context.delivery_reports_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.delivery_reports_list)}' + + +@when('I iterate manually over the SMS delivery reports pages') +def step_iterate_manually_delivery_reports(context): + """Manually iterate over SMS delivery reports pages""" + context.list_response = context.sinch.sms.delivery_reports.list(page_size=2) + + # Iterate through all pages + context.delivery_reports_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.delivery_reports_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the SMS delivery reports iteration result contains the data from "{count}" pages') +def step_validate_delivery_reports_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py new file mode 100644 index 00000000..255845a7 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_batch_delivery_report_endpoint.py @@ -0,0 +1,114 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import GetBatchDeliveryReportEndpoint +from sinch.domains.sms.models.v1.internal import GetBatchDeliveryReportRequest +from sinch.domains.sms.models.v1.response import BatchDeliveryReport +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus + + +@pytest.fixture +def request_data(): + return GetBatchDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "test_client_ref", + "statuses": [ + { + "code": 400, + "count": 5, + "recipients": ["+1234567890", "+0987654321"], + "status": "DELIVERED", + }, + { + "code": 401, + "count": 2, + "recipients": ["+5555555555"], + "status": "FAILED", + }, + ], + "total_message_count": 7, + "type": "summary", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetBatchDeliveryReportEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_report" + ) + + +def test_build_query_params(endpoint): + query_params = endpoint.build_query_params() + expected_params = { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "type": "summary", + "status": "DELIVERED", + "code": "400", + "client_reference": "test_client_ref", + } + assert query_params == expected_params + + +def test_build_query_params_with_multiple_status_and_code(): + """Test that multiple status and code values are converted to comma-separated strings""" + request_data = GetBatchDeliveryReportRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH1", + status=["DELIVERED", "FAILED", "QUEUED"], + code=[400, 401, 402], + ) + endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + expected_params = { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH1", + "status": "DELIVERED,FAILED,QUEUED", + "code": "400,401,402", + } + assert query_params == expected_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, BatchDeliveryReport) + assert parsed_response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.client_reference == "test_client_ref" + assert parsed_response.total_message_count == 7 + assert parsed_response.type == "summary" + + assert len(parsed_response.statuses) == 2 + + first_status = parsed_response.statuses[0] + assert isinstance(first_status, MessageDeliveryStatus) + assert first_status.code == 400 + assert first_status.count == 5 + assert first_status.status == "DELIVERED" + assert first_status.recipients == ["+1234567890", "+0987654321"] + + second_status = parsed_response.statuses[1] + assert isinstance(second_status, MessageDeliveryStatus) + assert second_status.code == 401 + assert second_status.count == 2 + assert second_status.status == "FAILED" + assert second_status.recipients == ["+5555555555"] diff --git a/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py new file mode 100644 index 00000000..92544fa7 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/delivery_reports/test_get_recipient_delivery_report_endpoint.py @@ -0,0 +1,75 @@ +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ( + GetRecipientDeliveryReportEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, +) +from sinch.domains.sms.models.v1.response import RecipientDeliveryReport + + +@pytest.fixture +def request_data(): + return GetRecipientDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient_msisdn="+1234567890" + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "applied_originator": "+1234567890", + "at": "2025-01-15T10:30:45.123Z", + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "client_reference": "test_client_ref", + "code": 400, + "encoding": "GSM7", + "number_of_message_parts": 1, + "operator": "35000", + "operator_status_at": "2025-01-15T10:30:50.456Z", + "recipient": "+1234567890", + "status": "DELIVERED", + "type": "recipient_delivery_report_sms", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetRecipientDeliveryReportEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_report/+1234567890" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + assert isinstance(parsed_response, RecipientDeliveryReport) + assert parsed_response.applied_originator == "+1234567890" + assert parsed_response.at == ( + datetime(2025, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + ) + assert parsed_response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.client_reference == "test_client_ref" + assert parsed_response.code == 400 + assert parsed_response.encoding == "GSM7" + assert parsed_response.number_of_message_parts == 1 + assert parsed_response.operator == "35000" + assert parsed_response.operator_status_at == ( + datetime(2025, 1, 15, 10, 30, 50, 456000, tzinfo=timezone.utc) + ) + assert parsed_response.recipient == "+1234567890" + assert parsed_response.status == "DELIVERED" + assert parsed_response.type == "recipient_delivery_report_sms" diff --git a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py new file mode 100644 index 00000000..45ec3d3b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py @@ -0,0 +1,57 @@ +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +def test_model_dump_for_query_params_expects_simple_fields(): + """ + Test that simple fields are returned as-is. + """ + + class TestModel(BaseModelConfigurationRequest): + batch_id: str + status: str = None + + model = TestModel( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered" + ) + result = model_dump_for_query_params(model) + + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert result["status"] == "delivered" + + +def test_model_dump_for_query_params_expects_list_to_comma_separated_string(): + """ + Test that lists are converted to comma-separated strings. + """ + + class TestModel(BaseModelConfigurationRequest): + status: list[str] = None + code: list[int] = None + + model = TestModel(status=["Delivered", "Failed"], code=[15, 0]) + result = model_dump_for_query_params(model) + + assert result["status"] == "Delivered,Failed" + assert result["code"] == "15,0" + + +def test_model_dump_for_query_params_expects_empty_values_filtered(): + """ + Test that empty strings and empty lists are filtered out. + """ + + class TestModel(BaseModelConfigurationRequest): + batch_id: str + status: str = "" + code: list[int] = [] + + model = TestModel(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[]) + result = model_dump_for_query_params(model) + + assert "batch_id" in result + assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + assert "status" not in result + assert "code" not in result diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py index b8cac734..1c6e3503 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_get_batch_delivery_report_request_model.py @@ -6,10 +6,16 @@ @pytest.mark.parametrize( "batch_id, report_type, status, code, expected_report_type", [ - ("batch123", "summary", ["Queued", "Dispatched"], [400, 401], "summary"), + ( + "batch123", + "summary", + ["Queued", "Dispatched"], + [400, 401], + "summary", + ), ("batch456", "full", ["Dispatched", "Delivered"], [401, 400], "full"), ("batch789", None, ["Failed", "Cancelled"], [402, 403], None), - ] + ], ) def test_get_batch_delivery_report_request_expects_valid_input( batch_id, report_type, status, code, expected_report_type @@ -21,14 +27,14 @@ def test_get_batch_delivery_report_request_expects_valid_input( "batch_id": batch_id, "type": report_type, "status": status, - "code": code + "code": code, } - + # Remove None values data = {k: v for k, v in data.items() if v is not None} - + request = GetBatchDeliveryReportRequest(**data) - + assert request.batch_id == batch_id assert request.type == expected_report_type assert request.status == status @@ -41,11 +47,11 @@ def test_get_batch_delivery_report_request_expects_status_list(): """ data = { "batch_id": "batch123", - "status": ["QUEUED", "DELIVERED", "FAILED"] + "status": ["QUEUED", "DELIVERED", "FAILED"], } - + request = GetBatchDeliveryReportRequest(**data) - + assert request.batch_id == "batch123" assert request.status == ["QUEUED", "DELIVERED", "FAILED"] assert request.type is None @@ -56,13 +62,10 @@ def test_get_batch_delivery_report_request_expects_code_list(): """ Test that the model correctly handles code list input. """ - data = { - "batch_id": "batch123", - "code": [400, 401, 402] - } - + data = {"batch_id": "batch123", "code": [400, 401, 402]} + request = GetBatchDeliveryReportRequest(**data) - + assert request.batch_id == "batch123" assert request.code == [400, 401, 402] assert request.type is None @@ -73,13 +76,11 @@ def test_get_batch_delivery_report_request_expects_validation_error_for_missing_ """ Test that missing required batch_id field raises a ValidationError. """ - data = { - "type": "summary" - } - + data = {"type": "summary"} + with pytest.raises(ValidationError) as exc_info: GetBatchDeliveryReportRequest(**data) - + assert "batch_id" in str(exc_info.value) @@ -89,21 +90,15 @@ def test_get_batch_delivery_report_request_expects_delivery_report_type_validati """ # Test with valid enum values valid_types = ["summary", "full"] - + for report_type in valid_types: - data = { - "batch_id": "batch123", - "type": report_type - } - + data = {"batch_id": "batch123", "type": report_type} + request = GetBatchDeliveryReportRequest(**data) assert request.type == report_type assert request.batch_id == "batch123" - data = { - "batch_id": "batch123", - "type": "custom_type" - } - + data = {"batch_id": "batch123", "type": "custom_type"} + request = GetBatchDeliveryReportRequest(**data) assert request.type == "custom_type" diff --git a/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py index 4291f26b..2dc2b720 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_get_recipient_delivery_report_request_model.py @@ -1,6 +1,8 @@ import pytest from pydantic import ValidationError -from sinch.domains.sms.models.v1.internal import GetRecipientDeliveryReportRequest +from sinch.domains.sms.models.v1.internal import ( + GetRecipientDeliveryReportRequest, +) @pytest.mark.parametrize( @@ -9,16 +11,15 @@ ("01FC66621XXXXX119Z8PMV1QPQ", "+44231235674"), ("batch123", "+15551234567"), ("test-batch-456", "+1234567890"), - ] + ], ) -def test_get_recipient_delivery_report_request_expects_valid_inputs(batch_id, recipient_msisdn): +def test_get_recipient_delivery_report_request_expects_valid_inputs( + batch_id, recipient_msisdn +): """ Test that the model correctly parses valid inputs. """ - data = { - "batch_id": batch_id, - "recipient_msisdn": recipient_msisdn - } + data = {"batch_id": batch_id, "recipient_msisdn": recipient_msisdn} request = GetRecipientDeliveryReportRequest(**data) @@ -30,13 +31,11 @@ def test_get_recipient_delivery_report_request_expects_validation_error_for_miss """ Test that missing batch_id raises a ValidationError. """ - data = { - "recipient_msisdn": "+44231235674" - } - + data = {"recipient_msisdn": "+44231235674"} + with pytest.raises(ValidationError) as exc_info: - GetRecipientDeliveryReportRequest(**data) - + GetRecipientDeliveryReportRequest(**data) + assert "batch_id" in str(exc_info.value) @@ -44,13 +43,11 @@ def test_get_recipient_delivery_report_request_expects_validation_error_for_miss """ Test that missing recipient_msisdn raises a ValidationError. """ - data = { - "batch_id": "01FC66621XXXXX119Z8PMV1QPQ" - } - + data = {"batch_id": "01FC66621XXXXX119Z8PMV1QPQ"} + with pytest.raises(ValidationError) as exc_info: - GetRecipientDeliveryReportRequest(**data) - + GetRecipientDeliveryReportRequest(**data) + assert "recipient_msisdn" in str(exc_info.value) @@ -61,7 +58,7 @@ def test_get_recipient_delivery_report_request_with_additional_kwargs(): data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", "recipient_msisdn": "+44231235674", - "extra_field": "extra_value" + "extra_field": "extra_value", } request = GetRecipientDeliveryReportRequest(**data) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py index e38afdc1..a1f8b144 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py @@ -20,7 +20,7 @@ def test_list_delivery_reports_request_expects_parsed_input(): """Test that the model correctly parses input with all parameters.""" start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) - + model = ListDeliveryReportsRequest( page=1, page_size=50, @@ -45,9 +45,11 @@ def test_list_delivery_reports_request_expects_parsed_input(): [ (-1, ValidationError), (-10, ValidationError), - ] + ], ) -def test_list_delivery_reports_request_expects_validation_error_for_invalid_page(page, expected_error): +def test_list_delivery_reports_request_expects_validation_error_for_invalid_page( + page, expected_error +): """Test that invalid page values raise ValidationError.""" with pytest.raises(expected_error): ListDeliveryReportsRequest(page=page) @@ -59,9 +61,11 @@ def test_list_delivery_reports_request_expects_validation_error_for_invalid_page (0, ValidationError), (101, ValidationError), (-1, ValidationError), - ] + ], ) -def test_list_delivery_reports_request_expects_validation_error_for_invalid_page_size(page_size, expected_error): +def test_list_delivery_reports_request_expects_validation_error_for_invalid_page_size( + page_size, expected_error +): """Test that invalid page_size values raise ValidationError.""" with pytest.raises(expected_error): ListDeliveryReportsRequest(page_size=page_size) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py index d2154aff..bdea44e5 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_response_model.py @@ -32,7 +32,13 @@ def test_data(): } -def assert_delivery_report_fields(delivery_report, expected_batch_id, expected_recipient, expected_code, expected_status): +def assert_delivery_report_fields( + delivery_report, + expected_batch_id, + expected_recipient, + expected_code, + expected_status, +): """Helper function to assert delivery report fields.""" assert delivery_report.batch_id == expected_batch_id assert delivery_report.recipient == expected_recipient @@ -43,7 +49,9 @@ def assert_delivery_report_fields(delivery_report, expected_batch_id, expected_r def test_list_delivery_reports_response_empty_content_expects_empty_list(): """Test that empty delivery reports list returns empty content.""" - model = ListDeliveryReportsResponse(count=0, page=0, page_size=30, delivery_reports=None) + model = ListDeliveryReportsResponse( + count=0, page=0, page_size=30, delivery_reports=None + ) assert model.count == 0 assert model.page == 0 assert model.page_size == 30 @@ -57,43 +65,49 @@ def test_list_delivery_reports_response_expects_correct_mapping(test_data): response = ListDeliveryReportsResponse(**test_data) assert hasattr(response, "content") assert response.content == response.delivery_reports - + # Test top-level fields assert response.count == 2 assert response.page == 0 assert response.page_size == 2 - + # Test content property content = response.content assert isinstance(content, list) assert len(content) == 2 - + # Test first delivery report first_report = content[0] - expected_first_at = datetime(2025, 1, 19, 16, 45, 31, 935000, tzinfo=timezone.utc) - expected_first_operator_at = datetime(2025, 1, 19, 16, 45, 0, tzinfo=timezone.utc) + expected_first_at = datetime( + 2025, 1, 19, 16, 45, 31, 935000, tzinfo=timezone.utc + ) + expected_first_operator_at = datetime( + 2025, 1, 19, 16, 45, 0, tzinfo=timezone.utc + ) assert first_report.at == expected_first_at assert first_report.operator_status_at == expected_first_operator_at assert_delivery_report_fields( - first_report, - "01K7YNS82JMYGAKAATHFP0QTB5", - "34683607594", - 401, - "Delivered" + first_report, + "01K7YNS82JMYGAKAATHFP0QTB5", + "34683607594", + 401, + "Delivered", ) - + # Test second delivery report second_report = content[1] - expected_second_at = datetime(2025, 1, 19, 16, 40, 26, 855000, tzinfo=timezone.utc) - expected_second_operator_at = datetime(2025, 1, 19, 16, 40, 0, tzinfo=timezone.utc) + expected_second_at = datetime( + 2025, 1, 19, 16, 40, 26, 855000, tzinfo=timezone.utc + ) + expected_second_operator_at = datetime( + 2025, 1, 19, 16, 40, 0, tzinfo=timezone.utc + ) assert second_report.at == expected_second_at assert second_report.operator_status_at == expected_second_operator_at assert_delivery_report_fields( - second_report, - "01K7YNFY30DS2KKVQZVBFANHMR", - "34683607595", - 402, - "Dispatched" + second_report, + "01K7YNFY30DS2KKVQZVBFANHMR", + "34683607595", + 402, + "Dispatched", ) - - diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py index fdd4338d..8a9503c4 100644 --- a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py +++ b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py @@ -1,6 +1,9 @@ import pytest from pydantic import ValidationError -from sinch.domains.sms.models.v1.response.batch_delivery_report import BatchDeliveryReport +from sinch.domains.sms.models.v1.response.batch_delivery_report import ( + BatchDeliveryReport, +) + @pytest.fixture def sample_message_delivery_status(): @@ -11,7 +14,7 @@ def sample_message_delivery_status(): "code": 401, "count": 1, "recipients": ["+1234567890"], - "status": "Dispatched" + "status": "Dispatched", } @@ -25,22 +28,24 @@ def sample_batch_delivery_report_data(sample_message_delivery_status): "client_reference": "my_client_reference", "statuses": [sample_message_delivery_status], "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } -def test_batch_delivery_report_expects_valid_input(sample_batch_delivery_report_data): +def test_batch_delivery_report_expects_valid_input( + sample_batch_delivery_report_data, +): """ Test that the model correctly parses valid input. """ report = BatchDeliveryReport(**sample_batch_delivery_report_data) - + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" assert report.client_reference == "my_client_reference" assert report.total_message_count == 1 assert report.type == "delivery_report_sms" assert len(report.statuses) == 1 - + status = report.statuses[0] assert status.code == 401 assert status.count == 1 @@ -54,18 +59,20 @@ def test_batch_delivery_report_expects_without_client_reference(): """ data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{ - "code": 401, - "count": 1, - "recipients": ["+44231235674"], - "status": "Dispatched" - }], + "statuses": [ + { + "code": 401, + "count": 1, + "recipients": ["+44231235674"], + "status": "Dispatched", + } + ], "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + report = BatchDeliveryReport(**data) - + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" assert report.client_reference is None assert report.total_message_count == 1 @@ -83,29 +90,29 @@ def test_batch_delivery_report_expects_with_multiple_statuses(): "code": 401, "count": 1, "recipients": ["+44231235674"], - "status": "Dispatched" + "status": "Dispatched", }, { "code": 0, "count": 1, "recipients": ["+44231235675"], - "status": "Delivered" - } + "status": "Delivered", + }, ], "total_message_count": 2, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + report = BatchDeliveryReport(**data) - + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" assert report.total_message_count == 2 assert len(report.statuses) == 2 - + # Check first status assert report.statuses[0].code == 401 assert report.statuses[0].status == "Dispatched" - + # Check second status assert report.statuses[1].code == 0 assert report.statuses[1].status == "Delivered" @@ -117,21 +124,17 @@ def test_batch_delivery_report_expects_no_recipients(): """ data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{ - "code": 401, - "count": 1, - "status": "Dispatched" - }], + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + report = BatchDeliveryReport(**data) - + assert report.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" assert report.total_message_count == 1 assert len(report.statuses) == 1 - + status = report.statuses[0] assert status.code == 401 assert status.count == 1 @@ -144,18 +147,14 @@ def test_batch_delivery_report_expects_validation_error_for_missing_batch_id(): Test that missing required batch_id field raises a ValidationError. """ data = { - "statuses": [{ - "code": 401, - "count": 1, - "status": "Dispatched" - }], + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: BatchDeliveryReport(**data) - + assert "batch_id" in str(exc_info.value) @@ -166,12 +165,12 @@ def test_batch_delivery_report_expects_validation_error_for_missing_statuses(): data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: BatchDeliveryReport(**data) - + assert "statuses" in str(exc_info.value) @@ -181,17 +180,13 @@ def test_batch_delivery_report_expects_validation_error_for_missing_total_messag """ data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{ - "code": 401, - "count": 1, - "status": "Dispatched" - }], - "type": "delivery_report_sms" + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "type": "delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: BatchDeliveryReport(**data) - + assert "total_message_count" in str(exc_info.value) @@ -201,17 +196,13 @@ def test_batch_delivery_report_expects_validation_error_for_missing_type(): """ data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{ - "code": 401, - "count": 1, - "status": "Dispatched" - }], - "total_message_count": 1 + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], + "total_message_count": 1, } - + with pytest.raises(ValidationError) as exc_info: BatchDeliveryReport(**data) - + assert "type" in str(exc_info.value) @@ -221,18 +212,14 @@ def test_batch_delivery_report_expects_validation_error_for_negative_total_messa """ data = { "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{ - "code": 401, - "count": 1, - "status": "Dispatched" - }], + "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], "total_message_count": -1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: BatchDeliveryReport(**data) - + assert "total_message_count" in str(exc_info.value) @@ -244,7 +231,7 @@ def test_batch_delivery_report_expects_empty_statuses(): "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", "statuses": [], "total_message_count": 1, - "type": "delivery_report_sms" + "type": "delivery_report_sms", } report = BatchDeliveryReport(**data) diff --git a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py index 1eb1b826..130d157d 100644 --- a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py +++ b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py @@ -1,7 +1,9 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.sms.models.v1.response.recipient_delivery_report import RecipientDeliveryReport +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) from tests.conftest import parse_iso_datetime @@ -16,7 +18,7 @@ def sample_recipient_delivery_report_data(): "code": 401, "recipient": "+44231235674", "status": "Dispatched", - "type": "recipient_delivery_report_sms" + "type": "recipient_delivery_report_sms", } @@ -27,9 +29,11 @@ def sample_recipient_delivery_report_data(): ("Failed", 402, "recipient_delivery_report_sms"), ("Queued", 400, "recipient_delivery_report_mms"), ("Dispatched", 401, "recipient_delivery_report_mms"), - ] + ], ) -def test_recipient_delivery_report_expects_valid_inputs(status, code, report_type): +def test_recipient_delivery_report_expects_valid_inputs( + status, code, report_type +): """ Test that the model correctly parses valid inputs with different statuses and codes. """ @@ -39,7 +43,7 @@ def test_recipient_delivery_report_expects_valid_inputs(status, code, report_typ "code": code, "recipient": "+44231235674", "status": status, - "type": report_type + "type": report_type, } report = RecipientDeliveryReport(**data) @@ -52,19 +56,23 @@ def test_recipient_delivery_report_expects_valid_inputs(status, code, report_typ assert report.type == report_type -def test_recipient_delivery_report_expects_with_optional_fields(sample_recipient_delivery_report_data): +def test_recipient_delivery_report_expects_with_optional_fields( + sample_recipient_delivery_report_data, +): """ Test that the model works with all optional fields provided. """ data = sample_recipient_delivery_report_data.copy() - data.update({ - "applied_originator": "My Originator", - "client_reference": "my_client_reference", - "encoding": "GSM", - "number_of_message_parts": 1, - "operator": "35000", - "operator_status_at": parse_iso_datetime("2019-08-24T14:15:22Z") - }) + data.update( + { + "applied_originator": "My Originator", + "client_reference": "my_client_reference", + "encoding": "GSM", + "number_of_message_parts": 1, + "operator": "35000", + "operator_status_at": parse_iso_datetime("2019-08-24T14:15:22Z"), + } + ) report = RecipientDeliveryReport(**data) @@ -73,10 +81,14 @@ def test_recipient_delivery_report_expects_with_optional_fields(sample_recipient assert report.encoding == "GSM" assert report.number_of_message_parts == 1 assert report.operator == "35000" - assert report.operator_status_at == parse_iso_datetime("2019-08-24T14:15:22Z") + assert report.operator_status_at == parse_iso_datetime( + "2019-08-24T14:15:22Z" + ) -def test_recipient_delivery_report_expects_without_optional_fields(sample_recipient_delivery_report_data): +def test_recipient_delivery_report_expects_without_optional_fields( + sample_recipient_delivery_report_data, +): """ Test that the model works without optional fields. """ @@ -99,12 +111,12 @@ def test_recipient_delivery_report_expects_validation_error_for_missing_at(): "code": 401, "recipient": "+44231235674", "status": "Dispatched", - "type": "recipient_delivery_report_sms" + "type": "recipient_delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: RecipientDeliveryReport(**data) - + assert "at" in str(exc_info.value) @@ -117,12 +129,12 @@ def test_recipient_delivery_report_expects_validation_error_for_missing_batch_id "code": 401, "recipient": "+44231235674", "status": "Dispatched", - "type": "recipient_delivery_report_sms" + "type": "recipient_delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: RecipientDeliveryReport(**data) - + assert "batch_id" in str(exc_info.value) @@ -136,12 +148,12 @@ def test_recipient_delivery_report_expects_invalid_datetime_format(): "code": 401, "recipient": "+44231235674", "status": "Dispatched", - "type": "recipient_delivery_report_sms" + "type": "recipient_delivery_report_sms", } - + with pytest.raises(ValidationError) as exc_info: RecipientDeliveryReport(**data) - + assert "at" in str(exc_info.value) @@ -156,7 +168,7 @@ def test_recipient_delivery_report_expects_custom_encoding(): "recipient": "+44231235674", "status": "Dispatched", "type": "recipient_delivery_report_sms", - "encoding": "CUSTOM_ENCODING" + "encoding": "CUSTOM_ENCODING", } report = RecipientDeliveryReport(**data) @@ -173,7 +185,7 @@ def test_recipient_delivery_report_expects_custom_status(): "code": 401, "recipient": "+44231235674", "status": "CUSTOM_STATUS", - "type": "recipient_delivery_report_sms" + "type": "recipient_delivery_report_sms", } report = RecipientDeliveryReport(**data) @@ -190,7 +202,7 @@ def test_recipient_delivery_report_expects_custom_type(): "code": 401, "recipient": "+44231235674", "status": "Dispatched", - "type": "custom_delivery_report_type" + "type": "custom_delivery_report_type", } report = RecipientDeliveryReport(**data) diff --git a/tests/unit/domains/sms/v1/test_delivery_reports.py b/tests/unit/domains/sms/v1/test_delivery_reports.py new file mode 100644 index 00000000..eaab6bb9 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_delivery_reports.py @@ -0,0 +1,218 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1 import DeliveryReports +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal import ( + GetBatchDeliveryReportEndpoint, + GetRecipientDeliveryReportEndpoint, + ListDeliveryReportsEndpoint, +) +from sinch.domains.sms.models.v1.internal import ( + GetBatchDeliveryReportRequest, + GetRecipientDeliveryReportRequest, + ListDeliveryReportsRequest, + ListDeliveryReportsResponse, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus + + +def test_get_batch_delivery_report_expects_valid_request( + mock_sinch_client_sms, mocker +): + """ + Test that the DeliveryReports.get() method sends the correct request + and handles the response properly. + """ + mock_response = BatchDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + statuses=[ + MessageDeliveryStatus(code=400, count=1, status="DELIVERED") + ], + total_message_count=1, + type="summary", + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + # Spy on the GetBatchDeliveryReportEndpoint to capture calls + spy_endpoint = mocker.spy(GetBatchDeliveryReportEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + report_type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == GetBatchDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + type="summary", + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + assert isinstance(response, BatchDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_get_for_number_expects_correct_request(mock_sinch_client_sms, mocker): + """ + Test that the DeliveryReports.get_for_number() method sends the correct request + and handles the response properly. + """ + + mock_response = RecipientDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipient="+1234567890", + code=400, + status="DELIVERED", + type="recipient_delivery_report_sms", + at=datetime(2025, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + spy_endpoint = mocker.spy(GetRecipientDeliveryReportEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get_for_number( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient="+1234567890" + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == GetRecipientDeliveryReportRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", recipient_msisdn="+1234567890" + ) + + assert isinstance(response, RecipientDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert response.recipient == "+1234567890" + + +def test_list_delivery_reports_expects_valid_request( + mock_sinch_client_sms, mocker +): + """ + Test that the DeliveryReports.list() method sends the correct request + and handles the response properly. + """ + + mock_response = ListDeliveryReportsResponse( + page=0, page_size=2, count=1, delivery_reports=[] + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_response + ) + + spy_endpoint = mocker.spy(ListDeliveryReportsEndpoint, "__init__") + + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.list( + page=0, + page_size=2, + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListDeliveryReportsRequest( + page=0, + page_size=2, + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + status=["DELIVERED"], + code=[400], + client_reference="test_client_ref", + ) + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_sms_endpoint_handle_response_raises_exception_on_error(mock_sinch_client_sms): + """ + Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. + """ + + request_data = GetBatchDeliveryReportRequest( + batch_id="test_batch_id", + type="summary" + ) + endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) + + error_response = HTTPResponse( + status_code=400, + body=1, + headers={} + ) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 400" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 400 + + +def test_delivery_reports_expects_validation_recalculates_auth_method_when_credentials_change(mock_sinch_client_sms): + """ + Test that SMS requests validate authentication and recalculate auth method + when credentials change after initialization. + """ + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + mock_response = BatchDeliveryReport( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + statuses=[ + MessageDeliveryStatus(code=400, count=1, status="DELIVERED") + ], + total_message_count=1, + type="summary", + ) + config.transport.request.return_value = mock_response + + # Change credentials to SMS auth (add sms_api_token, service_plan_id already exists in fixture) + config.sms_api_token = "test_sms_token" + + # Auth method should still be project_auth (not updated automatically) + assert config.authentication_method == "project_auth" + + # Make an SMS request. This should trigger validation and recalculate auth method + delivery_reports = DeliveryReports(mock_sinch_client_sms) + response = delivery_reports.get( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + report_type="summary" + ) + + assert config.authentication_method == "sms_auth" + assert isinstance(response, BatchDeliveryReport) + assert response.batch_id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 76b86c72..1ecefe03 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,3 +1,4 @@ +import pytest from sinch import SinchClient from sinch.core.clients.sinch_client_configuration import Configuration @@ -12,13 +13,29 @@ def test_sinch_client_initialization(): assert sinch_client -def test_sinch_client_expects_all_attributes(sinch_client_sync): +def test_sinch_client_expects_to_be_initialized_with_sms(): + """ Test that SinchClient can be initialized with sms_region, service_plan_id and sms_api_token """ + sinch_client = SinchClient( + sms_region="us", + service_plan_id="test_service_plan", + sms_api_token="test_sms_token" + ) + assert sinch_client + + +def test_sinch_client_expects_all_attributes(): """ Test that SinchClient has all attributes""" - assert hasattr(sinch_client_sync, "authentication") - assert hasattr(sinch_client_sync, "sms") - assert hasattr(sinch_client_sync, "conversation") - assert hasattr(sinch_client_sync, "numbers") - assert hasattr(sinch_client_sync, "verification") - assert hasattr(sinch_client_sync, "voice") - assert hasattr(sinch_client_sync, "configuration") - assert isinstance(sinch_client_sync.configuration, Configuration) + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id" + ) + + assert hasattr(sinch_client, "authentication") + assert hasattr(sinch_client, "sms") + assert hasattr(sinch_client, "conversation") + assert hasattr(sinch_client, "numbers") + assert hasattr(sinch_client, "verification") + assert hasattr(sinch_client, "voice") + assert hasattr(sinch_client, "configuration") + assert isinstance(sinch_client.configuration, Configuration) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index d6af9ff1..dc2935a2 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -1,4 +1,5 @@ from logging import Logger, getLogger +import pytest from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.adapters.requests_http_transport import HTTPTransportRequests from sinch.core.token_manager import TokenManager @@ -7,6 +8,8 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): """ Test that Configuration can be initialized with all parameters """ client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), key_id="CapyKey", key_secret="CapybaraWhisper", project_id="CapybaraProjectX", @@ -16,8 +19,7 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): application_secret="SecretHabitatEntry", service_plan_id="CappyPremiumPlan", sms_api_token="HappyCappyToken", - transport=HTTPTransportRequests(sinch_client_sync), - token_manager=TokenManager(sinch_client_sync) + sms_region="us" ) assert client_configuration.key_id == "CapyKey" @@ -28,6 +30,7 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): assert client_configuration.application_secret == "SecretHabitatEntry" assert client_configuration.service_plan_id == "CappyPremiumPlan" assert client_configuration.sms_api_token == "HappyCappyToken" + assert client_configuration.sms_region == "us" assert isinstance(client_configuration.transport, HTTPTransportRequests) assert isinstance(client_configuration.token_manager, TokenManager) @@ -37,7 +40,8 @@ def test_set_sms_region_property_and_check_that_sms_origin_was_updated(sinch_cli assert "https://zt.pl.sms.api.sinch.com" == sinch_client_sync.configuration.sms_origin -def test_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): +def test_configuration_expects_set_sms_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): + sinch_client_sync.configuration.sms_region = "us" sinch_client_sync.configuration.sms_domain = "{}.monty.python" assert "us.monty.python" == sinch_client_sync.configuration.sms_origin @@ -62,12 +66,12 @@ def test_set_conversation_domain_property_and_check_that_sms_origin_was_updated( def test_if_logger_name_was_preserved_correctly(sinch_client_sync): clever_monty_python_quote = "Its_just_a_flesh_wound" client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), key_id="Do", key_secret="a", project_id="Kickflip!", logger_name=clever_monty_python_quote, - transport=HTTPTransportRequests(sinch_client_sync), - token_manager=TokenManager(sinch_client_sync) ) client_configuration.logger.name = clever_monty_python_quote assert client_configuration.logger.name == clever_monty_python_quote @@ -83,3 +87,123 @@ def test_set_templates_domain_property_and_check_that_templates_origin_was_updat sinch_client_sync.configuration.templates_domain = "Are_you_suggesting_that_coconuts_migrate?" assert "coconuts" in sinch_client_sync.configuration.templates_origin assert "migrate" in sinch_client_sync.configuration.templates_origin + + +def test_configuration_expects_authentication_method_determination_sms_auth_priority(sinch_client_sync): + """ Test that SMS authentication takes priority over project authentication """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token", + project_id="test_project_id" + ) + + assert client_configuration.authentication_method == "sms_auth" + + +def test_configuration_expects_authentication_method_determination_project_auth_fallback(sinch_client_sync): + """ Test that project authentication is used when SMS auth parameters are not provided """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + key_id="test_key_id", + key_secret="test_key_secret" + ) + + assert client_configuration.authentication_method == "project_auth" + + +def test_configuration_expects_sms_authentication_method_setting_sms_auth(sinch_client_sync): + """ Test that SMS authentication method is set to SMS_TOKEN for SMS auth """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token" + ) + + assert client_configuration.authentication_method == "sms_auth" + + +def test_configuration_expects_authentication_method_determination_insufficient_parameters(sinch_client_sync): + """ Test that insufficient authentication parameters raise an error when validated """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync) + ) + + assert client_configuration.authentication_method is None + + with pytest.raises(ValueError, match="The project_id is required"): + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_authentication_method_determination_only_service_plan_id(sinch_client_sync): + """ Test that only service_plan_id without sms_api_token raises appropriate error """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan" + ) + + assert client_configuration.authentication_method is None + + with pytest.raises(ValueError, match="The sms_api_token is required when using service_plan_id"): + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_no_error_when_both_auth_methods_provided_with_complete_project_auth(sinch_client_sync): + """ + Test that when both service_plan_id and complete project auth are provided, + no error is raised even though sms_api_token is missing. + This ensures project auth takes precedence when fully configured. + """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", # Incomplete SMS auth + project_id="test_project_id", # Complete project auth + key_id="test_key_id", + key_secret="test_key_secret" + ) + + # Should use project_auth as the authentication method + assert client_configuration.authentication_method == "project_auth" + + # Should not raise an error because complete project auth is provided + client_configuration.validate_authentication_parameters() + + +def test_configuration_expects_sms_origin_for_auth_sms_authentication(sinch_client_sync): + """ Test that SMS authentication returns sms_origin_with_service_plan_id """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + service_plan_id="test_service_plan", + sms_api_token="test_sms_token", + sms_region="us" + ) + + expected_origin = client_configuration.sms_origin_with_service_plan_id + actual_origin = client_configuration.get_sms_origin_for_auth() + + assert actual_origin == expected_origin + assert actual_origin == "https://us.sms.api.sinch.com" + + +def test_configuration_expects_get_sms_origin_for_auth_project_authentication(sinch_client_sync): + """ Test that project authentication returns regular sms_origin """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + sms_region="eu" + ) + + expected_origin = client_configuration.sms_origin + actual_origin = client_configuration.get_sms_origin_for_auth() + + assert actual_origin == expected_origin + assert actual_origin == "https://zt.eu.sms.api.sinch.com" From 679df0193de2d1486cc9bc65d451f14130b2bf3a Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 12 Nov 2025 19:20:30 +0100 Subject: [PATCH 061/106] DEVEXP-786: SMS Batches - E2E tests (#91) --- .github/workflows/ci.yml | 5 +- sinch/domains/sms/__init__.py | 16 +- sinch/domains/sms/api/v1/__init__.py | 10 +- sinch/domains/sms/api/v1/base/base_sms.py | 22 +- sinch/domains/sms/api/v1/batches_apis.py | 538 ++++++++++++++++++ .../sms/api/v1/delivery_reports_apis.py | 9 +- sinch/domains/sms/api/v1/internal/__init__.py | 18 + .../sms/api/v1/internal/base/sms_endpoint.py | 19 +- .../sms/api/v1/internal/batches_endpoints.py | 261 +++++++++ .../sms/models/v1/internal/__init__.py | 45 ++ .../models/v1/internal/batch_id_request.py | 11 + .../v1/internal/delivery_feedback_request.py | 15 + .../sms/models/v1/internal/dry_run_request.py | 47 ++ .../v1/internal/list_batches_request.py | 17 + .../list_delivery_reports_response.py | 4 +- .../v1/internal/replace_batch_request.py | 34 ++ .../models/v1/internal/send_sms_request.py | 15 + .../internal/update_batch_message_request.py | 36 ++ .../v1/internal/update_binary_request.py | 68 +++ .../v1/internal/update_media_request.py | 61 ++ .../models/v1/internal/update_text_request.py | 80 +++ .../sms/models/v1/response/__init__.py | 8 + .../models/v1/response/dry_run_response.py | 17 + .../v1/response/list_batches_response.py | 29 + .../v1/response/recipient_delivery_report.py | 8 +- .../domains/sms/models/v1/shared/__init__.py | 18 + .../sms/models/v1/shared/batch_id_mixin.py | 10 + .../sms/models/v1/shared/binary_request.py | 66 +++ .../sms/models/v1/shared/binary_response.py | 79 +++ .../shared/dry_run_per_recipient_details.py | 15 + .../sms/models/v1/shared/media_body.py | 20 + .../sms/models/v1/shared/media_request.py | 59 ++ .../sms/models/v1/shared/media_response.py | 74 +++ .../sms/models/v1/shared/text_request.py | 78 +++ .../sms/models/v1/shared/text_response.py | 91 +++ sinch/domains/sms/models/v1/types/__init__.py | 13 + .../sms/models/v1/types/batch_response.py | 11 + .../models/v1/types/delivery_report_type.py | 5 +- sinch/domains/sms/sms.py | 136 +++++ .../features/steps/delivery_reports.steps.py | 166 ------ tests/e2e/sms/features/steps/batches.steps.py | 364 ++++++++++++ .../response/test_batch_response_model.py | 166 ++++++ 42 files changed, 2555 insertions(+), 209 deletions(-) create mode 100644 sinch/domains/sms/api/v1/batches_apis.py create mode 100644 sinch/domains/sms/api/v1/internal/batches_endpoints.py create mode 100644 sinch/domains/sms/models/v1/internal/batch_id_request.py create mode 100644 sinch/domains/sms/models/v1/internal/delivery_feedback_request.py create mode 100644 sinch/domains/sms/models/v1/internal/dry_run_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_batches_request.py create mode 100644 sinch/domains/sms/models/v1/internal/replace_batch_request.py create mode 100644 sinch/domains/sms/models/v1/internal/send_sms_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_batch_message_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_binary_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_media_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_text_request.py create mode 100644 sinch/domains/sms/models/v1/response/dry_run_response.py create mode 100644 sinch/domains/sms/models/v1/response/list_batches_response.py create mode 100644 sinch/domains/sms/models/v1/shared/batch_id_mixin.py create mode 100644 sinch/domains/sms/models/v1/shared/binary_request.py create mode 100644 sinch/domains/sms/models/v1/shared/binary_response.py create mode 100644 sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py create mode 100644 sinch/domains/sms/models/v1/shared/media_body.py create mode 100644 sinch/domains/sms/models/v1/shared/media_request.py create mode 100644 sinch/domains/sms/models/v1/shared/media_response.py create mode 100644 sinch/domains/sms/models/v1/shared/text_request.py create mode 100644 sinch/domains/sms/models/v1/shared/text_response.py create mode 100644 sinch/domains/sms/models/v1/types/batch_response.py create mode 100644 sinch/domains/sms/sms.py delete mode 100644 tests/e2e/numbers/features/steps/delivery_reports.steps.py create mode 100644 tests/e2e/sms/features/steps/batches.steps.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_batch_response_model.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db41ce66..9222ee91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,8 @@ jobs: cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh @@ -85,4 +87,5 @@ jobs: - name: Run e2e tests sync run: | - behave tests/e2e/**/features + python -m behave tests/e2e/numbers/features + python -m behave tests/e2e/sms/features diff --git a/sinch/domains/sms/__init__.py b/sinch/domains/sms/__init__.py index 7bd3068b..120e6d28 100644 --- a/sinch/domains/sms/__init__.py +++ b/sinch/domains/sms/__init__.py @@ -1,15 +1,3 @@ -from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports -# from sinch.domains.sms.api.v1.groups_apis import Groups -# from sinch.domains.sms.api.v1.inbounds_apis import Inbounds -# from sinch.domains.sms.api.v1.webhooks_apis import Webhooks -# from sinch.domains.sms.api.v1.batches_apis import Batches +from sinch.domains.sms.sms import SMS - -class SMS: - def __init__(self, sinch): - self._sinch = sinch - self.delivery_reports = DeliveryReports(sinch) - # self.groups = Groups(sinch) - # self.inbounds = Inbounds(sinch) - # self.webhooks = Webhooks(sinch) - # self.batches = Batches(sinch) +__all__ = ["SMS"] diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index b77f5c3e..db903927 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,13 +1,7 @@ +from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports -# from sinch.domains.sms.api.v1.groups_apis import Groups -# from sinch.domains.sms.api.v1.inbounds_apis import Inbounds -# from sinch.domains.sms.api.v1.webhooks_apis import Webhooks -# from sinch.domains.sms.api.v1.batches_apis import Batches __all__ = [ + "Batches", "DeliveryReports", - # "Groups", - # "Inbounds", - # "Webhooks", - # "Batches", ] diff --git a/sinch/domains/sms/api/v1/base/base_sms.py b/sinch/domains/sms/api/v1/base/base_sms.py index a4898aa8..a1b01b46 100644 --- a/sinch/domains/sms/api/v1/base/base_sms.py +++ b/sinch/domains/sms/api/v1/base/base_sms.py @@ -4,6 +4,20 @@ class BaseSms: def __init__(self, sinch): self._sinch = sinch + def _get_path_identifier(self) -> str: + """ + Returns the appropriate path identifier based on authentication method. + - SMS auth: returns service_plan_id + - Project auth: returns project_id + + Returns: + str: The path identifier to use for the endpoint. + """ + if self._sinch.configuration.authentication_method == "sms_auth": + return self._sinch.configuration.service_plan_id + else: + return self._sinch.configuration.project_id + def _request(self, endpoint_class, request_data): """ A helper method to make requests to endpoints. @@ -17,14 +31,8 @@ def _request(self, endpoint_class, request_data): """ self._sinch.configuration.validate_authentication_parameters() - # Use service_plan_id for SMS auth, project_id for project auth - if self._sinch.configuration.authentication_method == "sms_auth": - path_identifier = self._sinch.configuration.service_plan_id - else: - path_identifier = self._sinch.configuration.project_id - endpoint = endpoint_class( - project_id=path_identifier, + project_id=self._get_path_identifier(), request_data=request_data, ) diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py new file mode 100644 index 00000000..5ea7a075 --- /dev/null +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -0,0 +1,538 @@ +from datetime import datetime +from typing import Optional, List, Dict +from pydantic import TypeAdapter, BaseModel +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.internal import ( + BatchIdRequest, + DeliveryFeedbackRequest, + DryRunRequest, + ListBatchesRequest, + ReplaceBatchRequest, + SendSMSRequest, + UpdateBatchMessageRequest, +) +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.api.v1.internal import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) +from sinch.domains.sms.api.v1.base import BaseSms +from sinch.domains.sms.models.v1.types import BatchResponse + + +class Batches(BaseSms): + def cancel(self, batch_id: str, **kwargs) -> BatchResponse: + request_data = BatchIdRequest(batch_id=batch_id, **kwargs) + return self._request(CancelBatchMessageEndpoint, request_data) + + def _dry_run( + self, + request: Optional[DryRunRequest] = None, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + # DryRunRequest is a Union type, so we need to use TypeAdapter to validate + adapter = TypeAdapter(DryRunRequest) + + # Check if we have any overrides (kwargs or explicit per_recipient/number_of_recipients) + has_overrides = ( + bool(kwargs) + or per_recipient is not None + or number_of_recipients is not None + ) + + if ( + request is not None + and isinstance(request, BaseModel) + and not has_overrides + ): + request_data = request + else: + # Build input data from all sources and merge overrides + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + + # Merge overrides: kwargs, per_recipient, number_of_recipients + input_data.update(kwargs) + if per_recipient is not None: + input_data["per_recipient"] = per_recipient + if number_of_recipients is not None: + input_data["number_of_recipients"] = number_of_recipients + + request_data = adapter.validate_python(input_data) + + return self._request(DryRunEndpoint, request_data) + + def dry_run_sms( + self, + to: List[str], + from_: str, + body: str, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + """ + Perform a dry run for a text SMS batch. + """ + request = DryRunTextRequest( + to=to, + from_=from_, + body=body, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + parameters=parameters, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self._dry_run(request=request) + + def dry_run_binary( + self, + to: List[str], + from_: str, + body: str, + udh: str, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> DryRunResponse: + """ + Perform a dry run for a binary SMS batch. + """ + request = DryRunBinaryRequest( + to=to, + from_=from_, + body=body, + udh=udh, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self._dry_run(request=request) + + def dry_run_mms( + self, + to: List[str], + from_: str, + body: MediaBody, + per_recipient: Optional[bool] = None, + number_of_recipients: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + **kwargs, + ) -> DryRunResponse: + request = DryRunMediaRequest( + to=to, + from_=from_, + body=body, + per_recipient=per_recipient, + number_of_recipients=number_of_recipients, + parameters=parameters, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + **kwargs, + ) + return self._dry_run(request=request) + + def get(self, batch_id: str, **kwargs) -> BatchResponse: + request_data = BatchIdRequest(batch_id=batch_id, **kwargs) + return self._request(GetBatchMessageEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + from_: Optional[List[str]] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[BatchResponse]: + endpoint = ListBatchesEndpoint( + project_id=self._get_path_identifier(), + request_data=ListBatchesRequest( + page=page, + page_size=page_size, + start_date=start_date, + end_date=end_date, + from_=from_, + client_reference=client_reference, + **kwargs, + ), + ) + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) + + def _replace( + self, + batch_id: str, + request: Optional[ReplaceBatchRequest] = None, + **kwargs, + ) -> BatchResponse: + adapter = TypeAdapter(ReplaceBatchRequest) + + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + + input_data.update(kwargs) + input_data["batch_id"] = batch_id + + request_data = adapter.validate_python(input_data) + + return self._request(ReplaceBatchEndpoint, request_data) + + def replace_sms( + self, + batch_id: str, + to: List[str], + from_: str, + body: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + request = ReplaceTextRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + parameters=parameters, + **kwargs, + ) + return self._replace(batch_id=batch_id, request=request) + + def replace_binary( + self, + batch_id: str, + to: List[str], + from_: str, + body: str, + udh: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + request = ReplaceBinaryRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + udh=udh, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self._replace(batch_id=batch_id, request=request) + + def replace_mms( + self, + batch_id: str, + to: List[str], + from_: str, + body: MediaBody, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + request = ReplaceMediaRequest( + batch_id=batch_id, + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + parameters=parameters, + **kwargs, + ) + return self._replace(batch_id=batch_id, request=request) + + def _send( + self, request: Optional[SendSMSRequest] = None, **kwargs + ) -> BatchResponse: + # SendSMSRequest is a Union type, so we need to use TypeAdapter to validate + adapter = TypeAdapter(SendSMSRequest) + + # If request is provided and is already a BaseModel instance, use it directly + # Otherwise, validate the input (either request dict or kwargs) + if request is not None and isinstance(request, BaseModel): + request_data = request + else: + # Validate either the request dict or kwargs + request_data = adapter.validate_python( + request if request is not None else kwargs + ) + + return self._request(SendSMSEndpoint, request_data) + + def send_delivery_feedback( + self, batch_id: str, recipients: List[str], **kwargs + ) -> None: + request_data = DeliveryFeedbackRequest( + batch_id=batch_id, recipients=recipients, **kwargs + ) + return self._request(DeliveryFeedbackEndpoint, request_data) + + def _update( + self, + batch_id: str, + request: Optional[UpdateBatchMessageRequest] = None, + **kwargs, + ) -> BatchResponse: + adapter = TypeAdapter(UpdateBatchMessageRequest) + + input_data = {} + if request is not None: + if isinstance(request, BaseModel): + input_data = request.model_dump(exclude_none=True) + elif isinstance(request, dict): + input_data = dict(request) + + input_data.update(kwargs) + input_data["batch_id"] = batch_id + + request_data = adapter.validate_python(input_data) + + return self._request(UpdateBatchMessageEndpoint, request_data) + + def update_sms( + self, + batch_id: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[str] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + flash_message: Optional[bool] = None, + **kwargs, + ) -> BatchResponse: + request = UpdateTextRequestWithBatchId( + batch_id=batch_id, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + parameters=parameters, + from_ton=from_ton, + from_npi=from_npi, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + flash_message=flash_message, + **kwargs, + ) + return self._update(batch_id=batch_id, request=request) + + def update_binary( + self, + batch_id: str, + udh: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[str] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + request = UpdateBinaryRequestWithBatchId( + batch_id=batch_id, + udh=udh, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self._update(batch_id=batch_id, request=request) + + def update_mms( + self, + batch_id: str, + from_: Optional[str] = None, + to_add: Optional[List[str]] = None, + to_remove: Optional[List[str]] = None, + body: Optional[MediaBody] = None, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + strict_validation: Optional[bool] = None, + **kwargs, + ) -> BatchResponse: + """ + Update an MMS batch. + """ + request = UpdateMediaRequestWithBatchId( + batch_id=batch_id, + from_=from_, + to_add=to_add, + to_remove=to_remove, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + parameters=parameters, + strict_validation=strict_validation, + **kwargs, + ) + return self._update(batch_id=batch_id, request=request) diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py index 455284b5..ba6b1b9d 100644 --- a/sinch/domains/sms/api/v1/delivery_reports_apis.py +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -62,14 +62,8 @@ def list( client_reference: Optional[str] = None, **kwargs, ) -> Paginator[RecipientDeliveryReport]: - # Use service_plan_id for SMS auth, project_id for project auth - if self._sinch.configuration.authentication_method == "sms_auth": - path_identifier = self._sinch.configuration.service_plan_id - else: - path_identifier = self._sinch.configuration.project_id - endpoint = ListDeliveryReportsEndpoint( - project_id=path_identifier, + project_id=self._get_path_identifier(), request_data=ListDeliveryReportsRequest( page=page, page_size=page_size, @@ -81,7 +75,6 @@ def list( **kwargs, ), ) - # Set the authentication method based on configuration endpoint.set_authentication_method(self._sinch) return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/__init__.py b/sinch/domains/sms/api/v1/internal/__init__.py index f8fbf9f2..1c3d0a24 100644 --- a/sinch/domains/sms/api/v1/internal/__init__.py +++ b/sinch/domains/sms/api/v1/internal/__init__.py @@ -1,3 +1,13 @@ +from sinch.domains.sms.api.v1.internal.batches_endpoints import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) from sinch.domains.sms.api.v1.internal.delivery_reports_endpoints import ( GetBatchDeliveryReportEndpoint, GetRecipientDeliveryReportEndpoint, @@ -6,6 +16,14 @@ __all__ = [ + "CancelBatchMessageEndpoint", + "DryRunEndpoint", + "GetBatchMessageEndpoint", + "ListBatchesEndpoint", + "ReplaceBatchEndpoint", + "SendSMSEndpoint", + "DeliveryFeedbackEndpoint", + "UpdateBatchMessageEndpoint", "GetBatchDeliveryReportEndpoint", "GetRecipientDeliveryReportEndpoint", "ListDeliveryReportsEndpoint", diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py index 037e891d..569d1aac 100644 --- a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -1,5 +1,6 @@ from abc import ABC -from typing import Type +from typing import Annotated, Type, Union, get_origin, get_args +from pydantic import TypeAdapter from sinch.core.models.http_response import HTTPResponse from sinch.core.endpoint import HTTPEndpoint from sinch.core.types import BM @@ -61,12 +62,26 @@ def process_response_model( Args: response_body (dict): The raw response body. - response_model (type): The Pydantic model class to map the response. + response_model (type): The Pydantic model class or Union type to map the response. Returns: Parsed response object. """ try: + origin = get_origin(response_model) + # Check if response_model is an Annotated type (e.g., discriminated union) + if origin is Annotated: + args = get_args(response_model) + if args and get_origin(args[0]) is Union: + # Use TypeAdapter for Annotated Union types (discriminated unions) + adapter = TypeAdapter(response_model) + return adapter.validate_python(response_body) + # Check if response_model is a Union type + elif origin is Union: + # Use TypeAdapter for Union types + adapter = TypeAdapter(response_model) + return adapter.validate_python(response_body) + # Use standard model_validate for regular Pydantic models return response_model.model_validate(response_body) except Exception as e: raise ValueError(f"Invalid response structure: {e}") from e diff --git a/sinch/domains/sms/api/v1/internal/batches_endpoints.py b/sinch/domains/sms/api/v1/internal/batches_endpoints.py new file mode 100644 index 00000000..68ba919e --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/batches_endpoints.py @@ -0,0 +1,261 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.models.v1.internal import ( + BatchIdRequest, + DryRunRequest, + ListBatchesRequest, + ReplaceBatchRequest, + SendSMSRequest, + DeliveryFeedbackRequest, + UpdateBatchMessageRequest, +) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.api.v1.internal.base import SmsEndpoint +from sinch.domains.sms.api.v1.exceptions import SmsException + + +class CancelBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: BatchIdRequest): + super(CancelBatchMessageEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(CancelBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class DryRunEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/dry_run" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + # Define which fields are query parameters (not part of the request body) + QUERY_PARAM_FIELDS = {"per_recipient", "number_of_recipients"} + + def __init__(self, project_id: str, request_data: DryRunRequest): + super(DryRunEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + """Extract query parameters from request data.""" + # Extract only query param fields using include, and exclude None values + query_params = self.request_data.model_dump( + include=self.QUERY_PARAM_FIELDS, exclude_none=True, by_alias=True + ) + return query_params + + def request_body(self): + """Extract body (excluding query params) and serialize datetime to JSON.""" + # Exclude query params from body using the same constant + # Use mode='json' to serialize datetime objects to ISO-8601 strings + request_data = self.request_data.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + exclude=self.QUERY_PARAM_FIELDS, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> DryRunResponse: + try: + super(DryRunEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, DryRunResponse) + + +class GetBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: BatchIdRequest): + super(GetBatchMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(GetBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class ListBatchesEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListBatchesRequest): + super(ListBatchesEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return self.request_data.model_dump(exclude_none=True, by_alias=True) + + def handle_response(self, response: HTTPResponse) -> ListBatchesResponse: + try: + super(ListBatchesEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListBatchesResponse) + + +class ReplaceBatchEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.PUT.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ReplaceBatchRequest): + super(ReplaceBatchEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Used mode='json' to serialize datetime objects to ISO-8601 strings + # Exclude batch_id from body since it's in the URL path + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude={"batch_id"} + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(ReplaceBatchEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class SendSMSEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: SendSMSRequest): + super(SendSMSEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(SendSMSEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) + + +class DeliveryFeedbackEndpoint(SmsEndpoint): + ENDPOINT_URL = ( + "{origin}/xms/v1/{project_id}/batches/{batch_id}/delivery_feedback" + ) + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: DeliveryFeedbackRequest): + super(DeliveryFeedbackEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse): + try: + super(DeliveryFeedbackEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + + +class UpdateBatchMessageEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/batches/{batch_id}" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, project_id: str, request_data: UpdateBatchMessageRequest + ): + super(UpdateBatchMessageEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + # Use mode='json' to serialize datetime objects to ISO-8601 strings + # Exclude batch_id from body since it's in the URL path + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude={"batch_id"} + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> BatchResponse: + try: + super(UpdateBatchMessageEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, BatchResponse) diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py index 39f6d856..24ca21db 100644 --- a/sinch/domains/sms/models/v1/internal/__init__.py +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -1,3 +1,12 @@ +from sinch.domains.sms.models.v1.internal.batch_id_request import ( + BatchIdRequest, +) +from sinch.domains.sms.models.v1.internal.delivery_feedback_request import ( + DeliveryFeedbackRequest, +) +from sinch.domains.sms.models.v1.internal.list_batches_request import ( + ListBatchesRequest, +) from sinch.domains.sms.models.v1.internal.list_delivery_reports_response import ( ListDeliveryReportsResponse, ) @@ -12,8 +21,44 @@ ) __all__ = [ + "BatchIdRequest", + "DeliveryFeedbackRequest", + "ListBatchesRequest", "ListDeliveryReportsResponse", "GetRecipientDeliveryReportRequest", "ListDeliveryReportsRequest", "GetBatchDeliveryReportRequest", + "DryRunRequest", + "ReplaceBatchRequest", + "SendSMSRequest", + "UpdateBatchMessageRequest", ] + + +# Lazy import to avoid circular dependency +def __getattr__(name: str): + if name == "DryRunRequest": + from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunRequest, + ) + + return DryRunRequest + if name == "ReplaceBatchRequest": + from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceBatchRequest, + ) + + return ReplaceBatchRequest + if name == "SendSMSRequest": + from sinch.domains.sms.models.v1.internal.send_sms_request import ( + SendSMSRequest, + ) + + return SendSMSRequest + if name == "UpdateBatchMessageRequest": + from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateBatchMessageRequest, + ) + + return UpdateBatchMessageRequest + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/internal/batch_id_request.py b/sinch/domains/sms/models/v1/internal/batch_id_request.py new file mode 100644 index 00000000..c67d496e --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/batch_id_request.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class BatchIdRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The unique identifier of the batch message.", + ) diff --git a/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py b/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py new file mode 100644 index 00000000..6eebb7d8 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/delivery_feedback_request.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class DeliveryFeedbackRequest(BaseModelConfigurationRequest): + batch_id: StrictStr = Field( + default=..., + description="The unique identifier of the batch message for which delivery feedback is being provided.", + ) + recipients: conlist(StrictStr) = Field( + default=..., + description="A list of phone numbers (MSISDNs) that have successfully received the message. The key is required, however, the value can be an empty array (`[]`) for *a batch*. If the feedback was enabled for *a group*, at least one phone number is required.", + ) diff --git a/sinch/domains/sms/models/v1/internal/dry_run_request.py b/sinch/domains/sms/models/v1/internal/dry_run_request.py new file mode 100644 index 00000000..eb05ede4 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/dry_run_request.py @@ -0,0 +1,47 @@ +from typing import Union, Optional +from pydantic import BaseModel, Field, StrictBool, conint +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) + + +class DryRunMixin(BaseModel): + """Mixin that adds dry run query parameters to request models.""" + + per_recipient: Optional[StrictBool] = Field( + default=False, + description="Whether to include per recipient details in the response", + ) + number_of_recipients: Optional[conint(strict=True, le=1000, ge=0)] = Field( + default=None, + description="Max number of recipients to include per recipient details for in the response", + ) + + +class DryRunTextRequest(DryRunMixin, TextRequest): + """Request model for dry run with a text message.""" + + pass + + +class DryRunBinaryRequest(DryRunMixin, BinaryRequest): + """Request model for dry run with a binary message.""" + + pass + + +class DryRunMediaRequest(DryRunMixin, MediaRequest): + """Request model for dry run with a media message.""" + + pass + + +DryRunRequest = Union[ + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/list_batches_request.py b/sinch/domains/sms/models/v1/internal/list_batches_request.py new file mode 100644 index 00000000..a9e3eed1 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_batches_request.py @@ -0,0 +1,17 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictStr, conlist, conint, constr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListBatchesRequest(BaseModelConfigurationRequest): + page: Optional[conint(strict=True, ge=0)] = 0 + page_size: Optional[conint(strict=True, le=100, ge=1)] = 30 + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + from_: Optional[conlist(StrictStr)] = Field(default=None, alias="from") + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = None diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py index efc85cb7..1b22519a 100644 --- a/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py @@ -1,6 +1,8 @@ from typing import Optional from pydantic import Field, StrictInt, conlist -from sinch.domains.sms.models.v1.response import RecipientDeliveryReport +from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( + RecipientDeliveryReport, +) from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) diff --git a/sinch/domains/sms/models/v1/internal/replace_batch_request.py b/sinch/domains/sms/models/v1/internal/replace_batch_request.py new file mode 100644 index 00000000..7fe3ff9d --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/replace_batch_request.py @@ -0,0 +1,34 @@ +from typing import Union +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) +from sinch.domains.sms.models.v1.shared.batch_id_mixin import BatchIdMixin + + +class ReplaceTextRequest(BatchIdMixin, TextRequest): + """Request model for replacing a batch with a text message.""" + + pass + + +class ReplaceBinaryRequest(BatchIdMixin, BinaryRequest): + """Request model for replacing a batch with a binary message.""" + + pass + + +class ReplaceMediaRequest(BatchIdMixin, MediaRequest): + """Request model for replacing a batch with a media message.""" + + pass + + +ReplaceBatchRequest = Union[ + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/send_sms_request.py b/sinch/domains/sms/models/v1/internal/send_sms_request.py new file mode 100644 index 00000000..45aed821 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/send_sms_request.py @@ -0,0 +1,15 @@ +from typing import Union +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import ( + BinaryRequest, +) +from sinch.domains.sms.models.v1.shared.media_request import ( + MediaRequest, +) + + +SendSMSRequest = Union[ + TextRequest, + BinaryRequest, + MediaRequest, +] diff --git a/sinch/domains/sms/models/v1/internal/update_batch_message_request.py b/sinch/domains/sms/models/v1/internal/update_batch_message_request.py new file mode 100644 index 00000000..71e05ab0 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_batch_message_request.py @@ -0,0 +1,36 @@ +from typing import Union +from sinch.domains.sms.models.v1.internal.update_text_request import ( + UpdateTextRequest, +) +from sinch.domains.sms.models.v1.internal.update_binary_request import ( + UpdateBinaryRequest, +) +from sinch.domains.sms.models.v1.internal.update_media_request import ( + UpdateMediaRequest, +) +from sinch.domains.sms.models.v1.shared.batch_id_mixin import BatchIdMixin + + +class UpdateTextRequestWithBatchId(BatchIdMixin, UpdateTextRequest): + """Request model for updating a batch with a text message.""" + + pass + + +class UpdateBinaryRequestWithBatchId(BatchIdMixin, UpdateBinaryRequest): + """Request model for updating a batch with a binary message.""" + + pass + + +class UpdateMediaRequestWithBatchId(BatchIdMixin, UpdateMediaRequest): + """Request model for updating a batch with a media message.""" + + pass + + +UpdateBatchMessageRequest = Union[ + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +] diff --git a/sinch/domains/sms/models/v1/internal/update_binary_request.py b/sinch/domains/sms/models/v1/internal/update_binary_request.py new file mode 100644 index 00000000..bc8d8dbb --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_binary_request.py @@ -0,0 +1,68 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, conint, constr +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateBinaryRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field( + default="mt_binary", + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + body: Optional[StrictStr] = Field( + default=None, + description="The message content Base64 encoded. Max 140 bytes together with udh.", + ) + udh: StrictStr = Field( + default=..., + description="The UDH header of a binary message HEX encoded. Max 140 bytes together with body.", + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/internal/update_media_request.py b/sinch/domains/sms/models/v1/internal/update_media_request.py new file mode 100644 index 00000000..697b81af --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_media_request.py @@ -0,0 +1,61 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateMediaRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field(default="mt_media", description="MMS") + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + body: Optional[MediaBody] = None + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + strict_validation: Optional[StrictBool] = Field( + default=False, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/internal/update_text_request.py b/sinch/domains/sms/models/v1/internal/update_text_request.py new file mode 100644 index 00000000..0ce169b8 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_text_request.py @@ -0,0 +1,80 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateTextRequest(BaseModelConfigurationRequest): + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric.", + ) + type: Optional[StrictStr] = Field( + default="mt_text", description="Regular SMS" + ) + to_add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to add to the batch.", + ) + to_remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers and group IDs to remove from the batch.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set, in the future the message will be delayed until `send_at` occurs. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Constraints: Must be before expire_at. If set in the past, messages will be sent immediately. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: Optional[constr(strict=True, max_length=2000, min_length=0)] = Field( + default=None, description="The message content" + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) + max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to true the message will be shortened when exceeding one part.", + ) + flash_message: Optional[StrictBool] = Field( + default=False, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py index 954a9fa9..9648cf44 100644 --- a/sinch/domains/sms/models/v1/response/__init__.py +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -1,11 +1,19 @@ from sinch.domains.sms.models.v1.response.batch_delivery_report import ( BatchDeliveryReport, ) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, ) __all__ = [ "BatchDeliveryReport", + "DryRunResponse", + "ListBatchesResponse", "RecipientDeliveryReport", ] diff --git a/sinch/domains/sms/models/v1/response/dry_run_response.py b/sinch/domains/sms/models/v1/response/dry_run_response.py new file mode 100644 index 00000000..e2342cef --- /dev/null +++ b/sinch/domains/sms/models/v1/response/dry_run_response.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.shared import DryRunPerRecipientDetails +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class DryRunResponse(BaseModelConfigurationResponse): + number_of_recipients: StrictInt = Field( + default=..., description="The number of recipients in the batch" + ) + number_of_messages: StrictInt = Field( + default=..., + description="The total number of SMS message parts to be sent in the batch", + ) + per_recipient: Optional[conlist(DryRunPerRecipientDetails)] = None diff --git a/sinch/domains/sms/models/v1/response/list_batches_response.py b/sinch/domains/sms/models/v1/response/list_batches_response.py new file mode 100644 index 00000000..fd60674a --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_batches_response.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictInt, conlist +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListBatchesResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + batches: Optional[conlist(BatchResponse)] = Field( + default=None, + description="The page of batches matching the given filters.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + + @property + def content(self): + """Returns the content of batches list.""" + return self.batches or [] diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py index c0440950..76f29a21 100644 --- a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -1,10 +1,16 @@ from typing import Optional from datetime import datetime from pydantic import Field, StrictInt, StrictStr -from sinch.domains.sms.models.v1.types import ( +from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( DeliveryReceiptStatusCodeType, +) +from sinch.domains.sms.models.v1.types.delivery_status_type import ( DeliveryStatusType, +) +from sinch.domains.sms.models.v1.types.encoding_type import ( EncodingType, +) +from sinch.domains.sms.models.v1.types.recipient_delivery_report_type import ( RecipientDeliveryReportType, ) from sinch.domains.sms.models.v1.internal.base import ( diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 0cca0879..5139c795 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -1,7 +1,25 @@ +from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.dry_run_per_recipient_details import ( + DryRunPerRecipientDetails, +) +from sinch.domains.sms.models.v1.shared.media_body import MediaBody +from sinch.domains.sms.models.v1.shared.media_request import MediaRequest +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse from sinch.domains.sms.models.v1.shared.message_delivery_status import ( MessageDeliveryStatus, ) +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse __all__ = [ + "BinaryRequest", + "BinaryResponse", + "DryRunPerRecipientDetails", + "MediaBody", + "MediaRequest", + "MediaResponse", "MessageDeliveryStatus", + "TextRequest", + "TextResponse", ] diff --git a/sinch/domains/sms/models/v1/shared/batch_id_mixin.py b/sinch/domains/sms/models/v1/shared/batch_id_mixin.py new file mode 100644 index 00000000..776b58c3 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/batch_id_mixin.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field, StrictStr + + +class BatchIdMixin(BaseModel): + """Mixin that adds batch_id field to request models.""" + + batch_id: StrictStr = Field( + default=..., + description="The batch ID you received from sending a message.", + ) diff --git a/sinch/domains/sms/models/v1/shared/binary_request.py b/sinch/domains/sms/models/v1/shared/binary_request.py new file mode 100644 index 00000000..dd500c54 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/binary_request.py @@ -0,0 +1,66 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from sinch.domains.sms.models.v1.types import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class BinaryRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="A list of phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628).", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + body: StrictStr = Field( + default=..., + description="The message content Base64 encoded. Max 140 bytes including `udh`.", + ) + udh: StrictStr = Field( + default=..., + description="The UDH header of a binary message HEX encoded. Max 140 bytes including the `body`.", + ) + type: Optional[StrictStr] = Field( + default="mt_binary", + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch.", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/shared/binary_response.py b/sinch/domains/sms/models/v1/shared/binary_response.py new file mode 100644 index 00000000..eb9a97ac --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/binary_response.py @@ -0,0 +1,79 @@ +from typing import Literal, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BinaryResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch." + ) + to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + default=None, + description="A list of phone numbers and group IDs that have received the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628).", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="The sender number provided. Required if the Automatic Default Originator is not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=False, + description="Indicates whether or not the batch has been canceled.", + ) + body: Optional[StrictStr] = Field( + default=None, + description="The message content provided. Base64 encoded. ", + ) + udh: Optional[StrictStr] = Field( + default=None, + description="The [UDH](https://community.sinch.com/t5/Glossary/UDH-User-Data-Header/ta-p/7776) header of a binary message HEX encoded. Max 140 bytes including the `body`.", + ) + type: Literal["mt_binary"] = Field( + default=..., + description="SMS in [binary](https://community.sinch.com/t5/Glossary/Binary-SMS/ta-p/7470) format.", + ) + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + delivery_report: Optional[StrictStr] = Field( + default=None, + description="The delivery report callback option selected. Will be either `none`, `summary`, `full`, `per_recipient`, or `per_recipient_final`.", + ) + send_at: Optional[datetime] = Field( + default=None, + description="If set, the date and time the message should be delivered. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the date and time the message will expire. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, description="The callback URL provided in the request." + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The string input to identify this batch message. If set, the identifier will be added in the delivery report/callback of this batch.", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to true, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, description="The type of number for the sender number." + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number.", + ) diff --git a/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py b/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py new file mode 100644 index 00000000..0ca68837 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/dry_run_per_recipient_details.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types import EncodingType + + +class DryRunPerRecipientDetails(BaseModelConfigurationResponse): + recipient: StrictStr = Field( + default=..., + description="Sender number. Required if Automatic Default Originator not configured.", + ) + body: StrictStr = Field(...) + number_of_parts: StrictInt = Field(...) + encoding: EncodingType = Field(...) diff --git a/sinch/domains/sms/models/v1/shared/media_body.py b/sinch/domains/sms/models/v1/shared/media_body.py new file mode 100644 index 00000000..b7a53d21 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_body.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, constr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaBody(BaseModelConfigurationResponse): + subject: Optional[constr(strict=True, max_length=80, min_length=0)] = ( + Field(default=None, description="The subject text") + ) + message: Optional[constr(strict=True, max_length=2000, min_length=0)] = ( + Field( + default=None, + description="The message text. Text only media messages will be rejected, please use SMS instead.", + ) + ) + url: constr(strict=True, max_length=2048, min_length=0) = Field( + default=..., description="URL to the media file" + ) diff --git a/sinch/domains/sms/models/v1/shared/media_request.py b/sinch/domains/sms/models/v1/shared/media_request.py new file mode 100644 index 00000000..baf37844 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_request.py @@ -0,0 +1,59 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr +from sinch.domains.sms.models.v1.types import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class MediaRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + body: MediaBody = Field(...) + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + type: Optional[StrictStr] = Field(default="mt_media", description="MMS") + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + strict_validation: Optional[StrictBool] = Field( + default=False, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/shared/media_response.py b/sinch/domains/sms/models/v1/shared/media_response.py new file mode 100644 index 00000000..b94ba43a --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/media_response.py @@ -0,0 +1,74 @@ +from typing import Dict, Literal, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.shared.media_body import MediaBody +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch" + ) + to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + default=None, + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Required if Automatic Default Originator not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=False, + description="Indicates if the batch has been canceled or not.", + ) + body: Optional[MediaBody] = None + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + type: Literal["mt_media"] = Field(default=..., description="Media message") + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future the message will be delayed until send_at occurs. Must be before `expire_at`. If set in the past messages will be sent immediately. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after send_at. YYYY-MM-DDThh:mm:ss.SSSZ format", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + strict_validation: Optional[StrictBool] = Field( + default=None, + description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", + ) diff --git a/sinch/domains/sms/models/v1/shared/text_request.py b/sinch/domains/sms/models/v1/shared/text_request.py new file mode 100644 index 00000000..89d6aeeb --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/text_request.py @@ -0,0 +1,78 @@ +from typing import Dict, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class TextRequest(BaseModelConfigurationRequest): + to: conlist(StrictStr) = Field( + default=..., + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: constr(strict=True, max_length=2000, min_length=0) = Field( + default=..., description="The message content" + ) + type: Optional[StrictStr] = Field( + default="mt_text", description="Regular SMS" + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + flash_message: Optional[StrictBool] = Field( + default=False, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) + max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to `true` the message will be shortened when exceeding one part.", + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/shared/text_response.py b/sinch/domains/sms/models/v1/shared/text_response.py new file mode 100644 index 00000000..543e1b11 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/text_response.py @@ -0,0 +1,91 @@ +from typing import Dict, Literal, Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from sinch.domains.sms.models.v1.types.delivery_report_type import ( + DeliveryReportType, +) +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TextResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, description="Unique identifier for batch" + ) + to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + default=None, + description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", + ) + from_: Optional[StrictStr] = Field( + default=None, + alias="from", + description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", + ) + canceled: Optional[StrictBool] = Field( + default=False, + description="Indicates if the batch has been canceled or not.", + ) + parameters: Optional[ + Dict[str, Dict[str, constr(strict=True, max_length=1600)]] + ] = Field( + default=None, + description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", + ) + body: Optional[constr(strict=True, max_length=2000, min_length=0)] = Field( + default=None, description="The message content" + ) + type: Literal["mt_text"] = Field(default=..., description="Regular SMS") + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was created. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601):`YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when batch was last updated. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601):`YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + delivery_report: Optional[DeliveryReportType] = None + send_at: Optional[datetime] = Field( + default=None, + description="If set in the future, the message will be delayed until `send_at` occurs. Must be before `expire_at`. If set in the past, messages will be sent immediately. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + expire_at: Optional[datetime] = Field( + default=None, + description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", + ) + callback_url: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="Override the default callback URL for this batch. Must be valid URL.", + ) + client_reference: Optional[ + constr(strict=True, max_length=2048, min_length=0) + ] = Field( + default=None, + description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", + ) + feedback_enabled: Optional[StrictBool] = Field( + default=False, + description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", + ) + flash_message: Optional[StrictBool] = Field( + default=False, + description="Shows message on screen without user interaction while not saving the message to the inbox.", + ) + truncate_concat: Optional[StrictBool] = Field( + default=None, + description="If set to `true` the message will be shortened when exceeding one part.", + ) + max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + default=None, + description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", + ) + from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + default=None, + description="The type of number for the sender number. Use to override the automatic detection.", + ) + from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + default=None, + description="Number Plan Indicator for the sender number. Use to override the automatic detection.", + ) diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py index bed362c9..f30dd14a 100644 --- a/sinch/domains/sms/models/v1/types/__init__.py +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -13,9 +13,22 @@ ) __all__ = [ + "BatchResponse", "DeliveryReceiptStatusCodeType", "DeliveryReportType", "DeliveryStatusType", "EncodingType", "RecipientDeliveryReportType", ] + + +# Lazy import to avoid circular dependency +# BatchResponse imports from shared which may import from types +def __getattr__(name: str): + if name == "BatchResponse": + from sinch.domains.sms.models.v1.types.batch_response import ( + BatchResponse, + ) + + return BatchResponse + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/types/batch_response.py b/sinch/domains/sms/models/v1/types/batch_response.py new file mode 100644 index 00000000..6f63efd0 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/batch_response.py @@ -0,0 +1,11 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse + +# Union type for isinstance checks +_BatchResponseUnion = Union[TextResponse, BinaryResponse, MediaResponse] + +# Discriminated union for validation +BatchResponse = Annotated[_BatchResponseUnion, Field(discriminator="type")] diff --git a/sinch/domains/sms/models/v1/types/delivery_report_type.py b/sinch/domains/sms/models/v1/types/delivery_report_type.py index c7eb56c9..9933b0a3 100644 --- a/sinch/domains/sms/models/v1/types/delivery_report_type.py +++ b/sinch/domains/sms/models/v1/types/delivery_report_type.py @@ -2,4 +2,7 @@ from pydantic import StrictStr -DeliveryReportType = Union[Literal["summary", "full"], StrictStr] +DeliveryReportType = Union[ + Literal["none", "summary", "full", "per_recipient", "per_recipient_final"], + StrictStr, +] diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py new file mode 100644 index 00000000..5013fe68 --- /dev/null +++ b/sinch/domains/sms/sms.py @@ -0,0 +1,136 @@ +from datetime import datetime +from typing import Optional, List, Dict +from sinch.domains.sms.api.v1 import ( + Batches, + DeliveryReports, +) +from sinch.domains.sms.models.delivery_reports import DeliveryReport +from sinch.domains.sms.models.v1.shared import ( + BinaryRequest, + MediaRequest, + MediaBody, + TextRequest, +) +from sinch.domains.sms.models.v1.types import BatchResponse + + +class SMS: + """ + Documentation for Sinch SMS is found at + https://developers.sinch.com/docs/sms/. + """ + + def __init__(self, sinch): + self._sinch = sinch + + self.batches = Batches(self._sinch) + self.delivery_reports = DeliveryReports(self._sinch) + + # ====== High-Level Convenience Methods ====== + + # ====== Batches Operations ====== + def send_sms_batch( + self, + to: List[str], + from_: str, + body: str, + delivery_report: Optional[DeliveryReport] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + return self.batches._send( + request=TextRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + parameters=parameters, + ), + **kwargs, + ) + + def send_binary_batch( + self, + to: List[str], + from_: str, + body: str, + udh: str, + delivery_report: Optional[DeliveryReport] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ): + return self.batches._send( + request=BinaryRequest( + to=to, + from_=from_, + body=body, + udh=udh, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + ), + **kwargs, + ) + + def send_mms_batch( + self, + to: List[str], + from_: str, + body: MediaBody, + delivery_report: Optional[DeliveryReport] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + return self.batches._send( + request=MediaRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + parameters=parameters, + ), + **kwargs, + ) diff --git a/tests/e2e/numbers/features/steps/delivery_reports.steps.py b/tests/e2e/numbers/features/steps/delivery_reports.steps.py deleted file mode 100644 index 69fb0cc2..00000000 --- a/tests/e2e/numbers/features/steps/delivery_reports.steps.py +++ /dev/null @@ -1,166 +0,0 @@ -from datetime import datetime, timezone -from behave import given, when, then -from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport - - -@given('the SMS service "{service_name}" is available') -def step_sms_service_available(context, service_name): - """Ensures the Sinch client is initialized""" - assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' - - -@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') -def step_sms_service_available_with_service_plan(context, service_name): - """Ensures the Sinch client is initialized with service_plan_id authentication""" - from sinch import SinchClient - - # Create a new client with service_plan_id authentication - context.sinch = SinchClient( - service_plan_id='CappyPremiumPlan', - sms_api_token='HappyCappyToken', - ) - context.sinch.configuration.auth_origin = 'http://localhost:3011' - context.sinch.configuration.sms_origin = 'http://localhost:3017' - context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' - - -@when('I send a request to retrieve a summary SMS delivery report') -def step_retrieve_summary_delivery_report(context): - """Retrieve a summary SMS delivery report""" - context.response = context.sinch.sms.delivery_reports.get( - batch_id='01W4FFL35P4NC4K35SMSBATCH1', - status=['DELIVERED', 'FAILED'], - code=[15, 0] - ) - - -@then('the response contains a summary SMS delivery report') -def step_validate_summary_delivery_report(context): - """Validate summary SMS delivery report response""" - data: BatchDeliveryReport = context.response - assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' - assert data.client_reference == 'reference_e2e' - assert data.statuses is not None - assert len(data.statuses) >= 2 - - status = data.statuses[0] - assert status.code == 15 - assert status.count == 1 - assert status.recipients is None - assert status.status == 'Failed' - - status = data.statuses[1] - assert status.code == 0 - assert status.count == 1 - assert status.recipients is None - assert status.status == 'Delivered' - - assert data.total_message_count == 2 - assert data.type == 'delivery_report_sms' - - -@when('I send a request to retrieve a full SMS delivery report') -def step_retrieve_full_delivery_report(context): - """Retrieve a full SMS delivery report""" - context.response = context.sinch.sms.delivery_reports.get( - batch_id='01W4FFL35P4NC4K35SMSBATCH1', - report_type='full' - ) - - -@then('the response contains a full SMS delivery report') -def step_validate_full_delivery_report(context): - """Validate full SMS delivery report response""" - data: BatchDeliveryReport = context.response - assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' - assert data.statuses is not None - status = data.statuses[0] - assert status.recipients is not None - assert status.code == 0 - assert status.count == 1 - assert status.recipients[0] == '12017777777' - assert status.status == 'Delivered' - - -@when('I send a request to retrieve a recipient\'s delivery report') -def step_retrieve_recipient_delivery_report(context): - """Retrieve a recipient's delivery report""" - context.response = context.sinch.sms.delivery_reports.get_for_number( - batch_id='01W4FFL35P4NC4K35SMSBATCH1', - recipient='12017777777' - ) - - -@then('the response contains the recipient\'s delivery report details') -def step_validate_recipient_delivery_report(context): - """Validate recipient delivery report response""" - data: RecipientDeliveryReport = context.response - assert data.batch_id == '01W4FFL35P4NC4K35SMSBATCH1' - assert data.recipient == '12017777777' - assert data.client_reference == 'reference_e2e' - assert data.status == 'Delivered' - assert data.type == 'recipient_delivery_report_sms' - assert data.code == 0 - assert data.at == datetime(2024, 6, 6, 13, 6, 27, 833000, tzinfo=timezone.utc) - assert data.operator_status_at == datetime(2024, 6, 6, 13, 6, 0, tzinfo=timezone.utc) - - -@when('I send a request to list the SMS delivery reports') -def step_list_delivery_reports(context): - """List a page of SMS delivery reports""" - context.response = context.sinch.sms.delivery_reports.list() - - -@then('the response contains "{count}" SMS delivery reports') -def step_validate_delivery_reports_count(context, count): - """Validate the count of SMS delivery reports in response""" - expected_count = int(count) - assert len(context.response.content()) == expected_count, \ - f'Expected {expected_count}, got {len(context.response.content())}' - - -@when('I send a request to list all the SMS delivery reports') -def step_list_all_delivery_reports(context): - """List all SMS delivery reports using iterator""" - response = context.sinch.sms.delivery_reports.list(page_size=2) - delivery_reports_list = [] - - for delivery_report in response.iterator(): - delivery_reports_list.append(delivery_report) - - context.delivery_reports_list = delivery_reports_list - - -@then('the SMS delivery reports list contains "{count}" SMS delivery reports') -def step_validate_delivery_reports_list_count(context, count): - """Validate the count of SMS delivery reports in the full list""" - expected_count = int(count) - assert len(context.delivery_reports_list) == expected_count, \ - f'Expected {expected_count}, got {len(context.delivery_reports_list)}' - - -@when('I iterate manually over the SMS delivery reports pages') -def step_iterate_manually_delivery_reports(context): - """Manually iterate over SMS delivery reports pages""" - context.list_response = context.sinch.sms.delivery_reports.list(page_size=2) - - # Iterate through all pages - context.delivery_reports_list = [] - context.pages_iteration = 0 - reached_last_page = False - - while not reached_last_page: - context.delivery_reports_list.extend(context.list_response.content()) - context.pages_iteration += 1 - if context.list_response.has_next_page: - context.list_response = context.list_response.next_page() - else: - reached_last_page = True - - -@then('the SMS delivery reports iteration result contains the data from "{count}" pages') -def step_validate_delivery_reports_pages_count(context, count): - """Validate the count of pages in the iteration result""" - expected_pages_count = int(count) - assert context.pages_iteration == expected_pages_count, \ - f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/sms/features/steps/batches.steps.py b/tests/e2e/sms/features/steps/batches.steps.py new file mode 100644 index 00000000..f3a83bdb --- /dev/null +++ b/tests/e2e/sms/features/steps/batches.steps.py @@ -0,0 +1,364 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.response.dry_run_response import DryRunResponse +from sinch.domains.sms.models.v1.shared.text_response import TextResponse + + +def _setup_sinch_client(context, use_service_plan_auth=False): + """Helper function to setup Sinch client""" + from sinch import SinchClient + + if use_service_plan_auth: + sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + else: + sinch = SinchClient( + project_id='tinyfrog-jump-high-over-lilypadbasin', + key_id='keyId', + key_secret='keySecret', + ) + + sinch.configuration.auth_origin = 'http://localhost:3011' + sinch.configuration.sms_origin = 'http://localhost:3017' + + context.sinch = sinch + context.sms = sinch.sms + + +@given('the SMS service "Batches" is available') +def step_sms_service_batches_available(context): + """Ensures the Sinch client is initialized""" + _setup_sinch_client(context, use_service_plan_auth=False) + + +@given('the SMS service "Batches" is available and is configured for servicePlanId authentication') +def step_sms_service_batches_available_with_service_plan(context): + """Ensures the Sinch client is initialized with service_plan_id authentication""" + _setup_sinch_client(context, use_service_plan_auth=True) + + +@when('I send a request to send a text message') +def step_send_text_message(context): + """Send a text message""" + context.response = context.sms.send_sms_batch( + body='SMS body message', + to=['+12017777777'], + from_='+12015555555', + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + delivery_report='full', + feedback_enabled=True, + ) + + +@then('the response contains the text SMS details') +def step_validate_text_sms_details(context): + """Validate text SMS response""" + data: BatchResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert data.to == ['12017777777'] + assert data.from_ == '12015555555' + assert data.canceled is False + assert data.body == 'SMS body message' + assert data.type == 'mt_text' + assert data.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.delivery_report == 'full' + assert data.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert data.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert data.feedback_enabled is True + assert isinstance(data, TextResponse) + assert data.flash_message is False + + +@when('I send a request to send a text message with multiple parameters') +def step_send_text_message_with_parameters(context): + """Send a text message with multiple parameters""" + context.response = context.sms.send_sms_batch( + body='Hello ${name}! Get 20% off with this discount code ${code}', + to=['+12017777777', '+12018888888'], + from_='+12015555555', + parameters={ + 'name': { + '+12017777777': 'John', + '+12018888888': 'Paul', + 'default': 'there', + }, + 'code': { + '+12017777777': 'HALLOWEEN20 🎃', + }, + }, + delivery_report='full', + ) + + +@then('the response contains the text SMS details with multiple parameters') +def step_validate_text_sms_with_parameters(context): + """Validate text SMS response with parameters""" + data: BatchResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSBATCH2' + assert data.to == ['12017777777', '12018888888'] + assert data.from_ == '12015555555' + assert data.canceled is False + + expected_parameters = { + 'name': { + 'default': 'there', + '+12017777777': 'John', + '+12018888888': 'Paul', + }, + 'code': { + '+12017777777': 'HALLOWEEN20 🎃', + }, + } + assert data.parameters == expected_parameters + assert data.body == 'Hello ${name}! Get 20% off with this discount code ${code}' + assert data.type == 'mt_text' + assert data.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert data.delivery_report == 'full' + assert data.expire_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert isinstance(data, TextResponse) + assert data.flash_message is False + + +@when('I send a request to perform a dry run of a batch') +def step_perform_dry_run(context): + """Perform a dry run of a batch""" + context.dry_run_response = context.sms.batches.dry_run_sms( + from_='+12015555555', + to=[ + '+12017777777', + '+12018888888', + '+12019999999', + ], + parameters={ + 'name': { + '+12017777777': 'John', + 'default': 'there', + }, + }, + body='Hello ${name}!', + delivery_report='none', + ) + + +@then('the response contains the calculated bodies and number of parts for all messages in the batch') +def step_validate_dry_run_response(context): + """Validate dry run response""" + data: DryRunResponse = context.dry_run_response + assert data.number_of_messages == 3 + assert data.number_of_recipients == 3 + assert data.per_recipient is not None + assert len(data.per_recipient) == 3 + + john_message = next( + (msg for msg in data.per_recipient if msg.recipient == '12017777777'), + None + ) + assert john_message is not None + assert john_message.body == 'Hello John!' + assert john_message.number_of_parts == 1 + assert john_message.encoding == 'text' + + default_message = next( + (msg for msg in data.per_recipient if msg.recipient == '12018888888'), + None + ) + assert default_message is not None + assert default_message.body == 'Hello there!' + assert default_message.number_of_parts == 1 + assert default_message.encoding == 'text' + + +@when('I send a request to list the SMS batches') +def step_list_sms_batches(context): + """List SMS batches""" + context.response = context.sms.batches.list( + page_size=2, + ) + + +@then('the response contains "{count}" SMS batches') +def step_validate_batches_count(context, count): + """Validate the count of SMS batches in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the SMS batches') +def step_list_all_sms_batches(context): + """List all SMS batches using iterator""" + response = context.sms.batches.list(page_size=2) + batches_list = [] + + for batch in response.iterator(): + batches_list.append(batch) + + context.batches_list = batches_list + + +@when('I iterate manually over the SMS batches pages') +def step_iterate_manually_batches(context): + """Manually iterate over SMS batches pages""" + context.list_response = context.sms.batches.list( + page_size=2, + ) + + context.batches_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.batches_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True + + +@then('the SMS batches list contains "{count}" SMS batches') +def step_validate_batches_list_count(context, count): + """Validate the count of SMS batches in the full list""" + expected_count = int(count) + assert len(context.batches_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.batches_list)}' + + +@then('the SMS batches iteration result contains the data from "{count}" pages') +def step_validate_batches_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + + +@when('I send a request to retrieve an SMS batch') +def step_retrieve_sms_batch(context): + """Retrieve an SMS batch""" + context.batch = context.sms.batches.get( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + ) + + +@then('the response contains the SMS batch details') +def step_validate_batch_details(context): + """Validate SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12017777777'] + assert batch.from_ == '12015555555' + assert batch.canceled is False + assert batch.body == 'SMS body message' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.delivery_report == 'full' + assert batch.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is True + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to update an SMS batch') +def step_update_sms_batch(context): + """Update an SMS batch""" + context.batch = context.sms.batches.update_sms( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + from_='+12016666666', + to_add=[ + '01W4FFL35P4NC4K35SMSGROUP1', + ], + delivery_report='summary', + ) + + +@then('the response contains the SMS batch details with updated data') +def step_validate_updated_batch_details(context): + """Validate updated SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12017777777', '01W4FFL35P4NC4K35SMSGROUP1'] + assert batch.from_ == '12016666666' + assert batch.canceled is False + assert batch.body == 'SMS body message' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + assert batch.delivery_report == 'summary' + assert batch.send_at == datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is True + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to replace an SMS batch') +def step_replace_sms_batch(context): + """Replace an SMS batch""" + context.batch = context.sms.batches.replace_sms( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + from_='+12016666666', + to=['+12018888888'], + body='This is the replacement batch', + send_at=datetime(2024, 6, 6, 9, 35, 0, tzinfo=timezone.utc), + ) + + +@then('the response contains the new SMS batch details with the provided data for replacement') +def step_validate_replaced_batch_details(context): + """Validate replaced SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.to == ['12018888888'] + assert batch.from_ == '12016666666' + assert batch.canceled is False + assert batch.body == 'This is the replacement batch' + assert batch.type == 'mt_text' + assert batch.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert batch.modified_at == datetime(2024, 6, 6, 9, 23, 32, 504000, tzinfo=timezone.utc) + assert batch.delivery_report == 'none' + assert batch.send_at == datetime(2024, 6, 6, 9, 35, 0, tzinfo=timezone.utc) + assert batch.expire_at == datetime(2024, 6, 9, 9, 35, 0, tzinfo=timezone.utc) + assert batch.feedback_enabled is False + assert isinstance(batch, TextResponse) + assert batch.flash_message is False + + +@when('I send a request to cancel an SMS batch') +def step_cancel_sms_batch(context): + """Cancel an SMS batch""" + context.batch = context.sms.batches.cancel( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + ) + + +@then('the response contains the SMS batch details with a cancelled status') +def step_validate_cancelled_batch(context): + """Validate cancelled SMS batch response""" + batch: BatchResponse = context.batch + assert batch.id == '01W4FFL35P4NC4K35SMSBATCH1' + assert batch.canceled is True + + +@when('I send a request to send delivery feedbacks') +def step_send_delivery_feedback(context): + """Send delivery feedback""" + context.delivery_feedback_response = context.sms.batches.send_delivery_feedback( + batch_id='01W4FFL35P4NC4K35SMSBATCH1', + recipients=[ + '+12017777777', + ], + ) + + +@then('the delivery feedback response contains no data') +def step_validate_delivery_feedback_response(context): + """Validate delivery feedback response""" + assert context.delivery_feedback_response is None diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py new file mode 100644 index 00000000..f09cb808 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py @@ -0,0 +1,166 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError, TypeAdapter +from sinch.domains.sms.models.v1.types import BatchResponse +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse + + +@pytest.fixture +def text_response_data(): + """Sample TextResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": "Hello World!", + "type": "mt_text", + "created_at": "2024-01-15T14:30:22.123Z", + "modified_at": "2024-01-15T14:35:45.789Z", + "delivery_report": "full", + "send_at": "2024-01-15T15:00:00Z", + "expire_at": "2024-01-18T15:00:00Z", + "feedback_enabled": True, + "flash_message": False, + } + + +@pytest.fixture +def binary_response_data(): + """Sample BinaryResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + "type": "mt_binary", + "created_at": "2024-03-20T08:15:33.456Z", + "modified_at": "2024-03-20T08:16:12.890Z", + } + + +@pytest.fixture +def media_response_data(): + """Sample MediaResponse data for testing.""" + return { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": False, + "body": { + "url": "https://example.com/image.jpg", + "message": "Check out this image!", + }, + "type": "mt_media", + "created_at": "2024-11-10T16:45:10.234Z", + "modified_at": "2024-11-10T16:47:22.567Z", + } + + +def test_batch_response_expects_parses_all_response_types( + text_response_data, binary_response_data, media_response_data +): + """ + Test that BatchResponse correctly parses all three response types. + Verifies discriminator routes correctly based on type field. + """ + adapter = TypeAdapter(BatchResponse) + + text_response = adapter.validate_python(text_response_data) + assert isinstance(text_response, TextResponse) + assert text_response.type == "mt_text" + assert text_response.body == "Hello World!" + assert text_response.delivery_report == "full" + + binary_response = adapter.validate_python(binary_response_data) + assert isinstance(binary_response, BinaryResponse) + assert not isinstance(binary_response, TextResponse) + assert binary_response.type == "mt_binary" + assert binary_response.udh == "06050423F423F4" + + media_response = adapter.validate_python(media_response_data) + assert isinstance(media_response, MediaResponse) + assert media_response.type == "mt_media" + assert media_response.body.url == "https://example.com/image.jpg" + + +def test_batch_response_expects_text_response_variations(text_response_data): + """ + Test TextResponse variations: minimal fields, parameters, and datetime parsing. + """ + adapter = TypeAdapter(BatchResponse) + + minimal_data = {"type": "mt_text", "id": "01FC66621XXXXX119Z8PMV1QPQ"} + response = adapter.validate_python(minimal_data) + assert isinstance(response, TextResponse) + assert response.type == "mt_text" + assert response.canceled is False + + text_response_data["parameters"] = { + "name": {"+12017777777": "John", "default": "there"}, + "code": {"+12017777777": "HALLOWEEN20"}, + } + response = adapter.validate_python(text_response_data) + assert isinstance(response, TextResponse) + assert response.parameters["name"]["+12017777777"] == "John" + assert response.parameters["code"]["+12017777777"] == "HALLOWEEN20" + + expected_created_at = datetime(2024, 1, 15, 14, 30, 22, 123000, tzinfo=timezone.utc) + expected_modified_at = datetime(2024, 1, 15, 14, 35, 45, 789000, tzinfo=timezone.utc) + expected_send_at = datetime(2024, 1, 15, 15, 0, 0, tzinfo=timezone.utc) + expected_expire_at = datetime(2024, 1, 18, 15, 0, 0, tzinfo=timezone.utc) + assert response.created_at == expected_created_at + assert response.modified_at == expected_modified_at + assert response.send_at == expected_send_at + assert response.expire_at == expected_expire_at + + text_response_data["canceled"] = True + response = adapter.validate_python(text_response_data) + assert response.canceled is True + + +def test_batch_response_expects_discriminator_behavior(): + """ + Test discriminator behavior: routing by type field, handling invalid/missing types, + and working with extra fields. + """ + adapter = TypeAdapter(BatchResponse) + + # Discriminator routes by type field, not field presence + data_with_binary_fields_but_text_type = { + "type": "mt_text", + "id": "test123", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + response = adapter.validate_python(data_with_binary_fields_but_text_type) + assert isinstance(response, TextResponse) + assert response.type == "mt_text" + assert hasattr(response, "udh") + + # Invalid type should be rejected + with pytest.raises(ValidationError): + adapter.validate_python({"id": "test", "type": "invalid_type"}) + + # Missing type field should cause validation error + with pytest.raises(ValidationError): + adapter.validate_python({"id": "test", "body": "Hello"}) + + # Extra fields are allowed and don't affect routing + text_with_extra = {"type": "mt_text", "id": "test", "body": "Hello", "extra": "value"} + response = adapter.validate_python(text_with_extra) + assert isinstance(response, TextResponse) + + binary_with_extra = { + "type": "mt_binary", + "id": "test", + "body": "SGVsbG8=", + "udh": "06050423F4", + "extra": "value", + } + response = adapter.validate_python(binary_with_extra) + assert isinstance(response, BinaryResponse) From 25c49ff01bacf1997f98292ac443ab916e496221 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 18 Nov 2025 17:53:51 +0100 Subject: [PATCH 062/106] DEVEXP-786: Endpoint Unit Tests (#93) --- sinch/core/models/http_response.py | 2 +- sinch/domains/sms/api/v1/batches_apis.py | 145 ++++++- .../sms/api/v1/internal/base/sms_endpoint.py | 20 + .../sms/api/v1/internal/batches_endpoints.py | 13 +- .../sms/models/v1/internal/dry_run_request.py | 6 +- .../v1/internal/list_batches_request.py | 10 +- .../internal/list_delivery_reports_request.py | 10 +- .../v1/internal/update_binary_request.py | 16 +- .../v1/internal/update_media_request.py | 18 +- .../models/v1/internal/update_text_request.py | 32 +- .../v1/response/batch_delivery_report.py | 4 +- .../sms/models/v1/shared/binary_request.py | 16 +- .../sms/models/v1/shared/binary_response.py | 26 +- .../sms/models/v1/shared/media_body.py | 18 +- .../sms/models/v1/shared/media_request.py | 18 +- .../sms/models/v1/shared/media_response.py | 20 +- .../v1/shared/message_delivery_status.py | 16 +- .../sms/models/v1/shared/text_request.py | 34 +- .../sms/models/v1/shared/text_response.py | 32 +- sinch/domains/sms/sms.py | 119 ------ tests/e2e/sms/features/steps/batches.steps.py | 6 +- .../test_cancel_batch_message_endpoint.py | 141 +++++++ .../batches/test_dry_run_batches_endpoint.py | 243 ++++++++++++ .../test_get_batch_message_endpoint.py | 76 ++++ .../batches/test_list_batches_endpoint.py | 146 +++++++ .../batches/test_replace_batches_endpoint.py | 168 +++++++++ .../batches/test_send_batches_endpoint.py | 152 ++++++++ .../test_send_delivery_feedback_endpoint.py | 77 ++++ .../batches/test_update_batches_endpoint.py | 174 +++++++++ .../base/test_base_model_configuration.py | 4 +- .../internal/test_batch_id_request_model.py | 33 ++ .../internal/test_dry_run_request_model.py | 357 ++++++++++++++++++ ...est_list_delivery_reports_request_model.py | 37 +- .../test_batch_delivery_report_model.py | 17 - .../response/test_batch_response_model.py | 23 +- .../response/test_dry_run_response_model.py | 75 ++++ .../test_recipient_delivery_report_model.py | 1 - tests/unit/domains/sms/v1/test_batches.py | 146 +++++++ .../domains/sms/v1/test_delivery_reports.py | 28 +- 39 files changed, 2106 insertions(+), 373 deletions(-) create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py create mode 100644 tests/unit/domains/sms/v1/test_batches.py diff --git a/sinch/core/models/http_response.py b/sinch/core/models/http_response.py index 21db3fd3..390921b4 100644 --- a/sinch/core/models/http_response.py +++ b/sinch/core/models/http_response.py @@ -4,5 +4,5 @@ @dataclass class HTTPResponse: status_code: int - body: dict headers: dict + body: dict = None diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py index 5ea7a075..0838f36c 100644 --- a/sinch/domains/sms/api/v1/batches_apis.py +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -29,7 +29,12 @@ ReplaceBinaryRequest, ReplaceMediaRequest, ) -from sinch.domains.sms.models.v1.shared import MediaBody +from sinch.domains.sms.models.v1.shared import ( + MediaBody, + TextRequest, + BinaryRequest, + MediaRequest, +) from sinch.domains.sms.models.v1.types import DeliveryReportType from sinch.domains.sms.api.v1.internal import ( CancelBatchMessageEndpoint, @@ -50,7 +55,7 @@ def cancel(self, batch_id: str, **kwargs) -> BatchResponse: request_data = BatchIdRequest(batch_id=batch_id, **kwargs) return self._request(CancelBatchMessageEndpoint, request_data) - def _dry_run( + def dry_run( self, request: Optional[DryRunRequest] = None, per_recipient: Optional[bool] = None, @@ -135,7 +140,7 @@ def dry_run_sms( from_npi=from_npi, **kwargs, ) - return self._dry_run(request=request) + return self.dry_run(request=request) def dry_run_binary( self, @@ -175,7 +180,7 @@ def dry_run_binary( from_npi=from_npi, **kwargs, ) - return self._dry_run(request=request) + return self.dry_run(request=request) def dry_run_mms( self, @@ -210,7 +215,7 @@ def dry_run_mms( strict_validation=strict_validation, **kwargs, ) - return self._dry_run(request=request) + return self.dry_run(request=request) def get(self, batch_id: str, **kwargs) -> BatchResponse: request_data = BatchIdRequest(batch_id=batch_id, **kwargs) @@ -242,7 +247,7 @@ def list( return SMSPaginator(sinch=self._sinch, endpoint=endpoint) - def _replace( + def replace( self, batch_id: str, request: Optional[ReplaceBatchRequest] = None, @@ -301,7 +306,7 @@ def replace_sms( parameters=parameters, **kwargs, ) - return self._replace(batch_id=batch_id, request=request) + return self.replace(batch_id=batch_id, request=request) def replace_binary( self, @@ -336,7 +341,7 @@ def replace_binary( from_npi=from_npi, **kwargs, ) - return self._replace(batch_id=batch_id, request=request) + return self.replace(batch_id=batch_id, request=request) def replace_mms( self, @@ -369,9 +374,9 @@ def replace_mms( parameters=parameters, **kwargs, ) - return self._replace(batch_id=batch_id, request=request) + return self.replace(batch_id=batch_id, request=request) - def _send( + def send( self, request: Optional[SendSMSRequest] = None, **kwargs ) -> BatchResponse: # SendSMSRequest is a Union type, so we need to use TypeAdapter to validate @@ -389,6 +394,118 @@ def _send( return self._request(SendSMSEndpoint, request_data) + def send_sms( + self, + to: List[str], + from_: str, + body: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + flash_message: Optional[bool] = None, + max_number_of_message_parts: Optional[int] = None, + truncate_concat: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + Send a text SMS batch. + """ + request = TextRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + flash_message=flash_message, + max_number_of_message_parts=max_number_of_message_parts, + truncate_concat=truncate_concat, + from_ton=from_ton, + from_npi=from_npi, + parameters=parameters, + **kwargs, + ) + return self.send(request=request) + + def send_binary( + self, + to: List[str], + from_: str, + body: str, + udh: str, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + from_ton: Optional[int] = None, + from_npi: Optional[int] = None, + **kwargs, + ) -> BatchResponse: + """ + Send a binary SMS batch. + """ + request = BinaryRequest( + to=to, + from_=from_, + body=body, + udh=udh, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + from_ton=from_ton, + from_npi=from_npi, + **kwargs, + ) + return self.send(request=request) + + def send_mms( + self, + to: List[str], + from_: str, + body: MediaBody, + delivery_report: Optional[DeliveryReportType] = None, + send_at: Optional[datetime] = None, + expire_at: Optional[datetime] = None, + callback_url: Optional[str] = None, + client_reference: Optional[str] = None, + feedback_enabled: Optional[bool] = None, + strict_validation: Optional[bool] = None, + parameters: Optional[Dict[str, Dict[str, str]]] = None, + **kwargs, + ) -> BatchResponse: + """ + Send an MMS batch. + """ + request = MediaRequest( + to=to, + from_=from_, + body=body, + delivery_report=delivery_report, + send_at=send_at, + expire_at=expire_at, + callback_url=callback_url, + client_reference=client_reference, + feedback_enabled=feedback_enabled, + strict_validation=strict_validation, + parameters=parameters, + **kwargs, + ) + return self.send(request=request) + def send_delivery_feedback( self, batch_id: str, recipients: List[str], **kwargs ) -> None: @@ -397,7 +514,7 @@ def send_delivery_feedback( ) return self._request(DeliveryFeedbackEndpoint, request_data) - def _update( + def update( self, batch_id: str, request: Optional[UpdateBatchMessageRequest] = None, @@ -460,7 +577,7 @@ def update_sms( flash_message=flash_message, **kwargs, ) - return self._update(batch_id=batch_id, request=request) + return self.update(batch_id=batch_id, request=request) def update_binary( self, @@ -497,7 +614,7 @@ def update_binary( from_npi=from_npi, **kwargs, ) - return self._update(batch_id=batch_id, request=request) + return self.update(batch_id=batch_id, request=request) def update_mms( self, @@ -535,4 +652,4 @@ def update_mms( strict_validation=strict_validation, **kwargs, ) - return self._update(batch_id=batch_id, request=request) + return self.update(batch_id=batch_id, request=request) diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py index 569d1aac..cca9bf3f 100644 --- a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -1,3 +1,4 @@ +import re from abc import ABC from typing import Annotated, Type, Union, get_origin, get_args from pydantic import TypeAdapter @@ -36,6 +37,25 @@ def build_url(self, sinch) -> str: **vars(self.request_data), ) + def _get_path_params_from_url(self) -> set: + """ + Extracts path parameters from ENDPOINT_URL template. + + Returns: + set: Set of path parameter names that should be excluded from request body. + """ + if not self.ENDPOINT_URL: + return set() + + # Extract all placeholders from the URL template (e.g., {batch_id}, {project_id}) + path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL)) + + # Exclude 'origin' and 'project_id' as they are always path params but not from request_data + path_params.discard("origin") + path_params.discard("project_id") + + return path_params + def build_query_params(self) -> dict: """ Constructs the query parameters for the endpoint. diff --git a/sinch/domains/sms/api/v1/internal/batches_endpoints.py b/sinch/domains/sms/api/v1/internal/batches_endpoints.py index 68ba919e..f6270d69 100644 --- a/sinch/domains/sms/api/v1/internal/batches_endpoints.py +++ b/sinch/domains/sms/api/v1/internal/batches_endpoints.py @@ -148,10 +148,10 @@ def __init__(self, project_id: str, request_data: ReplaceBatchRequest): self.request_data = request_data def request_body(self): - # Used mode='json' to serialize datetime objects to ISO-8601 strings - # Exclude batch_id from body since it's in the URL path + # Use mode='json' to serialize datetime objects to ISO-8601 strings + path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude={"batch_id"} + mode="json", by_alias=True, exclude_none=True, exclude=path_params ) return json.dumps(request_data) @@ -211,8 +211,9 @@ def __init__(self, project_id: str, request_data: DeliveryFeedbackRequest): self.request_data = request_data def request_body(self): + path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - by_alias=True, exclude_none=True + by_alias=True, exclude_none=True, exclude=path_params ) return json.dumps(request_data) @@ -243,9 +244,9 @@ def __init__( def request_body(self): # Use mode='json' to serialize datetime objects to ISO-8601 strings - # Exclude batch_id from body since it's in the URL path + path_params = self._get_path_params_from_url() request_data = self.request_data.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude={"batch_id"} + mode="json", by_alias=True, exclude_none=True, exclude=path_params ) return json.dumps(request_data) diff --git a/sinch/domains/sms/models/v1/internal/dry_run_request.py b/sinch/domains/sms/models/v1/internal/dry_run_request.py index eb05ede4..e33f3e4b 100644 --- a/sinch/domains/sms/models/v1/internal/dry_run_request.py +++ b/sinch/domains/sms/models/v1/internal/dry_run_request.py @@ -1,5 +1,5 @@ from typing import Union, Optional -from pydantic import BaseModel, Field, StrictBool, conint +from pydantic import BaseModel, Field, StrictBool, StrictInt from sinch.domains.sms.models.v1.shared.text_request import TextRequest from sinch.domains.sms.models.v1.shared.binary_request import ( BinaryRequest, @@ -13,10 +13,10 @@ class DryRunMixin(BaseModel): """Mixin that adds dry run query parameters to request models.""" per_recipient: Optional[StrictBool] = Field( - default=False, + default=None, description="Whether to include per recipient details in the response", ) - number_of_recipients: Optional[conint(strict=True, le=1000, ge=0)] = Field( + number_of_recipients: Optional[StrictInt] = Field( default=None, description="Max number of recipients to include per recipient details for in the response", ) diff --git a/sinch/domains/sms/models/v1/internal/list_batches_request.py b/sinch/domains/sms/models/v1/internal/list_batches_request.py index a9e3eed1..ffe38212 100644 --- a/sinch/domains/sms/models/v1/internal/list_batches_request.py +++ b/sinch/domains/sms/models/v1/internal/list_batches_request.py @@ -1,17 +1,15 @@ from typing import Optional from datetime import datetime -from pydantic import Field, StrictStr, conlist, conint, constr +from pydantic import Field, StrictStr, conlist, StrictInt from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationRequest, ) class ListBatchesRequest(BaseModelConfigurationRequest): - page: Optional[conint(strict=True, ge=0)] = 0 - page_size: Optional[conint(strict=True, le=100, ge=1)] = 30 + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None start_date: Optional[datetime] = None end_date: Optional[datetime] = None from_: Optional[conlist(StrictStr)] = Field(default=None, alias="from") - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py index 3c818c04..8594e5aa 100644 --- a/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py +++ b/sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py @@ -1,6 +1,6 @@ from typing import Optional from datetime import datetime -from pydantic import conlist, conint, constr +from pydantic import conlist, StrictInt, StrictStr from sinch.domains.sms.models.v1.types import DeliveryReceiptStatusCodeType from sinch.domains.sms.models.v1.types import DeliveryStatusType from sinch.domains.sms.models.v1.internal.base import ( @@ -9,12 +9,10 @@ class ListDeliveryReportsRequest(BaseModelConfigurationRequest): - page: Optional[conint(strict=True, ge=0)] = 0 - page_size: Optional[conint(strict=True, le=100, ge=1)] = 30 + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None start_date: Optional[datetime] = None end_date: Optional[datetime] = None status: Optional[conlist(DeliveryStatusType)] = None code: Optional[conlist(DeliveryReceiptStatusCodeType)] = None - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/update_binary_request.py b/sinch/domains/sms/models/v1/internal/update_binary_request.py index bc8d8dbb..a73f090f 100644 --- a/sinch/domains/sms/models/v1/internal/update_binary_request.py +++ b/sinch/domains/sms/models/v1/internal/update_binary_request.py @@ -1,6 +1,6 @@ from typing import Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, conint, constr +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt from sinch.domains.sms.models.v1.types import DeliveryReportType from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationRequest, @@ -34,20 +34,16 @@ class UpdateBinaryRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) body: Optional[StrictStr] = Field( @@ -58,11 +54,11 @@ class UpdateBinaryRequest(BaseModelConfigurationRequest): default=..., description="The UDH header of a binary message HEX encoded. Max 140 bytes together with body.", ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number. Use to override the automatic detection.", ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number. Use to override the automatic detection.", ) diff --git a/sinch/domains/sms/models/v1/internal/update_media_request.py b/sinch/domains/sms/models/v1/internal/update_media_request.py index 697b81af..1aa5f6dc 100644 --- a/sinch/domains/sms/models/v1/internal/update_media_request.py +++ b/sinch/domains/sms/models/v1/internal/update_media_request.py @@ -1,6 +1,6 @@ from typing import Dict, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr +from pydantic import Field, StrictBool, StrictStr, conlist from sinch.domains.sms.models.v1.types import DeliveryReportType from sinch.domains.sms.models.v1.shared import MediaBody from sinch.domains.sms.models.v1.internal.base import ( @@ -32,30 +32,24 @@ class UpdateMediaRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) body: Optional[MediaBody] = None - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) strict_validation: Optional[StrictBool] = Field( - default=False, + default=None, description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", ) diff --git a/sinch/domains/sms/models/v1/internal/update_text_request.py b/sinch/domains/sms/models/v1/internal/update_text_request.py index 0ce169b8..81e4e2ea 100644 --- a/sinch/domains/sms/models/v1/internal/update_text_request.py +++ b/sinch/domains/sms/models/v1/internal/update_text_request.py @@ -1,6 +1,12 @@ from typing import Dict, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) from sinch.domains.sms.models.v1.types import DeliveryReportType from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationRequest, @@ -33,40 +39,34 @@ class UpdateTextRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) - body: Optional[constr(strict=True, max_length=2000, min_length=0)] = Field( + body: Optional[StrictStr] = Field( default=None, description="The message content" ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number. Use to override the automatic detection.", ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number. Use to override the automatic detection.", ) - max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + max_number_of_message_parts: Optional[StrictInt] = Field( default=None, description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", ) @@ -75,6 +75,6 @@ class UpdateTextRequest(BaseModelConfigurationRequest): description="If set to true the message will be shortened when exceeding one part.", ) flash_message: Optional[StrictBool] = Field( - default=False, + default=None, description="Shows message on screen without user interaction while not saving the message to the inbox.", ) diff --git a/sinch/domains/sms/models/v1/response/batch_delivery_report.py b/sinch/domains/sms/models/v1/response/batch_delivery_report.py index 22c8f0e5..7a50509f 100644 --- a/sinch/domains/sms/models/v1/response/batch_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/batch_delivery_report.py @@ -1,5 +1,5 @@ from typing import Optional -from pydantic import Field, StrictStr, conlist, conint +from pydantic import Field, StrictStr, conlist, StrictInt from sinch.domains.sms.models.v1.shared import MessageDeliveryStatus from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, @@ -19,7 +19,7 @@ class BatchDeliveryReport(BaseModelConfigurationResponse): default=..., description="Array with status objects. Only status codes with at least one recipient will be listed.", ) - total_message_count: conint(strict=True, ge=0) = Field( + total_message_count: StrictInt = Field( default=..., description="The total number of messages in the batch." ) type: StrictStr = Field( diff --git a/sinch/domains/sms/models/v1/shared/binary_request.py b/sinch/domains/sms/models/v1/shared/binary_request.py index dd500c54..027d98e9 100644 --- a/sinch/domains/sms/models/v1/shared/binary_request.py +++ b/sinch/domains/sms/models/v1/shared/binary_request.py @@ -1,6 +1,6 @@ from typing import Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt from sinch.domains.sms.models.v1.types import ( DeliveryReportType, ) @@ -40,27 +40,23 @@ class BinaryRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch.", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number. Use to override the automatic detection.", ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number. Use to override the automatic detection.", ) diff --git a/sinch/domains/sms/models/v1/shared/binary_response.py b/sinch/domains/sms/models/v1/shared/binary_response.py index eb9a97ac..171b2fd2 100644 --- a/sinch/domains/sms/models/v1/shared/binary_response.py +++ b/sinch/domains/sms/models/v1/shared/binary_response.py @@ -1,6 +1,12 @@ from typing import Literal, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) @@ -10,7 +16,7 @@ class BinaryResponse(BaseModelConfigurationResponse): id: Optional[StrictStr] = Field( default=None, description="Unique identifier for batch." ) - to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + to: Optional[conlist(StrictStr)] = Field( default=None, description="A list of phone numbers and group IDs that have received the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628).", ) @@ -20,7 +26,7 @@ class BinaryResponse(BaseModelConfigurationResponse): description="The sender number provided. Required if the Automatic Default Originator is not configured.", ) canceled: Optional[StrictBool] = Field( - default=False, + default=None, description="Indicates whether or not the batch has been canceled.", ) body: Optional[StrictStr] = Field( @@ -55,25 +61,21 @@ class BinaryResponse(BaseModelConfigurationResponse): default=None, description="If set, the date and time the message will expire. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="The callback URL provided in the request." ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The string input to identify this batch message. If set, the identifier will be added in the delivery report/callback of this batch.", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to true, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number." ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number.", ) diff --git a/sinch/domains/sms/models/v1/shared/media_body.py b/sinch/domains/sms/models/v1/shared/media_body.py index b7a53d21..20f30d98 100644 --- a/sinch/domains/sms/models/v1/shared/media_body.py +++ b/sinch/domains/sms/models/v1/shared/media_body.py @@ -1,20 +1,16 @@ from typing import Optional -from pydantic import Field, constr +from pydantic import Field, StrictStr from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) class MediaBody(BaseModelConfigurationResponse): - subject: Optional[constr(strict=True, max_length=80, min_length=0)] = ( - Field(default=None, description="The subject text") + subject: Optional[StrictStr] = Field( + default=None, description="The subject text" ) - message: Optional[constr(strict=True, max_length=2000, min_length=0)] = ( - Field( - default=None, - description="The message text. Text only media messages will be rejected, please use SMS instead.", - ) - ) - url: constr(strict=True, max_length=2048, min_length=0) = Field( - default=..., description="URL to the media file" + message: Optional[StrictStr] = Field( + default=None, + description="The message text. Text only media messages will be rejected, please use SMS instead.", ) + url: StrictStr = Field(default=..., description="URL to the media file") diff --git a/sinch/domains/sms/models/v1/shared/media_request.py b/sinch/domains/sms/models/v1/shared/media_request.py index baf37844..54ef9203 100644 --- a/sinch/domains/sms/models/v1/shared/media_request.py +++ b/sinch/domains/sms/models/v1/shared/media_request.py @@ -1,6 +1,6 @@ from typing import Dict, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr +from pydantic import Field, StrictBool, StrictStr, conlist from sinch.domains.sms.models.v1.types import ( DeliveryReportType, ) @@ -21,9 +21,7 @@ class MediaRequest(BaseModelConfigurationRequest): description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", ) body: MediaBody = Field(...) - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) @@ -37,23 +35,19 @@ class MediaRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Must be valid URL.", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) strict_validation: Optional[StrictBool] = Field( - default=False, + default=None, description="Whether or not you want the media included in your message to be checked against [Sinch MMS channel best practices](/docs/mms/bestpractices/). If set to true, your message will be rejected if it doesn't conform to the listed recommendations, otherwise no validation will be performed. ", ) diff --git a/sinch/domains/sms/models/v1/shared/media_response.py b/sinch/domains/sms/models/v1/shared/media_response.py index b94ba43a..7da3a82f 100644 --- a/sinch/domains/sms/models/v1/shared/media_response.py +++ b/sinch/domains/sms/models/v1/shared/media_response.py @@ -1,6 +1,6 @@ from typing import Dict, Literal, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr +from pydantic import Field, StrictBool, StrictStr, conlist from sinch.domains.sms.models.v1.types.delivery_report_type import ( DeliveryReportType, ) @@ -14,7 +14,7 @@ class MediaResponse(BaseModelConfigurationResponse): id: Optional[StrictStr] = Field( default=None, description="Unique identifier for batch" ) - to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + to: Optional[conlist(StrictStr)] = Field( default=None, description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", ) @@ -24,13 +24,11 @@ class MediaResponse(BaseModelConfigurationResponse): description="Sender number. Required if Automatic Default Originator not configured.", ) canceled: Optional[StrictBool] = Field( - default=False, + default=None, description="Indicates if the batch has been canceled or not.", ) body: Optional[MediaBody] = None - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) @@ -52,20 +50,16 @@ class MediaResponse(BaseModelConfigurationResponse): default=None, description="If set the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after send_at. YYYY-MM-DDThh:mm:ss.SSSZ format", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Must be valid URL.", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to true then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) strict_validation: Optional[StrictBool] = Field( diff --git a/sinch/domains/sms/models/v1/shared/message_delivery_status.py b/sinch/domains/sms/models/v1/shared/message_delivery_status.py index d172856e..27057a41 100644 --- a/sinch/domains/sms/models/v1/shared/message_delivery_status.py +++ b/sinch/domains/sms/models/v1/shared/message_delivery_status.py @@ -3,17 +3,23 @@ from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) +from sinch.domains.sms.models.v1.types import ( + DeliveryReceiptStatusCodeType, + DeliveryStatusType, +) class MessageDeliveryStatus(BaseModelConfigurationResponse): - code: StrictInt = Field( - default=..., description="The delivery receipt error code." + code: DeliveryReceiptStatusCodeType = Field( + default=..., + description="The detailed [status code](/docs/sms/api-reference/sms/tag/Delivery-reports/#tag/Delivery-reports/section/Delivery-report-error-codes).", ) count: StrictInt = Field( - default=..., description="The number of messages with this status." + default=..., + description="The number of messages that currently has this code.", ) recipients: Optional[conlist(StrictStr)] = Field( default=None, - description="List of phone numbers (MSISDNs) with this status. Only present in full reports.", + description="Only for `full` report. A list of the phone number recipients which messages has this status code.", ) - status: StrictStr = Field(default=..., description="The delivery status.") + status: DeliveryStatusType = Field(..., description="The delivery status.") diff --git a/sinch/domains/sms/models/v1/shared/text_request.py b/sinch/domains/sms/models/v1/shared/text_request.py index 89d6aeeb..2416633b 100644 --- a/sinch/domains/sms/models/v1/shared/text_request.py +++ b/sinch/domains/sms/models/v1/shared/text_request.py @@ -1,6 +1,12 @@ from typing import Dict, Optional from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from pydantic import ( + Field, + StrictBool, + StrictStr, + conlist, + StrictInt, +) from sinch.domains.sms.models.v1.types.delivery_report_type import ( DeliveryReportType, ) @@ -19,15 +25,11 @@ class TextRequest(BaseModelConfigurationRequest): alias="from", description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", ) - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) - body: constr(strict=True, max_length=2000, min_length=0) = Field( - default=..., description="The message content" - ) + body: StrictStr = Field(default=..., description="The message content") type: Optional[StrictStr] = Field( default="mt_text", description="Regular SMS" ) @@ -40,27 +42,23 @@ class TextRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) flash_message: Optional[StrictBool] = Field( - default=False, + default=None, description="Shows message on screen without user interaction while not saving the message to the inbox.", ) - max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + max_number_of_message_parts: Optional[StrictInt] = Field( default=None, description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", ) @@ -68,11 +66,11 @@ class TextRequest(BaseModelConfigurationRequest): default=None, description="If set to `true` the message will be shortened when exceeding one part.", ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number. Use to override the automatic detection.", ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number. Use to override the automatic detection.", ) diff --git a/sinch/domains/sms/models/v1/shared/text_response.py b/sinch/domains/sms/models/v1/shared/text_response.py index 543e1b11..650906e5 100644 --- a/sinch/domains/sms/models/v1/shared/text_response.py +++ b/sinch/domains/sms/models/v1/shared/text_response.py @@ -1,6 +1,6 @@ -from typing import Dict, Literal, Optional +from typing import Dict, Optional, Literal from datetime import datetime -from pydantic import Field, StrictBool, StrictStr, conlist, constr, conint +from pydantic import Field, StrictBool, StrictStr, conlist, StrictInt from sinch.domains.sms.models.v1.types.delivery_report_type import ( DeliveryReportType, ) @@ -13,7 +13,7 @@ class TextResponse(BaseModelConfigurationResponse): id: Optional[StrictStr] = Field( default=None, description="Unique identifier for batch" ) - to: Optional[conlist(StrictStr, min_length=1, max_length=1000)] = Field( + to: Optional[conlist(StrictStr)] = Field( default=None, description="List of Phone numbers and group IDs that will receive the batch. [More info](https://community.sinch.com/t5/Glossary/MSISDN/ta-p/7628)", ) @@ -23,16 +23,14 @@ class TextResponse(BaseModelConfigurationResponse): description="Sender number. Must be valid phone number, short code or alphanumeric. Required if Automatic Default Originator not configured.", ) canceled: Optional[StrictBool] = Field( - default=False, + default=None, description="Indicates if the batch has been canceled or not.", ) - parameters: Optional[ - Dict[str, Dict[str, constr(strict=True, max_length=1600)]] - ] = Field( + parameters: Optional[Dict[StrictStr, Dict[StrictStr, StrictStr]]] = Field( default=None, description="Contains the parameters that will be used for customizing the message for each recipient. [Click here to learn more about parameterization](/docs/sms/resources/message-info/message-parameterization).", ) - body: Optional[constr(strict=True, max_length=2000, min_length=0)] = Field( + body: Optional[StrictStr] = Field( default=None, description="The message content" ) type: Literal["mt_text"] = Field(default=..., description="Regular SMS") @@ -53,39 +51,35 @@ class TextResponse(BaseModelConfigurationResponse): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + callback_url: Optional[StrictStr] = Field( default=None, description="Override the default callback URL for this batch. Must be valid URL.", ) - client_reference: Optional[ - constr(strict=True, max_length=2048, min_length=0) - ] = Field( + client_reference: Optional[StrictStr] = Field( default=None, description="The client identifier of a batch message. If set, the identifier will be added in the delivery report/callback of this batch", ) feedback_enabled: Optional[StrictBool] = Field( - default=False, + default=None, description="If set to `true`, then [feedback](/docs/sms/api-reference/sms/tag/Batches/#tag/Batches/operation/deliveryFeedback) is expected after successful delivery.", ) flash_message: Optional[StrictBool] = Field( - default=False, + default=None, description="Shows message on screen without user interaction while not saving the message to the inbox.", ) truncate_concat: Optional[StrictBool] = Field( default=None, description="If set to `true` the message will be shortened when exceeding one part.", ) - max_number_of_message_parts: Optional[conint(strict=True, ge=1)] = Field( + max_number_of_message_parts: Optional[StrictInt] = Field( default=None, description="Message will be dispatched only if it is not split to more parts than Max Number of Message Parts", ) - from_ton: Optional[conint(strict=True, le=6, ge=0)] = Field( + from_ton: Optional[StrictInt] = Field( default=None, description="The type of number for the sender number. Use to override the automatic detection.", ) - from_npi: Optional[conint(strict=True, le=18, ge=0)] = Field( + from_npi: Optional[StrictInt] = Field( default=None, description="Number Plan Indicator for the sender number. Use to override the automatic detection.", ) diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index 5013fe68..f53bab95 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -1,17 +1,7 @@ -from datetime import datetime -from typing import Optional, List, Dict from sinch.domains.sms.api.v1 import ( Batches, DeliveryReports, ) -from sinch.domains.sms.models.delivery_reports import DeliveryReport -from sinch.domains.sms.models.v1.shared import ( - BinaryRequest, - MediaRequest, - MediaBody, - TextRequest, -) -from sinch.domains.sms.models.v1.types import BatchResponse class SMS: @@ -25,112 +15,3 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) - - # ====== High-Level Convenience Methods ====== - - # ====== Batches Operations ====== - def send_sms_batch( - self, - to: List[str], - from_: str, - body: str, - delivery_report: Optional[DeliveryReport] = None, - send_at: Optional[datetime] = None, - expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, - client_reference: Optional[str] = None, - feedback_enabled: Optional[bool] = None, - flash_message: Optional[bool] = None, - max_number_of_message_parts: Optional[int] = None, - truncate_concat: Optional[bool] = None, - from_ton: Optional[int] = None, - from_npi: Optional[int] = None, - parameters: Optional[Dict[str, Dict[str, str]]] = None, - **kwargs, - ) -> BatchResponse: - return self.batches._send( - request=TextRequest( - to=to, - from_=from_, - body=body, - delivery_report=delivery_report, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - client_reference=client_reference, - feedback_enabled=feedback_enabled, - flash_message=flash_message, - max_number_of_message_parts=max_number_of_message_parts, - truncate_concat=truncate_concat, - from_ton=from_ton, - from_npi=from_npi, - parameters=parameters, - ), - **kwargs, - ) - - def send_binary_batch( - self, - to: List[str], - from_: str, - body: str, - udh: str, - delivery_report: Optional[DeliveryReport] = None, - send_at: Optional[datetime] = None, - expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, - client_reference: Optional[str] = None, - feedback_enabled: Optional[bool] = None, - from_ton: Optional[int] = None, - from_npi: Optional[int] = None, - **kwargs, - ): - return self.batches._send( - request=BinaryRequest( - to=to, - from_=from_, - body=body, - udh=udh, - delivery_report=delivery_report, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - client_reference=client_reference, - feedback_enabled=feedback_enabled, - from_ton=from_ton, - from_npi=from_npi, - ), - **kwargs, - ) - - def send_mms_batch( - self, - to: List[str], - from_: str, - body: MediaBody, - delivery_report: Optional[DeliveryReport] = None, - send_at: Optional[datetime] = None, - expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, - client_reference: Optional[str] = None, - feedback_enabled: Optional[bool] = None, - strict_validation: Optional[bool] = None, - parameters: Optional[Dict[str, Dict[str, str]]] = None, - **kwargs, - ) -> BatchResponse: - return self.batches._send( - request=MediaRequest( - to=to, - from_=from_, - body=body, - delivery_report=delivery_report, - send_at=send_at, - expire_at=expire_at, - callback_url=callback_url, - client_reference=client_reference, - feedback_enabled=feedback_enabled, - strict_validation=strict_validation, - parameters=parameters, - ), - **kwargs, - ) diff --git a/tests/e2e/sms/features/steps/batches.steps.py b/tests/e2e/sms/features/steps/batches.steps.py index f3a83bdb..bf59f4a0 100644 --- a/tests/e2e/sms/features/steps/batches.steps.py +++ b/tests/e2e/sms/features/steps/batches.steps.py @@ -44,7 +44,7 @@ def step_sms_service_batches_available_with_service_plan(context): @when('I send a request to send a text message') def step_send_text_message(context): """Send a text message""" - context.response = context.sms.send_sms_batch( + context.response = context.sms.batches.send_sms( body='SMS body message', to=['+12017777777'], from_='+12015555555', @@ -77,7 +77,7 @@ def step_validate_text_sms_details(context): @when('I send a request to send a text message with multiple parameters') def step_send_text_message_with_parameters(context): """Send a text message with multiple parameters""" - context.response = context.sms.send_sms_batch( + context.response = context.sms.batches.send_sms( body='Hello ${name}! Get 20% off with this discount code ${code}', to=['+12017777777', '+12018888888'], from_='+12015555555', @@ -326,7 +326,7 @@ def step_validate_replaced_batch_details(context): assert batch.delivery_report == 'none' assert batch.send_at == datetime(2024, 6, 6, 9, 35, 0, tzinfo=timezone.utc) assert batch.expire_at == datetime(2024, 6, 9, 9, 35, 0, tzinfo=timezone.utc) - assert batch.feedback_enabled is False + assert batch.feedback_enabled is None assert isinstance(batch, TextResponse) assert batch.flash_message is False diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py new file mode 100644 index 00000000..3fe94ced --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_cancel_batch_message_endpoint.py @@ -0,0 +1,141 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import CancelBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal import BatchIdRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return BatchIdRequest(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": True, + "body": "SMS body message", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Batch not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return CancelBatchMessageEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_build_url_with_different_batch_id(mock_sinch_client_sms): + """Test that the URL is built correctly with different batch_id.""" + request_data = BatchIdRequest(batch_id="01W4FFL35P4NC4K35SMSBATCH1") + endpoint = CancelBatchMessageEndpoint("test_project_id", request_data) + + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01W4FFL35P4NC4K35SMSBATCH1" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """ + Check if response is handled and mapped to the appropriate fields correctly. + """ + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["12017777777"] + assert parsed_response.from_ == "12015555555" + assert parsed_response.canceled is True + assert parsed_response.body == "SMS body message" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + expected_created_at = datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + expected_modified_at = datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + expected_send_at = datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc) + expected_expire_at = datetime(2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc) + + assert parsed_response.created_at == expected_created_at + assert parsed_response.modified_at == expected_modified_at + assert parsed_response.send_at == expected_send_at + assert parsed_response.expire_at == expected_expire_at + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 + + +def test_handle_response_expects_canceled_batch(endpoint): + """ + Test that a canceled batch response is correctly parsed. + """ + canceled_response = HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["12017777777"], + "from": "12015555555", + "canceled": True, + "body": "SMS body message", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + }, + headers={"Content-Type": "application/json"}, + ) + + parsed_response = endpoint.handle_response(canceled_response) + assert parsed_response.canceled is True + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py new file mode 100644 index 00000000..736704ce --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_dry_run_batches_endpoint.py @@ -0,0 +1,243 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import DryRunEndpoint +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.shared import ( + DryRunPerRecipientDetails, + MediaBody, +) + + +@pytest.fixture +def text_request_data(): + return DryRunTextRequest( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return DryRunBinaryRequest( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return DryRunMediaRequest( + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_dry_run_response(): + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=[ + DryRunPerRecipientDetails( + recipient="+46701234567", + body="Your order #12345 has been shipped", + number_of_parts=1, + encoding="text", + ), + DryRunPerRecipientDetails( + recipient="+46709876543", + body="Reminder: Your appointment is tomorrow at 2 PM", + number_of_parts=1, + encoding="text", + ), + ], + ) + + +@pytest.fixture +def mock_dry_run_response_without_per_recipient(): + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=None, + ) + + +@pytest.fixture +def mock_http_response_for_dry_run(): + return HTTPResponse( + status_code=200, + body={ + "number_of_recipients": 2, + "number_of_messages": 1, + "per_recipient": [ + { + "recipient": "+46701234567", + "body": "Your order #12345 has been shipped", + "number_of_parts": 1, + "encoding": "text", + }, + { + "recipient": "+46709876543", + "body": "Reminder: Your appointment is tomorrow at 2 PM", + "number_of_parts": 1, + "encoding": "text", + }, + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_http_response_without_per_recipient(): + return HTTPResponse( + status_code=200, + body={ + "number_of_recipients": 2, + "number_of_messages": 1, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return DryRunEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/dry_run" + ) + + +def test_build_query_params_expects_per_recipient_and_number_of_recipients( + text_request_data, +): + """Test that query parameters are extracted correctly.""" + text_request_data.per_recipient = True + text_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + query_params = endpoint.build_query_params() + + assert query_params == {"per_recipient": True, "number_of_recipients": 100} + + +def test_build_query_params_expects_excludes_none_values(text_request_data): + """Test that None values are excluded from query parameters.""" + text_request_data.per_recipient = None + text_request_data.number_of_recipients = None + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + query_params = endpoint.build_query_params() + + assert query_params == {} + + +def test_request_body_expects_excludes_query_params(text_request_data): + """Test that query params are excluded from request body.""" + text_request_data.per_recipient = True + text_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + binary_request_data.per_recipient = False + binary_request_data.number_of_recipients = 100 + + endpoint = DryRunEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + media_request_data.per_recipient = True + media_request_data.number_of_recipients = 50 + + endpoint = DryRunEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "per_recipient" not in body + assert "number_of_recipients" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping( + endpoint, mock_http_response_for_dry_run +): + """Test that response is correctly mapped to DryRunResponse.""" + parsed_response = endpoint.handle_response(mock_http_response_for_dry_run) + + assert isinstance(parsed_response, DryRunResponse) + assert parsed_response.number_of_recipients == 2 + assert parsed_response.number_of_messages == 1 + assert parsed_response.per_recipient is not None + assert len(parsed_response.per_recipient) == 2 + assert parsed_response.per_recipient[0].recipient == "+46701234567" + assert ( + parsed_response.per_recipient[0].body + == "Your order #12345 has been shipped" + ) + assert parsed_response.per_recipient[0].number_of_parts == 1 + + +def test_handle_response_expects_response_without_per_recipient( + endpoint, mock_http_response_without_per_recipient +): + """Test that response without per_recipient is handled correctly.""" + parsed_response = endpoint.handle_response( + mock_http_response_without_per_recipient + ) + + assert isinstance(parsed_response, DryRunResponse) + assert parsed_response.number_of_recipients == 2 + assert parsed_response.number_of_messages == 1 + assert parsed_response.per_recipient is None diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py new file mode 100644 index 00000000..157b55f0 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_get_batch_message_endpoint.py @@ -0,0 +1,76 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import GetBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal import BatchIdRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return BatchIdRequest(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetBatchMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py new file mode 100644 index 00000000..f983429c --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_list_batches_endpoint.py @@ -0,0 +1,146 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ListBatchesEndpoint +from sinch.domains.sms.models.v1.internal import ListBatchesRequest +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) +from datetime import datetime, timezone + + +@pytest.fixture +def request_data(): + return ListBatchesRequest( + page=0, + page_size=10, + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 2, + "page": 0, + "page_size": 10, + "batches": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + }, + { + "id": "01W4FFL35P4NC4K35SMSBATCH1", + "to": ["+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your order #12345 has been shipped", + "type": "mt_text", + "created_at": "2024-06-07T10:15:30.123Z", + "modified_at": "2024-06-07T10:15:35.456Z", + }, + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListBatchesEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches" + ) + + +def test_build_query_params_expects_all_params(): + """Test that query parameters are extracted correctly.""" + request_data = ListBatchesRequest( + page=1, + page_size=20, + start_date=datetime(2024, 6, 1, tzinfo=timezone.utc), + end_date=datetime(2024, 6, 30, tzinfo=timezone.utc), + from_=["+46701111111", "+46702222222"], + client_reference="test_ref_123", + ) + + endpoint = ListBatchesEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert query_params["page"] == 1 + assert query_params["page_size"] == 20 + assert query_params["from"] == ["+46701111111", "+46702222222"] + assert query_params["client_reference"] == "test_ref_123" + assert "start_date" in query_params + assert "end_date" in query_params + + +def test_build_query_params_expects_excludes_none_values(request_data): + """Test that None values are excluded from query parameters.""" + endpoint = ListBatchesEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert "start_date" not in query_params + assert "end_date" not in query_params + assert "from" not in query_params + assert "client_reference" not in query_params + assert query_params["page"] == 0 + assert query_params["page_size"] == 10 + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, ListBatchesResponse) + assert parsed_response.count == 2 + assert parsed_response.page == 0 + assert parsed_response.page_size == 10 + assert parsed_response.batches is not None + assert len(parsed_response.batches) == 2 + + first_batch = parsed_response.batches[0] + assert first_batch.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first_batch.to == ["+46701234567"] + assert first_batch.from_ == "+46701111111" + assert first_batch.body == "Your verification code is 123456" + assert first_batch.type == "mt_text" + + second_batch = parsed_response.batches[1] + assert second_batch.id == "01W4FFL35P4NC4K35SMSBATCH1" + assert second_batch.to == ["+46709876543"] + assert second_batch.body == "Your order #12345 has been shipped" + + +def test_handle_response_expects_empty_batches_list(): + """Test that response with empty batches list is handled correctly.""" + request_data = ListBatchesRequest(page=0, page_size=10) + endpoint = ListBatchesEndpoint("test_project_id", request_data) + + empty_response = HTTPResponse( + status_code=200, + body={ + "count": 0, + "page": 0, + "page_size": 10, + "batches": [], + }, + headers={"Content-Type": "application/json"}, + ) + + parsed_response = endpoint.handle_response(empty_response) + assert isinstance(parsed_response, ListBatchesResponse) + assert parsed_response.count == 0 + assert parsed_response.batches == [] + assert len(parsed_response.content) == 0 diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py new file mode 100644 index 00000000..d796a2f6 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_replace_batches_endpoint.py @@ -0,0 +1,168 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import ReplaceBatchEndpoint +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody +from datetime import datetime, timezone + + +@pytest.fixture +def text_request_data(): + return ReplaceTextRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return ReplaceBinaryRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return ReplaceMediaRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return ReplaceBatchEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_request_body_expects_excludes_batch_id(text_request_data): + """Test that the request body is correct for a text request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that the request body is correct for a text request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert body["to"] == ["+46701234567", "+46709876543"] + assert body["from"] == "+46701111111" + assert body["body"] == "Your verification code is 123456" + assert body["type"] == "mt_text" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that the request body is correct for a binary request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that the request body is correct for a media request.""" + endpoint = ReplaceBatchEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567", "+46709876543"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py new file mode 100644 index 00000000..f20303b1 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py @@ -0,0 +1,152 @@ +import json +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import SendSMSEndpoint +from sinch.domains.sms.models.v1.shared.text_request import TextRequest +from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest +from sinch.domains.sms.models.v1.shared.media_request import MediaRequest +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def text_request_data(): + return TextRequest( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Your verification code is 123456", + ) + + +@pytest.fixture +def binary_request_data(): + return BinaryRequest( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + +@pytest.fixture +def media_request_data(): + return MediaRequest( + to=["+46701234567"], + from_="+46701111111", + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=201, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from": "+46701111111", + "canceled": False, + "body": "Your verification code is 123456", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:14.304Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return SendSMSEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches" + ) + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that text request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_text" + assert body["to"] == ["+46701234567", "+46709876543"] + assert body["from"] == "+46701111111" + assert body["body"] == "Your verification code is 123456" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", binary_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "SGVsbG8gV29ybGQh" + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + endpoint = SendSMSEndpoint("test_project_id", media_request_data) + body = json.loads(endpoint.request_body()) + + assert "to" in body + assert "from" in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/image.jpg" + assert body["body"]["message"] == "Check out this image!" + assert body["body"]["subject"] == "Image" + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567", "+46709876543"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Your verification code is 123456" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py new file mode 100644 index 00000000..90d7f4a4 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_send_delivery_feedback_endpoint.py @@ -0,0 +1,77 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import DeliveryFeedbackEndpoint +from sinch.domains.sms.models.v1.internal import DeliveryFeedbackRequest + + +@pytest.fixture +def request_data(): + return DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567", "+46709876543"], + ) + + +@pytest.fixture +def endpoint(request_data): + return DeliveryFeedbackEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ/delivery_feedback" + ) + + +def test_request_body_expects_correct_data(request_data): + """Test that the request body contains correct fields.""" + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "recipients" in body + assert body["recipients"] == ["+46701234567", "+46709876543"] + + +def test_request_body_expects_single_recipient(): + """Test that the request body works with a single recipient.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567"], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["recipients"] == ["+46701234567"] + + +def test_request_body_expects_empty_recipients(): + """Test that the request body works with empty recipients list.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=[], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["recipients"] == [] + + +def test_handle_response_expects_success_with_empty_body(): + """Test that response with 202 status code and empty body is handled correctly.""" + request_data = DeliveryFeedbackRequest( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567"], + ) + endpoint = DeliveryFeedbackEndpoint("test_project_id", request_data) + + empty_response = HTTPResponse( + status_code=202, + headers={}, + ) + + result = endpoint.handle_response(empty_response) + assert result is None diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py new file mode 100644 index 00000000..0164b159 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_update_batches_endpoint.py @@ -0,0 +1,174 @@ +import json +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.internal import UpdateBatchMessageEndpoint +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def text_request_data(): + return UpdateTextRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body="Updated verification code: 789012", + ) + + +@pytest.fixture +def binary_request_data(): + return UpdateBinaryRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + udh="06050423F423F4", + body="VXBkYXRlZCBiaW5hcnkgZGF0YQ==", + ) + + +@pytest.fixture +def media_request_data(): + return UpdateMediaRequestWithBatchId( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body=MediaBody( + url="https://capybara.com/updated-image.jpg", + message="Updated image message!", + subject="Updated Image", + ), + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567"], + "from": "+46701111111", + "canceled": False, + "body": "Updated verification code: 789012", + "type": "mt_text", + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T10:30:00.123Z", + "delivery_report": "full", + "send_at": "2024-06-06T09:25:00Z", + "expire_at": "2024-06-09T09:25:00Z", + "feedback_enabled": True, + "flash_message": False, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(text_request_data): + return UpdateBatchMessageEndpoint("test_project_id", text_request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/batches/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_request_body_expects_excludes_batch_id(text_request_data): + """Test that batch_id is excluded from request body.""" + endpoint = UpdateBatchMessageEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "body" in body + assert body["body"] == "Updated verification code: 789012" + + +def test_request_body_expects_text_request_data(text_request_data): + """Test that text request body contains correct fields.""" + text_request_data.from_ = "+46702222222" + text_request_data.to_add = ["+46709999999"] + text_request_data.to_remove = ["+46708888888"] + + endpoint = UpdateBatchMessageEndpoint("test_project_id", text_request_data) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + assert body["to_remove"] == ["+46708888888"] + assert body["body"] == "Updated verification code: 789012" + + +def test_request_body_expects_binary_request_data(binary_request_data): + """Test that binary request body contains correct fields.""" + binary_request_data.from_ = "+46702222222" + binary_request_data.to_add = ["+46709999999"] + + endpoint = UpdateBatchMessageEndpoint( + "test_project_id", binary_request_data + ) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "udh" in body + assert body["type"] == "mt_binary" + assert body["udh"] == "06050423F423F4" + assert body["body"] == "VXBkYXRlZCBiaW5hcnkgZGF0YQ==" + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + + +def test_request_body_expects_media_request_data(media_request_data): + """Test that media request body contains correct fields.""" + media_request_data.from_ = "+46702222222" + media_request_data.to_add = ["+46709999999"] + + endpoint = UpdateBatchMessageEndpoint( + "test_project_id", media_request_data + ) + body = json.loads(endpoint.request_body()) + + assert "batch_id" not in body + assert "body" in body + assert body["type"] == "mt_media" + assert "url" in body["body"] + assert "message" in body["body"] + assert "subject" in body["body"] + assert body["body"]["url"] == "https://capybara.com/updated-image.jpg" + assert body["body"]["message"] == "Updated image message!" + assert body["body"]["subject"] == "Updated Image" + assert body["from"] == "+46702222222" + assert body["to_add"] == ["+46709999999"] + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed_response = endpoint.handle_response(mock_response) + + assert isinstance(parsed_response, TextResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.to == ["+46701234567"] + assert parsed_response.from_ == "+46701111111" + assert parsed_response.canceled is False + assert parsed_response.body == "Updated verification code: 789012" + assert parsed_response.type == "mt_text" + assert parsed_response.delivery_report == "full" + assert parsed_response.feedback_enabled is True + assert parsed_response.flash_message is False + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 10, 30, 0, 123000, tzinfo=timezone.utc + ) + assert parsed_response.send_at == datetime( + 2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc + ) + assert parsed_response.expire_at == datetime( + 2024, 6, 9, 9, 25, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py index 45ec3d3b..2ead9c16 100644 --- a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py +++ b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py @@ -48,7 +48,9 @@ class TestModel(BaseModelConfigurationRequest): status: str = "" code: list[int] = [] - model = TestModel(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[]) + model = TestModel( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[] + ) result = model_dump_for_query_params(model) assert "batch_id" in result diff --git a/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py new file mode 100644 index 00000000..34a75ed4 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_batch_id_request_model.py @@ -0,0 +1,33 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import BatchIdRequest + + +def test_batch_id_request_expects_valid_batch_id(): + """Test that the model correctly parses a valid batch_id.""" + batch_id = "01FC66621XXXXX119Z8PMV1QPQ" + request = BatchIdRequest(batch_id=batch_id) + + assert request.batch_id == batch_id + + +def test_batch_id_request_expects_batch_id_as_string(): + """Test that batch_id must be a string.""" + with pytest.raises(ValidationError): + BatchIdRequest(batch_id=12345) + + with pytest.raises(ValidationError): + BatchIdRequest(batch_id=None) + + +def test_batch_id_request_expects_model_dump(): + """Test that model_dump correctly serializes the request.""" + batch_id = "01W4FFL35P4NC4K35SMSBATCH1" + request = BatchIdRequest(batch_id=batch_id) + + dumped = request.model_dump(by_alias=True) + # batch_id field doesn't have an alias, so it stays as snake_case + assert dumped["batch_id"] == batch_id + + dumped_no_alias = request.model_dump(by_alias=False) + assert dumped_no_alias["batch_id"] == batch_id diff --git a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py new file mode 100644 index 00000000..2ee6f9d8 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py @@ -0,0 +1,357 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, + DryRunRequest, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_text_request_data(): + return { + "to": ["+12017777777", "+12018888888"], + "from_": "+12015555555", + "body": "Hello World!", + } + + +@pytest.fixture +def sample_binary_request_data(): + return { + "to": ["+12017777777"], + "from_": "+12015555555", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + + +@pytest.fixture +def sample_media_request_data(): + return { + "to": ["+12017777777"], + "from_": "+12015555555", + "body": MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + } + + +class TestDryRunMixin: + """Tests for DryRunMixin fields (per_recipient and number_of_recipients).""" + + def test_dry_run_mixin_expects_per_recipient_defaults_and_values( + self, sample_text_request_data + ): + """Test per_recipient defaults to False and can be set to True/False.""" + # Default value + request = DryRunTextRequest(**sample_text_request_data) + assert request.per_recipient is None + + request = DryRunTextRequest( + **sample_text_request_data, per_recipient=True + ) + assert request.per_recipient is True + + request = DryRunTextRequest( + **sample_text_request_data, per_recipient=False + ) + assert request.per_recipient is False + + def test_dry_run_mixin_expects_number_of_recipients_defaults_to_none( + self, sample_text_request_data + ): + """Test that number_of_recipients defaults to None.""" + request = DryRunTextRequest(**sample_text_request_data) + assert request.number_of_recipients is None + + @pytest.mark.parametrize( + "number_of_recipients", + [0, 100, 1000], + ) + def test_dry_run_mixin_expects_valid_number_of_recipients( + self, sample_text_request_data, number_of_recipients + ): + """Test that number_of_recipients accepts valid values (0-1000).""" + request = DryRunTextRequest( + **sample_text_request_data, + number_of_recipients=number_of_recipients, + ) + assert request.number_of_recipients == number_of_recipients + + + def test_dry_run_mixin_expects_number_of_recipients_not_string( + self, sample_text_request_data + ): + """Test that number_of_recipients must be an integer.""" + with pytest.raises(ValidationError): + DryRunTextRequest( + **sample_text_request_data, number_of_recipients="100" + ) + + +class TestDryRunTextRequest: + """Tests for DryRunTextRequest model.""" + + def test_dry_run_text_request_expects_valid_inputs_and_all_fields( + self, sample_text_request_data + ): + """Test DryRunTextRequest with valid inputs and all optional fields.""" + request = DryRunTextRequest(**sample_text_request_data) + assert request.to == sample_text_request_data["to"] + assert request.from_ == sample_text_request_data["from_"] + assert request.body == sample_text_request_data["body"] + assert request.type == "mt_text" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunTextRequest( + **sample_text_request_data, + per_recipient=True, + number_of_recipients=50, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/callback", + client_reference="test-ref", + feedback_enabled=True, + flash_message=False, + max_number_of_message_parts=3, + truncate_concat=True, + from_ton=1, + from_npi=1, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 50 + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/callback" + assert request.client_reference == "test-ref" + assert request.feedback_enabled is True + assert request.flash_message is False + assert request.max_number_of_message_parts == 3 + assert request.truncate_concat is True + assert request.from_ton == 1 + assert request.from_npi == 1 + + def test_dry_run_text_request_expects_required_fields_and_inheritance( + self, sample_text_request_data + ): + """Test required fields validation and inheritance from TextRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunTextRequest() + assert "to" in str(exc_info.value) or "body" in str(exc_info.value) + + request = DryRunTextRequest(**sample_text_request_data) + # Verify TextRequest fields + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + assert hasattr(request, "send_at") + assert hasattr(request, "expire_at") + # Verify DryRunMixin fields + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunBinaryRequest: + """Tests for DryRunBinaryRequest model.""" + + def test_dry_run_binary_request_expects_valid_inputs_and_all_fields( + self, sample_binary_request_data + ): + """Test DryRunBinaryRequest with valid inputs and all optional fields.""" + request = DryRunBinaryRequest(**sample_binary_request_data) + assert request.to == sample_binary_request_data["to"] + assert request.from_ == sample_binary_request_data["from_"] + assert request.body == sample_binary_request_data["body"] + assert request.udh == sample_binary_request_data["udh"] + assert request.type == "mt_binary" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunBinaryRequest( + **sample_binary_request_data, + per_recipient=True, + number_of_recipients=25, + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/callback", + client_reference="binary-ref", + feedback_enabled=False, + from_ton=0, + from_npi=1, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 25 + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/callback" + assert request.client_reference == "binary-ref" + assert request.feedback_enabled is False + assert request.from_ton == 0 + assert request.from_npi == 1 + + def test_dry_run_binary_request_expects_required_fields_and_inheritance( + self, sample_binary_request_data + ): + """Test required fields validation and inheritance from BinaryRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunBinaryRequest() + error_str = str(exc_info.value) + assert "to" in error_str or "body" in error_str or "udh" in error_str + + request = DryRunBinaryRequest(**sample_binary_request_data) + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "udh") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunMediaRequest: + """Tests for DryRunMediaRequest model.""" + + def test_dry_run_media_request_expects_valid_inputs_and_all_fields( + self, sample_media_request_data + ): + """Test DryRunMediaRequest with valid inputs and all optional fields.""" + request = DryRunMediaRequest(**sample_media_request_data) + assert request.to == sample_media_request_data["to"] + assert request.from_ == sample_media_request_data["from_"] + assert isinstance(request.body, MediaBody) + assert request.body.url == sample_media_request_data["body"].url + assert request.type == "mt_media" + assert request.per_recipient is None + assert request.number_of_recipients is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = DryRunMediaRequest( + **sample_media_request_data, + per_recipient=True, + number_of_recipients=75, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/callback", + client_reference="media-ref", + feedback_enabled=True, + ) + + assert request.per_recipient is True + assert request.number_of_recipients == 75 + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/callback" + assert request.client_reference == "media-ref" + assert request.feedback_enabled is True + + def test_dry_run_media_request_expects_required_fields_and_inheritance( + self, sample_media_request_data + ): + """Test required fields validation and inheritance from MediaRequest.""" + with pytest.raises(ValidationError) as exc_info: + DryRunMediaRequest() + assert "to" in str(exc_info.value) or "body" in str(exc_info.value) + + request = DryRunMediaRequest(**sample_media_request_data) + assert hasattr(request, "to") + assert hasattr(request, "from_") + assert hasattr(request, "body") + assert hasattr(request, "type") + assert hasattr(request, "delivery_report") + + assert hasattr(request, "per_recipient") + assert hasattr(request, "number_of_recipients") + + +class TestDryRunRequestUnion: + """Tests for DryRunRequest Union type.""" + + def test_dry_run_request_union_expects_accepts_text_request_object( + self, sample_text_request_data + ): + """Test that DryRunRequest Union accepts DryRunTextRequest object.""" + from pydantic import TypeAdapter + + text_request = DryRunTextRequest(**sample_text_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(text_request.model_dump()) + assert isinstance(validated, DryRunTextRequest) + + def test_dry_run_request_union_expects_accepts_binary_request_object( + self, sample_binary_request_data + ): + """Test that DryRunRequest Union accepts DryRunBinaryRequest object.""" + from pydantic import TypeAdapter + + binary_request = DryRunBinaryRequest(**sample_binary_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(binary_request.model_dump()) + assert isinstance(validated, DryRunBinaryRequest) + + def test_dry_run_request_union_expects_accepts_media_request_object( + self, sample_media_request_data + ): + """Test that DryRunRequest Union accepts DryRunMediaRequest object.""" + from pydantic import TypeAdapter + + media_request = DryRunMediaRequest(**sample_media_request_data) + adapter = TypeAdapter(DryRunRequest) + validated = adapter.validate_python(media_request.model_dump()) + assert isinstance(validated, DryRunMediaRequest) + + def test_dry_run_request_union_expects_accepts_dict_inputs( + self, + sample_text_request_data, + sample_binary_request_data, + sample_media_request_data, + ): + """Test that DryRunRequest Union accepts dict input for all types.""" + from pydantic import TypeAdapter + + adapter = TypeAdapter(DryRunRequest) + + validated = adapter.validate_python(sample_text_request_data) + assert isinstance(validated, DryRunTextRequest) + + validated = adapter.validate_python(sample_binary_request_data) + assert isinstance(validated, DryRunBinaryRequest) + + media_data = sample_media_request_data.copy() + media_data["body"] = media_data["body"].model_dump() + validated = adapter.validate_python(media_data) + assert isinstance(validated, DryRunMediaRequest) + + def test_dry_run_request_union_expects_rejects_invalid_dict(self): + """Test that DryRunRequest Union rejects invalid dict.""" + from pydantic import TypeAdapter, ValidationError + + adapter = TypeAdapter(DryRunRequest) + with pytest.raises(ValidationError): + adapter.validate_python({"invalid": "data"}) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py index a1f8b144..d6a19289 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_list_delivery_reports_request_model.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone import pytest from pydantic import ValidationError from sinch.domains.sms.models.v1.internal import ListDeliveryReportsRequest @@ -7,8 +7,8 @@ def test_list_delivery_reports_request_expects_defaults(): """Test that the model correctly sets default values.""" model = ListDeliveryReportsRequest() - assert model.page == 0 - assert model.page_size == 30 + assert model.page is None + assert model.page_size is None assert model.start_date is None assert model.end_date is None assert model.status is None @@ -38,34 +38,3 @@ def test_list_delivery_reports_request_expects_parsed_input(): assert model.status == ["DELIVERED", "FAILED"] assert model.code == [401, 402] assert model.client_reference == "my-client-ref" - - -@pytest.mark.parametrize( - "page, expected_error", - [ - (-1, ValidationError), - (-10, ValidationError), - ], -) -def test_list_delivery_reports_request_expects_validation_error_for_invalid_page( - page, expected_error -): - """Test that invalid page values raise ValidationError.""" - with pytest.raises(expected_error): - ListDeliveryReportsRequest(page=page) - - -@pytest.mark.parametrize( - "page_size, expected_error", - [ - (0, ValidationError), - (101, ValidationError), - (-1, ValidationError), - ], -) -def test_list_delivery_reports_request_expects_validation_error_for_invalid_page_size( - page_size, expected_error -): - """Test that invalid page_size values raise ValidationError.""" - with pytest.raises(expected_error): - ListDeliveryReportsRequest(page_size=page_size) diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py index 8a9503c4..242c4d0a 100644 --- a/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py +++ b/tests/unit/domains/sms/v1/models/response/test_batch_delivery_report_model.py @@ -206,23 +206,6 @@ def test_batch_delivery_report_expects_validation_error_for_missing_type(): assert "type" in str(exc_info.value) -def test_batch_delivery_report_expects_validation_error_for_negative_total_message_count(): - """ - Test that negative total_message_count raises a ValidationError. - """ - data = { - "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", - "statuses": [{"code": 401, "count": 1, "status": "Dispatched"}], - "total_message_count": -1, - "type": "delivery_report_sms", - } - - with pytest.raises(ValidationError) as exc_info: - BatchDeliveryReport(**data) - - assert "total_message_count" in str(exc_info.value) - - def test_batch_delivery_report_expects_empty_statuses(): """ Test that empty statuses list is allowed. diff --git a/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py index f09cb808..7f188c46 100644 --- a/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py +++ b/tests/unit/domains/sms/v1/models/response/test_batch_response_model.py @@ -69,19 +69,19 @@ def test_batch_response_expects_parses_all_response_types( Verifies discriminator routes correctly based on type field. """ adapter = TypeAdapter(BatchResponse) - + text_response = adapter.validate_python(text_response_data) assert isinstance(text_response, TextResponse) assert text_response.type == "mt_text" assert text_response.body == "Hello World!" assert text_response.delivery_report == "full" - + binary_response = adapter.validate_python(binary_response_data) assert isinstance(binary_response, BinaryResponse) assert not isinstance(binary_response, TextResponse) assert binary_response.type == "mt_binary" assert binary_response.udh == "06050423F423F4" - + media_response = adapter.validate_python(media_response_data) assert isinstance(media_response, MediaResponse) assert media_response.type == "mt_media" @@ -98,7 +98,7 @@ def test_batch_response_expects_text_response_variations(text_response_data): response = adapter.validate_python(minimal_data) assert isinstance(response, TextResponse) assert response.type == "mt_text" - assert response.canceled is False + assert response.canceled is None text_response_data["parameters"] = { "name": {"+12017777777": "John", "default": "there"}, @@ -109,8 +109,12 @@ def test_batch_response_expects_text_response_variations(text_response_data): assert response.parameters["name"]["+12017777777"] == "John" assert response.parameters["code"]["+12017777777"] == "HALLOWEEN20" - expected_created_at = datetime(2024, 1, 15, 14, 30, 22, 123000, tzinfo=timezone.utc) - expected_modified_at = datetime(2024, 1, 15, 14, 35, 45, 789000, tzinfo=timezone.utc) + expected_created_at = datetime( + 2024, 1, 15, 14, 30, 22, 123000, tzinfo=timezone.utc + ) + expected_modified_at = datetime( + 2024, 1, 15, 14, 35, 45, 789000, tzinfo=timezone.utc + ) expected_send_at = datetime(2024, 1, 15, 15, 0, 0, tzinfo=timezone.utc) expected_expire_at = datetime(2024, 1, 18, 15, 0, 0, tzinfo=timezone.utc) assert response.created_at == expected_created_at @@ -151,7 +155,12 @@ def test_batch_response_expects_discriminator_behavior(): adapter.validate_python({"id": "test", "body": "Hello"}) # Extra fields are allowed and don't affect routing - text_with_extra = {"type": "mt_text", "id": "test", "body": "Hello", "extra": "value"} + text_with_extra = { + "type": "mt_text", + "id": "test", + "body": "Hello", + "extra": "value", + } response = adapter.validate_python(text_with_extra) assert isinstance(response, TextResponse) diff --git a/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py b/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py new file mode 100644 index 00000000..26fe523e --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_dry_run_response_model.py @@ -0,0 +1,75 @@ +import pytest +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.shared import DryRunPerRecipientDetails + + +@pytest.fixture +def dry_run_response_data(): + """Sample DryRunResponse data with per_recipient details.""" + return { + "number_of_recipients": 2, + "number_of_messages": 1, + "per_recipient": [ + { + "recipient": "+46701234567", + "body": "Your order #12345 has been shipped", + "number_of_parts": 1, + "encoding": "text", + }, + { + "recipient": "+46709876543", + "body": "Reminder: Your appointment is tomorrow at 2 PM", + "number_of_parts": 1, + "encoding": "text", + }, + ], + } + + +@pytest.fixture +def dry_run_response_data_without_per_recipient(): + return { + "number_of_recipients": 5, + "number_of_messages": 3, + } + + +def test_dry_run_response_expects_valid_input_with_per_recipient( + dry_run_response_data, +): + """Test that DryRunResponse correctly parses data with per_recipient details.""" + response = DryRunResponse(**dry_run_response_data) + + assert response.number_of_recipients == 2 + assert response.number_of_messages == 1 + assert response.per_recipient is not None + assert len(response.per_recipient) == 2 + + first_recipient = response.per_recipient[0] + assert isinstance(first_recipient, DryRunPerRecipientDetails) + assert first_recipient.recipient == "+46701234567" + assert first_recipient.body == "Your order #12345 has been shipped" + assert first_recipient.number_of_parts == 1 + assert first_recipient.encoding == "text" + + second_recipient = response.per_recipient[1] + assert second_recipient.recipient == "+46709876543" + assert ( + second_recipient.body + == "Reminder: Your appointment is tomorrow at 2 PM" + ) + assert second_recipient.number_of_parts == 1 + assert second_recipient.encoding == "text" + + +def test_dry_run_response_expects_valid_input_without_per_recipient( + dry_run_response_data_without_per_recipient, +): + """Test that DryRunResponse correctly parses data without per_recipient details.""" + response = DryRunResponse(**dry_run_response_data_without_per_recipient) + + assert response.number_of_recipients == 5 + assert response.number_of_messages == 3 + assert response.per_recipient is None diff --git a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py index 130d157d..7cb9b6af 100644 --- a/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py +++ b/tests/unit/domains/sms/v1/models/response/test_recipient_delivery_report_model.py @@ -1,5 +1,4 @@ import pytest -from datetime import datetime, timezone from pydantic import ValidationError from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, diff --git a/tests/unit/domains/sms/v1/test_batches.py b/tests/unit/domains/sms/v1/test_batches.py new file mode 100644 index 00000000..0e87c667 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_batches.py @@ -0,0 +1,146 @@ +import pytest +from sinch.domains.sms.api.v1.batches_apis import Batches +from sinch.domains.sms.api.v1.internal import DryRunEndpoint +from sinch.domains.sms.models.v1.internal.dry_run_request import ( + DryRunTextRequest, + DryRunBinaryRequest, + DryRunMediaRequest, +) +from sinch.domains.sms.models.v1.response.dry_run_response import ( + DryRunResponse, +) +from sinch.domains.sms.models.v1.shared import ( + MediaBody, + DryRunPerRecipientDetails, +) + + +@pytest.fixture +def mock_dry_run_response(): + """Sample DryRunResponse for testing.""" + return DryRunResponse( + number_of_recipients=2, + number_of_messages=1, + per_recipient=[ + DryRunPerRecipientDetails( + recipient="+46701234567", + body="Hello World!", + number_of_parts=1, + encoding="text", + ), + DryRunPerRecipientDetails( + recipient="+46709876543", + body="Hello World!", + number_of_parts=1, + encoding="text", + ), + ], + ) + + +def test_batches_dry_run_sms_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + # Spy on the DryRunEndpoint to capture calls + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_sms( + to=["+46701234567"], + from_="+46701111111", + body="Hello World!", + per_recipient=True, + number_of_recipients=100, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunTextRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Hello World!" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 100 + + assert isinstance(response, DryRunResponse) + assert response.number_of_recipients == 2 + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_binary_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_binary sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_binary( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + per_recipient=False, + number_of_recipients=50, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunBinaryRequest) + assert kwargs["request_data"].udh == "06050423F423F4" + assert kwargs["request_data"].per_recipient is False + assert kwargs["request_data"].number_of_recipients == 50 + + assert isinstance(response, DryRunResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_mms_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run_mms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run_mms( + to=["+46701234567"], + from_="+46701111111", + body=media_body, + per_recipient=True, + number_of_recipients=75, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunMediaRequest) + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/image.jpg" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 75 + + assert isinstance(response, DryRunResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/sms/v1/test_delivery_reports.py b/tests/unit/domains/sms/v1/test_delivery_reports.py index eaab6bb9..cd81c722 100644 --- a/tests/unit/domains/sms/v1/test_delivery_reports.py +++ b/tests/unit/domains/sms/v1/test_delivery_reports.py @@ -155,33 +155,32 @@ def test_list_delivery_reports_expects_valid_request( mock_sinch_client_sms.configuration.transport.request.assert_called_once() -def test_sms_endpoint_handle_response_raises_exception_on_error(mock_sinch_client_sms): +def test_sms_endpoint_handle_response_raises_exception_on_error( + mock_sinch_client_sms, +): """ Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. """ - + request_data = GetBatchDeliveryReportRequest( - batch_id="test_batch_id", - type="summary" + batch_id="test_batch_id", type="summary" ) endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data) - - error_response = HTTPResponse( - status_code=400, - body=1, - headers={} - ) - + + error_response = HTTPResponse(status_code=400, body=1, headers={}) + with pytest.raises(SmsException) as exc_info: endpoint.handle_response(error_response) - + assert exc_info.value.args[0] == "Error 400" assert exc_info.value.http_response == error_response assert exc_info.value.is_from_server is True assert exc_info.value.response_status_code == 400 -def test_delivery_reports_expects_validation_recalculates_auth_method_when_credentials_change(mock_sinch_client_sms): +def test_delivery_reports_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, +): """ Test that SMS requests validate authentication and recalculate auth method when credentials change after initialization. @@ -209,8 +208,7 @@ def test_delivery_reports_expects_validation_recalculates_auth_method_when_crede # Make an SMS request. This should trigger validation and recalculate auth method delivery_reports = DeliveryReports(mock_sinch_client_sms) response = delivery_reports.get( - batch_id="01FC66621XXXXX119Z8PMV1QPQ", - report_type="summary" + batch_id="01FC66621XXXXX119Z8PMV1QPQ", report_type="summary" ) assert config.authentication_method == "sms_auth" From 223e23f38951088047d846ba3743ace5e32e86a3 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 20 Nov 2025 14:08:14 +0100 Subject: [PATCH 063/106] DEVEXP-786: Batches - Models and APIs Unit Tests (#94) --- .../test_delivery_feedback_request_model.py | 91 +++ .../internal/test_dry_run_request_model.py | 1 - .../test_list_batches_request_model.py | 75 ++ .../test_replace_binary_request_model.py | 71 ++ .../test_replace_media_request_model.py | 104 +++ ...date_binary_request_with_batch_id_model.py | 91 +++ ...pdate_media_request_with_batch_id_model.py | 102 +++ ...update_text_request_with_batch_id_model.py | 113 +++ tests/unit/domains/sms/v1/test_batches.py | 733 +++++++++++++++++- 9 files changed, 1378 insertions(+), 3 deletions(-) create mode 100644 tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py diff --git a/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py new file mode 100644 index 00000000..0e782ca8 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_delivery_feedback_request_model.py @@ -0,0 +1,91 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import DeliveryFeedbackRequest + + +@pytest.fixture +def sample_delivery_feedback_request_data(): + return { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH3", + "recipients": ["+46876543210", "+46987654321"], + } + + +def test_delivery_feedback_request_expects_valid_inputs( + sample_delivery_feedback_request_data, +): + """Test DeliveryFeedbackRequest with valid inputs.""" + request = DeliveryFeedbackRequest(**sample_delivery_feedback_request_data) + assert ( + request.batch_id == sample_delivery_feedback_request_data["batch_id"] + ) + assert ( + request.recipients + == sample_delivery_feedback_request_data["recipients"] + ) + + +def test_delivery_feedback_request_expects_single_recipient(): + """Test DeliveryFeedbackRequest with a single recipient.""" + request = DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=["+46876543210"], + ) + assert request.batch_id == "01W4FFL35P4NC4K35SMSBATCH3" + assert len(request.recipients) == 1 + assert request.recipients[0] == "+46876543210" + + +def test_delivery_feedback_request_expects_empty_recipients_list(): + """Test DeliveryFeedbackRequest with empty recipients list.""" + request = DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=[], + ) + assert request.batch_id == "01W4FFL35P4NC4K35SMSBATCH3" + assert request.recipients == [] + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "recipients"], +) +def test_delivery_feedback_request_expects_required_fields( + sample_delivery_feedback_request_data, missing_field +): + """Test that DeliveryFeedbackRequest requires batch_id and recipients fields.""" + data = sample_delivery_feedback_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + DeliveryFeedbackRequest(**data) + assert missing_field in str(exc_info.value) + + +@pytest.mark.parametrize( + "invalid_batch_id", + [12345, None], +) +def test_delivery_feedback_request_expects_batch_id_must_be_string( + invalid_batch_id, +): + """Test that batch_id must be a string.""" + with pytest.raises(ValidationError): + DeliveryFeedbackRequest( + batch_id=invalid_batch_id, + recipients=["+46876543210"], + ) + + +@pytest.mark.parametrize( + "invalid_recipients", + ["+46876543210", [123, 456], ["+46876543210", None]], +) +def test_delivery_feedback_request_expects_recipients_must_be_list_of_strings( + invalid_recipients, +): + """Test that recipients must be a list of strings.""" + with pytest.raises(ValidationError): + DeliveryFeedbackRequest( + batch_id="01W4FFL35P4NC4K35SMSBATCH3", + recipients=invalid_recipients, + ) diff --git a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py index 2ee6f9d8..c308af5a 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py @@ -84,7 +84,6 @@ def test_dry_run_mixin_expects_valid_number_of_recipients( ) assert request.number_of_recipients == number_of_recipients - def test_dry_run_mixin_expects_number_of_recipients_not_string( self, sample_text_request_data ): diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py new file mode 100644 index 00000000..b419c0b7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_batches_request_model.py @@ -0,0 +1,75 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal import ListBatchesRequest + + +def test_list_batches_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListBatchesRequest() + assert model.page is None + assert model.page_size is None + assert model.start_date is None + assert model.end_date is None + assert model.from_ is None + assert model.client_reference is None + + +def test_list_batches_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListBatchesRequest( + page=1, + page_size=50, + start_date=start, + end_date=end, + from_=["+46701234567", "+46709876543"], + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.start_date == start + assert model.end_date == end + assert model.from_ == ["+46701234567", "+46709876543"] + assert model.client_reference == "my-client-ref" + + +def test_list_batches_request_expects_from_alias(): + """Test that the 'from' alias works correctly.""" + model = ListBatchesRequest(from_=["+46701234567"]) + + assert model.from_ == ["+46701234567"] + + # Check that model_dump with by_alias=True uses "from" + dumped = model.model_dump(exclude_none=True, by_alias=True) + assert "from" in dumped + assert dumped["from"] == ["+46701234567"] + assert "from_" not in dumped + + # Check that model_dump with by_alias=False uses "from_" + dumped_no_alias = model.model_dump(exclude_none=True, by_alias=False) + assert "from_" in dumped_no_alias + assert dumped_no_alias["from_"] == ["+46701234567"] + assert "from" not in dumped_no_alias + + +def test_list_batches_request_expects_partial_input(): + """Test that the model works with partial input.""" + model = ListBatchesRequest(page=2, page_size=20) + + assert model.page == 2 + assert model.page_size == 20 + assert model.start_date is None + assert model.end_date is None + assert model.from_ is None + assert model.client_reference is None + + +def test_list_batches_request_expects_empty_from_list(): + """Test that from_ can be an empty list.""" + model = ListBatchesRequest(from_=[]) + + assert model.from_ == [] diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py new file mode 100644 index 00000000..7e632deb --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py @@ -0,0 +1,71 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceBinaryRequest, +) + + +@pytest.fixture +def sample_replace_binary_request_data(): + return { + "batch_id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": ["+46701234567", "+46709876543"], + "from_": "+46701111111", + "body": "SGVsbG8gV29ybGQh", + "udh": "06050423F423F4", + } + + +def test_replace_binary_request_expects_valid_inputs_and_all_fields( + sample_replace_binary_request_data, +): + """Test ReplaceBinaryRequest with valid inputs and all optional fields.""" + request = ReplaceBinaryRequest(**sample_replace_binary_request_data) + assert request.batch_id == sample_replace_binary_request_data["batch_id"] + assert request.to == sample_replace_binary_request_data["to"] + assert request.from_ == sample_replace_binary_request_data["from_"] + assert request.body == sample_replace_binary_request_data["body"] + assert request.udh == sample_replace_binary_request_data["udh"] + assert request.type == "mt_binary" + assert request.delivery_report is None + assert request.feedback_enabled is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = ReplaceBinaryRequest( + **sample_replace_binary_request_data, + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/callback", + client_reference="test-ref", + feedback_enabled=True, + from_ton=1, + from_npi=1, + ) + + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/callback" + assert request.client_reference == "test-ref" + assert request.feedback_enabled is True + assert request.from_ton == 1 + assert request.from_npi == 1 + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "to", "body", "udh"], +) +def test_replace_binary_request_expects_required_fields( + sample_replace_binary_request_data, missing_field +): + """Test that ReplaceBinaryRequest requires batch_id, to, body, and udh fields.""" + data = sample_replace_binary_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + ReplaceBinaryRequest(**data) + assert missing_field in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py new file mode 100644 index 00000000..49b3b9a5 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py @@ -0,0 +1,104 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_replace_media_request_data(): + return { + "batch_id": "01W4FFL35P4NC4K35SMSBATCH2", + "to": ["+46876543210", "+46987654321"], + "from_": "+46800123456", + "body": MediaBody( + url="https://capybara.com/video.mp4", + message="Hi ${name}! Watch this amazing capybara video!", + subject="Capybara Video", + ), + } + + +def test_replace_media_request_expects_valid_inputs_and_all_fields( + sample_replace_media_request_data, +): + """Test ReplaceMediaRequest with valid inputs and all optional fields.""" + request = ReplaceMediaRequest(**sample_replace_media_request_data) + assert request.batch_id == sample_replace_media_request_data["batch_id"] + assert request.to == sample_replace_media_request_data["to"] + assert request.from_ == sample_replace_media_request_data["from_"] + assert isinstance(request.body, MediaBody) + assert request.body.url == "https://capybara.com/video.mp4" + assert ( + request.body.message + == "Hi ${name}! Watch this amazing capybara video!" + ) + assert request.body.subject == "Capybara Video" + assert request.type == "mt_media" + assert request.delivery_report is None + assert request.feedback_enabled is None + assert request.strict_validation is None + + send_at = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc) + + request = ReplaceMediaRequest( + **sample_replace_media_request_data, + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/webhook", + client_reference="capybara-media-batch-123", + feedback_enabled=True, + strict_validation=True, + parameters={ + "name": {"+46876543210": "Alice", "default": "user"}, + "promo": {"+46876543210": "SUMMER2024"}, + }, + ) + + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/webhook" + assert request.client_reference == "capybara-media-batch-123" + assert request.feedback_enabled is True + assert request.strict_validation is True + assert request.parameters["name"]["+46876543210"] == "Alice" + assert request.parameters["promo"]["+46876543210"] == "SUMMER2024" + + +@pytest.mark.parametrize( + "missing_field", + ["batch_id", "to", "body"], +) +def test_replace_media_request_expects_required_fields( + sample_replace_media_request_data, missing_field +): + """Test that ReplaceMediaRequest requires batch_id, to, and body fields.""" + data = sample_replace_media_request_data.copy() + data.pop(missing_field) + with pytest.raises(ValidationError) as exc_info: + ReplaceMediaRequest(**data) + assert missing_field in str(exc_info.value) + + +def test_replace_media_request_expects_body_must_be_media_body( + sample_replace_media_request_data, +): + """Test that body must be a MediaBody instance or dict that can be converted.""" + data = sample_replace_media_request_data.copy() + data["body"] = "invalid" + with pytest.raises(ValidationError): + ReplaceMediaRequest(**data) + + data["body"] = None + with pytest.raises(ValidationError): + ReplaceMediaRequest(**data) + + data["body"] = {"url": "https://capybara.com/audio.mp3"} + request = ReplaceMediaRequest(**data) + assert isinstance(request.body, MediaBody) + assert request.body.url == "https://capybara.com/audio.mp3" diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py new file mode 100644 index 00000000..5587ee7b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py @@ -0,0 +1,91 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateBinaryRequestWithBatchId, +) + + +@pytest.fixture +def sample_update_binary_request_data(): + return { + "batch_id": "01FC88843ZZZZZ331B0ROX3STQ", + "udh": "06050423F423F5", + "body": "VXBkYXRlZCBiaW5hcnkgY29udGVudA==", + } + + +def test_update_binary_request_expects_valid_inputs_and_all_fields( + sample_update_binary_request_data, +): + """Test UpdateBinaryRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data + ) + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.body == sample_update_binary_request_data["body"] + assert request.type == "mt_binary" + + send_at = datetime(2025, 4, 10, 16, 45, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 4, 13, 16, 45, 0, tzinfo=timezone.utc) + + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data, + from_="+46706666666", + to_add=["+46707777777", "+46708888888"], + to_remove=["+46709999999"], + delivery_report="full", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/binary-callback", + client_reference="binary-update-456", + feedback_enabled=True, + from_ton=3, + from_npi=4, + ) + + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.from_ == "+46706666666" + assert request.to_add == ["+46707777777", "+46708888888"] + assert request.to_remove == ["+46709999999"] + assert request.delivery_report == "full" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/binary-callback" + assert request.client_reference == "binary-update-456" + assert request.feedback_enabled is True + assert request.from_ton == 3 + assert request.from_npi == 4 + + +def test_update_binary_request_expects_datetime_parsing( + sample_update_binary_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-05-25T08:20:45.456Z" + expire_at_str = "2025-05-28T08:20:45.456Z" + + request = UpdateBinaryRequestWithBatchId( + **sample_update_binary_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_binary_request_expects_minimal_input( + sample_update_binary_request_data, +): + """Test UpdateBinaryRequestWithBatchId with only required fields.""" + request = UpdateBinaryRequestWithBatchId( + batch_id=sample_update_binary_request_data["batch_id"], + udh=sample_update_binary_request_data["udh"], + ) + assert request.batch_id == sample_update_binary_request_data["batch_id"] + assert request.udh == sample_update_binary_request_data["udh"] + assert request.body is None + assert request.type == "mt_binary" + assert request.feedback_enabled is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py new file mode 100644 index 00000000..fe6b3700 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py @@ -0,0 +1,102 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateMediaRequestWithBatchId, +) +from sinch.domains.sms.models.v1.shared import MediaBody + + +@pytest.fixture +def sample_update_media_request_data(): + return { + "batch_id": "01FC99954AAAAA442C1SPY4TUQ", + "body": MediaBody( + url="https://capybara.com/media/video.mp4", + message="Updated capybara video message", + subject="Capybara Video Update", + ), + } + + +def test_update_media_request_expects_valid_inputs_and_all_fields( + sample_update_media_request_data, +): + """Test UpdateMediaRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateMediaRequestWithBatchId(**sample_update_media_request_data) + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.body.url == sample_update_media_request_data["body"].url + assert ( + request.body.message + == sample_update_media_request_data["body"].message + ) + assert ( + request.body.subject + == sample_update_media_request_data["body"].subject + ) + assert request.type == "mt_media" + + send_at = datetime(2025, 6, 5, 18, 0, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 6, 8, 18, 0, 0, tzinfo=timezone.utc) + + request = UpdateMediaRequestWithBatchId( + **sample_update_media_request_data, + from_="+46701010101", + to_add=["+46701111111", "+46701222222"], + to_remove=["+46701333333"], + delivery_report="none", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/media-callback", + client_reference="media-update-789", + feedback_enabled=True, + strict_validation=True, + parameters={ + "media": {"+46701111111": "video1", "default": "video2"}, + }, + ) + + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.from_ == "+46701010101" + assert request.to_add == ["+46701111111", "+46701222222"] + assert request.to_remove == ["+46701333333"] + assert request.delivery_report == "none" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/media-callback" + assert request.client_reference == "media-update-789" + assert request.feedback_enabled is True + assert request.strict_validation is True + assert request.parameters == { + "media": {"+46701111111": "video1", "default": "video2"}, + } + + +def test_update_media_request_expects_datetime_parsing( + sample_update_media_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-07-15T12:30:15.789Z" + expire_at_str = "2025-07-18T12:30:15.789Z" + + request = UpdateMediaRequestWithBatchId( + **sample_update_media_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_media_request_expects_minimal_input( + sample_update_media_request_data, +): + """Test UpdateMediaRequestWithBatchId with only required fields.""" + request = UpdateMediaRequestWithBatchId( + batch_id=sample_update_media_request_data["batch_id"], + ) + assert request.batch_id == sample_update_media_request_data["batch_id"] + assert request.body is None + assert request.type == "mt_media" + assert request.feedback_enabled is None + assert request.strict_validation is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py new file mode 100644 index 00000000..8737571c --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py @@ -0,0 +1,113 @@ +import pytest +from pydantic import ValidationError +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, +) + + +@pytest.fixture +def sample_update_text_request_data(): + return { + "batch_id": "01FC77732YYYYY220A9QNW2RSQ", + "body": "Updated message content here", + "from_": "+46702222222", + } + + +def test_update_text_request_expects_valid_inputs_and_all_fields( + sample_update_text_request_data, +): + """Test UpdateTextRequestWithBatchId with valid inputs and all optional fields.""" + request = UpdateTextRequestWithBatchId(**sample_update_text_request_data) + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.body == sample_update_text_request_data["body"] + assert request.from_ == sample_update_text_request_data["from_"] + assert request.type == "mt_text" + + send_at = datetime(2025, 2, 15, 14, 30, 0, tzinfo=timezone.utc) + expire_at = datetime(2025, 2, 18, 14, 30, 0, tzinfo=timezone.utc) + + request = UpdateTextRequestWithBatchId( + **sample_update_text_request_data, + to_add=["+46703333333", "+46704444444"], + to_remove=["+46705555555"], + delivery_report="summary", + send_at=send_at, + expire_at=expire_at, + callback_url="https://capybara.com/webhook", + client_reference="update-ref-123", + feedback_enabled=True, + flash_message=True, + max_number_of_message_parts=5, + truncate_concat=False, + from_ton=2, + from_npi=3, + parameters={ + "code": {"+46703333333": "ABC123", "default": "XYZ789"}, + }, + ) + + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.to_add == ["+46703333333", "+46704444444"] + assert request.to_remove == ["+46705555555"] + assert request.delivery_report == "summary" + assert request.send_at == send_at + assert request.expire_at == expire_at + assert request.callback_url == "https://capybara.com/webhook" + assert request.client_reference == "update-ref-123" + assert request.feedback_enabled is True + assert request.flash_message is True + assert request.max_number_of_message_parts == 5 + assert request.truncate_concat is False + assert request.from_ton == 2 + assert request.from_npi == 3 + assert request.parameters == { + "code": {"+46703333333": "ABC123", "default": "XYZ789"}, + } + + +@pytest.mark.parametrize( + "to_add_value", + ["+46701234567", [123, 456], ["+46701234567", None]], +) +def test_update_text_request_expects_to_add_must_be_list_of_strings( + sample_update_text_request_data, to_add_value +): + """Test that to_add must be a list of strings.""" + with pytest.raises(ValidationError): + UpdateTextRequestWithBatchId( + **sample_update_text_request_data, to_add=to_add_value + ) + + +def test_update_text_request_expects_datetime_parsing( + sample_update_text_request_data, +): + """Test datetime parsing for send_at and expire_at.""" + send_at_str = "2025-03-20T10:15:30.123Z" + expire_at_str = "2025-03-23T10:15:30.123Z" + + request = UpdateTextRequestWithBatchId( + **sample_update_text_request_data, + send_at=send_at_str, + expire_at=expire_at_str, + ) + + assert isinstance(request.send_at, datetime) + assert isinstance(request.expire_at, datetime) + + +def test_update_text_request_expects_minimal_input( + sample_update_text_request_data, +): + """Test UpdateTextRequestWithBatchId with only required fields.""" + request = UpdateTextRequestWithBatchId( + batch_id=sample_update_text_request_data["batch_id"] + ) + assert request.batch_id == sample_update_text_request_data["batch_id"] + assert request.body is None + assert request.from_ is None + assert request.type == "mt_text" + assert request.feedback_enabled is None + assert request.flash_message is None diff --git a/tests/unit/domains/sms/v1/test_batches.py b/tests/unit/domains/sms/v1/test_batches.py index 0e87c667..23064769 100644 --- a/tests/unit/domains/sms/v1/test_batches.py +++ b/tests/unit/domains/sms/v1/test_batches.py @@ -1,18 +1,51 @@ +from datetime import datetime, timezone +from unittest.mock import MagicMock import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator from sinch.domains.sms.api.v1.batches_apis import Batches -from sinch.domains.sms.api.v1.internal import DryRunEndpoint +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal import ( + CancelBatchMessageEndpoint, + DryRunEndpoint, + GetBatchMessageEndpoint, + ListBatchesEndpoint, + ReplaceBatchEndpoint, + SendSMSEndpoint, + DeliveryFeedbackEndpoint, + UpdateBatchMessageEndpoint, +) from sinch.domains.sms.models.v1.internal.dry_run_request import ( DryRunTextRequest, DryRunBinaryRequest, DryRunMediaRequest, ) +from sinch.domains.sms.models.v1.internal.replace_batch_request import ( + ReplaceTextRequest, + ReplaceBinaryRequest, + ReplaceMediaRequest, +) +from sinch.domains.sms.models.v1.internal.update_batch_message_request import ( + UpdateTextRequestWithBatchId, + UpdateBinaryRequestWithBatchId, + UpdateMediaRequestWithBatchId, +) from sinch.domains.sms.models.v1.response.dry_run_response import ( DryRunResponse, ) +from sinch.domains.sms.models.v1.response.list_batches_response import ( + ListBatchesResponse, +) from sinch.domains.sms.models.v1.shared import ( MediaBody, DryRunPerRecipientDetails, + TextRequest, + BinaryRequest, + MediaRequest, ) +from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse +from sinch.domains.sms.models.v1.shared.media_response import MediaResponse @pytest.fixture @@ -38,6 +71,295 @@ def mock_dry_run_response(): ) +@pytest.fixture +def mock_batch_response(): + """Sample BatchResponse (TextResponse) for testing.""" + return TextResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="Test message", + type="mt_text", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + + +def test_batches_cancel_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that cancel sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(CancelBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.cancel(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_get_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that get sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(GetBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.get(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_list_expects_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and returns a paginator.""" + mock_list_batches_response = ListBatchesResponse( + count=2, + page=0, + page_size=10, + batches=[], + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_list_batches_response + ) + + spy_endpoint = mocker.spy(ListBatchesEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + paginator = batches.list( + page=0, + page_size=10, + start_date=datetime(2024, 6, 1, tzinfo=timezone.utc), + end_date=datetime(2024, 6, 30, tzinfo=timezone.utc), + from_=["+46701111111"], + client_reference="test-ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 10 + assert kwargs["request_data"].from_ == ["+46701111111"] + assert kwargs["request_data"].client_reference == "test-ref" + + assert isinstance(paginator, SMSPaginator) + assert paginator.result == mock_list_batches_response + + +def test_batches_send_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send_sms( + to=["+46701234567", "+46709876543"], + from_="+46701111111", + body="Test message", + delivery_report="full", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + callback_url="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + flash_message=False, + max_number_of_message_parts=3, + truncate_concat=True, + from_ton=1, + from_npi=1, + parameters={"name": {"+46701234567": "John"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], TextRequest) + assert kwargs["request_data"].to == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Test message" + assert kwargs["request_data"].delivery_report == "full" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].flash_message is False + assert kwargs["request_data"].max_number_of_message_parts == 3 + assert kwargs["request_data"].truncate_concat is True + assert kwargs["request_data"].from_ton == 1 + assert kwargs["request_data"].from_npi == 1 + assert kwargs["request_data"].parameters == { + "name": {"+46701234567": "John"} + } + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send_binary( + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + delivery_report="summary", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + callback_url="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + from_ton=1, + from_npi=1, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], BinaryRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "SGVsbG8gV29ybGQh" + assert kwargs["request_data"].udh == "06050423F423F4" + assert kwargs["request_data"].delivery_report == "summary" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].from_ton == 1 + assert kwargs["request_data"].from_npi == 1 + + assert isinstance(response, BinaryResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/video.mp4", + message="Check out this video!", + subject="Video", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.send_mms( + to=["+46701234567"], + from_="+46701111111", + body=media_body, + delivery_report="full", + send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), + expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), + callback_url="https://example.com/callback", + client_reference="test-ref", + feedback_enabled=True, + strict_validation=True, + parameters={"name": {"+46701234567": "John"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MediaRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/video.mp4" + assert kwargs["request_data"].body.message == "Check out this video!" + assert kwargs["request_data"].body.subject == "Video" + assert kwargs["request_data"].delivery_report == "full" + assert kwargs["request_data"].feedback_enabled is True + assert kwargs["request_data"].strict_validation is True + assert kwargs["request_data"].parameters == { + "name": {"+46701234567": "John"} + } + + assert isinstance(response, MediaResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + def test_batches_dry_run_sms_expects_correct_request( mock_sinch_client_sms, mock_dry_run_response, mocker ): @@ -46,7 +368,6 @@ def test_batches_dry_run_sms_expects_correct_request( mock_dry_run_response ) - # Spy on the DryRunEndpoint to capture calls spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") batches = Batches(mock_sinch_client_sms) @@ -144,3 +465,411 @@ def test_batches_dry_run_mms_expects_correct_request( assert isinstance(response, DryRunResponse) mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_dry_run_with_request_object_expects_correct_request( + mock_sinch_client_sms, mock_dry_run_response, mocker +): + """Test that dry_run with DryRunRequest object sends the correct request.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_dry_run_response + ) + + spy_endpoint = mocker.spy(DryRunEndpoint, "__init__") + + request = DryRunTextRequest( + to=["+46701234567"], + from_="+46701111111", + body="Hello World!", + per_recipient=True, + number_of_recipients=100, + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.dry_run(request=request) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], DryRunTextRequest) + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Hello World!" + assert kwargs["request_data"].per_recipient is True + assert kwargs["request_data"].number_of_recipients == 100 + + assert isinstance(response, DryRunResponse) + assert response.number_of_recipients == 2 + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_sms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="Updated message", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceTextRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Updated message" + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_binary( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body="SGVsbG8gV29ybGQh", + udh="06050423F423F4", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceBinaryRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].udh == "06050423F423F4" + + assert isinstance(response, BinaryResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_replace_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that replace_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/image.jpg", + message="Check out this image!", + subject="Image", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(ReplaceBatchEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/video.mp4", + message="Updated video message", + subject="Video Update", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.replace_mms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + body=media_body, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], ReplaceMediaRequest) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert kwargs["request_data"].body.url == "https://capybara.com/video.mp4" + + assert isinstance(response, MediaResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_delivery_feedback_expects_correct_request( + mock_sinch_client_sms, mocker +): + """Test that send_delivery_feedback sends the correct request.""" + mock_sinch_client_sms.configuration.transport.request.return_value = None + + spy_endpoint = mocker.spy(DeliveryFeedbackEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + batches.send_delivery_feedback( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + recipients=["+46701234567", "+46709876543"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].recipients == [ + "+46701234567", + "+46709876543", + ] + + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_sms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_sms sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.update_sms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body="Updated body", + to_add=["+46709999999"], + to_remove=["+46708888888"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateTextRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].body == "Updated body" + assert kwargs["request_data"].to_add == ["+46709999999"] + assert kwargs["request_data"].to_remove == ["+46708888888"] + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_binary_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_binary sends the correct request and handles the response properly.""" + mock_binary_response = BinaryResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="VXBkYXRlZA==", + udh="06050423F423F5", + type="mt_binary", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_binary_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.update_binary( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + udh="06050423F423F5", + body="VXBkYXRlZA==", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateBinaryRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].udh == "06050423F423F5" + assert kwargs["request_data"].body == "VXBkYXRlZA==" + + assert isinstance(response, BinaryResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_update_mms_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that update_mms sends the correct request and handles the response properly.""" + mock_media_response = MediaResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body=MediaBody( + url="https://capybara.com/updated.jpg", + message="Updated message", + subject="Updated", + ), + type="mt_media", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_media_response + ) + + spy_endpoint = mocker.spy(UpdateBatchMessageEndpoint, "__init__") + + media_body = MediaBody( + url="https://capybara.com/new-image.jpg", + message="New image message", + subject="New Image", + ) + + batches = Batches(mock_sinch_client_sms) + response = batches.update_mms( + batch_id="01FC66621XXXXX119Z8PMV1QPQ", + body=media_body, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateMediaRequestWithBatchId) + assert kwargs["request_data"].batch_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert isinstance(kwargs["request_data"].body, MediaBody) + assert ( + kwargs["request_data"].body.url == "https://capybara.com/new-image.jpg" + ) + + assert isinstance(response, MediaResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_send_expects_correct_request( + mock_sinch_client_sms, mock_batch_response, mocker +): + """Test that send with TextRequest sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = ( + mock_batch_response + ) + + spy_endpoint = mocker.spy(SendSMSEndpoint, "__init__") + + batches = Batches(mock_sinch_client_sms) + response = batches.send( + request={ + "to": ["+46701234567"], + "from_": "+46701111111", + "body": "Test message", + "type": "mt_text", + } + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].to == ["+46701234567"] + assert kwargs["request_data"].from_ == "+46701111111" + assert kwargs["request_data"].body == "Test message" + + assert isinstance(response, TextResponse) + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_batches_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, mocker +): + """Test that SMS requests validate authentication and recalculate auth method when credentials change after initialization.""" + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + mock_response = TextResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + to=["+46701234567"], + from_="+46701111111", + canceled=False, + body="Test message", + type="mt_text", + created_at=datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ), + modified_at=datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ), + ) + config.transport.request.return_value = mock_response + + # Initialize Batches service BEFORE changing the authentication method + batches = Batches(mock_sinch_client_sms) + + spy_endpoint = mocker.spy(GetBatchMessageEndpoint, "__init__") + + config.sms_api_token = "test_sms_token" + + assert config.authentication_method == "project_auth" + + # Make an SMS request. This should trigger validation and recalculate auth method + response = batches.get(batch_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert config.authentication_method == "sms_auth" + + # Verify that project_id parameter contains the service_plan_id + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == config.service_plan_id + + assert isinstance(response, TextResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" From 6a18081bae0c9ac73473bcdc0809f5308c19191b Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 20 Nov 2025 18:12:16 +0100 Subject: [PATCH 064/106] feat: add snippets (#95) --- docs/.env.example | 12 ++++ docs/README.md | 61 +++++++++++++++++++ docs/numbers/active_numbers/get/snippet.py | 22 +++++++ docs/numbers/active_numbers/list/snippet.py | 32 ++++++++++ .../active_numbers/list_auto/snippet.py | 26 ++++++++ .../numbers/active_numbers/release/snippet.py | 24 ++++++++ docs/numbers/active_numbers/update/snippet.py | 27 ++++++++ .../check_availability/snippet.py | 24 ++++++++ .../numbers/available_numbers/rent/snippet.py | 30 +++++++++ .../available_numbers/rent_any/snippet.py | 32 ++++++++++ .../search_for_available_numbers/snippet.py | 26 ++++++++ .../numbers/available_regions/list/snippet.py | 25 ++++++++ .../callback_configuration/get/snippet.py | 21 +++++++ .../callback_configuration/update/snippet.py | 24 ++++++++ docs/pyproject.toml | 15 +++++ 15 files changed, 401 insertions(+) create mode 100644 docs/.env.example create mode 100644 docs/README.md create mode 100644 docs/numbers/active_numbers/get/snippet.py create mode 100644 docs/numbers/active_numbers/list/snippet.py create mode 100644 docs/numbers/active_numbers/list_auto/snippet.py create mode 100644 docs/numbers/active_numbers/release/snippet.py create mode 100644 docs/numbers/active_numbers/update/snippet.py create mode 100644 docs/numbers/available_numbers/check_availability/snippet.py create mode 100644 docs/numbers/available_numbers/rent/snippet.py create mode 100644 docs/numbers/available_numbers/rent_any/snippet.py create mode 100644 docs/numbers/available_numbers/search_for_available_numbers/snippet.py create mode 100644 docs/numbers/available_regions/list/snippet.py create mode 100644 docs/numbers/callback_configuration/get/snippet.py create mode 100644 docs/numbers/callback_configuration/update/snippet.py create mode 100644 docs/pyproject.toml diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 00000000..6a05be78 --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,12 @@ +# The project ID where are defined the resources you want to use. +SINCH_PROJECT_ID= + +# The API key ID and secret to authenticate your requests to the Sinch API. +SINCH_KEY_ID= +SINCH_KEY_SECRET= + +# The virtual phone number you have rented from Sinch or planning to rent. +SINCH_PHONE_NUMBER= + +# The service plan ID for your Sinch account to configure the SMS plan associated with your virtual phone number. +SINCH_SERVICE_PLAN_ID= \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..922fb91d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,61 @@ +# sinch-sdk-python-snippets + +Sinch Python SDK Code Snippets + +This repository contains code snippets demonstrating usage of the +[Sinch Python SDK](https://github.com/sinch/sinch-sdk-python). + +## Requirements +- Python 3.9 or later +- [Poetry](https://python-poetry.org/) for dependency management +- [Sinch account](https://dashboard.sinch.com) +- [Sinch package](https://pypi.org/project/sinch/) + + +## Snippets execution settings +When executing a snippet, you will need to provide some information about your Sinch account (credentials, Sinch virtual phone number, ...) + +These settings can be placed directly in the snippet source code, **or** you can use an environment file (`.env`). Using an environment file allows the settings to be shared and used automatically by every snippet. + +### Setting Up Your Environment File + +#### 1. Rename the example file + +**Linux / Mac:** +```bash +cp .env.example .env +``` + +**Windows (Command Prompt):** +```cmd +copy .env.example .env +``` + +Windows (PowerShell): +```powershell +Copy-Item .env.example .env +``` + +#### 2. Fill in your credentials + +Open the newly created [.env](.env) file in your preferred text editor and fill in the required values (e.g., SINCH_PROJECT_ID=your_project_id). + +Note: Do not share your .env file or credentials publicly. + + +### Install dependencies using Poetry: + +```bash +poetry install +``` + + +## Running snippets + +All available code snippets are located in subdirectories, structured by feature and corresponding actions (e.g., `numbers/`, `sms/`). + +To execute a specific snippet, navigate to the appropriate subdirectory and run: + +```shell +python run python snippet.py +``` \ No newline at end of file diff --git a/docs/numbers/active_numbers/get/snippet.py b/docs/numbers/active_numbers/get/snippet.py new file mode 100644 index 00000000..437694e5 --- /dev/null +++ b/docs/numbers/active_numbers/get/snippet.py @@ -0,0 +1,22 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" +response = sinch_client.numbers.get(phone_number=phone_number) + +print(f"Rented number details:\n{response}") diff --git a/docs/numbers/active_numbers/list/snippet.py b/docs/numbers/active_numbers/list/snippet.py new file mode 100644 index 00000000..ca9c4e41 --- /dev/null +++ b/docs/numbers/active_numbers/list/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +active_numbers = sinch_client.numbers.list( + region_code="US", + number_type="LOCAL" +) + +page_counter = 1 +while True: + print(f"Page {page_counter} List of Numbers: {active_numbers}") + + if not active_numbers.has_next_page: + break + + active_numbers = active_numbers.next_page() + page_counter += 1 diff --git a/docs/numbers/active_numbers/list_auto/snippet.py b/docs/numbers/active_numbers/list_auto/snippet.py new file mode 100644 index 00000000..d9707399 --- /dev/null +++ b/docs/numbers/active_numbers/list_auto/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +active_numbers = sinch_client.numbers.list( + region_code="US", + number_type="LOCAL" +) + +print("List of numbers printed one by one:\n") +for number in active_numbers.iterator(): + print(number) diff --git a/docs/numbers/active_numbers/release/snippet.py b/docs/numbers/active_numbers/release/snippet.py new file mode 100644 index 00000000..127a1c8c --- /dev/null +++ b/docs/numbers/active_numbers/release/snippet.py @@ -0,0 +1,24 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" +released_number = sinch_client.numbers.release( + phone_number=phone_number +) + +print("Released Number:", released_number) diff --git a/docs/numbers/active_numbers/update/snippet.py b/docs/numbers/active_numbers/update/snippet.py new file mode 100644 index 00000000..f01a07ad --- /dev/null +++ b/docs/numbers/active_numbers/update/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +phone_number_to_update = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" +updated_display_name = "Updated DISPLAY_NAME" + +response = sinch_client.numbers.update( + phone_number=phone_number_to_update, + display_name=updated_display_name +) + +print("Updated Number:\n", response) diff --git a/docs/numbers/available_numbers/check_availability/snippet.py b/docs/numbers/available_numbers/check_availability/snippet.py new file mode 100644 index 00000000..197e31df --- /dev/null +++ b/docs/numbers/available_numbers/check_availability/snippet.py @@ -0,0 +1,24 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +phone_number = "PHONE_NUMBER" +response = sinch_client.numbers.check_availability( + phone_number=phone_number +) + +print("The phone number is available:\n", response) diff --git a/docs/numbers/available_numbers/rent/snippet.py b/docs/numbers/available_numbers/rent/snippet.py new file mode 100644 index 00000000..272324ad --- /dev/null +++ b/docs/numbers/available_numbers/rent/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.numbers.models.v1.types import SmsConfigurationDict + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +phone_number_to_be_rented = "AVAILABLE_PHONE_NUMBER_TO_BE_RENTED" +service_plan_id_to_associate_with_the_number = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +sms_configuration: SmsConfigurationDict = { + "service_plan_id": service_plan_id_to_associate_with_the_number +} + +rented_number = sinch_client.numbers.rent( + phone_number=phone_number_to_be_rented, + sms_configuration=sms_configuration +) +print("Rented Number:\n", rented_number) diff --git a/docs/numbers/available_numbers/rent_any/snippet.py b/docs/numbers/available_numbers/rent_any/snippet.py new file mode 100644 index 00000000..4ed73dc8 --- /dev/null +++ b/docs/numbers/available_numbers/rent_any/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.numbers.models.v1.types import SmsConfigurationDict + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +service_plan_id_to_associate_with_the_number = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +sms_configuration: SmsConfigurationDict = { + "service_plan_id": service_plan_id_to_associate_with_the_number +} + +response = sinch_client.numbers.rent_any( + region_code="US", + type_="LOCAL", + capabilities=["SMS", "VOICE"], + sms_configuration=sms_configuration +) + +print("Rented Number:\n", response) diff --git a/docs/numbers/available_numbers/search_for_available_numbers/snippet.py b/docs/numbers/available_numbers/search_for_available_numbers/snippet.py new file mode 100644 index 00000000..980bb251 --- /dev/null +++ b/docs/numbers/available_numbers/search_for_available_numbers/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +available_numbers = sinch_client.numbers.search_for_available_numbers( + region_code="AR", + number_type="LOCAL" +) + +print("Available numbers to rent:\n") +for number in available_numbers.iterator(): + print(number) diff --git a/docs/numbers/available_regions/list/snippet.py b/docs/numbers/available_regions/list/snippet.py new file mode 100644 index 00000000..dee2ca84 --- /dev/null +++ b/docs/numbers/available_regions/list/snippet.py @@ -0,0 +1,25 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +available_regions = sinch_client.numbers.regions.list( + number_types=["MOBILE"] +) + +print("Available regions:\n") +for region in available_regions.iterator(): + print(region) diff --git a/docs/numbers/callback_configuration/get/snippet.py b/docs/numbers/callback_configuration/get/snippet.py new file mode 100644 index 00000000..4592ad6d --- /dev/null +++ b/docs/numbers/callback_configuration/get/snippet.py @@ -0,0 +1,21 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +response = sinch_client.numbers.callback_configuration.get() + +print("Callback Configuration:\n", response) diff --git a/docs/numbers/callback_configuration/update/snippet.py b/docs/numbers/callback_configuration/update/snippet.py new file mode 100644 index 00000000..efe9bb1c --- /dev/null +++ b/docs/numbers/callback_configuration/update/snippet.py @@ -0,0 +1,24 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" +) + +hmac_secret = "NEW_HMAC_SECRET" +response = sinch_client.numbers.callback_configuration.update( + hmac_secret=hmac_secret +) + +print("Updated callback configuration:\n", response) diff --git a/docs/pyproject.toml b/docs/pyproject.toml new file mode 100644 index 00000000..da427070 --- /dev/null +++ b/docs/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "sinch-sdk-python-snippets" +version = "0.1.0" +description = "Code snippets demonstrating usage of the Sinch Python SDK" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +sinch = {path = "..", develop = true} + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file From 974af1fa3c15a89de36b6b5ecb1af8cc7e516971 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 21 Nov 2025 15:00:28 +0100 Subject: [PATCH 065/106] chore: add batches docstrings (#96) --- sinch/domains/sms/api/v1/batches_apis.py | 587 +++++++++++++++++- .../sms/api/v1/delivery_reports_apis.py | 62 ++ 2 files changed, 646 insertions(+), 3 deletions(-) diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py index 0838f36c..b8e3283f 100644 --- a/sinch/domains/sms/api/v1/batches_apis.py +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -52,6 +52,19 @@ class Batches(BaseSms): def cancel(self, batch_id: str, **kwargs) -> BatchResponse: + """ + Cancel a batch message + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request_data = BatchIdRequest(batch_id=batch_id, **kwargs) return self._request(CancelBatchMessageEndpoint, request_data) @@ -62,6 +75,24 @@ def dry_run( number_of_recipients: Optional[int] = None, **kwargs, ) -> DryRunResponse: + """ + Dry run + + :param request: The request object. (optional) + :type request: Optional[DryRunRequest] + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ # DryRunRequest is a Union type, so we need to use TypeAdapter to validate adapter = TypeAdapter(DryRunRequest) @@ -118,7 +149,49 @@ def dry_run_sms( **kwargs, ) -> DryRunResponse: """ - Perform a dry run for a text SMS batch. + Dry run a text SMS batch. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = DryRunTextRequest( to=to, @@ -161,7 +234,43 @@ def dry_run_binary( **kwargs, ) -> DryRunResponse: """ - Perform a dry run for a binary SMS batch. + Dry run a binary SMS batch. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = DryRunBinaryRequest( to=to, @@ -199,6 +308,43 @@ def dry_run_mms( strict_validation: Optional[bool] = None, **kwargs, ) -> DryRunResponse: + """ + Dry run + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBody + :param per_recipient: Whether to include per recipient details in the response (optional) + :type per_recipient: Optional[bool] + :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) + :type number_of_recipients: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: DryRunResponse + :rtype: DryRunResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request = DryRunMediaRequest( to=to, from_=from_, @@ -218,6 +364,19 @@ def dry_run_mms( return self.dry_run(request=request) def get(self, batch_id: str, **kwargs) -> BatchResponse: + """ + Get a batch message + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request_data = BatchIdRequest(batch_id=batch_id, **kwargs) return self._request(GetBatchMessageEndpoint, request_data) @@ -231,6 +390,33 @@ def list( client_reference: Optional[str] = None, **kwargs, ) -> Paginator[BatchResponse]: + """ + List Batches + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page. (optional) + :type page_size: Optional[int] + :param start_date: Only list messages received at or after this date/time. Formatted as + [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Default: Now-24 + (optional) + :type start_date: Optional[datetime] + + :param end_date: Only list messages received before this date/time. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. (optional) + :type end_date: Optional[datetime] + + :param from_: Only list messages sent from this sender number. Multiple originating numbers can be comma separated. Must be phone numbers or short code. (optional) + :type from_: Optional[List[str]] + :param client_reference: Client reference to include (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[BatchResponse] + :rtype: Paginator[BatchResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ endpoint = ListBatchesEndpoint( project_id=self._get_path_identifier(), request_data=ListBatchesRequest( @@ -253,6 +439,22 @@ def replace( request: Optional[ReplaceBatchRequest] = None, **kwargs, ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. It is the same as cancelling a batch + and sending a new one instead. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param request: The request object. (optional) + :type request: Optional[ReplaceBatchRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ adapter = TypeAdapter(ReplaceBatchRequest) input_data = {} @@ -287,6 +489,51 @@ def replace_sms( parameters: Optional[Dict[str, Dict[str, str]]] = None, **kwargs, ) -> BatchResponse: + """ + + This operation will replace all the parameters of a batch with the provided values. It is the same as cancelling a batch + and sending a new one instead. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ request = ReplaceTextRequest( batch_id=batch_id, to=to, @@ -325,6 +572,44 @@ def replace_binary( from_npi: Optional[int] = None, **kwargs, ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ request = ReplaceBinaryRequest( batch_id=batch_id, to=to, @@ -359,6 +644,42 @@ def replace_mms( parameters: Optional[Dict[str, Dict[str, str]]] = None, **kwargs, ) -> BatchResponse: + """ + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead. + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBody + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For additional documentation, see https://www.sinch.com and visit our developer portal. + """ request = ReplaceMediaRequest( batch_id=batch_id, to=to, @@ -379,6 +700,18 @@ def replace_mms( def send( self, request: Optional[SendSMSRequest] = None, **kwargs ) -> BatchResponse: + """ + Send a SMS batch. + :param request: The request object. (optional) + :type request: Optional[SendSMSRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ # SendSMSRequest is a Union type, so we need to use TypeAdapter to validate adapter = TypeAdapter(SendSMSRequest) @@ -415,6 +748,44 @@ def send_sms( ) -> BatchResponse: """ Send a text SMS batch. + + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = TextRequest( to=to, @@ -454,6 +825,37 @@ def send_binary( ) -> BatchResponse: """ Send a binary SMS batch. + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: str + :param udh: The user data header. (required) + :type udh: str + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = BinaryRequest( to=to, @@ -489,6 +891,35 @@ def send_mms( ) -> BatchResponse: """ Send an MMS batch. + :param to: The list of phone numbers to send the message to. (required) + :type to: List[str] + :param from_: The sender phone number. (required) + :type from_: str + :param body: The message body. (required) + :type body: MediaBody + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :return: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = MediaRequest( to=to, @@ -509,6 +940,23 @@ def send_mms( def send_delivery_feedback( self, batch_id: str, recipients: List[str], **kwargs ) -> None: + """ + Send delivery feedback for a message + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param recipients: A list of phone numbers (MSISDNs) that have successfully received the message. The key is + required, however, the value can be an empty array (`[]`) for *a batch*. If the feedback was enabled for *a group*, + at least one phone number is required. (required) + :type recipients: List[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request_data = DeliveryFeedbackRequest( batch_id=batch_id, recipients=recipients, **kwargs ) @@ -520,6 +968,21 @@ def update( request: Optional[UpdateBatchMessageRequest] = None, **kwargs, ) -> BatchResponse: + """ + Update a Batch message + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param request: The request object. (optional) + :type request: Optional[UpdateBatchMessageRequest] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ adapter = TypeAdapter(UpdateBatchMessageRequest) input_data = {} @@ -557,6 +1020,51 @@ def update_sms( flash_message: Optional[bool] = None, **kwargs, ) -> BatchResponse: + """ + Update a Batch message (SMS) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[str] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param max_number_of_message_parts: The maximum number of message parts. (optional) + :type max_number_of_message_parts: Optional[int] + :param truncate_concat: Whether to truncate the message if it is too long. (optional) + :type truncate_concat: Optional[bool] + :param flash_message: Whether to enable flash message. (optional) + :type flash_message: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request = UpdateTextRequestWithBatchId( batch_id=batch_id, from_=from_, @@ -597,6 +1105,45 @@ def update_binary( from_npi: Optional[int] = None, **kwargs, ) -> BatchResponse: + """ + Update a Batch message (Binary) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param udh: The user data header. (required) + :type udh: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[str] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param from_ton: The type of number for the sender number. (optional) + :type from_ton: Optional[int] + :param from_npi: The number plan indicator for the sender number. (optional) + :type from_npi: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request = UpdateBinaryRequestWithBatchId( batch_id=batch_id, udh=udh, @@ -634,7 +1181,41 @@ def update_mms( **kwargs, ) -> BatchResponse: """ - Update an MMS batch. + Update a Batch message (MMS) + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param from_: The sender phone number. (optional) + :type from_: Optional[str] + :param to_add: The list of phone numbers to add to the batch. (optional) + :type to_add: Optional[List[str]] + :param to_remove: The list of phone numbers to remove from the batch. (optional) + :type to_remove: Optional[List[str]] + :param body: The message body. (optional) + :type body: Optional[MediaBody] + :param delivery_report: The delivery report type. (optional) + :type delivery_report: Optional[DeliveryReportType] + :param send_at: The time to send the message at. (optional) + :type send_at: Optional[datetime] + :param expire_at: The time to expire the message at. (optional) + :type expire_at: Optional[datetime] + :param callback_url: The callback URL to receive the delivery report. (optional) + :type callback_url: Optional[str] + :param client_reference: The client reference to identify the message. (optional) + :type client_reference: Optional[str] + :param feedback_enabled: Whether to enable feedback. (optional) + :type feedback_enabled: Optional[bool] + :param parameters: The parameters for the message. (optional) + :type parameters: Optional[Dict[str, Dict[str, str]]] + :param strict_validation: Whether to enable strict validation. (optional) + :type strict_validation: Optional[bool] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchResponse + :rtype: BatchResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. """ request = UpdateMediaRequestWithBatchId( batch_id=batch_id, diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py index ba6b1b9d..47e38f83 100644 --- a/sinch/domains/sms/api/v1/delivery_reports_apis.py +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -33,6 +33,28 @@ def get( client_reference: Optional[str] = None, **kwargs, ) -> BatchDeliveryReport: + """ + Retrieve a delivery report + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param report_type: The type of delivery report. - A `summary` will count the number of messages sent per status. - + A `full` report give that of a `summary` report but in addition, lists phone numbers. (optional) + :type report_type: Optional[str] + :param status: Comma separated list of delivery_report_statuses to include (optional) + :type status: Optional[List[DeliveryStatusType]] + :param code: Comma separated list of delivery_receipt_error_codes to include (optional) + :type code: Optional[List[DeliveryReceiptStatusCodeType]] + :param client_reference: The client identifier of the batch this delivery report belongs to, if set when submitting batch. (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: BatchDeliveryReport + :rtype: BatchDeliveryReport + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request_data = GetBatchDeliveryReportRequest( batch_id=batch_id, type=report_type, @@ -46,6 +68,21 @@ def get( def get_for_number( self, batch_id: str, recipient: str, **kwargs ) -> RecipientDeliveryReport: + """ + Retrieve a recipient delivery report + + :param batch_id: The batch ID you received from sending a message. (required) + :type batch_id: str + :param recipient: Phone number for which you want to search. (required) + :type recipient: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: RecipientDeliveryReport + :rtype: RecipientDeliveryReport + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ request_data = GetRecipientDeliveryReportRequest( batch_id=batch_id, recipient_msisdn=recipient, **kwargs ) @@ -62,6 +99,31 @@ def list( client_reference: Optional[str] = None, **kwargs, ) -> Paginator[RecipientDeliveryReport]: + """ + Retrieve a list of delivery reports + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page. (optional) + :type page_size: Optional[int] + :param start_date: Only list messages received at or after this date/time. Default: 24h ago (optional) + :type start_date: Optional[datetime] + :param end_date: Only list messages received before this date/time. (optional) + :type end_date: Optional[datetime] + :param status: Comma separated list of delivery report statuses to include. (optional) + :type status: Optional[List[DeliveryStatusType]] + :param code: Comma separated list of delivery receipt error codes to include. (optional) + :type code: Optional[List[DeliveryReceiptStatusCodeType]] + :param client_reference: Client reference to include (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[RecipientDeliveryReport] + :rtype: Paginator[RecipientDeliveryReport] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ endpoint = ListDeliveryReportsEndpoint( project_id=self._get_path_identifier(), request_data=ListDeliveryReportsRequest( From 59d59071135ec2d00bf103fe2efce80db075dde7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 21 Nov 2025 18:35:58 +0100 Subject: [PATCH 066/106] chore(sms): update docstrings (#97) --- sinch/domains/sms/api/v1/batches_apis.py | 95 ++++++++++++++----- .../sms/api/v1/delivery_reports_apis.py | 9 +- 2 files changed, 78 insertions(+), 26 deletions(-) diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py index b8e3283f..87616819 100644 --- a/sinch/domains/sms/api/v1/batches_apis.py +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -53,7 +53,12 @@ class Batches(BaseSms): def cancel(self, batch_id: str, **kwargs) -> BatchResponse: """ - Cancel a batch message + A batch can be canceled at any point. If a batch is canceled while it's currently being delivered some messages + currently being processed might still be delivered. The delivery report will indicate which messages were + canceled and which weren't. + + Canceling a batch scheduled in the future will result in an empty delivery report while canceling an already + sent batch would result in no change to the completed delivery report. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -76,7 +81,8 @@ def dry_run( **kwargs, ) -> DryRunResponse: """ - Dry run + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages. :param request: The request object. (optional) :type request: Optional[DryRunRequest] @@ -149,7 +155,8 @@ def dry_run_sms( **kwargs, ) -> DryRunResponse: """ - Dry run a text SMS batch. + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (SMS). :param to: The list of phone numbers to send the message to. (required) :type to: List[str] @@ -234,7 +241,8 @@ def dry_run_binary( **kwargs, ) -> DryRunResponse: """ - Dry run a binary SMS batch. + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (Binary). :param to: The list of phone numbers to send the message to. (required) :type to: List[str] @@ -309,7 +317,8 @@ def dry_run_mms( **kwargs, ) -> DryRunResponse: """ - Dry run + This operation will perform a dry run of a batch which calculates the bodies and number of parts for all + messages in the batch without actually sending any messages (MMS). :param to: The list of phone numbers to send the message to. (required) :type to: List[str] @@ -365,7 +374,7 @@ def dry_run_mms( def get(self, batch_id: str, **kwargs) -> BatchResponse: """ - Get a batch message + This operation returns a specific batch that matches the provided batch ID. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -391,7 +400,8 @@ def list( **kwargs, ) -> Paginator[BatchResponse]: """ - List Batches + With the list operation you can list batch messages created in the last 14 days that you have created. + This operation supports pagination. :param page: The page number starting from 0. (optional) :type page: Optional[int] @@ -440,8 +450,8 @@ def replace( **kwargs, ) -> BatchResponse: """ - This operation will replace all the parameters of a batch with the provided values. It is the same as cancelling a batch - and sending a new one instead. + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -490,9 +500,8 @@ def replace_sms( **kwargs, ) -> BatchResponse: """ - - This operation will replace all the parameters of a batch with the provided values. It is the same as cancelling a batch - and sending a new one instead. + This operation will replace all the parameters of a batch with the provided values. + It is the same as cancelling a batch and sending a new one instead (MMS). :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -574,7 +583,7 @@ def replace_binary( ) -> BatchResponse: """ This operation will replace all the parameters of a batch with the provided values. - It is the same as cancelling a batch and sending a new one instead. + It is the same as cancelling a batch and sending a new one instead (Binary). :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -646,7 +655,7 @@ def replace_mms( ) -> BatchResponse: """ This operation will replace all the parameters of a batch with the provided values. - It is the same as cancelling a batch and sending a new one instead. + It is the same as cancelling a batch and sending a new one instead (MMS). :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -701,7 +710,15 @@ def send( self, request: Optional[SendSMSRequest] = None, **kwargs ) -> BatchResponse: """ - Send a SMS batch. + Send a message or a batch of messages. + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + :param request: The request object. (optional) :type request: Optional[SendSMSRequest] :param **kwargs: Additional parameters for the request. @@ -747,7 +764,14 @@ def send_sms( **kwargs, ) -> BatchResponse: """ - Send a text SMS batch. + Send a message or a batch of messages (SMS). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. :param to: The list of phone numbers to send the message to. (required) :type to: List[str] @@ -824,7 +848,15 @@ def send_binary( **kwargs, ) -> BatchResponse: """ - Send a binary SMS batch. + Send a message or a batch of messages (Binary). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + :param to: The list of phone numbers to send the message to. (required) :type to: List[str] :param from_: The sender phone number. (required) @@ -890,7 +922,15 @@ def send_mms( **kwargs, ) -> BatchResponse: """ - Send an MMS batch. + Send a message or a batch of messages (MMS). + + Depending on the length of the body, one message might be split into multiple parts and charged accordingly. + + Any groups targeted in a scheduled batch will be evaluated at the time of sending. + If a group is deleted between batch creation and scheduled date, it will be considered empty. + + Be sure to use the correct [region](/docs/sms/api-reference/#base-url) in the server URL. + :param to: The list of phone numbers to send the message to. (required) :type to: List[str] :param from_: The sender phone number. (required) @@ -941,7 +981,16 @@ def send_delivery_feedback( self, batch_id: str, recipients: List[str], **kwargs ) -> None: """ - Send delivery feedback for a message + Send feedback if your system can confirm successful message delivery. + + Feedback can only be provided if `feedback_enabled` was set when batch was submitted. + + **Batches**: It is possible to submit feedback multiple times for the same batch for different recipients. + Feedback without specified recipients is treated as successful message delivery to all recipients referenced + in the batch. Note that the `recipients` key is still required even if the value is empty. + + **Groups**: If the batch message was creating using a group ID, at least one recipient is required. + Excluding recipients (an empty recipient list) does not work and will result in a failed request. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -969,7 +1018,7 @@ def update( **kwargs, ) -> BatchResponse: """ - Update a Batch message + This operation updates all specified parameters of a batch that matches the provided batch ID. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -1021,7 +1070,7 @@ def update_sms( **kwargs, ) -> BatchResponse: """ - Update a Batch message (SMS) + This operation updates all specified parameters of a batch that matches the provided batch ID. (SMS) :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -1106,7 +1155,7 @@ def update_binary( **kwargs, ) -> BatchResponse: """ - Update a Batch message (Binary) + This operation updates all specified parameters of a batch that matches the provided batch ID. (Binary) :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -1181,7 +1230,7 @@ def update_mms( **kwargs, ) -> BatchResponse: """ - Update a Batch message (MMS) + This operation updates all specified parameters of a batch that matches the provided batch ID. (MMS) :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str diff --git a/sinch/domains/sms/api/v1/delivery_reports_apis.py b/sinch/domains/sms/api/v1/delivery_reports_apis.py index 47e38f83..ede3cc36 100644 --- a/sinch/domains/sms/api/v1/delivery_reports_apis.py +++ b/sinch/domains/sms/api/v1/delivery_reports_apis.py @@ -34,7 +34,9 @@ def get( **kwargs, ) -> BatchDeliveryReport: """ - Retrieve a delivery report + Delivery reports can be retrieved even if no callback was requested. The difference between a summary and a full + report is only that the full report contains the phone numbers in + [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) format for each status code. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -69,7 +71,7 @@ def get_for_number( self, batch_id: str, recipient: str, **kwargs ) -> RecipientDeliveryReport: """ - Retrieve a recipient delivery report + A recipient delivery report contains the message status for a single recipient phone number. :param batch_id: The batch ID you received from sending a message. (required) :type batch_id: str @@ -100,7 +102,8 @@ def list( **kwargs, ) -> Paginator[RecipientDeliveryReport]: """ - Retrieve a list of delivery reports + Get a list of finished delivery reports. + This operation supports pagination. :param page: The page number starting from 0. (optional) :type page: Optional[int] From aa18ebc01f65f49a5f74f032c76b55aa1f30ccff Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Sat, 22 Nov 2025 13:51:55 +0100 Subject: [PATCH 067/106] DEVEXP-788: SMS Batches Snippets (#98) --- docs/.env.example | 5 ++- .../numbers/active_numbers/get/snippet.py | 0 .../numbers/active_numbers/list/snippet.py | 0 .../active_numbers/list_auto/snippet.py | 0 .../numbers/active_numbers/release/snippet.py | 0 .../numbers/active_numbers/update/snippet.py | 0 .../check_availability/snippet.py | 0 .../numbers/available_numbers/rent/snippet.py | 0 .../available_numbers/rent_any/snippet.py | 0 .../search_for_available_numbers/snippet.py | 0 .../numbers/available_regions/list/snippet.py | 0 .../callback_configuration/get/snippet.py | 0 .../callback_configuration/update/snippet.py | 0 docs/snippets/sms/batches/cancel/snippet.py | 18 ++++++++++ .../sms/batches/dry_run_binary/snippet.py | 29 ++++++++++++++++ .../sms/batches/dry_run_mms/snippet.py | 26 +++++++++++++++ .../sms/batches/dry_run_sms/snippet.py | 21 ++++++++++++ docs/snippets/sms/batches/get/snippet.py | 18 ++++++++++ docs/snippets/sms/batches/list/snippet.py | 29 ++++++++++++++++ .../sms/batches/replace_binary/snippet.py | 33 +++++++++++++++++++ .../sms/batches/replace_mms/snippet.py | 30 +++++++++++++++++ .../sms/batches/replace_sms/snippet.py | 25 ++++++++++++++ .../sms/batches/send_binary/snippet.py | 29 ++++++++++++++++ .../batches/send_delivery_feedback/snippet.py | 24 ++++++++++++++ docs/snippets/sms/batches/send_mms/snippet.py | 27 +++++++++++++++ docs/snippets/sms/batches/send_sms/snippet.py | 21 ++++++++++++ .../sms/batches/update_binary/snippet.py | 32 ++++++++++++++++++ .../sms/batches/update_mms/snippet.py | 29 ++++++++++++++++ .../sms/batches/update_sms/snippet.py | 24 ++++++++++++++ .../sms/delivery_reports/get/snippet.py | 18 ++++++++++ .../get_for_number/snippet.py | 24 ++++++++++++++ .../sms/delivery_reports/list/snippet.py | 29 ++++++++++++++++ 32 files changed, 490 insertions(+), 1 deletion(-) rename docs/{ => snippets}/numbers/active_numbers/get/snippet.py (100%) rename docs/{ => snippets}/numbers/active_numbers/list/snippet.py (100%) rename docs/{ => snippets}/numbers/active_numbers/list_auto/snippet.py (100%) rename docs/{ => snippets}/numbers/active_numbers/release/snippet.py (100%) rename docs/{ => snippets}/numbers/active_numbers/update/snippet.py (100%) rename docs/{ => snippets}/numbers/available_numbers/check_availability/snippet.py (100%) rename docs/{ => snippets}/numbers/available_numbers/rent/snippet.py (100%) rename docs/{ => snippets}/numbers/available_numbers/rent_any/snippet.py (100%) rename docs/{ => snippets}/numbers/available_numbers/search_for_available_numbers/snippet.py (100%) rename docs/{ => snippets}/numbers/available_regions/list/snippet.py (100%) rename docs/{ => snippets}/numbers/callback_configuration/get/snippet.py (100%) rename docs/{ => snippets}/numbers/callback_configuration/update/snippet.py (100%) create mode 100644 docs/snippets/sms/batches/cancel/snippet.py create mode 100644 docs/snippets/sms/batches/dry_run_binary/snippet.py create mode 100644 docs/snippets/sms/batches/dry_run_mms/snippet.py create mode 100644 docs/snippets/sms/batches/dry_run_sms/snippet.py create mode 100644 docs/snippets/sms/batches/get/snippet.py create mode 100644 docs/snippets/sms/batches/list/snippet.py create mode 100644 docs/snippets/sms/batches/replace_binary/snippet.py create mode 100644 docs/snippets/sms/batches/replace_mms/snippet.py create mode 100644 docs/snippets/sms/batches/replace_sms/snippet.py create mode 100644 docs/snippets/sms/batches/send_binary/snippet.py create mode 100644 docs/snippets/sms/batches/send_delivery_feedback/snippet.py create mode 100644 docs/snippets/sms/batches/send_mms/snippet.py create mode 100644 docs/snippets/sms/batches/send_sms/snippet.py create mode 100644 docs/snippets/sms/batches/update_binary/snippet.py create mode 100644 docs/snippets/sms/batches/update_mms/snippet.py create mode 100644 docs/snippets/sms/batches/update_sms/snippet.py create mode 100644 docs/snippets/sms/delivery_reports/get/snippet.py create mode 100644 docs/snippets/sms/delivery_reports/get_for_number/snippet.py create mode 100644 docs/snippets/sms/delivery_reports/list/snippet.py diff --git a/docs/.env.example b/docs/.env.example index 6a05be78..e4a62a3d 100644 --- a/docs/.env.example +++ b/docs/.env.example @@ -9,4 +9,7 @@ SINCH_KEY_SECRET= SINCH_PHONE_NUMBER= # The service plan ID for your Sinch account to configure the SMS plan associated with your virtual phone number. -SINCH_SERVICE_PLAN_ID= \ No newline at end of file +SINCH_SERVICE_PLAN_ID= + +# The SMS region code. See https://developers.sinch.com/docs/sms/api-reference/#base-url for available regions +SINCH_SMS_REGION= \ No newline at end of file diff --git a/docs/numbers/active_numbers/get/snippet.py b/docs/snippets/numbers/active_numbers/get/snippet.py similarity index 100% rename from docs/numbers/active_numbers/get/snippet.py rename to docs/snippets/numbers/active_numbers/get/snippet.py diff --git a/docs/numbers/active_numbers/list/snippet.py b/docs/snippets/numbers/active_numbers/list/snippet.py similarity index 100% rename from docs/numbers/active_numbers/list/snippet.py rename to docs/snippets/numbers/active_numbers/list/snippet.py diff --git a/docs/numbers/active_numbers/list_auto/snippet.py b/docs/snippets/numbers/active_numbers/list_auto/snippet.py similarity index 100% rename from docs/numbers/active_numbers/list_auto/snippet.py rename to docs/snippets/numbers/active_numbers/list_auto/snippet.py diff --git a/docs/numbers/active_numbers/release/snippet.py b/docs/snippets/numbers/active_numbers/release/snippet.py similarity index 100% rename from docs/numbers/active_numbers/release/snippet.py rename to docs/snippets/numbers/active_numbers/release/snippet.py diff --git a/docs/numbers/active_numbers/update/snippet.py b/docs/snippets/numbers/active_numbers/update/snippet.py similarity index 100% rename from docs/numbers/active_numbers/update/snippet.py rename to docs/snippets/numbers/active_numbers/update/snippet.py diff --git a/docs/numbers/available_numbers/check_availability/snippet.py b/docs/snippets/numbers/available_numbers/check_availability/snippet.py similarity index 100% rename from docs/numbers/available_numbers/check_availability/snippet.py rename to docs/snippets/numbers/available_numbers/check_availability/snippet.py diff --git a/docs/numbers/available_numbers/rent/snippet.py b/docs/snippets/numbers/available_numbers/rent/snippet.py similarity index 100% rename from docs/numbers/available_numbers/rent/snippet.py rename to docs/snippets/numbers/available_numbers/rent/snippet.py diff --git a/docs/numbers/available_numbers/rent_any/snippet.py b/docs/snippets/numbers/available_numbers/rent_any/snippet.py similarity index 100% rename from docs/numbers/available_numbers/rent_any/snippet.py rename to docs/snippets/numbers/available_numbers/rent_any/snippet.py diff --git a/docs/numbers/available_numbers/search_for_available_numbers/snippet.py b/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py similarity index 100% rename from docs/numbers/available_numbers/search_for_available_numbers/snippet.py rename to docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py diff --git a/docs/numbers/available_regions/list/snippet.py b/docs/snippets/numbers/available_regions/list/snippet.py similarity index 100% rename from docs/numbers/available_regions/list/snippet.py rename to docs/snippets/numbers/available_regions/list/snippet.py diff --git a/docs/numbers/callback_configuration/get/snippet.py b/docs/snippets/numbers/callback_configuration/get/snippet.py similarity index 100% rename from docs/numbers/callback_configuration/get/snippet.py rename to docs/snippets/numbers/callback_configuration/get/snippet.py diff --git a/docs/numbers/callback_configuration/update/snippet.py b/docs/snippets/numbers/callback_configuration/update/snippet.py similarity index 100% rename from docs/numbers/callback_configuration/update/snippet.py rename to docs/snippets/numbers/callback_configuration/update/snippet.py diff --git a/docs/snippets/sms/batches/cancel/snippet.py b/docs/snippets/sms/batches/cancel/snippet.py new file mode 100644 index 00000000..dca4ca9f --- /dev/null +++ b/docs/snippets/sms/batches/cancel/snippet.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to cancel +batch_id = "BATCH_ID" +response = sinch_client.sms.batches.cancel(batch_id=batch_id) + +print(f"Cancelled batch:\n{response}") diff --git a/docs/snippets/sms/batches/dry_run_binary/snippet.py b/docs/snippets/sms/batches/dry_run_binary/snippet.py new file mode 100644 index 00000000..b5825970 --- /dev/null +++ b/docs/snippets/sms/batches/dry_run_binary/snippet.py @@ -0,0 +1,29 @@ +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# Example: Encode message body as Base64 +message = "Test message for dry run" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.dry_run_binary( + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Dry run result:\n{response}") diff --git a/docs/snippets/sms/batches/dry_run_mms/snippet.py b/docs/snippets/sms/batches/dry_run_mms/snippet.py new file mode 100644 index 00000000..b27a51af --- /dev/null +++ b/docs/snippets/sms/batches/dry_run_mms/snippet.py @@ -0,0 +1,26 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +body = MediaBody( + url="https://example.com/image.jpg", + message="Test message for dry run" +) + +response = sinch_client.sms.batches.dry_run_mms( + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Dry run result:\n{response}") diff --git a/docs/snippets/sms/batches/dry_run_sms/snippet.py b/docs/snippets/sms/batches/dry_run_sms/snippet.py new file mode 100644 index 00000000..133d2308 --- /dev/null +++ b/docs/snippets/sms/batches/dry_run_sms/snippet.py @@ -0,0 +1,21 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +response = sinch_client.sms.batches.dry_run_sms( + to=["+1234567890"], + from_="+2345678901", + body="Test message for dry run" +) + +print(f"Dry run result:\n{response}") + diff --git a/docs/snippets/sms/batches/get/snippet.py b/docs/snippets/sms/batches/get/snippet.py new file mode 100644 index 00000000..94bc9c04 --- /dev/null +++ b/docs/snippets/sms/batches/get/snippet.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to retrieve +batch_id = "BATCH_ID" +response = sinch_client.sms.batches.get(batch_id=batch_id) + +print(f"Batch details:\n{response}") diff --git a/docs/snippets/sms/batches/list/snippet.py b/docs/snippets/sms/batches/list/snippet.py new file mode 100644 index 00000000..64556ecf --- /dev/null +++ b/docs/snippets/sms/batches/list/snippet.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +batches = sinch_client.sms.batches.list( + page=0, + page_size=10 +) + +page_counter = 1 +reached_last_page = False + +while not reached_last_page: + print(f"Page {page_counter} List of Batches: {batches}") + + if batches.has_next_page: + batches = batches.next_page() + page_counter += 1 + else: + reached_last_page = True diff --git a/docs/snippets/sms/batches/replace_binary/snippet.py b/docs/snippets/sms/batches/replace_binary/snippet.py new file mode 100644 index 00000000..7536db7b --- /dev/null +++ b/docs/snippets/sms/batches/replace_binary/snippet.py @@ -0,0 +1,33 @@ +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +# Example: Encode message body as Base64 +message = "Updated binary message content" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.replace_binary( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Replaced batch:\n{response}") diff --git a/docs/snippets/sms/batches/replace_mms/snippet.py b/docs/snippets/sms/batches/replace_mms/snippet.py new file mode 100644 index 00000000..44623d3c --- /dev/null +++ b/docs/snippets/sms/batches/replace_mms/snippet.py @@ -0,0 +1,30 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +body = MediaBody( + url="https://example.com/image.jpg", + message="Updated MMS message content" +) + +response = sinch_client.sms.batches.replace_mms( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Replaced batch:\n{response}") diff --git a/docs/snippets/sms/batches/replace_sms/snippet.py b/docs/snippets/sms/batches/replace_sms/snippet.py new file mode 100644 index 00000000..33b01ee0 --- /dev/null +++ b/docs/snippets/sms/batches/replace_sms/snippet.py @@ -0,0 +1,25 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to replace +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.replace_sms( + batch_id=batch_id, + to=["+1234567890"], + from_="+2345678901", + body="Updated message content" +) + +print(f"Replaced batch:\n{response}") + diff --git a/docs/snippets/sms/batches/send_binary/snippet.py b/docs/snippets/sms/batches/send_binary/snippet.py new file mode 100644 index 00000000..baa8791e --- /dev/null +++ b/docs/snippets/sms/batches/send_binary/snippet.py @@ -0,0 +1,29 @@ +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# Example: Encode message body as Base64 +message = "Hello, this is a binary message!" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +response = sinch_client.sms.batches.send_binary( + to=["+1234567890"], + from_="+2345678901", + body=body, + udh=udh +) + +print(f"Batch sent:\n{response}") diff --git a/docs/snippets/sms/batches/send_delivery_feedback/snippet.py b/docs/snippets/sms/batches/send_delivery_feedback/snippet.py new file mode 100644 index 00000000..0e38061f --- /dev/null +++ b/docs/snippets/sms/batches/send_delivery_feedback/snippet.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to send delivery feedback for +batch_id = "BATCH_ID" +# The recipient phone numbers in E.164 format +recipients = ["+1234567890"] + +sinch_client.sms.batches.send_delivery_feedback( + batch_id=batch_id, + recipients=recipients +) + +print("Delivery feedback sent successfully") diff --git a/docs/snippets/sms/batches/send_mms/snippet.py b/docs/snippets/sms/batches/send_mms/snippet.py new file mode 100644 index 00000000..50ab2f50 --- /dev/null +++ b/docs/snippets/sms/batches/send_mms/snippet.py @@ -0,0 +1,27 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +body = MediaBody( + url="https://example.com/image.jpg", + message="Hello, this is an MMS message!", + subject="Test MMS" +) + +response = sinch_client.sms.batches.send_mms( + to=["+1234567890"], + from_="+2345678901", + body=body +) + +print(f"Batch sent:\n{response}") diff --git a/docs/snippets/sms/batches/send_sms/snippet.py b/docs/snippets/sms/batches/send_sms/snippet.py new file mode 100644 index 00000000..fa97700f --- /dev/null +++ b/docs/snippets/sms/batches/send_sms/snippet.py @@ -0,0 +1,21 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +response = sinch_client.sms.batches.send_sms( + to=["+1234567890"], + from_="+2345678901", + body="Hello, this is a test message!" +) + +print(f"Batch sent:\n{response}") + diff --git a/docs/snippets/sms/batches/update_binary/snippet.py b/docs/snippets/sms/batches/update_binary/snippet.py new file mode 100644 index 00000000..72c6b7a6 --- /dev/null +++ b/docs/snippets/sms/batches/update_binary/snippet.py @@ -0,0 +1,32 @@ +import os +import base64 +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +# Example: UDH header (HEX encoded) +udh = "06050423F423F4" + +# Example: Encode message body as Base64 (optional) +message = "Updated binary message body" +body = base64.b64encode(message.encode('utf-8')).decode('utf-8') + +response = sinch_client.sms.batches.update_binary( + batch_id=batch_id, + udh=udh, + body=body, + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") diff --git a/docs/snippets/sms/batches/update_mms/snippet.py b/docs/snippets/sms/batches/update_mms/snippet.py new file mode 100644 index 00000000..7ffc5d6c --- /dev/null +++ b/docs/snippets/sms/batches/update_mms/snippet.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient +from sinch.domains.sms.models.v1.shared import MediaBody + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +body = MediaBody( + url="https://example.com/image.jpg", + message="Updated MMS message body" +) + +response = sinch_client.sms.batches.update_mms( + batch_id=batch_id, + body=body, + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") diff --git a/docs/snippets/sms/batches/update_sms/snippet.py b/docs/snippets/sms/batches/update_sms/snippet.py new file mode 100644 index 00000000..453b8a1d --- /dev/null +++ b/docs/snippets/sms/batches/update_sms/snippet.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to update +batch_id = "BATCH_ID" + +response = sinch_client.sms.batches.update_sms( + batch_id=batch_id, + body="Updated message body", + to_add=["+1987654321"] +) + +print(f"Updated batch:\n{response}") + diff --git a/docs/snippets/sms/delivery_reports/get/snippet.py b/docs/snippets/sms/delivery_reports/get/snippet.py new file mode 100644 index 00000000..e1287b1e --- /dev/null +++ b/docs/snippets/sms/delivery_reports/get/snippet.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to get delivery report for +batch_id = "BATCH_ID" +response = sinch_client.sms.delivery_reports.get(batch_id=batch_id) + +print(f"Delivery report for batch:\n{response}") diff --git a/docs/snippets/sms/delivery_reports/get_for_number/snippet.py b/docs/snippets/sms/delivery_reports/get_for_number/snippet.py new file mode 100644 index 00000000..09b06496 --- /dev/null +++ b/docs/snippets/sms/delivery_reports/get_for_number/snippet.py @@ -0,0 +1,24 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the batch to get delivery reports for +batch_id = "BATCH_ID" +# The phone number to get delivery reports for +recipient = "+1234567890" + +response = sinch_client.sms.delivery_reports.get_for_number( + batch_id=batch_id, + recipient=recipient +) + +print(f"Delivery report for recipient:\n{response}") diff --git a/docs/snippets/sms/delivery_reports/list/snippet.py b/docs/snippets/sms/delivery_reports/list/snippet.py new file mode 100644 index 00000000..230d0d30 --- /dev/null +++ b/docs/snippets/sms/delivery_reports/list/snippet.py @@ -0,0 +1,29 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +delivery_reports = sinch_client.sms.delivery_reports.list( + page=0, + page_size=10 +) + +page_counter = 1 +reached_last_page = False + +while not reached_last_page: + print(f"Page {page_counter} List of Delivery Reports: {delivery_reports}") + + if delivery_reports.has_next_page: + delivery_reports = delivery_reports.next_page() + page_counter += 1 + else: + reached_last_page = True From a85bfb03ea25f6e88d7cbc3bde55160b9ec456e5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 26 Nov 2025 20:33:43 +0100 Subject: [PATCH 068/106] DEVEXP-1145: Number Lookup (#99) --- .github/workflows/ci.yml | 2 + .../clients/sinch_client_configuration.py | 1 + sinch/core/clients/sinch_client_sync.py | 2 + sinch/core/models/__init__.py | 10 +- sinch/core/models/utils.py | 47 ++++- sinch/domains/number_lookup/__init__.py | 3 + .../domains/number_lookup/api/v1/__init__.py | 3 + .../number_lookup/api/v1/base/__init__.py | 3 + .../number_lookup/api/v1/base/base_lookup.py | 26 +++ .../number_lookup/api/v1/internal/__init__.py | 7 + .../api/v1/internal/base/__init__.py | 7 + .../api/v1/internal/base/lookup_endpoint.py | 19 ++ .../v1/internal/number_lookup_endpoints.py | 51 +++++ .../api/v1/number_lookup_apis.py | 42 ++++ sinch/domains/number_lookup/exceptions.py | 5 + .../domains/number_lookup/models/__init__.py | 0 .../models/v1/internal/__init__.py | 7 + .../models/v1/internal/base/__init__.py | 9 + .../internal/base/base_model_configuration.py | 44 +++++ .../v1/internal/lookup_number_request.py | 26 +++ .../models/v1/response/__init__.py | 7 + .../v1/response/lookup_number_response.py | 25 +++ .../models/v1/shared/__init__.py | 17 ++ .../number_lookup/models/v1/shared/line.py | 23 +++ .../models/v1/shared/lookup_error.py | 12 ++ .../number_lookup/models/v1/shared/rnd.py | 13 ++ .../models/v1/shared/sim_swap.py | 17 ++ .../models/v1/shared/voip_detection.py | 13 ++ .../number_lookup/models/v1/types/__init__.py | 19 ++ .../models/v1/types/lookup_features.py | 7 + .../models/v1/types/rnd_feature_options.py | 7 + .../models/v1/types/swap_period.py | 19 ++ .../models/v1/types/voip_probability.py | 7 + tests/conftest.py | 14 ++ .../number-lookup/features/lookups.feature | 13 ++ .../endpoints/test_lookup_number_endpoint.py | 125 ++++++++++++ .../v1/models/shared/test_line_model.py | 38 ++++ .../models/shared/test_lookup_error_model.py | 32 +++ .../v1/models/shared/test_rnd_model.py | 17 ++ .../v1/models/shared/test_sim_swap_model.py | 38 ++++ .../shared/test_voip_detection_model.py | 17 ++ .../v1/models/test_lookup_request_model.py | 120 ++++++++++++ .../v1/models/test_lookup_response_model.py | 121 ++++++++++++ .../number_lookup/v1/test_number_lookup.py | 182 ++++++++++++++++++ 44 files changed, 1207 insertions(+), 10 deletions(-) create mode 100644 sinch/domains/number_lookup/__init__.py create mode 100644 sinch/domains/number_lookup/api/v1/__init__.py create mode 100644 sinch/domains/number_lookup/api/v1/base/__init__.py create mode 100644 sinch/domains/number_lookup/api/v1/base/base_lookup.py create mode 100644 sinch/domains/number_lookup/api/v1/internal/__init__.py create mode 100644 sinch/domains/number_lookup/api/v1/internal/base/__init__.py create mode 100644 sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py create mode 100644 sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py create mode 100644 sinch/domains/number_lookup/api/v1/number_lookup_apis.py create mode 100644 sinch/domains/number_lookup/exceptions.py create mode 100644 sinch/domains/number_lookup/models/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/internal/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/internal/base/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py create mode 100644 sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py create mode 100644 sinch/domains/number_lookup/models/v1/response/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/response/lookup_number_response.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/line.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/lookup_error.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/rnd.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/sim_swap.py create mode 100644 sinch/domains/number_lookup/models/v1/shared/voip_detection.py create mode 100644 sinch/domains/number_lookup/models/v1/types/__init__.py create mode 100644 sinch/domains/number_lookup/models/v1/types/lookup_features.py create mode 100644 sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py create mode 100644 sinch/domains/number_lookup/models/v1/types/swap_period.py create mode 100644 sinch/domains/number_lookup/models/v1/types/voip_probability.py create mode 100644 tests/e2e/number-lookup/features/lookups.feature create mode 100644 tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py create mode 100644 tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py create mode 100644 tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py create mode 100644 tests/unit/domains/number_lookup/v1/test_number_lookup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9222ee91..b30d1f60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,8 @@ jobs: ruff format sinch/domains/numbers --check --diff ruff check sinch/domains/sms --statistics ruff format sinch/domains/sms --check --diff + ruff check sinch/domains/number_lookup --statistics + ruff format sinch/domains/number_lookup --check --diff - name: Test with Pytest run: | diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 28472608..0fe559ec 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -41,6 +41,7 @@ def __init__( self.numbers_origin = "https://numbers.api.sinch.com" self.verification_origin = "https://verification.api.sinch.com" self.voice_applications_origin = "https://callingapi.sinch.com" + self.number_lookup_origin = "https://lookup.api.sinch.com" self._voice_domain = "https://{}.api.sinch.com" self._voice_region = None self._conversation_region = "eu" diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index 96a45d1d..22308c05 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -8,6 +8,7 @@ from sinch.domains.sms import SMS from sinch.domains.verification import Verification from sinch.domains.voice import Voice +from sinch.domains.number_lookup import NumberLookup class SinchClient: @@ -50,3 +51,4 @@ def __init__( self.sms = SMS(self) self.verification = Verification(self) self.voice = Voice(self) + self.number_lookup = NumberLookup(self) diff --git a/sinch/core/models/__init__.py b/sinch/core/models/__init__.py index 914e728c..7457f49f 100644 --- a/sinch/core/models/__init__.py +++ b/sinch/core/models/__init__.py @@ -1,4 +1,10 @@ -from sinch.core.models.utils import model_dump_for_query_params +from sinch.core.models.utils import ( + model_dump_for_query_params, + serialize_datetime_in_dict, +) -__all__ = ["model_dump_for_query_params"] +__all__ = [ + "model_dump_for_query_params", + "serialize_datetime_in_dict", +] diff --git a/sinch/core/models/utils.py b/sinch/core/models/utils.py index 2f5a340b..aef322e2 100644 --- a/sinch/core/models/utils.py +++ b/sinch/core/models/utils.py @@ -1,16 +1,48 @@ +from datetime import datetime, date +from typing import Optional, Dict, Any + + +def serialize_datetime_in_dict(value: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """ + Serialize datetime/date objects in a dictionary to ISO 8601 date strings. + + :param value: Optional dictionary that may contain datetime/date objects + :type value: Optional[Dict[str, Any]] + :returns: Dictionary with datetime/date objects converted to ISO 8601 date strings, + or None if input is None + :rtype: Optional[Dict[str, Any]] + """ + if value is None: + return None + + serialized = {} + for key, val in value.items(): + if isinstance(val, (datetime, date)): + # Convert datetime/date to ISO 8601 date format (YYYY-MM-DD) + if isinstance(val, datetime): + serialized[key] = val.date().isoformat() + else: + serialized[key] = val.isoformat() + else: + # Pass string values directly to the backend without modification + serialized[key] = val + return serialized + + def model_dump_for_query_params(model, exclude_none=True, by_alias=True): """ Serializes a Pydantic model for use as query parameters. Converts list values to comma-separated strings for APIs that expect this format. Filters out empty values (empty strings and empty lists). - Args: - model: A Pydantic BaseModel instance - exclude_none: Whether to exclude None values (default: True) - by_alias: Whether to use field aliases (default: True) - - Returns: - dict: Serialized model data with lists converted to comma-separated strings + :param model: A Pydantic BaseModel instance + :type model: BaseModel + :param exclude_none: Whether to exclude None values (default: True) + :type exclude_none: bool + :param by_alias: Whether to use field aliases (default: True) + :type by_alias: bool + :returns: Serialized model data with lists converted to comma-separated strings + :rtype: dict """ data = model.model_dump(exclude_none=exclude_none, by_alias=by_alias) filtered_data = {} @@ -27,4 +59,3 @@ def model_dump_for_query_params(model, exclude_none=True, by_alias=True): else: filtered_data[key] = value return filtered_data - diff --git a/sinch/domains/number_lookup/__init__.py b/sinch/domains/number_lookup/__init__.py new file mode 100644 index 00000000..315d37f1 --- /dev/null +++ b/sinch/domains/number_lookup/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup + +__all__ = ["NumberLookup"] diff --git a/sinch/domains/number_lookup/api/v1/__init__.py b/sinch/domains/number_lookup/api/v1/__init__.py new file mode 100644 index 00000000..83c137df --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint + +__all__ = ["LookupNumberEndpoint"] diff --git a/sinch/domains/number_lookup/api/v1/base/__init__.py b/sinch/domains/number_lookup/api/v1/base/__init__.py new file mode 100644 index 00000000..4ea698a7 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/base/__init__.py @@ -0,0 +1,3 @@ +from sinch.domains.number_lookup.api.v1.base.base_lookup import BaseLookup + +__all__ = ["BaseLookup"] diff --git a/sinch/domains/number_lookup/api/v1/base/base_lookup.py b/sinch/domains/number_lookup/api/v1/base/base_lookup.py new file mode 100644 index 00000000..bdb97fdf --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/base/base_lookup.py @@ -0,0 +1,26 @@ +class BaseLookup: + """Base class for handling Sinch Lookup operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + if not self._sinch.configuration.project_id: + raise ValueError("project_id is required for Lookup API") + + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/number_lookup/api/v1/internal/__init__.py b/sinch/domains/number_lookup/api/v1/internal/__init__.py new file mode 100644 index 00000000..151449ef --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.api.v1.internal.number_lookup_endpoints import ( + LookupNumberEndpoint, +) + +__all__ = [ + "LookupNumberEndpoint", +] diff --git a/sinch/domains/number_lookup/api/v1/internal/base/__init__.py b/sinch/domains/number_lookup/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..4f1459d9 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/base/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.api.v1.internal.base.lookup_endpoint import ( + LookupEndpoint, +) + +__all__ = [ + "LookupEndpoint", +] diff --git a/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py b/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py new file mode 100644 index 00000000..c3923f90 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/base/lookup_endpoint.py @@ -0,0 +1,19 @@ +from abc import ABC +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.domains.number_lookup.exceptions import NumberLookupException + + +class LookupEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data): + super().__init__(project_id, request_data) + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + error_message = f"Error {response.status_code}" + + raise NumberLookupException( + message=error_message, + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py b/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py new file mode 100644 index 00000000..03dac66c --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/internal/number_lookup_endpoints.py @@ -0,0 +1,51 @@ +import json +from typing import Type +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.types import BM +from sinch.domains.number_lookup.api.v1.internal.base import LookupEndpoint +from sinch.domains.number_lookup.exceptions import NumberLookupException +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +class LookupNumberEndpoint(LookupEndpoint): + ENDPOINT_URL = "{origin}/v2/projects/{project_id}/lookups" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: LookupNumberRequest): + super(LookupNumberEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_url(self, sinch) -> str: + return self.ENDPOINT_URL.format( + origin=sinch.configuration.number_lookup_origin, + project_id=self.project_id, + ) + + def request_body(self) -> str: + request_data = self.request_data.model_dump( + by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + try: + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse) -> LookupNumberResponse: + try: + super(LookupNumberEndpoint, self).handle_response(response) + except NumberLookupException as e: + raise NumberLookupException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, LookupNumberResponse) diff --git a/sinch/domains/number_lookup/api/v1/number_lookup_apis.py b/sinch/domains/number_lookup/api/v1/number_lookup_apis.py new file mode 100644 index 00000000..afb2f949 --- /dev/null +++ b/sinch/domains/number_lookup/api/v1/number_lookup_apis.py @@ -0,0 +1,42 @@ +from typing import Optional, List +from sinch.domains.number_lookup.api.v1.base import BaseLookup +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse +from sinch.domains.number_lookup.models.v1.types import ( + RndFeatureOptionsDict, + LookupFeaturesType, +) + + +class NumberLookup(BaseLookup): + def lookup( + self, + number: str, + features: Optional[List[LookupFeaturesType]] = None, + rnd_feature_options: Optional[RndFeatureOptionsDict] = None, + **kwargs, + ) -> LookupNumberResponse: + """ + Performs a number lookup. + You can make a minimal request or add additional options to the features array. + + :param number: MSISDN in E.164 format to query (e.g., "+12312312312") + :type number: str + :param features: List of requested features. Options: "LineType", "SimSwap", "VoIPDetection", "RND" + :type features: Optional[List[str]] + :param rnd_feature_options: Optional dictionary with RND feature options + :type rnd_feature_options: Optional[RndFeatureOptionsDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: LookupNumberResponse + :rtype: LookupNumberResponse + """ + request_data = LookupNumberRequest( + number=number, + features=features, + rnd_feature_options=rnd_feature_options, + **kwargs, + ) + return self._request(LookupNumberEndpoint, request_data) diff --git a/sinch/domains/number_lookup/exceptions.py b/sinch/domains/number_lookup/exceptions.py new file mode 100644 index 00000000..8b77f573 --- /dev/null +++ b/sinch/domains/number_lookup/exceptions.py @@ -0,0 +1,5 @@ +from sinch.core.exceptions import SinchException + + +class NumberLookupException(SinchException): + pass diff --git a/sinch/domains/number_lookup/models/__init__.py b/sinch/domains/number_lookup/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sinch/domains/number_lookup/models/v1/internal/__init__.py b/sinch/domains/number_lookup/models/v1/internal/__init__.py new file mode 100644 index 00000000..6d3e609a --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.models.v1.internal.lookup_number_request import ( + LookupNumberRequest, +) + +__all__ = [ + "LookupNumberRequest", +] diff --git a/sinch/domains/number_lookup/models/v1/internal/base/__init__.py b/sinch/domains/number_lookup/models/v1/internal/base/__init__.py new file mode 100644 index 00000000..96762760 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.number_lookup.models.v1.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py new file mode 100644 index 00000000..204ea49d --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py @@ -0,0 +1,44 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py b/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py new file mode 100644 index 00000000..c3ddc3bc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/internal/lookup_number_request.py @@ -0,0 +1,26 @@ +from typing import Optional, Dict, Any +from pydantic import Field, StrictStr, conlist, field_serializer +from sinch.core.models.utils import serialize_datetime_in_dict +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.number_lookup.models.v1.types import LookupFeaturesType + + +class LookupNumberRequest(BaseModelConfigurationRequest): + number: StrictStr = Field( + ..., description="MSISDN in E.164 format to query" + ) + features: Optional[conlist(LookupFeaturesType)] = Field( + default=None, + description="Contains requested features. Fallback to LineType if not provided.", + ) + rnd_feature_options: Optional[Dict[str, Any]] = Field( + default=None, alias="rndFeatureOptions" + ) + + @field_serializer("rnd_feature_options") + def serialize_rnd_feature_options( + self, value: Optional[Dict[str, Any]] + ) -> Optional[Dict[str, Any]]: + return serialize_datetime_in_dict(value) diff --git a/sinch/domains/number_lookup/models/v1/response/__init__.py b/sinch/domains/number_lookup/models/v1/response/__init__.py new file mode 100644 index 00000000..827a74dc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/response/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.number_lookup.models.v1.response.lookup_number_response import ( + LookupNumberResponse, +) + +__all__ = [ + "LookupNumberResponse", +] diff --git a/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py b/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py new file mode 100644 index 00000000..e01e4175 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/response/lookup_number_response.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared import ( + Line, + SimSwap, + VoIPDetection, + Rnd, +) + + +class LookupNumberResponse(BaseModelConfigurationResponse): + line: Optional[Line] = None + sim_swap: Optional[SimSwap] = Field(default=None, alias="simSwap") + voip_detection: Optional[VoIPDetection] = Field( + default=None, alias="voIPDetection" + ) + rnd: Optional[Rnd] = None + country_code: Optional[StrictStr] = Field( + default=None, alias="countryCode" + ) + trace_id: Optional[StrictStr] = Field(default=None, alias="traceId") + number: Optional[StrictStr] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/__init__.py b/sinch/domains/number_lookup/models/v1/shared/__init__.py new file mode 100644 index 00000000..f6faeaa1 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared.line import Line +from sinch.domains.number_lookup.models.v1.shared.sim_swap import SimSwap +from sinch.domains.number_lookup.models.v1.shared.voip_detection import ( + VoIPDetection, +) +from sinch.domains.number_lookup.models.v1.shared.rnd import Rnd +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + +__all__ = [ + "Line", + "SimSwap", + "VoIPDetection", + "Rnd", + "LookupError", +] diff --git a/sinch/domains/number_lookup/models/v1/shared/line.py b/sinch/domains/number_lookup/models/v1/shared/line.py new file mode 100644 index 00000000..d822f24b --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/line.py @@ -0,0 +1,23 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictBool, StrictStr +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + + +class Line(BaseModelConfigurationResponse): + carrier: Optional[StrictStr] = None + type: Optional[StrictStr] = None + mobile_country_code: Optional[StrictStr] = Field( + default=None, alias="mobileCountryCode" + ) + mobile_network_code: Optional[StrictStr] = Field( + default=None, alias="mobileNetworkCode" + ) + ported: Optional[StrictBool] = None + porting_date: Optional[datetime] = Field(default=None, alias="portingDate") + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/lookup_error.py b/sinch/domains/number_lookup/models/v1/shared/lookup_error.py new file mode 100644 index 00000000..514ce3e3 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/lookup_error.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import StrictStr, StrictInt +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class LookupError(BaseModelConfigurationResponse): + status: Optional[StrictInt] = None + title: Optional[StrictStr] = None + detail: Optional[StrictStr] = None + type: Optional[StrictStr] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/rnd.py b/sinch/domains/number_lookup/models/v1/shared/rnd.py new file mode 100644 index 00000000..750a83b7 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/rnd.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import StrictBool +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) + + +class Rnd(BaseModelConfigurationResponse): + disconnected: Optional[StrictBool] = None + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/sim_swap.py b/sinch/domains/number_lookup/models/v1/shared/sim_swap.py new file mode 100644 index 00000000..0b65b507 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/sim_swap.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictBool +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) +from sinch.domains.number_lookup.models.v1.types import SwapPeriodType + + +class SimSwap(BaseModelConfigurationResponse): + swapped: Optional[StrictBool] = None + swap_period: Optional[SwapPeriodType] = Field( + default=None, alias="swapPeriod" + ) + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/shared/voip_detection.py b/sinch/domains/number_lookup/models/v1/shared/voip_detection.py new file mode 100644 index 00000000..6809130c --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/shared/voip_detection.py @@ -0,0 +1,13 @@ +from typing import Optional +from sinch.domains.number_lookup.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.number_lookup.models.v1.shared.lookup_error import ( + LookupError, +) +from sinch.domains.number_lookup.models.v1.types import VoIPProbabilityType + + +class VoIPDetection(BaseModelConfigurationResponse): + probability: Optional[VoIPProbabilityType] = None + error: Optional[LookupError] = None diff --git a/sinch/domains/number_lookup/models/v1/types/__init__.py b/sinch/domains/number_lookup/models/v1/types/__init__.py new file mode 100644 index 00000000..aebdf4e1 --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/__init__.py @@ -0,0 +1,19 @@ +from sinch.domains.number_lookup.models.v1.types.lookup_features import ( + LookupFeaturesType, +) +from sinch.domains.number_lookup.models.v1.types.rnd_feature_options import ( + RndFeatureOptionsDict, +) +from sinch.domains.number_lookup.models.v1.types.voip_probability import ( + VoIPProbabilityType, +) +from sinch.domains.number_lookup.models.v1.types.swap_period import ( + SwapPeriodType, +) + +__all__ = [ + "LookupFeaturesType", + "RndFeatureOptionsDict", + "VoIPProbabilityType", + "SwapPeriodType", +] diff --git a/sinch/domains/number_lookup/models/v1/types/lookup_features.py b/sinch/domains/number_lookup/models/v1/types/lookup_features.py new file mode 100644 index 00000000..7dd6f70c --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/lookup_features.py @@ -0,0 +1,7 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +LookupFeaturesType = Union[ + Literal["LineType", "SimSwap", "VoIPDetection", "RND"], StrictStr +] diff --git a/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py b/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py new file mode 100644 index 00000000..5ad2cced --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/rnd_feature_options.py @@ -0,0 +1,7 @@ +from datetime import datetime +from typing import TypedDict, Union +from typing_extensions import NotRequired + + +class RndFeatureOptionsDict(TypedDict): + contact_date: NotRequired[Union[str, datetime]] diff --git a/sinch/domains/number_lookup/models/v1/types/swap_period.py b/sinch/domains/number_lookup/models/v1/types/swap_period.py new file mode 100644 index 00000000..6c6d7f0d --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/swap_period.py @@ -0,0 +1,19 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +SwapPeriodType = Union[ + Literal[ + "Undefined", + "SP4H", + "SP12H", + "SP24H", + "SP48H", + "SP5D", + "SP7D", + "SP14D", + "SP30D", + "SPMAX", + ], + StrictStr, +] diff --git a/sinch/domains/number_lookup/models/v1/types/voip_probability.py b/sinch/domains/number_lookup/models/v1/types/voip_probability.py new file mode 100644 index 00000000..16c7f4bc --- /dev/null +++ b/sinch/domains/number_lookup/models/v1/types/voip_probability.py @@ -0,0 +1,7 @@ +from typing import Union, Literal +from pydantic import StrictStr + + +VoIPProbabilityType = Union[ + Literal["Unknown", "High", "Likely", "Low"], StrictStr +] diff --git a/tests/conftest.py b/tests/conftest.py index 1fc00a59..424073bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -252,6 +252,20 @@ class MockSinchClient: return MockSinchClient() +@pytest.fixture +def mock_sinch_client_number_lookup(): + class MockConfiguration: + number_lookup_origin = "https://lookup.api.sinch.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() + + class MockSinchClient: + configuration = MockConfiguration() + + return MockSinchClient() + + @pytest.fixture def mock_sinch_client_sms(): from sinch.core.clients.sinch_client_configuration import Configuration diff --git a/tests/e2e/number-lookup/features/lookups.feature b/tests/e2e/number-lookup/features/lookups.feature new file mode 100644 index 00000000..b43f9ccb --- /dev/null +++ b/tests/e2e/number-lookup/features/lookups.feature @@ -0,0 +1,13 @@ +Feature: [Number Lookup] + E2E test for Number Lookup API + + Background: + Given the Number Lookup service is available + + Scenario: [Lookup] lookup for a phone number with no additional features + When I send a request to lookup for a phone number with no additional features + Then the response contains the details of the phone number lookup with line details only + + Scenario: [Lookup] lookup for a phone number with all the features + When I send a request to lookup for a phone number with all the features + Then the response contains the details of the phone number lookup with all the features diff --git a/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py b/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py new file mode 100644 index 00000000..5d7b63ba --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/endpoints/test_lookup_number_endpoint.py @@ -0,0 +1,125 @@ +import json +from unittest.mock import MagicMock +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +@pytest.fixture +def request_data(): + return LookupNumberRequest( + number="+12312312312", features=["LineType", "SimSwap"] + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2000-01-01T00:00:00+00:00", + }, + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + "countryCode": "US", + "traceId": "84c1fd4063c38d9f3900d06e56542d48", + "number": "+12312312312", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return LookupNumberEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url( + endpoint, mock_sinch_client_number_lookup +): + """Check if endpoint URL is constructed correctly.""" + expected_url = ( + "https://lookup.api.sinch.com/v2/projects/test_project_id/lookups" + ) + assert endpoint.build_url(mock_sinch_client_number_lookup) == expected_url + + +def test_build_url_with_custom_domain_expects_overridden_url(): + """Check if endpoint URL uses custom domain when configured.""" + request_data = LookupNumberRequest( + number="+12312312312", features=["LineType", "SimSwap"] + ) + endpoint = LookupNumberEndpoint("test_project_id", request_data) + + class MockConfiguration: + number_lookup_origin = "https://custom.lookup.domain.com" + project_id = "test_project_id" + transport = MagicMock() + transport.request = MagicMock() + + class MockSinchClient: + configuration = MockConfiguration() + + mock_sinch = MockSinchClient() + + expected_url = ( + "https://custom.lookup.domain.com/v2/projects/test_project_id/lookups" + ) + assert endpoint.build_url(mock_sinch) == expected_url + + +def test_request_body_expects_correct_json(endpoint): + """Check if request body is correctly serialized to JSON.""" + body = endpoint.request_body() + parsed_body = json.loads(body) + assert parsed_body["number"] == "+12312312312" + assert parsed_body["features"] == ["LineType", "SimSwap"] + + +def test_request_body_with_rnd_options_expects_correct_json(): + """Check if request body includes RND options when provided.""" + request_data = LookupNumberRequest( + number="+12312312312", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + endpoint = LookupNumberEndpoint("test_project_id", request_data) + body = endpoint.request_body() + parsed_body = json.loads(body) + assert parsed_body["number"] == "+12312312312" + assert parsed_body["features"] == ["RND"] + assert parsed_body["rndFeatureOptions"] == {"contact_date": "2025-01-01"} + + +def test_request_body_excludes_none_expects_correct_json(): + """Check if None values are excluded from request body.""" + request_data = LookupNumberRequest(number="+12312312312") + endpoint = LookupNumberEndpoint("test_project_id", request_data) + body = endpoint.request_body() + parsed_body = json.loads(body) + assert "number" in parsed_body + assert "features" not in parsed_body + assert "rndFeatureOptions" not in parsed_body + + +def test_handle_response_success_expects_valid_response( + endpoint, mock_response +): + """Check if successful response is handled correctly.""" + response = endpoint.handle_response(mock_response) + assert isinstance(response, LookupNumberResponse) + assert response.number == "+12312312312" + assert response.country_code == "US" + assert response.trace_id == "84c1fd4063c38d9f3900d06e56542d48" + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.sim_swap is not None + assert response.sim_swap.swapped is True diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py new file mode 100644 index 00000000..c3ce8c14 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_line_model.py @@ -0,0 +1,38 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.models.v1.shared import Line + + +def test_line_valid_expects_parsed_data(): + """Test a valid instance of Line""" + data = { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-06-15T14:30:00+00:00", + } + line = Line(**data) + + assert line.carrier == "T-Mobile USA" + assert line.type == "Mobile" + assert line.mobile_country_code == "310" + assert line.mobile_network_code == "260" + assert line.ported is True + expected_porting_date = datetime( + 2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc + ) + assert line.porting_date == expected_porting_date + + +def test_line_optional_fields_expects_parsed_data(): + """Test missing optional fields in Line""" + data = {} + line = Line(**data) + + assert line.carrier is None + assert line.type is None + assert line.mobile_country_code is None + assert line.mobile_network_code is None + assert line.ported is None + assert line.porting_date is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py new file mode 100644 index 00000000..30a5f355 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_lookup_error_model.py @@ -0,0 +1,32 @@ +from sinch.domains.number_lookup.models.v1.shared import LookupError + + +def test_lookup_error_valid_expects_parsed_data(): + """ + Test a valid instance of LookupError + """ + data = { + "status": 100, + "title": "Feature Disabled", + "detail": "VoIPDetection feature is currently disabled.", + "type": "validation_error", + } + error = LookupError(**data) + + assert error.status == 100 + assert error.title == "Feature Disabled" + assert error.detail == "VoIPDetection feature is currently disabled." + assert error.type == "validation_error" + + +def test_lookup_error_optional_fields_expects_parsed_data(): + """ + Test missing optional fields in LookupError + """ + data = {} + error = LookupError(**data) + + assert error.status is None + assert error.title is None + assert error.detail is None + assert error.type is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py new file mode 100644 index 00000000..5723ce43 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_rnd_model.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared import Rnd + + +def test_rnd_valid_expects_parsed_data(): + """Test a valid instance of Rnd""" + data = {"disconnected": True} + rnd = Rnd(**data) + + assert rnd.disconnected is True + + +def test_rnd_optional_fields_expects_parsed_data(): + """Test missing optional fields in Rnd""" + data = {} + rnd = Rnd(**data) + + assert rnd.disconnected is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py new file mode 100644 index 00000000..7a448d0c --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_sim_swap_model.py @@ -0,0 +1,38 @@ +from sinch.domains.number_lookup.models.v1.shared import SimSwap + + +def test_sim_swap_valid_expects_parsed_data(): + """Test a valid instance of SimSwap""" + data = { + "swapped": True, + "swapPeriod": "SP24H", + } + sim_swap = SimSwap(**data) + + assert sim_swap.swapped is True + assert sim_swap.swap_period == "SP24H" + + +def test_sim_swap_error_expects_parsed_data(): + """Test a valid instance of SimSwap with error""" + data = { + "error": { + "status": 100, + "title": "Feature Disabled", + "detail": "SimSwap feature is currently disabled.", + } + } + sim_swap = SimSwap(**data) + + assert sim_swap.error.status == 100 + assert sim_swap.error.title == "Feature Disabled" + assert sim_swap.error.detail == "SimSwap feature is currently disabled." + + +def test_sim_swap_optional_fields_expects_parsed_data(): + """Test missing optional fields in SimSwap""" + data = {} + sim_swap = SimSwap(**data) + + assert sim_swap.swapped is None + assert sim_swap.swap_period is None diff --git a/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py b/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py new file mode 100644 index 00000000..472b7682 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/shared/test_voip_detection_model.py @@ -0,0 +1,17 @@ +from sinch.domains.number_lookup.models.v1.shared import VoIPDetection + + +def test_voip_detection_valid_expects_parsed_data(): + """Test a valid instance of VoIPDetection""" + data = {"probability": "High"} + voip_detection = VoIPDetection(**data) + + assert voip_detection.probability == "High" + + +def test_voip_detection_optional_fields_expects_parsed_data(): + """Test missing optional fields in VoIPDetection""" + data = {} + voip_detection = VoIPDetection(**data) + + assert voip_detection.probability is None diff --git a/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py b/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py new file mode 100644 index 00000000..2505bfab --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/test_lookup_request_model.py @@ -0,0 +1,120 @@ +from datetime import datetime, date +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest + + +def test_lookup_number_request_minimal_expects_valid_request(): + """Test minimal lookup request with only number.""" + request = LookupNumberRequest(number="+15551234567") + assert request.number == "+15551234567" + assert request.features is None + assert request.rnd_feature_options is None + + +def test_lookup_number_request_with_features_expects_valid_request(): + """Test lookup request with features.""" + request = LookupNumberRequest( + number="+15552345678", features=["LineType", "SimSwap"] + ) + assert request.number == "+15552345678" + assert request.features == ["LineType", "SimSwap"] + + +def test_lookup_number_request_with_rnd_options_expects_valid_request(): + """Test lookup request with RND feature options.""" + request = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + assert request.number == "+15553456789" + assert request.features == ["RND"] + assert request.rnd_feature_options == {"contact_date": "2025-01-01"} + + +def test_lookup_number_request_with_rnd_options_datetime_expects_valid_request(): + """Test lookup request with RND feature options using datetime object.""" + contact_date = datetime(2025, 1, 15) + request = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": contact_date}, + ) + assert request.number == "+15553456789" + assert request.features == ["RND"] + # The datetime should be stored as-is in the model + assert request.rnd_feature_options == {"contact_date": contact_date} + + # When serialized, datetime should be converted to ISO 8601 date format + dumped = request.model_dump(by_alias=True) + assert dumped["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_with_rnd_options_date_object_expects_iso_format(): + """Test that date objects in rnd_feature_options are converted to ISO 8601 format.""" + contact_date = datetime(2025, 1, 15, 10, 10, 10) + request = LookupNumberRequest( + number="+15553456785", + features=["RND"], + rnd_feature_options={"contact_date": contact_date}, + ) + assert request.number == "+15553456785" + dumped = request.model_dump(by_alias=True) + assert dumped["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_with_rnd_options_different_string_format_passed_directly(): + """Test that different string formats in rnd_feature_options are passed directly to backend.""" + formats = [ + "2025/01/15", + "01-15-2025", + "15.01.2025", + "2025-01-15T00:00:00Z", + ] + + for date_format in formats: + request = LookupNumberRequest( + number="+15553456785", + features=["RND"], + rnd_feature_options={"contact_date": date_format}, + ) + dumped = request.model_dump(by_alias=True) + # String should be passed directly to backend without modification + assert dumped["rndFeatureOptions"]["contact_date"] == date_format + + +def test_lookup_number_request_with_rnd_options_string_passed_directly(): + """Test that string values in rnd_feature_options are passed directly to backend.""" + request1 = LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options={"contact_date": "2025-01-15"}, + ) + dumped1 = request1.model_dump(by_alias=True) + assert dumped1["rndFeatureOptions"]["contact_date"] == "2025-01-15" + + +def test_lookup_number_request_model_dump_expects_camel_case(): + """Test that model dump converts to camelCase.""" + request = LookupNumberRequest( + number="+15554567890", + features=["LineType"], + rnd_feature_options={"contact_date": "2025-01-01"}, + ) + dumped = request.model_dump(by_alias=True) + assert "number" in dumped + assert "features" in dumped + assert "rndFeatureOptions" in dumped + assert "rnd_feature_options" not in dumped + + +def test_lookup_number_request_all_features_expects_valid_request(): + """Test lookup request with all available features.""" + request = LookupNumberRequest( + number="+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + assert len(request.features) == 4 + assert "LineType" in request.features + assert "SimSwap" in request.features + assert "VoIPDetection" in request.features + assert "RND" in request.features diff --git a/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py b/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py new file mode 100644 index 00000000..e1f7a162 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/models/test_lookup_response_model.py @@ -0,0 +1,121 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.models.v1.response import ( + LookupNumberResponse, +) + + +def test_lookup_number_response_minimal_expects_valid_response(): + """Test minimal lookup response.""" + response_data = { + "number": "+15551234567", + "countryCode": "US", + "traceId": "test-trace-id", + } + response = LookupNumberResponse.model_validate(response_data) + assert response.number == "+15551234567" + assert response.country_code == "US" + assert response.trace_id == "test-trace-id" + assert response.line is None + assert response.sim_swap is None + assert response.voip_detection is None + assert response.rnd is None + + +def test_lookup_number_response_with_line_info_expects_valid_response(): + """Test lookup response with line information.""" + response_data = { + "number": "+15552345678", + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-06-15T14:30:00+00:00", + }, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.line.mobile_country_code == "310" + assert response.line.mobile_network_code == "260" + assert response.line.ported is True + assert response.line.porting_date == datetime( + 2024, 6, 15, 14, 30, 0, tzinfo=timezone.utc + ) + + +def test_lookup_number_response_with_sim_swap_expects_valid_response(): + """Test lookup response with SIM swap information.""" + response_data = { + "number": "+15553456789", + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.sim_swap is not None + assert response.sim_swap.swapped is True + assert response.sim_swap.swap_period == "SP24H" + + +def test_lookup_number_response_with_voip_detection_expects_valid_response(): + """Test lookup response with VoIP detection information.""" + response_data = { + "number": "+15554567890", + "voIPDetection": {"probability": "High"}, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.voip_detection.probability == "High" + + +def test_lookup_number_response_with_rnd_expects_valid_response(): + """Test lookup response with RND information.""" + response_data = {"number": "+15555678901", "rnd": {"disconnected": True}} + response = LookupNumberResponse.model_validate(response_data) + assert response.rnd is not None + assert response.rnd.disconnected is True + + +def test_lookup_number_response_with_errors_expects_valid_response(): + """Test lookup response with error information.""" + response_data = { + "number": "+15556789012", + "line": {"error": {"code": "ERROR_CODE", "message": "Error message"}}, + "simSwap": { + "error": {"code": "ERROR_CODE_2", "message": "Error message 2"} + }, + } + response = LookupNumberResponse.model_validate(response_data) + assert response.line.error is not None + assert response.line.error.code == "ERROR_CODE" + assert response.line.error.message == "Error message" + assert response.sim_swap.error is not None + assert response.sim_swap.error.code == "ERROR_CODE_2" + + +def test_lookup_number_response_full_expects_valid_response(): + """Test complete lookup response with all features.""" + response_data = { + "line": { + "carrier": "T-Mobile USA", + "type": "Mobile", + "mobileCountryCode": "310", + "mobileNetworkCode": "260", + "ported": True, + "portingDate": "2024-08-20T10:15:30+00:00", + }, + "simSwap": {"swapped": True, "swapPeriod": "SP24H"}, + "voIPDetection": {"probability": "High"}, + "rnd": {"disconnected": True}, + "countryCode": "US", + "traceId": "84c1fd4063c38d9f3900d06e56542d48", + "number": "+15557890123", + } + response = LookupNumberResponse.model_validate(response_data) + assert response.number == "+15557890123" + assert response.country_code == "US" + assert response.trace_id == "84c1fd4063c38d9f3900d06e56542d48" + assert response.line is not None + assert response.sim_swap is not None + assert response.voip_detection is not None + assert response.rnd is not None diff --git a/tests/unit/domains/number_lookup/v1/test_number_lookup.py b/tests/unit/domains/number_lookup/v1/test_number_lookup.py new file mode 100644 index 00000000..534985b1 --- /dev/null +++ b/tests/unit/domains/number_lookup/v1/test_number_lookup.py @@ -0,0 +1,182 @@ +from datetime import datetime, timezone +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup +from sinch.domains.number_lookup.api.v1.internal import LookupNumberEndpoint +from sinch.domains.number_lookup.models.v1.internal import LookupNumberRequest +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse +from sinch.domains.number_lookup.models.v1.shared import Line, SimSwap, VoIPDetection, Rnd +from sinch.domains.number_lookup.models.v1.types import RndFeatureOptionsDict + + +def test_lookup_expects_valid_request(mock_sinch_client_number_lookup, mocker): + """ + Test that the NumberLookup.lookup() method sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15551234567", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + # Spy on the LookupNumberEndpoint to capture calls + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup("+15551234567") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest(number="+15551234567") + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15551234567" + assert response.country_code == "US" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_with_features_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with features sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15552345678", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15552345678", features=["LineType", "SimSwap", "VoIPDetection"] + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15552345678", + features=["LineType", "SimSwap", "VoIPDetection"], + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15552345678" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_with_rnd_options_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with RND options sends the correct request + and handles the response properly. + """ + mock_response = LookupNumberResponse( + number="+15553456789", country_code="US" + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + rnd_options: RndFeatureOptionsDict = {"contact_date": "2025-01-01"} + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15553456789", features=["RND"], rnd_feature_options=rnd_options + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15553456789", + features=["RND"], + rnd_feature_options=rnd_options, + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15553456789" + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() + + +def test_lookup_missing_project_id_expects_error(): + """ + Test that missing project_id raises an error. + """ + from unittest.mock import Mock + import pytest + + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.project_id = None + + number_lookup = NumberLookup(sinch) + + with pytest.raises(ValueError, match="project_id is required"): + number_lookup.lookup("+15554567890") + + +def test_lookup_full_response_expects_valid_request( + mock_sinch_client_number_lookup, mocker +): + """ + Test that the NumberLookup.lookup() method with all features sends the correct request + and handles the full response properly. + """ + mock_response = LookupNumberResponse( + number="+15555678901", + country_code="US", + trace_id="test-trace-id", + line=Line( + carrier="T-Mobile USA", + type="Mobile", + mobile_country_code="310", + mobile_network_code="260", + ported=True, + porting_date=datetime(2024, 8, 20, 10, 15, 30, tzinfo=timezone.utc), + ), + sim_swap=SimSwap(swapped=True, swap_period="SP24H"), + voip_detection=VoIPDetection(probability="High"), + rnd=Rnd(disconnected=True), + ) + mock_sinch_client_number_lookup.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(LookupNumberEndpoint, "__init__") + + number_lookup = NumberLookup(mock_sinch_client_number_lookup) + response = number_lookup.lookup( + "+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == LookupNumberRequest( + number="+15555678901", + features=["LineType", "SimSwap", "VoIPDetection", "RND"], + ) + + assert isinstance(response, LookupNumberResponse) + assert response.number == "+15555678901" + assert response.country_code == "US" + assert response.trace_id == "test-trace-id" + assert response.line is not None + assert response.line.carrier == "T-Mobile USA" + assert response.line.type == "Mobile" + assert response.line.mobile_country_code == "310" + assert response.line.mobile_network_code == "260" + assert response.line.ported is True + assert response.line.porting_date == datetime(2024, 8, 20, 10, 15, 30, tzinfo=timezone.utc) + assert response.sim_swap.swapped is True + assert response.sim_swap.swap_period == "SP24H" + assert response.voip_detection is not None + assert response.voip_detection.probability == "High" + assert response.rnd is not None + assert response.rnd.disconnected is True + mock_sinch_client_number_lookup.configuration.transport.request.assert_called_once() From c658038ad54c373bbf0281b4a8b547e6a95ebcf8 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 27 Nov 2025 10:18:50 +0100 Subject: [PATCH 069/106] DEVEXP-1145: Number Lookup - E2E tests (#101) --- .../e2e/number-lookup/features/environment.py | 6 ++ .../features/steps/lookups.steps.py | 81 +++++++++++++++++++ tests/e2e/shared_config.py | 1 + 3 files changed, 88 insertions(+) create mode 100644 tests/e2e/number-lookup/features/environment.py create mode 100644 tests/e2e/number-lookup/features/steps/lookups.steps.py diff --git a/tests/e2e/number-lookup/features/environment.py b/tests/e2e/number-lookup/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/number-lookup/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/number-lookup/features/steps/lookups.steps.py b/tests/e2e/number-lookup/features/steps/lookups.steps.py new file mode 100644 index 00000000..e8d6f6e8 --- /dev/null +++ b/tests/e2e/number-lookup/features/steps/lookups.steps.py @@ -0,0 +1,81 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.number_lookup.api.v1.number_lookup_apis import NumberLookup +from sinch.domains.number_lookup.models.v1.response import LookupNumberResponse + + +@given('the Number Lookup service is available') +def step_service_is_available(context): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.number_lookup, NumberLookup), 'Number Lookup service is not available' + context.number_lookup = context.sinch.number_lookup + + +@when('I send a request to lookup for a phone number with no additional features') +def step_lookup_number_no_features(context): + context.response = context.number_lookup.lookup( + number='+12016666666' + ) + + +@then('the response contains the details of the phone number lookup with line details only') +def step_validate_lookup_line_only(context): + data: LookupNumberResponse = context.response + assert data.number == '+12016666666' + assert data.country_code == 'US' + assert data.trace_id == '84c1fd4063c38d9f3900d06e56542d48' + assert data.line.carrier == 'T-Mobile USA' + assert data.line.type == 'Mobile' + assert data.line.mobile_country_code == '310' + assert data.line.mobile_network_code == '260' + assert data.line.ported is None + assert data.line.porting_date is None + assert data.line.error is None + assert data.sim_swap is None + assert data.voip_detection is None + assert data.rnd is None + + +@when('I send a request to lookup for a phone number with all the features') +def step_lookup_number_all_features(context): + context.response = context.number_lookup.lookup( + number='+12015555555', + features=['LineType', 'RND', 'SimSwap', 'VoIPDetection'], + rnd_feature_options={'contactDate': '2025-09-09'} + ) + + +@then('the response contains the details of the phone number lookup with all the features') +def step_validate_lookup_all_features(context): + data: LookupNumberResponse = context.response + assert data.number == '+12015555555' + assert data.country_code == 'US' + assert data.trace_id == '5c817a6b7351d80a6b1d8007e5c145b8' + + assert data.line is not None + assert data.line.carrier == 'AT&T' + assert data.line.type == 'Mobile' + assert data.line.mobile_country_code == '310' + assert data.line.mobile_network_code == '070' + assert data.line.ported is True + assert data.line.porting_date == datetime(2010, 8, 7, 23, 45, 49, tzinfo=timezone.utc) + assert data.line.error is None + + assert data.sim_swap.swapped is None + assert data.sim_swap.swap_period is None + assert data.sim_swap.error.status == 100 + assert data.sim_swap.error.title == 'Feature Disabled' + assert data.sim_swap.error.detail == 'SimSwap feature is currently disabled.' + + assert data.voip_detection is not None + assert data.voip_detection.probability is None + assert data.voip_detection.error.status == 100 + assert data.voip_detection.error.title == 'Feature Disabled' + assert data.voip_detection.error.detail == 'VoIPDetection feature is currently disabled.' + + assert data.rnd is not None + assert data.rnd.disconnected is None + assert data.rnd.error is not None + assert data.rnd.error.status == 100 + assert data.rnd.error.title == 'Feature Disabled' + assert data.rnd.error.detail == 'RND feature is currently disabled.' diff --git a/tests/e2e/shared_config.py b/tests/e2e/shared_config.py index 6940b7ef..a83eb4f1 100644 --- a/tests/e2e/shared_config.py +++ b/tests/e2e/shared_config.py @@ -12,4 +12,5 @@ def create_test_client(): client.configuration.auth_origin = 'http://localhost:3011' client.configuration.numbers_origin = 'http://localhost:3013' client.configuration.sms_origin = 'http://localhost:3017' + client.configuration.number_lookup_origin = 'http://localhost:3022' return client From b3b979e8ea5154c0c05f5bb80a7be210bde91d1c Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 27 Nov 2025 11:23:04 +0100 Subject: [PATCH 070/106] chore(e2e): refactor tests (#102) --- .../features/steps/available-regions.steps.py | 9 +++++--- .../steps/callback-configuration.steps.py | 7 ++++-- .../numbers/features/steps/numbers.steps.py | 23 +++++++++++-------- .../features/steps/delivery_reports.steps.py | 17 +++++++++----- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/tests/e2e/numbers/features/steps/available-regions.steps.py b/tests/e2e/numbers/features/steps/available-regions.steps.py index b28b6a4f..19b1ac13 100644 --- a/tests/e2e/numbers/features/steps/available-regions.steps.py +++ b/tests/e2e/numbers/features/steps/available-regions.steps.py @@ -1,4 +1,5 @@ from behave import given, when, then +from sinch.domains.numbers.virtual_numbers import VirtualNumbers def count_region_type(regions, number_types): @@ -10,17 +11,19 @@ def count_region_type(regions, number_types): def step_regions_service_is_available(context): """Ensures the Sinch client is initialized""" assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers @when('I send a request to list all the regions') def step_list_all_regions(context): - response = context.sinch.numbers.regions.list() + response = context.numbers.regions.list() context.response = response.content() @when('I send a request to list the TOLL_FREE regions') def step_list_toll_free_regions(context): - response = context.sinch.numbers.regions.list( + response = context.numbers.regions.list( types=['TOLL_FREE'] ) context.response = response.content() @@ -28,7 +31,7 @@ def step_list_toll_free_regions(context): @when('I send a request to list the TOLL_FREE or MOBILE regions') def step_list_toll_free_or_mobile_regions(context): - response = context.sinch.numbers.regions.list( + response = context.numbers.regions.list( types=['TOLL_FREE', 'MOBILE'] ) context.response = response.content() diff --git a/tests/e2e/numbers/features/steps/callback-configuration.steps.py b/tests/e2e/numbers/features/steps/callback-configuration.steps.py index f73d453e..522c4634 100644 --- a/tests/e2e/numbers/features/steps/callback-configuration.steps.py +++ b/tests/e2e/numbers/features/steps/callback-configuration.steps.py @@ -2,17 +2,20 @@ from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1.errors import NotFoundError +from sinch.domains.numbers.virtual_numbers import VirtualNumbers @given('the Numbers service "Callback Configuration" is available') def step_callback_config_service_is_available(context): """Ensures the Sinch client is initialized""" assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers @when('I send a request to retrieve the callback configuration') def step_retrieve_callback_configuration(context): - context.response = context.sinch.numbers.callback_configuration.get() + context.response = context.numbers.callback_configuration.get() @then('the response contains the project\'s callback configuration') @@ -24,7 +27,7 @@ def step_check_callback_configuration(context): @when('I send a request to update the callback configuration with the secret "{hmac_secret}"') def step_update_callback_configuration(context, hmac_secret): try: - context.response = context.sinch.numbers.callback_configuration.update(hmac_secret=hmac_secret) + context.response = context.numbers.callback_configuration.update(hmac_secret=hmac_secret) context.error = None except NumberNotFoundException as e: context.error = e diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 1e5e895a..94bec075 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -4,17 +4,20 @@ from sinch.domains.numbers.api.v1.exceptions import NumberNotFoundException from sinch.domains.numbers.models.v1.errors import NotFoundError from sinch.domains.numbers.models.v1.response import ActiveNumber +from sinch.domains.numbers.virtual_numbers import VirtualNumbers @given('the Numbers service is available') def step_service_is_available(context): """Ensures the Sinch client is initialized""" assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.numbers, VirtualNumbers), 'Numbers service is not available' + context.numbers = context.sinch.numbers @when('I send a request to search for available phone numbers') def step_search_available_numbers(context): - response = context.sinch.numbers.search_for_available_numbers( + response = context.numbers.search_for_available_numbers( region_code='US', number_type='LOCAL' ) @@ -45,7 +48,7 @@ def step_check_number_properties(context): @when('I send a request to check the availability of the phone number "{phone_number}"') def step_check_number_availability(context, phone_number): try: - context.response = context.sinch.numbers.check_availability(phone_number) + context.response = context.numbers.check_availability(phone_number) except NumberNotFoundException as e: context.error = e @@ -66,7 +69,7 @@ def step_check_unavailable_number(context, phone_number): @when('I send a request to rent a number with some criteria') def step_rent_any_number(context): - context.response = context.sinch.numbers.rent_any( + context.response = context.numbers.rent_any( region_code='US', number_type='LOCAL', capabilities=['SMS', 'VOICE'], @@ -126,7 +129,7 @@ def step_validate_rented_number(context): @when('I send a request to rent the phone number "{phone_number}"') def step_rent_specific_number(context, phone_number): - context.response = context.sinch.numbers.rent( + context.response = context.numbers.rent( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -146,7 +149,7 @@ def step_validate_rented_specific_number(context, phone_number): @when('I send a request to rent the unavailable phone number "{phone_number}"') def step_rent_unavailable_number(context, phone_number): try: - context.response = context.sinch.numbers.rent( + context.response = context.numbers.rent( phone_number=phone_number, sms_configuration={ 'service_plan_id': 'SpaceMonkeySquadron', @@ -161,7 +164,7 @@ def step_rent_unavailable_number(context, phone_number): @when("I send a request to list the phone numbers") def step_when_list_phone_numbers(context): - response = context.sinch.numbers.list( + response = context.numbers.list( region_code='US', number_type='LOCAL' ) @@ -177,7 +180,7 @@ def step_then_response_contains_x_phone_numbers(context, count): @when("I send a request to list all the phone numbers") def step_when_list_all_phone_numbers(context): - response = context.sinch.numbers.list( + response = context.numbers.list( region_code='US', number_type='LOCAL' ) @@ -205,7 +208,7 @@ def step_then_phone_numbers_list_contains_x_phone_numbers(context, count): @when('I send a request to update the phone number "{phone_number}"') def step_when_update_phone_number(context, phone_number): - context.response = context.sinch.numbers.update( + context.response = context.numbers.update( phone_number=phone_number, display_name='Updated description during E2E tests', sms_configuration={ @@ -250,7 +253,7 @@ def step_then_response_contains_updated_number(context): @when('I send a request to retrieve the phone number "{phone_number}"') def step_when_retrieve_phone_number(context, phone_number): try: - context.response = context.sinch.numbers.get( + context.response = context.numbers.get( phone_number=phone_number, ) except NumberNotFoundException as e: @@ -290,7 +293,7 @@ def step_then_response_contains_error_not_rented(context, phone_number): @when('I send a request to release the phone number "{phone_number}"') def step_when_release_phone_number(context, phone_number): - context.response = context.sinch.numbers.release( + context.response = context.numbers.release( phone_number=phone_number ) diff --git a/tests/e2e/sms/features/steps/delivery_reports.steps.py b/tests/e2e/sms/features/steps/delivery_reports.steps.py index 69fb0cc2..5234723e 100644 --- a/tests/e2e/sms/features/steps/delivery_reports.steps.py +++ b/tests/e2e/sms/features/steps/delivery_reports.steps.py @@ -1,12 +1,15 @@ from datetime import datetime, timezone from behave import given, when, then from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport +from sinch.domains.sms.sms import SMS @given('the SMS service "{service_name}" is available') def step_sms_service_available(context, service_name): """Ensures the Sinch client is initialized""" assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms @given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') @@ -22,12 +25,14 @@ def step_sms_service_available_with_service_plan(context, service_name): context.sinch.configuration.auth_origin = 'http://localhost:3011' context.sinch.configuration.sms_origin = 'http://localhost:3017' context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms @when('I send a request to retrieve a summary SMS delivery report') def step_retrieve_summary_delivery_report(context): """Retrieve a summary SMS delivery report""" - context.response = context.sinch.sms.delivery_reports.get( + context.response = context.sms.delivery_reports.get( batch_id='01W4FFL35P4NC4K35SMSBATCH1', status=['DELIVERED', 'FAILED'], code=[15, 0] @@ -62,7 +67,7 @@ def step_validate_summary_delivery_report(context): @when('I send a request to retrieve a full SMS delivery report') def step_retrieve_full_delivery_report(context): """Retrieve a full SMS delivery report""" - context.response = context.sinch.sms.delivery_reports.get( + context.response = context.sms.delivery_reports.get( batch_id='01W4FFL35P4NC4K35SMSBATCH1', report_type='full' ) @@ -85,7 +90,7 @@ def step_validate_full_delivery_report(context): @when('I send a request to retrieve a recipient\'s delivery report') def step_retrieve_recipient_delivery_report(context): """Retrieve a recipient's delivery report""" - context.response = context.sinch.sms.delivery_reports.get_for_number( + context.response = context.sms.delivery_reports.get_for_number( batch_id='01W4FFL35P4NC4K35SMSBATCH1', recipient='12017777777' ) @@ -108,7 +113,7 @@ def step_validate_recipient_delivery_report(context): @when('I send a request to list the SMS delivery reports') def step_list_delivery_reports(context): """List a page of SMS delivery reports""" - context.response = context.sinch.sms.delivery_reports.list() + context.response = context.sms.delivery_reports.list() @then('the response contains "{count}" SMS delivery reports') @@ -122,7 +127,7 @@ def step_validate_delivery_reports_count(context, count): @when('I send a request to list all the SMS delivery reports') def step_list_all_delivery_reports(context): """List all SMS delivery reports using iterator""" - response = context.sinch.sms.delivery_reports.list(page_size=2) + response = context.sms.delivery_reports.list(page_size=2) delivery_reports_list = [] for delivery_report in response.iterator(): @@ -142,7 +147,7 @@ def step_validate_delivery_reports_list_count(context, count): @when('I iterate manually over the SMS delivery reports pages') def step_iterate_manually_delivery_reports(context): """Manually iterate over SMS delivery reports pages""" - context.list_response = context.sinch.sms.delivery_reports.list(page_size=2) + context.list_response = context.sms.delivery_reports.list(page_size=2) # Iterate through all pages context.delivery_reports_list = [] From cf62fffaec5ac72b0a270e44992f33495c4516a8 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Sat, 29 Nov 2025 21:03:34 +0100 Subject: [PATCH 071/106] feat: sms webhooks (#103) --- .github/workflows/ci.yml | 2 + .../webhooks/v1/authentication_validation.py | 72 ++++++++++ .../webhooks/v1/webhook_utils.py | 50 +++++++ .../numbers/webhooks/v1/numbers_webhooks.py | 55 ++------ .../v1/response/recipient_delivery_report.py | 16 ++- .../sms/webhooks/v1/events/__init__.py | 17 +++ .../webhooks/v1/events/sms_webhooks_event.py | 97 ++++++++++++++ .../sms/webhooks/v1/internal/__init__.py | 5 + .../sms/webhooks/v1/internal/webhook_event.py | 7 + sinch/domains/sms/webhooks/v1/sms_webhooks.py | 106 +++++++++++++++ .../e2e/sms/features/steps/webhooks.steps.py | 126 ++++++++++++++++++ .../authentication/test_webhook_utils.py | 56 ++++++++ 12 files changed, 564 insertions(+), 45 deletions(-) create mode 100644 sinch/domains/authentication/webhooks/v1/webhook_utils.py create mode 100644 sinch/domains/sms/webhooks/v1/events/__init__.py create mode 100644 sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py create mode 100644 sinch/domains/sms/webhooks/v1/internal/__init__.py create mode 100644 sinch/domains/sms/webhooks/v1/internal/webhook_event.py create mode 100644 sinch/domains/sms/webhooks/v1/sms_webhooks.py create mode 100644 tests/e2e/sms/features/steps/webhooks.steps.py create mode 100644 tests/unit/domains/authentication/test_webhook_utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b30d1f60..e3e56bda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/webhooks/v1/authentication_validation.py index 0997bf73..304bde67 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/webhooks/v1/authentication_validation.py @@ -1,5 +1,7 @@ import hashlib import hmac +import base64 +import json from typing import Dict, Union, Optional, List @@ -61,3 +63,73 @@ def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: if isinstance(header_value, list): return header_value[0] if header_value else None return header_value + + +def validate_webhook_signature_with_nonce( + callback_secret: str, + headers: Dict[str, str], + body: str +) -> bool: + """ + Validate signature headers for webhook callbacks that use nonce and timestamp. + + :param callback_secret: Secret associated with the webhook. + :type callback_secret: str + :param headers: Incoming request's headers. + :type headers: Dict[str, str] + :param body: Incoming request's body. + :type body: str + :returns: True if the X-Sinch-Webhook-Signature header is valid. + :rtype: bool + """ + if callback_secret is None: + return False + + normalized_headers = normalize_headers(headers) + signature = get_header(normalized_headers.get('x-sinch-webhook-signature')) + if signature is None: + return False + + nonce = get_header(normalized_headers.get('x-sinch-webhook-signature-nonce')) + timestamp = get_header(normalized_headers.get('x-sinch-webhook-signature-timestamp')) + + if nonce is None or timestamp is None: + return False + + body_as_string = body + if isinstance(body, dict): + body_as_string = json.dumps(body) + + signed_data = compute_signed_data(body_as_string, nonce, timestamp) + + expected_signature = calculate_webhook_signature(signed_data, callback_secret) + return hmac.compare_digest(signature, expected_signature) + + +def compute_signed_data(body: str, nonce: str, timestamp: str) -> str: + """ + Compute signed data for webhook signature validation. + + Format: body.nonce.timestamp (with dots as separators) + """ + return f'{body}.{nonce}.{timestamp}' + + +def calculate_webhook_signature(signed_data: str, secret: str) -> str: + """ + Calculate webhook signature using HMAC-SHA256 with Base64 encoding. + + :param signed_data: The data to sign (body.nonce.timestamp) + :type signed_data: str + :param secret: The secret key for HMAC + :type secret: str + :returns: Base64-encoded HMAC-SHA256 signature + :rtype: str + """ + return base64.b64encode( + hmac.new( + key=secret.encode('utf-8'), + msg=signed_data.encode('utf-8'), + digestmod=hashlib.sha256 + ).digest() + ).decode('utf-8') diff --git a/sinch/domains/authentication/webhooks/v1/webhook_utils.py b/sinch/domains/authentication/webhooks/v1/webhook_utils.py new file mode 100644 index 00000000..5d05b88a --- /dev/null +++ b/sinch/domains/authentication/webhooks/v1/webhook_utils.py @@ -0,0 +1,50 @@ +import json +import re +from datetime import datetime +from typing import Any, Dict + + +def parse_json(payload: str) -> Dict[str, Any]: + """ + Parse JSON string into a dictionary. + + :param payload: JSON string to parse. + :type payload: str + :returns: Parsed dictionary. + :rtype: Dict[str, Any] + :raises ValueError: If JSON parsing fails. + """ + try: + return json.loads(payload) + except json.JSONDecodeError as e: + raise ValueError(f"Failed to decode JSON: {e}") + + +def normalize_iso_timestamp(timestamp: str) -> datetime: + """ + Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()`. + + - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. + - Replaces trailing "Z" with "+00:00" to indicate UTC. + - Trims microseconds to 6 digits. + + :param timestamp: Timestamp string to normalize. + :type timestamp: str + :returns: Timezone-aware datetime object. + :rtype: datetime + :raises ValueError: If timestamp format is invalid. + """ + if timestamp.endswith("Z"): + timestamp = timestamp.replace("Z", "+00:00") + elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): + timestamp += "+00:00" + match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) + if match_ms: + micro_trimmed = match_ms.group(1)[:6] + timestamp = re.sub( + r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp + ) + try: + return datetime.fromisoformat(timestamp) + except ValueError as e: + raise ValueError(f"Invalid timestamp format: {e}") diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py index 2645879a..b324ceeb 100644 --- a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py +++ b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py @@ -1,28 +1,28 @@ -import json from typing import Any, Dict, Union -from datetime import datetime -import re -from pydantic import StrictBool, StrictStr from sinch.domains.authentication.webhooks.v1.authentication_validation import ( validate_signature_header, ) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent class NumbersWebhooks: - def __init__(self, callback_secret: StrictStr): + def __init__(self, callback_secret: str): self.callback_secret = callback_secret def validate_authentication_header( - self, headers: Dict[StrictStr, StrictStr], json_payload: StrictStr - ) -> StrictBool: + self, headers: Dict[str, str], json_payload: str + ) -> bool: """ Validate the authorization header for a callback request :param headers: Incoming request's headers :type headers: Dict[str, str] :param json_payload: Incoming request's raw body - :type json_payload: StrictStr + :type json_payload: str :returns: True if the X-Sinch-Signature header is valid :rtype: bool """ @@ -31,7 +31,7 @@ def validate_authentication_header( ) def parse_event( - self, event_body: Union[StrictStr, Dict[StrictStr, Any]] + self, event_body: Union[str, Dict[str, Any]] ) -> NumbersWebhooksEvent: """ Parses the event payload into a NumbersWebhooksEvent object. @@ -41,47 +41,16 @@ def parse_event( UTC and returns a timezone-aware ``datetime`` object. :param event_body: The event payload. - :type event_body: Union[StrictStr, Dict[StrictStr, Any]] + :type event_body: Union[str, Dict[str, Any]] :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. :rtype: NumbersWebhooksEvent """ if isinstance(event_body, str): - event_body = self._parse_json(event_body) + event_body = parse_json(event_body) timestamp = event_body.get("timestamp") if timestamp: - event_body["timestamp"] = self._normalize_iso_timestamp(timestamp) + event_body["timestamp"] = normalize_iso_timestamp(timestamp) try: return NumbersWebhooksEvent(**event_body) except Exception as e: raise ValueError(f"Failed to parse event body: {e}") - - def _parse_json(self, payload: StrictStr) -> Dict[StrictStr, Any]: - """ - Parse JSON string into a dictionary. - """ - try: - return json.loads(payload) - except json.JSONDecodeError as e: - raise ValueError(f"Failed to decode JSON: {e}") - - def _normalize_iso_timestamp(self, timestamp: StrictStr) -> datetime: - """ - Normalize a timestamp string to ensure compatibility with Python's `datetime.fromisoformat()` - - Ensures that the timestamp includes a UTC offset (e.g., "+00:00") if missing. - - Replaces trailing "Z" with "+00:00" to indicate UTC. - - Trims microseconds to 6 digits. - """ - if timestamp.endswith("Z"): - timestamp = timestamp.replace("Z", "+00:00") - elif not re.search(r"(Z|[+-]\d{2}:?\d{2})$", timestamp): - timestamp += "+00:00" - match_ms = re.search(r"\.(\d{7,})(?=[+-])", timestamp) - if match_ms: - micro_trimmed = match_ms.group(1)[:6] - timestamp = re.sub( - r"\.\d{7,}(?=[+-])", f".{micro_trimmed}", timestamp - ) - try: - return datetime.fromisoformat(timestamp) - except ValueError as e: - raise ValueError(f"Invalid timestamp format: {e}") diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py index 76f29a21..91d4aa59 100644 --- a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Optional, Union from datetime import datetime -from pydantic import Field, StrictInt, StrictStr +from pydantic import Field, StrictInt, StrictStr, field_validator from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( DeliveryReceiptStatusCodeType, ) @@ -16,6 +16,9 @@ from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + normalize_iso_timestamp, +) class RecipientDeliveryReport(BaseModelConfigurationResponse): @@ -64,3 +67,12 @@ class RecipientDeliveryReport(BaseModelConfigurationResponse): type: RecipientDeliveryReportType = Field( default=..., description="The recipient delivery report type." ) + + @field_validator("at", "operator_status_at", mode="before") + @classmethod + def normalize_timestamp( + cls, value: Optional[Union[str, datetime]] + ) -> Optional[Union[str, datetime]]: + if isinstance(value, str): + return normalize_iso_timestamp(value) + return value diff --git a/sinch/domains/sms/webhooks/v1/events/__init__.py b/sinch/domains/sms/webhooks/v1/events/__init__.py new file mode 100644 index 00000000..bb5a10da --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/events/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import ( + IncomingSMSWebhookEvent, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, + MediaBody, + MediaItem, +) + +__all__ = [ + "IncomingSMSWebhookEvent", + "MOTextWebhookEvent", + "MOBinaryWebhookEvent", + "MOMediaWebhookEvent", + "MediaBody", + "MediaItem", +] diff --git a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py new file mode 100644 index 00000000..f8abe3cb --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py @@ -0,0 +1,97 @@ +from datetime import datetime +from typing import Optional, Union, Literal, Annotated +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.sms.webhooks.v1.internal import WebhookEvent + + +class MediaItem(WebhookEvent): + url: StrictStr = Field(..., description="URL to the media file") + content_type: StrictStr = Field( + ..., description="Content type of the media file" + ) + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload" + ) + code: StrictInt = Field(..., description="Status code") + + +class MediaBody(WebhookEvent): + subject: Optional[StrictStr] = Field( + default=None, description="The subject text" + ) + message: Optional[StrictStr] = Field( + default=None, description="The message text" + ) + media: conlist(MediaItem) = Field(..., description="Array of media items") + + +class BaseIncomingSMSWebhookEvent(WebhookEvent): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + + +class MOTextWebhookEvent(BaseIncomingSMSWebhookEvent): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) + + +class MOBinaryWebhookEvent(BaseIncomingSMSWebhookEvent): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) + + +class MOMediaWebhookEvent(BaseIncomingSMSWebhookEvent): + body: MediaBody = Field( + ..., + description="The media message body containing subject, message, and media items.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) + + +# Union type for isinstance checks +_IncomingSMSWebhookEventUnion = Union[ + MOTextWebhookEvent, MOBinaryWebhookEvent, MOMediaWebhookEvent +] + +# Discriminated union for validation +IncomingSMSWebhookEvent = Annotated[ + _IncomingSMSWebhookEventUnion, Field(discriminator="type") +] diff --git a/sinch/domains/sms/webhooks/v1/internal/__init__.py b/sinch/domains/sms/webhooks/v1/internal/__init__.py new file mode 100644 index 00000000..329bdf65 --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.webhooks.v1.internal.webhook_event import ( + WebhookEvent, +) + +__all__ = ["WebhookEvent"] diff --git a/sinch/domains/sms/webhooks/v1/internal/webhook_event.py b/sinch/domains/sms/webhooks/v1/internal/webhook_event.py new file mode 100644 index 00000000..0d2857ed --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/internal/webhook_event.py @@ -0,0 +1,7 @@ +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WebhookEvent(BaseModelConfigurationResponse): + pass diff --git a/sinch/domains/sms/webhooks/v1/sms_webhooks.py b/sinch/domains/sms/webhooks/v1/sms_webhooks.py new file mode 100644 index 00000000..d5f26563 --- /dev/null +++ b/sinch/domains/sms/webhooks/v1/sms_webhooks.py @@ -0,0 +1,106 @@ +import json +from typing import Any, Dict, Union, Optional +from pydantic import TypeAdapter +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + validate_webhook_signature_with_nonce, +) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.sms.webhooks.v1.events import ( + IncomingSMSWebhookEvent, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + + +SmsCallback = Union[ + BatchDeliveryReport, + RecipientDeliveryReport, + MOTextWebhookEvent, + MOBinaryWebhookEvent, + MOMediaWebhookEvent, +] + + +class SmsWebhooks: + def __init__(self, app_secret: Optional[str] = None): + self.app_secret = app_secret + + def validate_authentication_header( + self, headers: Dict[str, str], json_payload: str + ) -> bool: + """ + Validate the authorization header for a callback request. + + :param headers: Incoming request's headers + :type headers: Dict[str, str] + :param json_payload: Incoming request's raw body + :type json_payload: str + :returns: True if the X-Sinch-Webhook-Signature header is valid + :rtype: bool + """ + if not self.app_secret: + return False + return validate_webhook_signature_with_nonce( + self.app_secret, headers, json_payload + ) + + def parse_event( + self, event_body: Union[str, Dict[str, Any]] + ) -> SmsCallback: + """ + Parse the event payload into an SMS callback object. + + Handles datetime conversion for timestamp fields and routes to the + appropriate event type based on the `type` field. + + :param event_body: The event payload (JSON string or dict). + :type event_body: Union[str, Dict[str, Any]] + :returns: A parsed SMS callback object. + :rtype: SmsCallback + :raises ValueError: If the event type is unknown or parsing fails. + """ + if isinstance(event_body, str): + event_body = parse_json(event_body) + + event_type = event_body.get("type") + if not event_type: + raise ValueError(f"Unknown SMS event: {json.dumps(event_body)}") + + # Handle delivery reports + if event_type in ("delivery_report_sms", "delivery_report_mms"): + return BatchDeliveryReport(**event_body) + + # Handle recipient delivery reports + if event_type in ( + "recipient_delivery_report_sms", + "recipient_delivery_report_mms", + ): + return RecipientDeliveryReport(**event_body) + + # Handle incoming SMS messages using discriminated union + if event_type in ("mo_text", "mo_binary", "mo_media"): + if "received_at" in event_body and isinstance( + event_body["received_at"], str + ): + event_body["received_at"] = normalize_iso_timestamp( + event_body["received_at"] + ) + if "sent_at" in event_body and isinstance( + event_body["sent_at"], str + ): + event_body["sent_at"] = normalize_iso_timestamp( + event_body["sent_at"] + ) + + adapter = TypeAdapter(IncomingSMSWebhookEvent) + return adapter.validate_python(event_body) + + raise ValueError(f"Unknown SMS event type: {event_type}") diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py new file mode 100644 index 00000000..1ce5a626 --- /dev/null +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -0,0 +1,126 @@ +import requests +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks +from sinch.domains.sms.webhooks.v1.events import ( + MOTextWebhookEvent, +) +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) + +SINCH_SMS_CALLBACK_SECRET = 'KayakingTheSwell' + + +def parse_event(context, response): + context.headers = dict(response.headers) + context.raw_event = response.text + + +@given('the SMS Webhooks handler is available') +def step_webhook_handler_is_available(context): + context.sms_webhook = SmsWebhooks(SINCH_SMS_CALLBACK_SECRET) + + +@when('I send a request to trigger an "incoming SMS" event') +def step_send_incoming_sms_event(context): + response = requests.get('http://localhost:3017/webhooks/sms/incoming-sms') + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the header of the event "{event_type}" contains a valid signature') +@then('the header of the event "{event_type}" with the status "{status}" contains a valid signature') +def step_check_valid_signature(context, event_type, status=None): + assert context.sms_webhook.validate_authentication_header( + context.headers, context.raw_event + ), 'Signature validation failed' + + +@then('the SMS event describes an "incoming SMS" event') +def step_check_incoming_sms_event(context): + incoming_sms_event: MOTextWebhookEvent = context.event + assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' + assert incoming_sms_event.from_ == '12015555555' + assert incoming_sms_event.to == '12017777777' + assert incoming_sms_event.body == 'Hello John! 👋' + assert incoming_sms_event.type == 'mo_text' + assert incoming_sms_event.operator_id == '311071' + expected_received_at = datetime(2024, 6, 6, 7, 52, 37, 386000, tzinfo=timezone.utc) + assert incoming_sms_event.received_at == expected_received_at + + +@when('I send a request to trigger an "SMS delivery report" event') +def step_send_delivery_report_event(context): + response = requests.get('http://localhost:3017/webhooks/sms/delivery-report-sms') + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the SMS event describes an "SMS delivery report" event') +def step_check_delivery_report_event(context): + delivery_report_event: BatchDeliveryReport = context.event + assert delivery_report_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH8' + assert delivery_report_event.client_reference == 'client-ref' + assert delivery_report_event.statuses is not None + assert len(delivery_report_event.statuses) > 0 + + status = delivery_report_event.statuses[0] + assert status.code == 0 + assert status.count == 2 + assert status.status == 'Delivered' + assert status.recipients is not None + assert len(status.recipients) == 2 + assert status.recipients[0] == '12017777777' + assert status.recipients[1] == '33612345678' + assert delivery_report_event.type == 'delivery_report_sms' + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Delivered"') +def step_send_recipient_delivery_report_event_delivered(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' + ) + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@when('I send a request to trigger an "SMS recipient delivery report" event with the status "Aborted"') +def step_send_recipient_delivery_report_event_aborted(context): + response = requests.get( + 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' + ) + parse_event(context, response) + context.event = context.sms_webhook.parse_event(context.raw_event) + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Delivered"') +def step_check_recipient_delivery_report_delivered(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12017777777' + assert recipient_dr_event.code == 0 + assert recipient_dr_event.status == 'Delivered' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 19, 210000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at + + expected_operator_status_at = datetime(2024, 6, 6, 8, 17, 0, tzinfo=timezone.utc) + assert recipient_dr_event.operator_status_at == expected_operator_status_at + + +@then('the SMS event describes an SMS recipient delivery report event with the status "Aborted"') +def step_check_recipient_delivery_report_aborted(context): + recipient_dr_event: RecipientDeliveryReport = context.event + assert recipient_dr_event.batch_id == '01W4FFL35P4NC4K35SMSBATCH9' + assert recipient_dr_event.recipient == '12010000000' + assert recipient_dr_event.code == 412 + assert recipient_dr_event.status == 'Aborted' + assert recipient_dr_event.type == 'recipient_delivery_report_sms' + assert recipient_dr_event.client_reference == 'client-ref' + + expected_at = datetime(2024, 6, 6, 8, 17, 15, 603000, tzinfo=timezone.utc) + assert recipient_dr_event.at == expected_at diff --git a/tests/unit/domains/authentication/test_webhook_utils.py b/tests/unit/domains/authentication/test_webhook_utils.py new file mode 100644 index 00000000..059b4416 --- /dev/null +++ b/tests/unit/domains/authentication/test_webhook_utils.py @@ -0,0 +1,56 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + parse_json, + normalize_iso_timestamp, +) + + +class TestParseJson: + def test_parse_json_expects_valid_json_string(self): + """Test parse_json with a valid JSON string.""" + json_string = '{"key": "value", "number": 123}' + result = parse_json(json_string) + assert result == {"key": "value", "number": 123} + + def test_parse_json_expects_invalid_json_raises_value_error(self): + """Test parse_json with invalid JSON raises ValueError.""" + invalid_json = '{"key": "value"' + with pytest.raises(ValueError, match="Failed to decode JSON"): + parse_json(invalid_json) + + +class TestNormalizeIsoTimestamp: + def test_normalize_iso_timestamp_expects_zulu_suffix(self): + """Test normalize_iso_timestamp with Zulu timezone suffix (Z).""" + timestamp_str = "2025-03-15T14:30:45.123Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 3, 15, 14, 30, 45, 123000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_timezone_suffix(self): + """Test normalize_iso_timestamp without timezone suffix (assumes UTC).""" + timestamp_str = "2025-07-22T09:15:33.456" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 7, 22, 9, 15, 33, 456000, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_trims_microseconds(self): + """Test normalize_iso_timestamp trims microseconds to 6 digits.""" + timestamp_str = "2025-11-08T16:42:17.789123456+00:00" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 11, 8, 16, 42, 17, 789123, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_without_microseconds(self): + """Test normalize_iso_timestamp without microseconds.""" + timestamp_str = "2025-01-31T23:59:00Z" + result = normalize_iso_timestamp(timestamp_str) + expected = datetime(2025, 1, 31, 23, 59, 0, 0, tzinfo=timezone.utc) + assert result == expected + + def test_normalize_iso_timestamp_expects_invalid_format_raises_value_error(self): + """Test normalize_iso_timestamp with invalid format raises ValueError.""" + invalid_timestamp = "not-a-timestamp" + with pytest.raises(ValueError, match="Invalid timestamp format"): + normalize_iso_timestamp(invalid_timestamp) From 7810a6c71de88bf21c994cd25347b38532a58bfc Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 2 Dec 2025 12:13:30 +0100 Subject: [PATCH 072/106] DEVEXP-1145: Number Lookup Snippet (#104) --- docs/snippets/number_lookup/lookup/snippet.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 docs/snippets/number_lookup/lookup/snippet.py diff --git a/docs/snippets/number_lookup/lookup/snippet.py b/docs/snippets/number_lookup/lookup/snippet.py new file mode 100644 index 00000000..4426879e --- /dev/null +++ b/docs/snippets/number_lookup/lookup/snippet.py @@ -0,0 +1,18 @@ +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", +) + +# The phone number to lookup +phone_number = "+1234567890" + +response = sinch_client.number_lookup.lookup(number=phone_number) + +print(f"Number lookup result:\n{response}") From f41f1d76c47cedef2e0ae2a62ca1d137214a005a Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 9 Dec 2025 21:52:38 +0100 Subject: [PATCH 073/106] chore: uodate snippets (#105) --- docs/snippets/number_lookup/lookup/snippet.py | 11 +++++++++-- docs/snippets/numbers/active_numbers/get/snippet.py | 3 ++- docs/snippets/numbers/active_numbers/list/snippet.py | 3 ++- .../numbers/active_numbers/list_auto/snippet.py | 3 ++- .../numbers/active_numbers/release/snippet.py | 3 ++- .../snippets/numbers/active_numbers/update/snippet.py | 3 ++- .../available_numbers/check_availability/snippet.py | 3 ++- .../numbers/available_numbers/rent/snippet.py | 3 ++- .../numbers/available_numbers/rent_any/snippet.py | 3 ++- .../search_for_available_numbers/snippet.py | 3 ++- .../numbers/available_regions/list/snippet.py | 3 ++- .../numbers/callback_configuration/get/snippet.py | 3 ++- .../numbers/callback_configuration/update/snippet.py | 3 ++- docs/snippets/sms/batches/cancel/snippet.py | 7 +++++++ docs/snippets/sms/batches/dry_run_binary/snippet.py | 7 +++++++ docs/snippets/sms/batches/dry_run_mms/snippet.py | 7 +++++++ docs/snippets/sms/batches/dry_run_sms/snippet.py | 7 +++++++ docs/snippets/sms/batches/get/snippet.py | 7 +++++++ docs/snippets/sms/batches/list/snippet.py | 7 +++++++ docs/snippets/sms/batches/replace_binary/snippet.py | 7 +++++++ docs/snippets/sms/batches/replace_mms/snippet.py | 7 +++++++ docs/snippets/sms/batches/replace_sms/snippet.py | 7 +++++++ docs/snippets/sms/batches/send_binary/snippet.py | 7 +++++++ .../sms/batches/send_delivery_feedback/snippet.py | 7 +++++++ docs/snippets/sms/batches/send_mms/snippet.py | 7 +++++++ docs/snippets/sms/batches/send_sms/snippet.py | 7 +++++++ docs/snippets/sms/batches/update_binary/snippet.py | 7 +++++++ docs/snippets/sms/batches/update_mms/snippet.py | 7 +++++++ docs/snippets/sms/batches/update_sms/snippet.py | 7 +++++++ docs/snippets/sms/delivery_reports/get/snippet.py | 7 +++++++ .../sms/delivery_reports/get_for_number/snippet.py | 11 +++++++++-- docs/snippets/sms/delivery_reports/list/snippet.py | 7 +++++++ 32 files changed, 168 insertions(+), 16 deletions(-) diff --git a/docs/snippets/number_lookup/lookup/snippet.py b/docs/snippets/number_lookup/lookup/snippet.py index 4426879e..35b63e3b 100644 --- a/docs/snippets/number_lookup/lookup/snippet.py +++ b/docs/snippets/number_lookup/lookup/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient @@ -10,8 +17,8 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", ) -# The phone number to lookup -phone_number = "+1234567890" +# The phone number to lookup in E.164 format (e.g., +1234567890) +phone_number = "PHONE_NUMBER_TO_LOOKUP" response = sinch_client.number_lookup.lookup(number=phone_number) diff --git a/docs/snippets/numbers/active_numbers/get/snippet.py b/docs/snippets/numbers/active_numbers/get/snippet.py index 437694e5..37516175 100644 --- a/docs/snippets/numbers/active_numbers/get/snippet.py +++ b/docs/snippets/numbers/active_numbers/get/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/active_numbers/list/snippet.py b/docs/snippets/numbers/active_numbers/list/snippet.py index ca9c4e41..6ad2d486 100644 --- a/docs/snippets/numbers/active_numbers/list/snippet.py +++ b/docs/snippets/numbers/active_numbers/list/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/active_numbers/list_auto/snippet.py b/docs/snippets/numbers/active_numbers/list_auto/snippet.py index d9707399..c35b1294 100644 --- a/docs/snippets/numbers/active_numbers/list_auto/snippet.py +++ b/docs/snippets/numbers/active_numbers/list_auto/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/active_numbers/release/snippet.py b/docs/snippets/numbers/active_numbers/release/snippet.py index 127a1c8c..c0c56489 100644 --- a/docs/snippets/numbers/active_numbers/release/snippet.py +++ b/docs/snippets/numbers/active_numbers/release/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/active_numbers/update/snippet.py b/docs/snippets/numbers/active_numbers/update/snippet.py index f01a07ad..253fea5c 100644 --- a/docs/snippets/numbers/active_numbers/update/snippet.py +++ b/docs/snippets/numbers/active_numbers/update/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/available_numbers/check_availability/snippet.py b/docs/snippets/numbers/available_numbers/check_availability/snippet.py index 197e31df..6ad8ab89 100644 --- a/docs/snippets/numbers/available_numbers/check_availability/snippet.py +++ b/docs/snippets/numbers/available_numbers/check_availability/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/available_numbers/rent/snippet.py b/docs/snippets/numbers/available_numbers/rent/snippet.py index 272324ad..82f0f59f 100644 --- a/docs/snippets/numbers/available_numbers/rent/snippet.py +++ b/docs/snippets/numbers/available_numbers/rent/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/available_numbers/rent_any/snippet.py b/docs/snippets/numbers/available_numbers/rent_any/snippet.py index 4ed73dc8..b2d57247 100644 --- a/docs/snippets/numbers/available_numbers/rent_any/snippet.py +++ b/docs/snippets/numbers/available_numbers/rent_any/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py b/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py index 980bb251..ac1a8301 100644 --- a/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py +++ b/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/available_regions/list/snippet.py b/docs/snippets/numbers/available_regions/list/snippet.py index dee2ca84..1012de9e 100644 --- a/docs/snippets/numbers/available_regions/list/snippet.py +++ b/docs/snippets/numbers/available_regions/list/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/callback_configuration/get/snippet.py b/docs/snippets/numbers/callback_configuration/get/snippet.py index 4592ad6d..e1593f73 100644 --- a/docs/snippets/numbers/callback_configuration/get/snippet.py +++ b/docs/snippets/numbers/callback_configuration/get/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/numbers/callback_configuration/update/snippet.py b/docs/snippets/numbers/callback_configuration/update/snippet.py index efe9bb1c..cfe2ef9b 100644 --- a/docs/snippets/numbers/callback_configuration/update/snippet.py +++ b/docs/snippets/numbers/callback_configuration/update/snippet.py @@ -1,7 +1,8 @@ """ Sinch Python Snippet -This snippet is available at https://github.com/sinch/sinch-sdk-python-snippets +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ """ import os diff --git a/docs/snippets/sms/batches/cancel/snippet.py b/docs/snippets/sms/batches/cancel/snippet.py index dca4ca9f..55bb5738 100644 --- a/docs/snippets/sms/batches/cancel/snippet.py +++ b/docs/snippets/sms/batches/cancel/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/dry_run_binary/snippet.py b/docs/snippets/sms/batches/dry_run_binary/snippet.py index b5825970..03b6eb9c 100644 --- a/docs/snippets/sms/batches/dry_run_binary/snippet.py +++ b/docs/snippets/sms/batches/dry_run_binary/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os import base64 from dotenv import load_dotenv diff --git a/docs/snippets/sms/batches/dry_run_mms/snippet.py b/docs/snippets/sms/batches/dry_run_mms/snippet.py index b27a51af..11f54c31 100644 --- a/docs/snippets/sms/batches/dry_run_mms/snippet.py +++ b/docs/snippets/sms/batches/dry_run_mms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/dry_run_sms/snippet.py b/docs/snippets/sms/batches/dry_run_sms/snippet.py index 133d2308..ae0a7d09 100644 --- a/docs/snippets/sms/batches/dry_run_sms/snippet.py +++ b/docs/snippets/sms/batches/dry_run_sms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/get/snippet.py b/docs/snippets/sms/batches/get/snippet.py index 94bc9c04..3c30df32 100644 --- a/docs/snippets/sms/batches/get/snippet.py +++ b/docs/snippets/sms/batches/get/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/list/snippet.py b/docs/snippets/sms/batches/list/snippet.py index 64556ecf..c6364025 100644 --- a/docs/snippets/sms/batches/list/snippet.py +++ b/docs/snippets/sms/batches/list/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/replace_binary/snippet.py b/docs/snippets/sms/batches/replace_binary/snippet.py index 7536db7b..305e4f59 100644 --- a/docs/snippets/sms/batches/replace_binary/snippet.py +++ b/docs/snippets/sms/batches/replace_binary/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os import base64 from dotenv import load_dotenv diff --git a/docs/snippets/sms/batches/replace_mms/snippet.py b/docs/snippets/sms/batches/replace_mms/snippet.py index 44623d3c..fed4d71e 100644 --- a/docs/snippets/sms/batches/replace_mms/snippet.py +++ b/docs/snippets/sms/batches/replace_mms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/replace_sms/snippet.py b/docs/snippets/sms/batches/replace_sms/snippet.py index 33b01ee0..88066324 100644 --- a/docs/snippets/sms/batches/replace_sms/snippet.py +++ b/docs/snippets/sms/batches/replace_sms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/send_binary/snippet.py b/docs/snippets/sms/batches/send_binary/snippet.py index baa8791e..ad2366a1 100644 --- a/docs/snippets/sms/batches/send_binary/snippet.py +++ b/docs/snippets/sms/batches/send_binary/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os import base64 from dotenv import load_dotenv diff --git a/docs/snippets/sms/batches/send_delivery_feedback/snippet.py b/docs/snippets/sms/batches/send_delivery_feedback/snippet.py index 0e38061f..f19f83ad 100644 --- a/docs/snippets/sms/batches/send_delivery_feedback/snippet.py +++ b/docs/snippets/sms/batches/send_delivery_feedback/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/send_mms/snippet.py b/docs/snippets/sms/batches/send_mms/snippet.py index 50ab2f50..fff15360 100644 --- a/docs/snippets/sms/batches/send_mms/snippet.py +++ b/docs/snippets/sms/batches/send_mms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/send_sms/snippet.py b/docs/snippets/sms/batches/send_sms/snippet.py index fa97700f..64cedd2d 100644 --- a/docs/snippets/sms/batches/send_sms/snippet.py +++ b/docs/snippets/sms/batches/send_sms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/update_binary/snippet.py b/docs/snippets/sms/batches/update_binary/snippet.py index 72c6b7a6..ac67b610 100644 --- a/docs/snippets/sms/batches/update_binary/snippet.py +++ b/docs/snippets/sms/batches/update_binary/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os import base64 from dotenv import load_dotenv diff --git a/docs/snippets/sms/batches/update_mms/snippet.py b/docs/snippets/sms/batches/update_mms/snippet.py index 7ffc5d6c..b8a7ba1a 100644 --- a/docs/snippets/sms/batches/update_mms/snippet.py +++ b/docs/snippets/sms/batches/update_mms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/batches/update_sms/snippet.py b/docs/snippets/sms/batches/update_sms/snippet.py index 453b8a1d..d65aa994 100644 --- a/docs/snippets/sms/batches/update_sms/snippet.py +++ b/docs/snippets/sms/batches/update_sms/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/delivery_reports/get/snippet.py b/docs/snippets/sms/delivery_reports/get/snippet.py index e1287b1e..bcf068db 100644 --- a/docs/snippets/sms/delivery_reports/get/snippet.py +++ b/docs/snippets/sms/delivery_reports/get/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient diff --git a/docs/snippets/sms/delivery_reports/get_for_number/snippet.py b/docs/snippets/sms/delivery_reports/get_for_number/snippet.py index 09b06496..8e304697 100644 --- a/docs/snippets/sms/delivery_reports/get_for_number/snippet.py +++ b/docs/snippets/sms/delivery_reports/get_for_number/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient @@ -13,8 +20,8 @@ # The ID of the batch to get delivery reports for batch_id = "BATCH_ID" -# The phone number to get delivery reports for -recipient = "+1234567890" +# The phone number from which you will receive delivery reports, in E.164 format (e.g., +1234567890). +recipient = "RECIPIENT_PHONE_NUMBER" response = sinch_client.sms.delivery_reports.get_for_number( batch_id=batch_id, diff --git a/docs/snippets/sms/delivery_reports/list/snippet.py b/docs/snippets/sms/delivery_reports/list/snippet.py index 230d0d30..56f4af20 100644 --- a/docs/snippets/sms/delivery_reports/list/snippet.py +++ b/docs/snippets/sms/delivery_reports/list/snippet.py @@ -1,3 +1,10 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + import os from dotenv import load_dotenv from sinch import SinchClient From e2e23ed93da6458e1693192e4b89426004b1dc32 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 10 Dec 2025 14:26:50 +0100 Subject: [PATCH 074/106] chore: rename to examples (#106) --- {docs => examples}/.env.example | 0 {docs => examples}/README.md | 0 {docs => examples}/pyproject.toml | 0 {docs => examples}/snippets/number_lookup/lookup/snippet.py | 0 {docs => examples}/snippets/numbers/active_numbers/get/snippet.py | 0 .../snippets/numbers/active_numbers/list/snippet.py | 0 .../snippets/numbers/active_numbers/list_auto/snippet.py | 0 .../snippets/numbers/active_numbers/release/snippet.py | 0 .../snippets/numbers/active_numbers/update/snippet.py | 0 .../numbers/available_numbers/check_availability/snippet.py | 0 .../snippets/numbers/available_numbers/rent/snippet.py | 0 .../snippets/numbers/available_numbers/rent_any/snippet.py | 0 .../available_numbers/search_for_available_numbers/snippet.py | 0 .../snippets/numbers/available_regions/list/snippet.py | 0 .../snippets/numbers/callback_configuration/get/snippet.py | 0 .../snippets/numbers/callback_configuration/update/snippet.py | 0 {docs => examples}/snippets/sms/batches/cancel/snippet.py | 0 {docs => examples}/snippets/sms/batches/dry_run_binary/snippet.py | 0 {docs => examples}/snippets/sms/batches/dry_run_mms/snippet.py | 0 {docs => examples}/snippets/sms/batches/dry_run_sms/snippet.py | 0 {docs => examples}/snippets/sms/batches/get/snippet.py | 0 {docs => examples}/snippets/sms/batches/list/snippet.py | 0 {docs => examples}/snippets/sms/batches/replace_binary/snippet.py | 0 {docs => examples}/snippets/sms/batches/replace_mms/snippet.py | 0 {docs => examples}/snippets/sms/batches/replace_sms/snippet.py | 0 {docs => examples}/snippets/sms/batches/send_binary/snippet.py | 0 .../snippets/sms/batches/send_delivery_feedback/snippet.py | 0 {docs => examples}/snippets/sms/batches/send_mms/snippet.py | 0 {docs => examples}/snippets/sms/batches/send_sms/snippet.py | 0 {docs => examples}/snippets/sms/batches/update_binary/snippet.py | 0 {docs => examples}/snippets/sms/batches/update_mms/snippet.py | 0 {docs => examples}/snippets/sms/batches/update_sms/snippet.py | 0 {docs => examples}/snippets/sms/delivery_reports/get/snippet.py | 0 .../snippets/sms/delivery_reports/get_for_number/snippet.py | 0 {docs => examples}/snippets/sms/delivery_reports/list/snippet.py | 0 35 files changed, 0 insertions(+), 0 deletions(-) rename {docs => examples}/.env.example (100%) rename {docs => examples}/README.md (100%) rename {docs => examples}/pyproject.toml (100%) rename {docs => examples}/snippets/number_lookup/lookup/snippet.py (100%) rename {docs => examples}/snippets/numbers/active_numbers/get/snippet.py (100%) rename {docs => examples}/snippets/numbers/active_numbers/list/snippet.py (100%) rename {docs => examples}/snippets/numbers/active_numbers/list_auto/snippet.py (100%) rename {docs => examples}/snippets/numbers/active_numbers/release/snippet.py (100%) rename {docs => examples}/snippets/numbers/active_numbers/update/snippet.py (100%) rename {docs => examples}/snippets/numbers/available_numbers/check_availability/snippet.py (100%) rename {docs => examples}/snippets/numbers/available_numbers/rent/snippet.py (100%) rename {docs => examples}/snippets/numbers/available_numbers/rent_any/snippet.py (100%) rename {docs => examples}/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py (100%) rename {docs => examples}/snippets/numbers/available_regions/list/snippet.py (100%) rename {docs => examples}/snippets/numbers/callback_configuration/get/snippet.py (100%) rename {docs => examples}/snippets/numbers/callback_configuration/update/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/cancel/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/dry_run_binary/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/dry_run_mms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/dry_run_sms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/get/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/list/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/replace_binary/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/replace_mms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/replace_sms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/send_binary/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/send_delivery_feedback/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/send_mms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/send_sms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/update_binary/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/update_mms/snippet.py (100%) rename {docs => examples}/snippets/sms/batches/update_sms/snippet.py (100%) rename {docs => examples}/snippets/sms/delivery_reports/get/snippet.py (100%) rename {docs => examples}/snippets/sms/delivery_reports/get_for_number/snippet.py (100%) rename {docs => examples}/snippets/sms/delivery_reports/list/snippet.py (100%) diff --git a/docs/.env.example b/examples/.env.example similarity index 100% rename from docs/.env.example rename to examples/.env.example diff --git a/docs/README.md b/examples/README.md similarity index 100% rename from docs/README.md rename to examples/README.md diff --git a/docs/pyproject.toml b/examples/pyproject.toml similarity index 100% rename from docs/pyproject.toml rename to examples/pyproject.toml diff --git a/docs/snippets/number_lookup/lookup/snippet.py b/examples/snippets/number_lookup/lookup/snippet.py similarity index 100% rename from docs/snippets/number_lookup/lookup/snippet.py rename to examples/snippets/number_lookup/lookup/snippet.py diff --git a/docs/snippets/numbers/active_numbers/get/snippet.py b/examples/snippets/numbers/active_numbers/get/snippet.py similarity index 100% rename from docs/snippets/numbers/active_numbers/get/snippet.py rename to examples/snippets/numbers/active_numbers/get/snippet.py diff --git a/docs/snippets/numbers/active_numbers/list/snippet.py b/examples/snippets/numbers/active_numbers/list/snippet.py similarity index 100% rename from docs/snippets/numbers/active_numbers/list/snippet.py rename to examples/snippets/numbers/active_numbers/list/snippet.py diff --git a/docs/snippets/numbers/active_numbers/list_auto/snippet.py b/examples/snippets/numbers/active_numbers/list_auto/snippet.py similarity index 100% rename from docs/snippets/numbers/active_numbers/list_auto/snippet.py rename to examples/snippets/numbers/active_numbers/list_auto/snippet.py diff --git a/docs/snippets/numbers/active_numbers/release/snippet.py b/examples/snippets/numbers/active_numbers/release/snippet.py similarity index 100% rename from docs/snippets/numbers/active_numbers/release/snippet.py rename to examples/snippets/numbers/active_numbers/release/snippet.py diff --git a/docs/snippets/numbers/active_numbers/update/snippet.py b/examples/snippets/numbers/active_numbers/update/snippet.py similarity index 100% rename from docs/snippets/numbers/active_numbers/update/snippet.py rename to examples/snippets/numbers/active_numbers/update/snippet.py diff --git a/docs/snippets/numbers/available_numbers/check_availability/snippet.py b/examples/snippets/numbers/available_numbers/check_availability/snippet.py similarity index 100% rename from docs/snippets/numbers/available_numbers/check_availability/snippet.py rename to examples/snippets/numbers/available_numbers/check_availability/snippet.py diff --git a/docs/snippets/numbers/available_numbers/rent/snippet.py b/examples/snippets/numbers/available_numbers/rent/snippet.py similarity index 100% rename from docs/snippets/numbers/available_numbers/rent/snippet.py rename to examples/snippets/numbers/available_numbers/rent/snippet.py diff --git a/docs/snippets/numbers/available_numbers/rent_any/snippet.py b/examples/snippets/numbers/available_numbers/rent_any/snippet.py similarity index 100% rename from docs/snippets/numbers/available_numbers/rent_any/snippet.py rename to examples/snippets/numbers/available_numbers/rent_any/snippet.py diff --git a/docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py b/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py similarity index 100% rename from docs/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py rename to examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py diff --git a/docs/snippets/numbers/available_regions/list/snippet.py b/examples/snippets/numbers/available_regions/list/snippet.py similarity index 100% rename from docs/snippets/numbers/available_regions/list/snippet.py rename to examples/snippets/numbers/available_regions/list/snippet.py diff --git a/docs/snippets/numbers/callback_configuration/get/snippet.py b/examples/snippets/numbers/callback_configuration/get/snippet.py similarity index 100% rename from docs/snippets/numbers/callback_configuration/get/snippet.py rename to examples/snippets/numbers/callback_configuration/get/snippet.py diff --git a/docs/snippets/numbers/callback_configuration/update/snippet.py b/examples/snippets/numbers/callback_configuration/update/snippet.py similarity index 100% rename from docs/snippets/numbers/callback_configuration/update/snippet.py rename to examples/snippets/numbers/callback_configuration/update/snippet.py diff --git a/docs/snippets/sms/batches/cancel/snippet.py b/examples/snippets/sms/batches/cancel/snippet.py similarity index 100% rename from docs/snippets/sms/batches/cancel/snippet.py rename to examples/snippets/sms/batches/cancel/snippet.py diff --git a/docs/snippets/sms/batches/dry_run_binary/snippet.py b/examples/snippets/sms/batches/dry_run_binary/snippet.py similarity index 100% rename from docs/snippets/sms/batches/dry_run_binary/snippet.py rename to examples/snippets/sms/batches/dry_run_binary/snippet.py diff --git a/docs/snippets/sms/batches/dry_run_mms/snippet.py b/examples/snippets/sms/batches/dry_run_mms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/dry_run_mms/snippet.py rename to examples/snippets/sms/batches/dry_run_mms/snippet.py diff --git a/docs/snippets/sms/batches/dry_run_sms/snippet.py b/examples/snippets/sms/batches/dry_run_sms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/dry_run_sms/snippet.py rename to examples/snippets/sms/batches/dry_run_sms/snippet.py diff --git a/docs/snippets/sms/batches/get/snippet.py b/examples/snippets/sms/batches/get/snippet.py similarity index 100% rename from docs/snippets/sms/batches/get/snippet.py rename to examples/snippets/sms/batches/get/snippet.py diff --git a/docs/snippets/sms/batches/list/snippet.py b/examples/snippets/sms/batches/list/snippet.py similarity index 100% rename from docs/snippets/sms/batches/list/snippet.py rename to examples/snippets/sms/batches/list/snippet.py diff --git a/docs/snippets/sms/batches/replace_binary/snippet.py b/examples/snippets/sms/batches/replace_binary/snippet.py similarity index 100% rename from docs/snippets/sms/batches/replace_binary/snippet.py rename to examples/snippets/sms/batches/replace_binary/snippet.py diff --git a/docs/snippets/sms/batches/replace_mms/snippet.py b/examples/snippets/sms/batches/replace_mms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/replace_mms/snippet.py rename to examples/snippets/sms/batches/replace_mms/snippet.py diff --git a/docs/snippets/sms/batches/replace_sms/snippet.py b/examples/snippets/sms/batches/replace_sms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/replace_sms/snippet.py rename to examples/snippets/sms/batches/replace_sms/snippet.py diff --git a/docs/snippets/sms/batches/send_binary/snippet.py b/examples/snippets/sms/batches/send_binary/snippet.py similarity index 100% rename from docs/snippets/sms/batches/send_binary/snippet.py rename to examples/snippets/sms/batches/send_binary/snippet.py diff --git a/docs/snippets/sms/batches/send_delivery_feedback/snippet.py b/examples/snippets/sms/batches/send_delivery_feedback/snippet.py similarity index 100% rename from docs/snippets/sms/batches/send_delivery_feedback/snippet.py rename to examples/snippets/sms/batches/send_delivery_feedback/snippet.py diff --git a/docs/snippets/sms/batches/send_mms/snippet.py b/examples/snippets/sms/batches/send_mms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/send_mms/snippet.py rename to examples/snippets/sms/batches/send_mms/snippet.py diff --git a/docs/snippets/sms/batches/send_sms/snippet.py b/examples/snippets/sms/batches/send_sms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/send_sms/snippet.py rename to examples/snippets/sms/batches/send_sms/snippet.py diff --git a/docs/snippets/sms/batches/update_binary/snippet.py b/examples/snippets/sms/batches/update_binary/snippet.py similarity index 100% rename from docs/snippets/sms/batches/update_binary/snippet.py rename to examples/snippets/sms/batches/update_binary/snippet.py diff --git a/docs/snippets/sms/batches/update_mms/snippet.py b/examples/snippets/sms/batches/update_mms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/update_mms/snippet.py rename to examples/snippets/sms/batches/update_mms/snippet.py diff --git a/docs/snippets/sms/batches/update_sms/snippet.py b/examples/snippets/sms/batches/update_sms/snippet.py similarity index 100% rename from docs/snippets/sms/batches/update_sms/snippet.py rename to examples/snippets/sms/batches/update_sms/snippet.py diff --git a/docs/snippets/sms/delivery_reports/get/snippet.py b/examples/snippets/sms/delivery_reports/get/snippet.py similarity index 100% rename from docs/snippets/sms/delivery_reports/get/snippet.py rename to examples/snippets/sms/delivery_reports/get/snippet.py diff --git a/docs/snippets/sms/delivery_reports/get_for_number/snippet.py b/examples/snippets/sms/delivery_reports/get_for_number/snippet.py similarity index 100% rename from docs/snippets/sms/delivery_reports/get_for_number/snippet.py rename to examples/snippets/sms/delivery_reports/get_for_number/snippet.py diff --git a/docs/snippets/sms/delivery_reports/list/snippet.py b/examples/snippets/sms/delivery_reports/list/snippet.py similarity index 100% rename from docs/snippets/sms/delivery_reports/list/snippet.py rename to examples/snippets/sms/delivery_reports/list/snippet.py From 3b1a4a3a44c2f1149da90a4cd53440d4ae3fde4b Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 12 Dec 2025 17:03:46 +0100 Subject: [PATCH 075/106] DEVEXP-1160: Migration guide - SMS (#107) --- MIGRATION_GUIDE.md | 119 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 MIGRATION_GUIDE.md diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 00000000..d1331dcf --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,119 @@ +# Sinch Python SDK Migration Guide + +## 2.0.0 + +This release removes legacy SDK support. + +This guide lists all removed classes and interfaces from V1 and how to migrate to their V2 equivalents. + +### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) + +#### Replacement models + +##### Batches + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.batches.requests.BatchRequest` | [`sinch.domains.sms.models.v1.shared.TextRequest`](sinch/domains/sms/models/v1/shared/text_request.py), [`sinch.domains.sms.models.v1.shared.BinaryRequest`](sinch/domains/sms/models/v1/shared/binary_request.py), or [`sinch.domains.sms.models.v1.shared.MediaRequest`](sinch/domains/sms/models/v1/shared/media_request.py) | +| `sinch.domains.sms.models.batches.requests.SendBatchRequest` | [`sinch.domains.sms.models.v1.shared.TextRequest`](sinch/domains/sms/models/v1/shared/text_request.py), [`sinch.domains.sms.models.v1.shared.BinaryRequest`](sinch/domains/sms/models/v1/shared/binary_request.py), or [`sinch.domains.sms.models.v1.shared.MediaRequest`](sinch/domains/sms/models/v1/shared/media_request.py) | +| `sinch.domains.sms.models.batches.requests.ListBatchesRequest` | [`sinch.domains.sms.models.v1.internal.ListBatchesRequest`](sinch/domains/sms/models/v1/internal/list_batches_request.py) | +| `sinch.domains.sms.models.batches.requests.GetBatchRequest` | [`sinch.domains.sms.models.v1.internal.BatchIdRequest`](sinch/domains/sms/models/v1/internal/batch_id_request.py) | +| `sinch.domains.sms.models.batches.requests.CancelBatchRequest` | [`sinch.domains.sms.models.v1.internal.BatchIdRequest`](sinch/domains/sms/models/v1/internal/batch_id_request.py) | +| `sinch.domains.sms.models.batches.requests.BatchDryRunRequest` | [`sinch.domains.sms.models.v1.internal.DryRunRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py) (Union of [`DryRunTextRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py), [`DryRunBinaryRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py), [`DryRunMediaRequest`](sinch/domains/sms/models/v1/internal/dry_run_request.py)) | +| `sinch.domains.sms.models.batches.requests.UpdateBatchRequest` | [`sinch.domains.sms.models.v1.internal.UpdateBatchMessageRequest`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py) (Union of [`UpdateTextRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py), [`UpdateBinaryRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py), [`UpdateMediaRequestWithBatchId`](sinch/domains/sms/models/v1/internal/update_batch_message_request.py)) | +| `sinch.domains.sms.models.batches.requests.ReplaceBatchRequest` | [`sinch.domains.sms.models.v1.internal.ReplaceBatchRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py) (Union of [`ReplaceTextRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py), [`ReplaceBinaryRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py), [`ReplaceMediaRequest`](sinch/domains/sms/models/v1/internal/replace_batch_request.py)) | +| `sinch.domains.sms.models.batches.requests.SendDeliveryFeedbackRequest` | [`sinch.domains.sms.models.v1.internal.DeliveryFeedbackRequest`](sinch/domains/sms/models/v1/internal/delivery_feedback_request.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) (Union of [`TextResponse`](sinch/domains/sms/models/v1/shared/text_response.py), [`BinaryResponse`](sinch/domains/sms/models/v1/shared/binary_response.py), [`MediaResponse`](sinch/domains/sms/models/v1/shared/media_response.py)) | +| `sinch.domains.sms.models.batches.responses.ReplaceSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.ListSMSBatchesResponse` | [`sinch.domains.sms.models.v1.response.ListBatchesResponse`](sinch/domains/sms/models/v1/response/list_batches_response.py) | +| `sinch.domains.sms.models.batches.responses.GetSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.CancelSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSBatchDryRunResponse` | [`sinch.domains.sms.models.v1.response.DryRunResponse`](sinch/domains/sms/models/v1/response/dry_run_response.py) | +| `sinch.domains.sms.models.batches.responses.UpdateSMSBatchResponse` | [`sinch.domains.sms.models.v1.types.BatchResponse`](sinch/domains/sms/models/v1/types/batch_response.py) | +| `sinch.domains.sms.models.batches.responses.SendSMSDeliveryFeedbackResponse` | `None` (The method returns an empty 202 HTTP response) | + +##### Delivery Reports + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.delivery_reports.requests.ListSMSDeliveryReportsRequest` | [`sinch.domains.sms.models.v1.internal.ListDeliveryReportsRequest`](sinch/domains/sms/models/v1/internal/list_delivery_reports_request.py) | +| `sinch.domains.sms.models.delivery_reports.requests.GetSMSDeliveryReportForBatchRequest` | [`sinch.domains.sms.models.v1.internal.GetBatchDeliveryReportRequest`](sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py) | +| `sinch.domains.sms.models.delivery_reports.requests.GetSMSDeliveryReportForNumberRequest` | [`sinch.domains.sms.models.v1.internal.GetRecipientDeliveryReportRequest`](sinch/domains/sms/models/v1/internal/get_recipient_delivery_report_request.py) | +| `sinch.domains.sms.models.delivery_reports.responses.ListSMSDeliveryReportsResponse` | [`sinch.domains.sms.models.v1.internal.ListDeliveryReportsResponse`](sinch/domains/sms/models/v1/internal/list_delivery_reports_response.py) | +| `sinch.domains.sms.models.delivery_reports.responses.GetSMSDeliveryReportForBatchResponse` | [`sinch.domains.sms.models.v1.response.BatchDeliveryReport`](sinch/domains/sms/models/v1/response/batch_delivery_report.py) | +| `sinch.domains.sms.models.delivery_reports.responses.GetSMSDeliveryReportForNumberResponse` | [`sinch.domains.sms.models.v1.response.RecipientDeliveryReport`](sinch/domains/sms/models/v1/response/recipient_delivery_report.py) | + +#### Replacement APIs + +The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. +Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. + +##### Batches API + +| Old method | New method in `sms.batches` | +|------------|----------------------------| +| `send()` with `SendBatchRequest` | Use convenience methods: `send_sms()`, `send_binary()`, `send_mms()`
Or `send()` with `SendSMSRequest` (Union of `TextRequest`, `BinaryRequest`, `MediaRequest`) | +| `list()` with `ListBatchesRequest` | `list()` with individual parameters: `page`, `page_size`, `start_date`, `end_date`, `from_`, `client_reference` | +| `get()` with `GetBatchRequest` | `get()` with `batch_id: str` parameter | +| `send_dry_run()` with `BatchDryRunRequest` | Use convenience methods: `dry_run_sms()`, `dry_run_binary()`, `dry_run_mms()`
Or `dry_run()` with `DryRunRequest` (Union of `DryRunTextRequest`, `DryRunBinaryRequest`, `DryRunMediaRequest`) | +| `update()` with `UpdateBatchRequest` | Use convenience methods: `update_sms()`, `update_binary()`, `update_mms()`
Or `update()` with `UpdateBatchMessageRequest` (Union of `UpdateTextRequestWithBatchId`, `UpdateBinaryRequestWithBatchId`, `UpdateMediaRequestWithBatchId`) | +| `replace()` with `ReplaceBatchRequest` | Use convenience methods: `replace_sms()`, `replace_binary()`, `replace_mms()`
Or `replace()` with `ReplaceBatchRequest` (Union of `ReplaceTextRequest`, `ReplaceBinaryRequest`, `ReplaceMediaRequest`) | + +
+ +##### Delivery Reports API + +| Old method | New method in `sms.delivery_reports` | +|------------|-------------------------------------| +| `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | +| `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | +| `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | + +#### SMS client initialization (with region) +In V1: +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", +) +sinch_client.configuration.sms_region = "eu" + +# Or using SMS token auth +token_client = SinchClient( + service_plan_id='your-service-plan-id', + sms_api_token='your-sms-api-token' +) +token_client.configuration.sms_region_with_service_plan_id = "eu" + +``` + + +In V2: +- The `sms_region` no longer defaults to `us`. Set it explicitly before using the SMS API, otherwise calls will fail at runtime. The parameter is now exposed on `SinchClient` (not just the configuration object) to ensure the region is provided. Note that `sms_region` is only required when using the SMS API endpoints. + +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", + sms_region="eu", +) + +# or using SMS token auth +token_client = SinchClient( + service_plan_id="your-service-plan-id", + sms_api_token="your-sms-api-token", + sms_region="us", +) + +# Note: The code is backward compatible. The sms_region can still be set through the configuration object, +# but you must ensure this setting is done BEFORE any SMS API call: +sinch_client.configuration.sms_region = "eu" +``` From ea470e434ebb6f4e1af34be85185b3bc790a60a5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 18 Dec 2025 09:02:54 +0100 Subject: [PATCH 076/106] DEVEXP-788: SMS API - Webhooks Quickstart (#108) --- examples/{ => snippets}/.env.example | 0 examples/{ => snippets}/README.md | 2 +- examples/{ => snippets}/pyproject.toml | 0 examples/webhooks/.env.example | 9 ++ examples/webhooks/README.md | 105 +++++++++++++++++ examples/webhooks/numbers_api/__init__.py | 0 examples/webhooks/numbers_api/controller.py | 31 +++++ .../numbers_api/server_business_logic.py | 11 ++ examples/webhooks/pyproject.toml | 17 +++ examples/webhooks/server.py | 41 +++++++ examples/webhooks/sinch_client_helper.py | 34 ++++++ examples/webhooks/sms_api/__init__.py | 0 examples/webhooks/sms_api/controller.py | 37 ++++++ .../webhooks/sms_api/server_business_logic.py | 11 ++ .../clients/sinch_client_configuration.py | 17 ++- sinch/domains/sms/models/batches/__init__.py | 22 ---- sinch/domains/sms/models/batches/requests.py | 108 ------------------ sinch/domains/sms/models/batches/responses.py | 60 ---------- .../sms/models/delivery_reports/__init__.py | 18 --- .../sms/models/delivery_reports/requests.py | 27 ----- .../sms/models/delivery_reports/responses.py | 34 ------ sinch/domains/sms/sms.py | 12 ++ 22 files changed, 324 insertions(+), 272 deletions(-) rename examples/{ => snippets}/.env.example (100%) rename examples/{ => snippets}/README.md (95%) rename examples/{ => snippets}/pyproject.toml (100%) create mode 100644 examples/webhooks/.env.example create mode 100644 examples/webhooks/README.md create mode 100644 examples/webhooks/numbers_api/__init__.py create mode 100644 examples/webhooks/numbers_api/controller.py create mode 100644 examples/webhooks/numbers_api/server_business_logic.py create mode 100644 examples/webhooks/pyproject.toml create mode 100644 examples/webhooks/server.py create mode 100644 examples/webhooks/sinch_client_helper.py create mode 100644 examples/webhooks/sms_api/__init__.py create mode 100644 examples/webhooks/sms_api/controller.py create mode 100644 examples/webhooks/sms_api/server_business_logic.py delete mode 100644 sinch/domains/sms/models/batches/__init__.py delete mode 100644 sinch/domains/sms/models/batches/requests.py delete mode 100644 sinch/domains/sms/models/batches/responses.py delete mode 100644 sinch/domains/sms/models/delivery_reports/__init__.py delete mode 100644 sinch/domains/sms/models/delivery_reports/requests.py delete mode 100644 sinch/domains/sms/models/delivery_reports/responses.py diff --git a/examples/.env.example b/examples/snippets/.env.example similarity index 100% rename from examples/.env.example rename to examples/snippets/.env.example diff --git a/examples/README.md b/examples/snippets/README.md similarity index 95% rename from examples/README.md rename to examples/snippets/README.md index 922fb91d..35bb6550 100644 --- a/examples/README.md +++ b/examples/snippets/README.md @@ -2,7 +2,7 @@ Sinch Python SDK Code Snippets -This repository contains code snippets demonstrating usage of the +This directory contains code snippets demonstrating usage of the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python). ## Requirements diff --git a/examples/pyproject.toml b/examples/snippets/pyproject.toml similarity index 100% rename from examples/pyproject.toml rename to examples/snippets/pyproject.toml diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example new file mode 100644 index 00000000..02e98c4b --- /dev/null +++ b/examples/webhooks/.env.example @@ -0,0 +1,9 @@ +# Server Configuration +SERVER_PORT = + +# Webhook Configuration +# The secret value used for webhook calls validation +# See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/ +NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET +# See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks +SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET \ No newline at end of file diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md new file mode 100644 index 00000000..8d9674c5 --- /dev/null +++ b/examples/webhooks/README.md @@ -0,0 +1,105 @@ +# Webhook Handlers for Sinch Python SDK + +This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) +to process incoming webhooks from Sinch services. + +The webhook handlers are organized by service: +- **SMS**: Handlers for SMS webhook events (`sms_api/`) +- **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`) + +This directory contains both the webhook handlers and the server application (`server.py`) that uses them. + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- [ngrok](https://ngrok.com/docs) +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment Variables**: + Rename [.env.example](.env.example) to `.env` in this directory (`examples/webhooks/`), then add your credentials from the Sinch dashboard under the Access Keys section. + + - Server Port: + Define the port your server will listen to on (default: 3001): + ``` + SERVER_PORT=3001 + ``` + + - Controller Settings + - Numbers controller: Set the `numbers` webhook secret. You can retrieve it using the `/callback_configuration` endpoint (see SDK implementation: [callback_configuration_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/callback_configuration_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): + ``` + NUMBERS_WEBHOOKS_SECRET=Your Sinch Numbers Webhook Secret + ``` + - SMS controller: To configure the `sms` webhooks secret, contact your account manager to enable authentication for SMS callbacks. For more details, refer to + [SMS API](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks), + + ``` + SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret + ``` + +## Usage + +### Running the server application + +1. Navigate to the webhooks' directory: +``` + cd examples/webhooks +``` + +2. Install the project dependencies: +``` bash + poetry install +``` + +3. Start the server: +``` bash + poetry run python server.py +``` +Or run it directly: +``` bash + python server.py +``` + +The server will start on the port specified in your `.env` file (default: 3001). + +### Endpoints + +The server exposes the following endpoints: + +| Service | Endpoint | +|--------------|--------------------| +| Numbers | /NumbersEvent | +| SMS | /SmsEvent | + +## Using ngrok to expose your local server + +To test your webhook locally, you can tunnel requests to your local server using ngrok. + +*Note: The default port is `3001`, but this can be changed (see [Server port](#Configuration))* + +```bash + ngrok http 3001 +``` + +You'll see output similar to this: +``` +ngrok (Ctrl+C to quit) +... +Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http://localhost:3001 +``` +Use the `https` forwarding URL in your callback configuration. For example: + - Numbers: https://adbd-79-148-170-158.ngrok-free.app/NumbersEvent + - SMS: https://adbd-79-148-170-158.ngrok-free.app/SmsEvent + +Use this value to configure the callback URLs: +- **Numbers**: Set the `callback_url` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)). +- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L147), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. + +You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there. + +> **Note**: If you have set a webhook secret (e.g., `SMS_WEBHOOKS_SECRET`), the webhook URL must be configured in the Sinch dashboard +> and cannot be overridden via API parameters. The webhook secret is used to validate incoming webhook requests, +> and the URL associated with it must be set in the dashboard. diff --git a/examples/webhooks/numbers_api/__init__.py b/examples/webhooks/numbers_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/numbers_api/controller.py b/examples/webhooks/numbers_api/controller.py new file mode 100644 index 00000000..afe5666a --- /dev/null +++ b/examples/webhooks/numbers_api/controller.py @@ -0,0 +1,31 @@ +from flask import request, Response +from webhooks.numbers_api.server_business_logic import handle_numbers_event + + +class NumbersController: + def __init__(self, sinch_client, webhooks_secret): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.logger = self.sinch_client.configuration.logger + + def numbers_event(self): + headers = dict(request.headers) + body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + + webhooks_service = self.sinch_client.numbers.webhooks(self.webhooks_secret) + + ensure_valid_authentication = False + if ensure_valid_authentication: + valid_auth = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=body_str + ) + + if not valid_auth: + return Response(status=401) + + event = webhooks_service.parse_event(body_str) + + handle_numbers_event(numbers_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/numbers_api/server_business_logic.py b/examples/webhooks/numbers_api/server_business_logic.py new file mode 100644 index 00000000..80082812 --- /dev/null +++ b/examples/webhooks/numbers_api/server_business_logic.py @@ -0,0 +1,11 @@ +from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import NumbersWebhooksEvent + + +def handle_numbers_event(numbers_event: NumbersWebhooksEvent, logger): + """ + This method handles a Numbers event. + Args: + numbers_event (NumbersWebhooksEvent): The Numbers event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling Numbers event:\n{numbers_event.model_dump_json(indent=2)}') diff --git a/examples/webhooks/pyproject.toml b/examples/webhooks/pyproject.toml new file mode 100644 index 00000000..76a53090 --- /dev/null +++ b/examples/webhooks/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "sinch-sdk-python-quickstart-server" +version = "0.1.0" +description = "Sinch SDK Python Quickstart Webhooks Server" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" +# TODO: Uncomment once v2.0 is released +# sinch = "^2.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py new file mode 100644 index 00000000..d7f6f1ca --- /dev/null +++ b/examples/webhooks/server.py @@ -0,0 +1,41 @@ +import logging +import sys +from pathlib import Path + +# Add examples directory to Python path to allow importing webhooks +examples_dir = Path(__file__).resolve().parent.parent +if str(examples_dir) not in sys.path: + sys.path.insert(0, str(examples_dir)) + +from flask import Flask, request +from webhooks.numbers_api.controller import NumbersController +from webhooks.sms_api.controller import SmsController +from webhooks.sinch_client_helper import get_sinch_client, load_config + +app = Flask(__name__) + +config = load_config() +port = int(config.get('SERVER_PORT') or 3001) +numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET') +sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET') +sinch_client = get_sinch_client(config) + +# Set up logging at the INFO level +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret) +sms_controller = SmsController(sinch_client, sms_webhooks_secret) + + +# Middleware to capture raw body +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +app.add_url_rule('/NumbersEvent', methods=['POST'], view_func=numbers_controller.numbers_event) +app.add_url_rule('/SmsEvent', methods=['POST'], view_func=sms_controller.sms_event) + +if __name__ == '__main__': + app.run(port=port) diff --git a/examples/webhooks/sinch_client_helper.py b/examples/webhooks/sinch_client_helper.py new file mode 100644 index 00000000..109fce1c --- /dev/null +++ b/examples/webhooks/sinch_client_helper.py @@ -0,0 +1,34 @@ +from pathlib import Path +from sinch import SinchClient +from dotenv import dotenv_values + + +def load_config() -> dict[str, str]: + """ + Load configuration from the .env file in the webhooks directory. + + Returns: + dict[str, str]: Dictionary containing configuration values + """ + # Get the directory where this file is located + current_dir = Path(__file__).resolve().parent + env_file = current_dir / '.env' + + if not env_file.exists(): + raise FileNotFoundError(f"Could not find .env file in webhooks directory: {env_file}") + + config_dict = dotenv_values(env_file) + + return config_dict + + +def get_sinch_client(config: dict) -> SinchClient: + """ + Create and return a configured SinchClient instance. + + Args: + config (dict): Dictionary containing configuration values + Returns: + SinchClient: Configured Sinch client instance + """ + return SinchClient() diff --git a/examples/webhooks/sms_api/__init__.py b/examples/webhooks/sms_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/sms_api/controller.py b/examples/webhooks/sms_api/controller.py new file mode 100644 index 00000000..dbebd1de --- /dev/null +++ b/examples/webhooks/sms_api/controller.py @@ -0,0 +1,37 @@ +from flask import request, Response +from webhooks.sms_api.server_business_logic import ( + handle_sms_event, +) + + +class SmsController: + def __init__(self, sinch_client, webhooks_secret): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.logger = self.sinch_client.configuration.logger + + def sms_event(self): + headers = dict(request.headers) + + body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + + webhooks_service = self.sinch_client.sms.webhooks(self.webhooks_secret) + + # Signature headers may be absent unless your account manager enables them + # (see README: Configuration -> Controller Settings -> SMS controller); + # leave auth disabled here unless SMS callbacks are configured. + ensure_valid_authentication = False + if ensure_valid_authentication: + valid_auth = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=body_str + ) + + if not valid_auth: + return Response(status=401) + + event = webhooks_service.parse_event(body_str) + + handle_sms_event(sms_event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/sms_api/server_business_logic.py b/examples/webhooks/sms_api/server_business_logic.py new file mode 100644 index 00000000..aa47e470 --- /dev/null +++ b/examples/webhooks/sms_api/server_business_logic.py @@ -0,0 +1,11 @@ +from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import IncomingSMSWebhookEvent + + +def handle_sms_event(sms_event: IncomingSMSWebhookEvent, logger): + """ + This method handles an SMS event. + Args: + sms_event (SmsWebhooksEvent): The SMS event data. + logger (logging.Logger, optional): Logger instance for logging. Defaults to None. + """ + logger.info(f'Handling SMS event:\n{sms_event.model_dump_json(indent=2)}') diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 0fe559ec..1db8e7d9 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -266,8 +266,21 @@ def get_sms_origin_for_auth(self): Returns the appropriate SMS origin based on the authentication method. - SMS auth (service_plan_id + sms_api_token): uses sms_origin_with_service_plan_id - Project auth (project_id): uses regular sms_origin + + Raises: + ValueError: If the SMS origin is None (sms_region not set) """ if self._authentication_method == "sms_auth": - return self.sms_origin_with_service_plan_id + origin = self.sms_origin_with_service_plan_id else: - return self.sms_origin + origin = self.sms_origin + + if origin is None: + raise ValueError( + "SMS region is required. " + "Provide sms_region when initializing SinchClient " + "Example: SinchClient(project_id='...', key_id='...', key_secret='...', sms_region='eu')" + "or set it via sinch_client.configuration.sms_region. " + ) + + return origin diff --git a/sinch/domains/sms/models/batches/__init__.py b/sinch/domains/sms/models/batches/__init__.py deleted file mode 100644 index 1e352ded..00000000 --- a/sinch/domains/sms/models/batches/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class Batch(SinchRequestBaseModel): - id: str - to: list - from_: str - body: str - delivery_report: str - cancelled: str - type: str - campaign_id: str - created_at: str - modified_at: str - send_at: str - expire_at: str - callback_url: str = None - client_reference: str = None - feedback_enabled: bool = None - flash_message: bool = None diff --git a/sinch/domains/sms/models/batches/requests.py b/sinch/domains/sms/models/batches/requests.py deleted file mode 100644 index 99b6cb9d..00000000 --- a/sinch/domains/sms/models/batches/requests.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class BatchRequest(SinchRequestBaseModel): - def as_dict(self): - payload = super(BatchRequest, self).as_dict() - payload["to"] = payload.pop("to") - if payload.get("from_"): - payload["from"] = payload.pop("from_") - return payload - - -@dataclass -class SendBatchRequest(BatchRequest): - to: list - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - feedback_enabled: bool - flash_message: bool - truncate_concat: bool - type_: str - max_number_of_message_parts: int - from_ton: int - from_npi: int - - -@dataclass -class ListBatchesRequest(SinchRequestBaseModel): - page_size: int - from_s: str - start_date: str - end_date: str - client_reference: str - page: int = 0 - - -@dataclass -class GetBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class CancelBatchRequest(SinchRequestBaseModel): - batch_id: str - - -@dataclass -class BatchDryRunRequest(BatchRequest): - per_recipient: bool - number_of_recipients: int - to: str - from_: str - body: str - type_: str - udh: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - - -@dataclass -class UpdateBatchRequest(SinchRequestBaseModel): - batch_id: str - to_add: list - to_remove: list - from_: str - body: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - - -@dataclass -class ReplaceBatchRequest(BatchRequest): - batch_id: str - to: str - from_: str - body: str - delivery_report: str - parameters: dict - send_at: str - expire_at: str - type_: str - callback_url: str - client_reference: str - flash_message: bool - max_number_of_message_parts: int - udh: str - - -@dataclass -class SendDeliveryFeedbackRequest(SinchRequestBaseModel): - batch_id: str - recipients: list diff --git a/sinch/domains/sms/models/batches/responses.py b/sinch/domains/sms/models/batches/responses.py deleted file mode 100644 index a0e918f5..00000000 --- a/sinch/domains/sms/models/batches/responses.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.batches import Batch - - -@dataclass -class SendSMSBatchResponse(Batch): - pass - - -@dataclass -class ReplaceSMSBatchResponse(Batch): - pass - - -@dataclass -class ListSMSBatchesResponse(SinchBaseModel): - page: str - page_size: str - count: str - batches: list - - -@dataclass -class GetSMSBatchResponse(Batch): - pass - - -@dataclass -class CancelSMSBatchResponse(Batch): - pass - - -@dataclass -class SendSMSBatchDryRunResponse(SinchBaseModel): - number_of_recipients: int - number_of_messages: int - per_recipient: list - - -@dataclass -class UpdateSMSBatchResponse(SinchBaseModel): - id: str - to: list - from_: str - body: str - campaign_id: str - delivery_report: str - send_at: str - expire_at: str - callback_url: str - cancelled: bool - type: str - created_at: str - modified_at: str - - -@dataclass -class SendSMSDeliveryFeedbackResponse(SinchBaseModel): - pass diff --git a/sinch/domains/sms/models/delivery_reports/__init__.py b/sinch/domains/sms/models/delivery_reports/__init__.py deleted file mode 100644 index b6c40915..00000000 --- a/sinch/domains/sms/models/delivery_reports/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class DeliveryReport(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - encoding: str - number_of_message_parts: int - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/models/delivery_reports/requests.py b/sinch/domains/sms/models/delivery_reports/requests.py deleted file mode 100644 index 09857fd3..00000000 --- a/sinch/domains/sms/models/delivery_reports/requests.py +++ /dev/null @@ -1,27 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListSMSDeliveryReportsRequest(SinchRequestBaseModel): - code: str - status: str - start_date: str - end_date: str - client_reference: str - page_size: int - page: int = 0 - - -@dataclass -class GetSMSDeliveryReportForBatchRequest(SinchRequestBaseModel): - batch_id: str - type_: str - status: list - code: list - - -@dataclass -class GetSMSDeliveryReportForNumberRequest(SinchRequestBaseModel): - batch_id: str - recipient_number: str diff --git a/sinch/domains/sms/models/delivery_reports/responses.py b/sinch/domains/sms/models/delivery_reports/responses.py deleted file mode 100644 index 18a53ba4..00000000 --- a/sinch/domains/sms/models/delivery_reports/responses.py +++ /dev/null @@ -1,34 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ListSMSDeliveryReportsResponse(SinchBaseModel): - page: str - page_size: str - count: str - delivery_reports: list - - -@dataclass -class GetSMSDeliveryReportForBatchResponse(SinchBaseModel): - type: str - batch_id: str - total_message_count: str - statuses: list - client_reference: str - - -@dataclass -class GetSMSDeliveryReportForNumberResponse(SinchBaseModel): - at: str - batch_id: str - code: int - recipient: str - status: str - applied_originator: str - client_reference: str - number_of_message_parts: str - operator: str - operator_status_at: str - type: str diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index f53bab95..3c5c3ff3 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,6 +2,7 @@ Batches, DeliveryReports, ) +from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks class SMS: @@ -15,3 +16,14 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + + def webhooks(self, callback_secret: str) -> SmsWebhooks: + """ + Create an SMS webhooks handler with the specified callback secret. + + :param callback_secret: Secret used for webhook validation. + :type callback_secret: str + :returns: A configured webhooks handler + :rtype: SmsWebhooks + """ + return SmsWebhooks(callback_secret) From eb2f8a8baadfa8d08734f7d0702b9aeede2a1bc6 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 5 Jan 2026 18:14:40 +0100 Subject: [PATCH 077/106] DEVEXP-794: Conversation API - Messages (E2E - delete/get/update) (#109) --- .github/workflows/ci.yml | 3 + sinch/domains/conversation/__init__.py | 994 +----------------- .../{endpoints => api}/__init__.py | 0 sinch/domains/conversation/api/v1/__init__.py | 5 + .../conversation/api/v1/base/__init__.py | 5 + .../api/v1/base/base_conversation.py | 23 + .../domains/conversation/api/v1/exceptions.py | 5 + .../conversation/api/v1/internal/__init__.py | 11 + .../api/v1/internal/base/__init__.py | 5 + .../v1/internal/base/conversation_endpoint.py | 114 ++ .../api/v1/internal/messages_endpoints.py | 126 +++ .../conversation/api/v1/messages_apis.py | 116 ++ sinch/domains/conversation/conversation.py | 14 + .../conversation/endpoints/app/create_app.py | 39 - .../conversation/endpoints/app/delete_app.py | 27 - .../conversation/endpoints/app/get_app.py | 37 - .../conversation/endpoints/app/list_apps.py | 39 - .../conversation/endpoints/app/update_app.py | 46 - .../conversation/endpoints/capability.py | 33 - .../endpoints/contact/create_contact.py | 38 - .../endpoints/contact/delete_contact.py | 26 - .../endpoints/contact/get_channel_profile.py | 31 - .../endpoints/contact/get_contact.py | 36 - .../endpoints/contact/list_contact.py | 48 - .../endpoints/contact/merge_contacts.py | 34 - .../endpoints/contact/update_contact.py | 31 - .../conversation/create_conversation.py | 38 - .../conversation/delete_conversation.py | 27 - .../conversation/get_conversation.py | 36 - .../inject_message_to_conversation.py | 30 - .../conversation/list_conversations.py | 58 - .../conversation/stop_conversation.py | 27 - .../conversation/update_conversation.py | 40 - .../endpoints/conversation_endpoint.py | 13 - .../domains/conversation/endpoints/events.py | 32 - .../endpoints/message/delete_message.py | 32 - .../endpoints/message/get_message.py | 43 - .../endpoints/message/list_message.py | 71 -- .../endpoints/message/send_message.py | 31 - .../domains/conversation/endpoints/opt_in.py | 39 - .../domains/conversation/endpoints/opt_out.py | 39 - .../endpoints/templates/create_template.py | 37 - .../endpoints/templates/delete_template.py | 27 - .../endpoints/templates/get_template.py | 35 - .../endpoints/templates/list_templates.py | 38 - .../endpoints/templates/update_template.py | 39 - .../conversation/endpoints/transcode.py | 31 - .../endpoints/webhooks/create_webhook.py | 37 - .../endpoints/webhooks/delete_webhook.py | 27 - .../endpoints/webhooks/get_webhook.py | 35 - .../endpoints/webhooks/list_webhooks.py | 38 - .../endpoints/webhooks/update_webhook.py | 39 - sinch/domains/conversation/models/__init__.py | 82 -- .../conversation/models/app/requests.py | 36 - .../conversation/models/app/responses.py | 31 - .../models/capability/requests.py | 9 - .../models/capability/responses.py | 9 - .../conversation/models/contact/requests.py | 60 -- .../conversation/models/contact/responses.py | 40 - .../models/conversation/__init__.py | 15 - .../models/conversation/requests.py | 62 -- .../models/conversation/responses.py | 41 - .../conversation/models/event/requests.py | 13 - .../conversation/models/event/responses.py | 8 - .../conversation/models/message/requests.py | 43 - .../conversation/models/message/responses.py | 26 - .../models/opt_in_opt_out/requests.py | 20 - .../models/opt_in_opt_out/responses.py | 14 - .../conversation/models/templates/__init__.py | 13 - .../conversation/models/templates/requests.py | 29 - .../models/templates/responses.py | 30 - .../models/transcoding/__init__.py | 0 .../models/transcoding/requests.py | 11 - .../models/transcoding/responses.py | 7 - .../{endpoints/app => models/v1}/__init__.py | 0 .../v1/messages/categories}/__init__.py | 0 .../v1/messages/categories/app}/__init__.py | 0 .../v1/messages/categories/app/app_message.py | 78 ++ .../messages/categories/calendar/__init__.py | 14 + .../categories/calendar/calendar_message.py | 29 + .../v1/messages/categories/call/__init__.py | 14 + .../messages/categories/call/call_message.py | 14 + .../v1/messages/categories/card/__init__.py | 21 + .../messages/categories/card/card_message.py | 36 + .../categories/card/card_message_field.py | 11 + .../categories/card/message_properties.py | 15 + .../messages/categories/carousel/__init__.py | 21 + .../categories/carousel/carousel_message.py | 21 + .../carousel/carousel_message_field.py | 11 + .../categories/channelspecific}/__init__.py | 0 ...hannel_specific_contact_message_message.py | 17 + .../channel_specific_message.py | 17 + .../flow_channel_specific_message.py | 26 + .../channelspecific/kakaotalk}/__init__.py | 0 .../kakaotalk/kakaotalk_app_link_button.py | 17 + .../kakaotalk/kakaotalk_bot_keyword_button.py | 9 + .../kakaotalk/kakaotalk_button.py | 8 + .../kakaotalk/kakaotalk_carousel.py | 26 + ...ousel_commerce_channel_specific_message.py | 13 + .../kakaotalk/kakaotalk_carousel_head.py | 31 + .../kakaotalk/kakaotalk_carousel_tail.py | 22 + .../kakaotalk_channel_specific_message.py | 16 + ...otalk_commerce_channel_specific_message.py | 29 + .../kakaotalk/kakaotalk_commerce_image.py | 12 + .../kakaotalk/kakaotalk_commerce_message.py | 29 + .../kakaotalk/kakaotalk_coupon.py | 25 + .../kakaotalk_discount_fixed_commerce.py | 15 + .../kakaotalk_discount_rate_commerce.py | 16 + .../kakaotalk_discount_rate_coupon.py | 12 + .../kakaotalk_fixed_discount_coupon.py | 12 + .../kakaotalk/kakaotalk_free_coupon.py | 12 + .../kakaotalk_regular_price_commerce.py | 15 + .../kakaotalk_shipping_discount_coupon.py | 11 + .../kakaotalk/kakaotalk_up_coupon.py | 10 + .../kakaotalk/kakaotalk_web_link_button.py | 15 + .../channelspecific/whatsapp}/__init__.py | 0 .../whatsapp/flows}/__init__.py | 0 .../whatsapp/flows/flow_action_payload.py | 15 + .../flows/flow_channel_specific_message.py | 26 + .../flows/whatsapp_interactive_body.py | 11 + .../whatsapp_interactive_document_header.py | 17 + .../flows/whatsapp_interactive_footer.py | 11 + .../whatsapp_interactive_header_media.py | 8 + .../whatsapp_interactive_image_header.py | 17 + .../flows/whatsapp_interactive_text_header.py | 13 + .../whatsapp_interactive_video_header.py | 17 + .../whatsapp/nfmreply}/__init__.py | 0 .../whatsapp_interactive_nfm_reply.py | 17 + ...p_interactive_nfm_reply_contact_message.py | 17 + .../whatsapp_interactive_nfm_reply_message.py | 17 + .../whatsapp/payment}/__init__.py | 0 .../whatsapp/payment/boleto.py | 11 + .../whatsapp/payment/dynamic_pix.py | 16 + .../whatsapp/payment/order_item.py | 21 + .../whatsapp/payment/payment_link.py | 10 + .../whatsapp/payment/payment_order.py | 52 + ..._order_details_channel_specific_message.py | 13 + .../payment/payment_order_details_content.py | 34 + ...t_order_status_channel_specific_message.py | 13 + .../payment/payment_order_status_content.py | 16 + .../payment/payment_order_status_order.py | 18 + .../whatsapp/whatsapp_common_props.py | 26 + .../v1/messages/categories/choice/__init__.py | 21 + .../categories/choice/choice_message.py | 22 + .../categories/choice/choice_message_field.py | 11 + .../categories/choice/choice_options.py | 54 + .../categories/choiceresponse/__init__.py | 7 + .../choiceresponse/choice_response_message.py | 13 + .../v1/messages/categories/common/__init__.py | 15 + .../v1/messages/categories/common/reply_to.py | 11 + .../messages/categories/contact}/__init__.py | 0 .../categories/contact/contact_message.py | 83 ++ .../categories/contactinfo/__init__.py | 11 + .../contactinfo/contact_info_message.py | 45 + .../contactinfo/contact_info_message_field.py | 11 + .../messages/categories/fallback/__init__.py | 7 + .../categories/fallback/fallback_message.py | 14 + .../v1/messages/categories/list/__init__.py | 21 + .../categories/list/list_item_choice.py | 11 + .../categories/list/list_item_product.py | 11 + .../messages/categories/list/list_message.py | 31 + .../categories/list/list_message_field.py | 11 + .../list/list_message_properties.py | 20 + .../messages/categories/location/__init__.py | 21 + .../categories/location/location_message.py | 19 + .../location/location_message_field.py | 11 + .../v1/messages/categories/media/__init__.py | 11 + .../categories/media/media_message_field.py | 11 + .../categories/media/media_properties.py | 16 + .../messages/categories/mediacard/__init__.py | 7 + .../mediacard/media_card_message.py | 13 + .../categories/productresponse/__init__.py | 7 + .../product_response_message.py | 22 + .../categories/sharelocation/__init__.py | 14 + .../sharelocation/share_location_message.py | 15 + .../messages/categories/template/__init__.py | 35 + .../categories/template/template_message.py | 19 + .../template_reference_channel_specific.py | 24 + .../template/template_reference_field.py | 11 + .../template_reference_omni_channel.py | 11 + .../v1/messages/categories/text/__init__.py | 11 + .../messages/categories/text/text_message.py | 10 + .../categories/text/text_message_field.py | 11 + .../v1/messages/categories/url/__init__.py | 14 + .../v1/messages/categories/url/url_message.py | 12 + .../models/v1/messages/internal/__init__.py | 1 + .../v1/messages/internal/base/__init__.py | 9 + .../internal/base/base_model_configuration.py | 44 + .../v1/messages/internal/request/__init__.py | 11 + .../internal/request/message_id_request.py | 16 + .../update_message_metadata_request.py | 19 + .../messages/response}/__init__.py | 0 .../v1/messages/response/message_response.py | 24 + .../v1/messages/response/types/__init__.py | 47 + .../v1/messages/response/types/app_message.py | 24 + .../types/channel_specific_message_content.py | 25 + .../messages/response/types/choice_option.py | 19 + .../response/types/contact_message.py | 22 + .../types/conversation_message_response.py | 11 + .../response/types/kakaotalk_button.py | 17 + .../response/types/kakaotalk_commerce.py | 17 + .../response/types/kakaotalk_coupon.py | 28 + .../v1/messages/response/types/list_item.py | 10 + .../response/types/payment_settings.py | 26 + .../types/whatsapp_interactive_header.py | 26 + .../models/v1/messages/shared/__init__.py | 61 ++ .../models/v1/messages/shared/address_info.py | 24 + .../models/v1/messages/shared/agent.py | 16 + .../shared/app_message_common_props.py | 32 + .../v1/messages/shared/channel_identity.py | 20 + .../models/v1/messages/shared/choice_item.py | 27 + .../shared/contact_message_common_props.py | 11 + .../models/v1/messages/shared/coordinates.py | 14 + .../models/v1/messages/shared/email_info.py | 12 + .../models/v1/messages/shared/list_section.py | 15 + .../shared/message_response_common_props.py | 51 + .../models/v1/messages/shared/name_info.py | 27 + .../v1/messages/shared/organization_info.py | 17 + .../messages/shared/override}/__init__.py | 0 .../shared/override/omni_message_override.py | 50 + .../v1/messages/shared/phone_number_info.py | 14 + .../models/v1/messages/shared/product_item.py | 27 + .../models/v1/messages/shared/reason.py | 23 + .../v1/messages/shared/reason_sub_code.py | 13 + .../models/v1/messages/shared/url_info.py | 12 + .../models/v1/messages/types/__init__.py | 55 + .../models/v1/messages/types/agent_type.py | 5 + .../v1/messages/types/card_height_type.py | 7 + .../types/channel_specific_message_type.py | 14 + .../types/conversation_channel_type.py | 21 + .../types/conversation_direction_type.py | 7 + .../v1/messages/types/messages_source_type.py | 7 + .../types/payment_order_goods_type.py | 7 + .../types/payment_order_status_type.py | 15 + .../v1/messages/types/payment_order_type.py | 7 + .../models/v1/messages/types/pix_key_type.py | 7 + .../v1/messages/types/processing_mode_type.py | 7 + .../v1/messages/types/reason_code_type.py | 36 + ...hatsapp_interactive_nfm_reply_name_type.py | 7 + .../conversation/models/webhook/__init__.py | 14 - .../conversation/models/webhook/requests.py | 39 - .../conversation/models/webhook/responses.py | 30 - .../api/v1/internal/base/numbers_endpoint.py | 2 +- .../sms/api/v1/internal/base/sms_endpoint.py | 2 +- .../e2e/conversation/features/environment.py | 6 + .../features/steps/conversation.steps.py | 107 ++ tests/e2e/shared_config.py | 1 + tests/unit/test_user_agent_header.py | 15 +- 248 files changed, 3488 insertions(+), 3087 deletions(-) rename sinch/domains/conversation/{endpoints => api}/__init__.py (100%) create mode 100644 sinch/domains/conversation/api/v1/__init__.py create mode 100644 sinch/domains/conversation/api/v1/base/__init__.py create mode 100644 sinch/domains/conversation/api/v1/base/base_conversation.py create mode 100644 sinch/domains/conversation/api/v1/exceptions.py create mode 100644 sinch/domains/conversation/api/v1/internal/__init__.py create mode 100644 sinch/domains/conversation/api/v1/internal/base/__init__.py create mode 100644 sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py create mode 100644 sinch/domains/conversation/api/v1/internal/messages_endpoints.py create mode 100644 sinch/domains/conversation/api/v1/messages_apis.py create mode 100644 sinch/domains/conversation/conversation.py delete mode 100644 sinch/domains/conversation/endpoints/app/create_app.py delete mode 100644 sinch/domains/conversation/endpoints/app/delete_app.py delete mode 100644 sinch/domains/conversation/endpoints/app/get_app.py delete mode 100644 sinch/domains/conversation/endpoints/app/list_apps.py delete mode 100644 sinch/domains/conversation/endpoints/app/update_app.py delete mode 100644 sinch/domains/conversation/endpoints/capability.py delete mode 100644 sinch/domains/conversation/endpoints/contact/create_contact.py delete mode 100644 sinch/domains/conversation/endpoints/contact/delete_contact.py delete mode 100644 sinch/domains/conversation/endpoints/contact/get_channel_profile.py delete mode 100644 sinch/domains/conversation/endpoints/contact/get_contact.py delete mode 100644 sinch/domains/conversation/endpoints/contact/list_contact.py delete mode 100644 sinch/domains/conversation/endpoints/contact/merge_contacts.py delete mode 100644 sinch/domains/conversation/endpoints/contact/update_contact.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/create_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/delete_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/get_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/list_conversations.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/stop_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation/update_conversation.py delete mode 100644 sinch/domains/conversation/endpoints/conversation_endpoint.py delete mode 100644 sinch/domains/conversation/endpoints/events.py delete mode 100644 sinch/domains/conversation/endpoints/message/delete_message.py delete mode 100644 sinch/domains/conversation/endpoints/message/get_message.py delete mode 100644 sinch/domains/conversation/endpoints/message/list_message.py delete mode 100644 sinch/domains/conversation/endpoints/message/send_message.py delete mode 100644 sinch/domains/conversation/endpoints/opt_in.py delete mode 100644 sinch/domains/conversation/endpoints/opt_out.py delete mode 100644 sinch/domains/conversation/endpoints/templates/create_template.py delete mode 100644 sinch/domains/conversation/endpoints/templates/delete_template.py delete mode 100644 sinch/domains/conversation/endpoints/templates/get_template.py delete mode 100644 sinch/domains/conversation/endpoints/templates/list_templates.py delete mode 100644 sinch/domains/conversation/endpoints/templates/update_template.py delete mode 100644 sinch/domains/conversation/endpoints/transcode.py delete mode 100644 sinch/domains/conversation/endpoints/webhooks/create_webhook.py delete mode 100644 sinch/domains/conversation/endpoints/webhooks/delete_webhook.py delete mode 100644 sinch/domains/conversation/endpoints/webhooks/get_webhook.py delete mode 100644 sinch/domains/conversation/endpoints/webhooks/list_webhooks.py delete mode 100644 sinch/domains/conversation/endpoints/webhooks/update_webhook.py delete mode 100644 sinch/domains/conversation/models/app/requests.py delete mode 100644 sinch/domains/conversation/models/app/responses.py delete mode 100644 sinch/domains/conversation/models/capability/requests.py delete mode 100644 sinch/domains/conversation/models/capability/responses.py delete mode 100644 sinch/domains/conversation/models/contact/requests.py delete mode 100644 sinch/domains/conversation/models/contact/responses.py delete mode 100644 sinch/domains/conversation/models/conversation/__init__.py delete mode 100644 sinch/domains/conversation/models/conversation/requests.py delete mode 100644 sinch/domains/conversation/models/conversation/responses.py delete mode 100644 sinch/domains/conversation/models/event/requests.py delete mode 100644 sinch/domains/conversation/models/event/responses.py delete mode 100644 sinch/domains/conversation/models/message/requests.py delete mode 100644 sinch/domains/conversation/models/message/responses.py delete mode 100644 sinch/domains/conversation/models/opt_in_opt_out/requests.py delete mode 100644 sinch/domains/conversation/models/opt_in_opt_out/responses.py delete mode 100644 sinch/domains/conversation/models/templates/__init__.py delete mode 100644 sinch/domains/conversation/models/templates/requests.py delete mode 100644 sinch/domains/conversation/models/templates/responses.py delete mode 100644 sinch/domains/conversation/models/transcoding/__init__.py delete mode 100644 sinch/domains/conversation/models/transcoding/requests.py delete mode 100644 sinch/domains/conversation/models/transcoding/responses.py rename sinch/domains/conversation/{endpoints/app => models/v1}/__init__.py (100%) rename sinch/domains/conversation/{endpoints/contact => models/v1/messages/categories}/__init__.py (100%) rename sinch/domains/conversation/{endpoints/conversation => models/v1/messages/categories/app}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/app/app_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/call/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/call/call_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/card/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/card/card_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py rename sinch/domains/conversation/{endpoints/message => models/v1/messages/categories/channelspecific}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py rename sinch/domains/conversation/{endpoints/templates => models/v1/messages/categories/channelspecific/kakaotalk}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py rename sinch/domains/conversation/{endpoints/webhooks => models/v1/messages/categories/channelspecific/whatsapp}/__init__.py (100%) rename sinch/domains/conversation/models/{app => v1/messages/categories/channelspecific/whatsapp/flows}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py rename sinch/domains/conversation/models/{capability => v1/messages/categories/channelspecific/whatsapp/nfmreply}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py rename sinch/domains/conversation/models/{contact => v1/messages/categories/channelspecific/whatsapp/payment}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/common/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py rename sinch/domains/conversation/models/{event => v1/messages/categories/contact}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/list_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/location/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/location/location_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/media/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/template/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/template/template_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/text/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/text/text_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/url/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/url/url_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/base/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py rename sinch/domains/conversation/models/{message => v1/messages/response}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/response/message_response.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/app_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/choice_option.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/contact_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/list_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/address_info.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/agent.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/channel_identity.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/choice_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/coordinates.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/email_info.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/list_section.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/name_info.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/organization_info.py rename sinch/domains/conversation/models/{opt_in_opt_out => v1/messages/shared/override}/__init__.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/product_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/reason.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py create mode 100644 sinch/domains/conversation/models/v1/messages/shared/url_info.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/agent_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/card_height_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/messages_source_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/payment_order_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/pix_key_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/reason_code_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py delete mode 100644 sinch/domains/conversation/models/webhook/__init__.py delete mode 100644 sinch/domains/conversation/models/webhook/requests.py delete mode 100644 sinch/domains/conversation/models/webhook/responses.py create mode 100644 tests/e2e/conversation/features/environment.py create mode 100644 tests/e2e/conversation/features/steps/conversation.steps.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3e56bda..bb2eba9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,8 @@ jobs: ruff format sinch/domains/sms --check --diff ruff check sinch/domains/number_lookup --statistics ruff format sinch/domains/number_lookup --check --diff + ruff check sinch/domains/conversation --statistics + ruff format sinch/domains/conversation --check --diff - name: Test with Pytest run: | @@ -84,6 +86,7 @@ jobs: cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ + cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/sinch/domains/conversation/__init__.py b/sinch/domains/conversation/__init__.py index 8b6035bc..ec48a5a3 100644 --- a/sinch/domains/conversation/__init__.py +++ b/sinch/domains/conversation/__init__.py @@ -1,993 +1,3 @@ -from typing import List +from sinch.domains.conversation.conversation import Conversation -from sinch.core.pagination import TokenBasedPaginator - -from sinch.domains.conversation.models import ( - SinchConversationChannelIdentities, - SinchConversationRecipient, - ConversationChannel -) - -from sinch.domains.conversation.models.app.requests import ( - CreateConversationAppRequest, - DeleteConversationAppRequest, - GetConversationAppRequest, - UpdateConversationAppRequest -) - -from sinch.domains.conversation.models.app.responses import ( - CreateConversationAppResponse, - DeleteConversationAppResponse, - ListConversationAppsResponse, - GetConversationAppResponse, - UpdateConversationAppResponse -) - -from sinch.domains.conversation.models.contact.requests import ( - CreateConversationContactRequest, - UpdateConversationContactRequest, - ListConversationContactRequest, - DeleteConversationContactRequest, - GetConversationContactRequest, - MergeConversationContactsRequest, - GetConversationChannelProfileRequest -) - -from sinch.domains.conversation.models.contact.responses import ( - UpdateConversationContactResponse, - ListConversationContactsResponse, - DeleteConversationContactResponse, - MergeConversationContactsResponse, - CreateConversationContactResponse, - GetConversationContactResponse, - GetConversationChannelProfileResponse -) - -from sinch.domains.conversation.models.message.requests import ( - SendConversationMessageRequest, - ListConversationMessagesRequest, - DeleteConversationMessageRequest, - GetConversationMessageRequest -) - -from sinch.domains.conversation.models.message.responses import ( - SendConversationMessageResponse, - ListConversationMessagesResponse, - GetConversationMessageResponse, - DeleteConversationMessageResponse -) - -from sinch.domains.conversation.models.conversation.requests import ( - CreateConversationRequest, - ListConversationsRequest, - GetConversationRequest, - DeleteConversationRequest, - UpdateConversationRequest, - StopConversationRequest, - InjectMessageToConversationRequest -) - -from sinch.domains.conversation.models.conversation.responses import ( - SinchCreateConversationResponse, - SinchUpdateConversationResponse, - SinchGetConversationResponse, - SinchDeleteConversationResponse, - SinchListConversationsResponse, - SinchStopConversationResponse, - SinchInjectMessageResponse -) - -from sinch.domains.conversation.models.webhook.requests import ( - CreateConversationWebhookRequest, - GetConversationWebhookRequest, - DeleteConversationWebhookRequest, - UpdateConversationWebhookRequest, - ListConversationWebhookRequest -) - -from sinch.domains.conversation.models.webhook.responses import ( - CreateWebhookResponse, - GetWebhookResponse, - SinchListWebhooksResponse, - SinchDeleteWebhookResponse, - UpdateWebhookResponse -) - -from sinch.domains.conversation.models.templates.requests import ( - CreateConversationTemplateRequest, - GetConversationTemplateRequest, - DeleteConversationTemplateRequest, - UpdateConversationTemplateRequest -) - -from sinch.domains.conversation.models.templates.responses import ( - CreateConversationTemplateResponse, - UpdateConversationTemplateResponse, - DeleteConversationTemplateResponse, - ListConversationTemplatesResponse, - GetConversationTemplateResponse -) - -from sinch.domains.conversation.models.event.requests import SendConversationEventRequest -from sinch.domains.conversation.models.event.responses import SendConversationEventResponse - -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptInRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptInResponse - -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptOutRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptOutResponse - -from sinch.domains.conversation.models.capability.requests import QueryConversationCapabilityRequest -from sinch.domains.conversation.models.capability.responses import QueryConversationCapabilityResponse - -from sinch.domains.conversation.models.transcoding.requests import TranscodeConversationMessageRequest -from sinch.domains.conversation.models.transcoding.responses import TranscodeConversationMessageResponse - -from sinch.domains.conversation.endpoints.message.send_message import SendConversationMessageEndpoint -from sinch.domains.conversation.endpoints.message.list_message import ListConversationMessagesEndpoint -from sinch.domains.conversation.endpoints.message.get_message import GetConversationMessageEndpoint -from sinch.domains.conversation.endpoints.message.delete_message import DeleteConversationMessageEndpoint -from sinch.domains.conversation.endpoints.contact.list_contact import ListContactsEndpoint -from sinch.domains.conversation.endpoints.contact.create_contact import CreateConversationContactEndpoint -from sinch.domains.conversation.endpoints.contact.get_contact import GetContactEndpoint -from sinch.domains.conversation.endpoints.contact.delete_contact import DeleteContactEndpoint -from sinch.domains.conversation.endpoints.contact.update_contact import UpdateConversationContactEndpoint -from sinch.domains.conversation.endpoints.contact.merge_contacts import MergeConversationContactsEndpoint -from sinch.domains.conversation.endpoints.contact.get_channel_profile import GetChannelProfileEndpoint -from sinch.domains.conversation.endpoints.app.create_app import CreateConversationAppEndpoint -from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint -from sinch.domains.conversation.endpoints.app.list_apps import ListAppsEndpoint -from sinch.domains.conversation.endpoints.app.get_app import GetAppEndpoint -from sinch.domains.conversation.endpoints.app.update_app import UpdateConversationAppEndpoint -from sinch.domains.conversation.endpoints.conversation.create_conversation import CreateConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.list_conversations import ListConversationsEndpoint -from sinch.domains.conversation.endpoints.conversation.get_conversation import GetConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.delete_conversation import DeleteConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.update_conversation import UpdateConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.stop_conversation import StopConversationEndpoint -from sinch.domains.conversation.endpoints.conversation.inject_message_to_conversation import ( - InjectMessageToConversationEndpoint -) -from sinch.domains.conversation.endpoints.webhooks.create_webhook import CreateWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.list_webhooks import ListWebhooksEndpoint -from sinch.domains.conversation.endpoints.webhooks.get_webhook import GetWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.delete_webhook import DeleteWebhookEndpoint -from sinch.domains.conversation.endpoints.webhooks.update_webhook import UpdateWebhookEndpoint -from sinch.domains.conversation.endpoints.templates.create_template import CreateTemplateEndpoint -from sinch.domains.conversation.endpoints.templates.list_templates import ListTemplatesEndpoint -from sinch.domains.conversation.endpoints.templates.get_template import GetTemplatesEndpoint -from sinch.domains.conversation.endpoints.templates.delete_template import DeleteTemplateEndpoint -from sinch.domains.conversation.endpoints.templates.update_template import UpdateTemplateEndpoint -from sinch.domains.conversation.endpoints.events import SendEventEndpoint -from sinch.domains.conversation.endpoints.transcode import TranscodeMessageEndpoint -from sinch.domains.conversation.endpoints.opt_in import RegisterOptInEndpoint -from sinch.domains.conversation.endpoints.opt_out import RegisterOptOutEndpoint -from sinch.domains.conversation.endpoints.capability import CapabilityQueryEndpoint - - -class ConversationMessage: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - app_id: str, - recipient: dict, - message: dict, - callback_url: str = None, - channel_priority_order: list = None, - channel_properties: dict = None, - message_metadata: str = None, - conversation_metadata: dict = None, - queue: str = None, - ttl: str = None, - processing_strategy: str = None - ) -> SendConversationMessageResponse: - return self._sinch.configuration.transport.request( - SendConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=SendConversationMessageRequest( - app_id=app_id, - recipient=recipient, - message=message, - callback_url=callback_url, - channel_priority_order=channel_priority_order, - channel_properties=channel_properties, - message_metadata=message_metadata, - conversation_metadata=conversation_metadata, - queue=queue, - ttl=ttl, - processing_strategy=processing_strategy - ) - ) - ) - - def get( - self, - message_id: str, - messages_source: str = None - ) -> GetConversationMessageResponse: - return self._sinch.configuration.transport.request( - GetConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationMessageRequest( - message_id=message_id, - messages_source=messages_source - ) - ) - ) - - def delete( - self, - message_id: str, - messages_source: str = None - ) -> DeleteConversationMessageResponse: - return self._sinch.configuration.transport.request( - DeleteConversationMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationMessageRequest( - message_id=message_id, - messages_source=messages_source - ) - ) - ) - - def list( - self, - conversation_id: str = None, - contact_id: str = None, - app_id: str = None, - page_size: int = None, - page_token: str = None, - view: str = None, - messages_source: str = None, - only_recipient_originated: bool = None - ) -> ListConversationMessagesResponse: - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationMessagesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationMessagesRequest( - contact_id=contact_id, - conversation_id=conversation_id, - app_id=app_id, - page_size=page_size, - page_token=page_token, - view=view, - messages_source=messages_source, - only_recipient_originated=only_recipient_originated - ) - ) - ) - - -class ConversationApp: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - display_name: str, - channel_credentials: list, - conversation_metadata_report_view: str = None, - retention_policy: dict = None, - dispatch_retention_policy: dict = None, - processing_mode: str = None - ) -> CreateConversationAppResponse: - """ - Creates a new Conversation API app with one or more configured channels. - The ID of the app is generated at creation and is returned in the response. - https://developers.sinch.com/docs/conversation/api-reference/conversation/tag/App/#tag/App/operation/App_CreateApp - """ - return self._sinch.configuration.transport.request( - CreateConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationAppRequest( - display_name=display_name, - channel_credentials=channel_credentials, - conversation_metadata_report_view=conversation_metadata_report_view, - retention_policy=retention_policy, - dispatch_retention_policy=dispatch_retention_policy, - processing_mode=processing_mode - ) - ) - ) - - def delete(self, app_id: str) -> DeleteConversationAppResponse: - """ - Deletes the app identified by the app_id. - """ - return self._sinch.configuration.transport.request( - DeleteConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationAppRequest(app_id) - ) - ) - - def list(self) -> ListConversationAppsResponse: - """ - Lists all apps for the project identified by the project_id. - Returns the information as an array of app objects in the response. - """ - return self._sinch.configuration.transport.request( - ListAppsEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def get(self, app_id: str) -> GetConversationAppResponse: - """ - Returns the configuration information of the app, specified by the app_id, in the response. - """ - return self._sinch.configuration.transport.request( - GetAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationAppRequest( - app_id=app_id - ) - ) - ) - - def update( - self, - app_id: str, - display_name: str, - channel_credentials: list = None, - update_mask=None, - conversation_metadata_report_view=None, - retention_policy=None, - dispatch_retention_policy=None, - processing_mode=None - ) -> UpdateConversationAppResponse: - """ - Updates an existing Conversation API app with new configuration options defined in the request. - The details of the updated app are returned in the response. - """ - return self._sinch.configuration.transport.request( - UpdateConversationAppEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationAppRequest( - app_id=app_id, - display_name=display_name, - channel_credentials=channel_credentials, - update_mask=update_mask, - conversation_metadata_report_view=conversation_metadata_report_view, - retention_policy=retention_policy, - dispatch_retention_policy=dispatch_retention_policy, - processing_mode=processing_mode - ) - ) - ) - - -class ConversationContact: - def __init__(self, sinch): - self._sinch = sinch - - def update( - self, - contact_id: str, - channel_identities: List[SinchConversationChannelIdentities] = None, - language: str = None, - display_name: str = None, - email: str = None, - external_id: str = None, - metadata: str = None, - channel_priority: list = None - ) -> UpdateConversationContactResponse: - """ - Updates an existing Conversation API contact with new configuration options defined in the request. - The details of the updated contact are returned in the response. - """ - return self._sinch.configuration.transport.request( - UpdateConversationContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationContactRequest( - channel_identities=channel_identities, - language=language, - display_name=display_name, - email=email, - external_id=external_id, - metadata=metadata, - channel_priority=channel_priority, - id=contact_id - ) - ) - ) - - def create( - self, - channel_identities: List[SinchConversationChannelIdentities], - language: str, - display_name: str = None, - email: str = None, - external_id: str = None, - metadata: str = None, - channel_priority: list = None - ) -> CreateConversationContactResponse: - """ - Creates a new Conversation API contact. - The ID of the contact is generated at creation and is returned in the response. - """ - return self._sinch.configuration.transport.request( - CreateConversationContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationContactRequest( - channel_identities=channel_identities, - language=language, - display_name=display_name, - email=email, - external_id=external_id, - metadata=metadata, - channel_priority=channel_priority - ) - ) - ) - - def delete(self, contact_id: str) -> DeleteConversationContactResponse: - """ - Deletes the Conversation API contact identified by the contact_id. - """ - return self._sinch.configuration.transport.request( - DeleteContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationContactRequest( - contact_id=contact_id - ) - ) - ) - - def get(self, contact_id: str) -> GetConversationContactResponse: - """ - Returns the configuration information of the - Conversation API contact, specified by the contact_id, in the response. - """ - return self._sinch.configuration.transport.request( - GetContactEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationContactRequest( - contact_id=contact_id - ) - ) - ) - - def list( - self, - page_size: int = None, - page_token: str = None, - external_id: str = None, - channel: str = None, - identity: str = None - ) -> ListConversationContactsResponse: - """ - Lists all Conversation API contacts for the project identified by the project_id. - Returns the information as an array of contact objects in the response. - """ - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationContactRequest( - page_size=page_size, - page_token=page_token, - external_id=external_id, - channel=channel, - identity=identity - ) - ) - ) - - def merge( - self, - source_id: str, - destination_id: str, - strategy: str = None - ) -> MergeConversationContactsResponse: - """ - Merges two existing Conversation API contacts. - The contact specified by the destination_id will be kept. - The contact specified by the source_id will be deleted. - All conversations from source contact are merged into destination. - Channel identities and optional fields from source contact are only - merged if corresponding entries do not exist in destination. - """ - return self._sinch.configuration.transport.request( - MergeConversationContactsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=MergeConversationContactsRequest( - destination_id=destination_id, - strategy=strategy, - source_id=source_id - ) - ) - ) - - def get_channel_profile( - self, - app_id: str, - recipient: SinchConversationRecipient, - channel: ConversationChannel, - ) -> GetConversationChannelProfileResponse: - """ - Returns the user profile information for the specified recipient on the specified channel. - This request is not supported for all Conversation API channels. - """ - return self._sinch.configuration.transport.request( - GetChannelProfileEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationChannelProfileRequest( - app_id=app_id, - recipient=recipient, - channel=channel - ) - ) - ) - - -class ConversationEvent: - def __init__(self, sinch): - self._sinch = sinch - - def send( - self, - app_id: str, - recipient: dict, - event: dict, - callback_url: str = None, - channel_priority_order: str = None, - event_metadata: str = None, - queue: str = None - ) -> SendConversationEventResponse: - return self._sinch.configuration.transport.request( - SendEventEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=SendConversationEventRequest( - app_id=app_id, - recipient=recipient, - event=event, - callback_url=callback_url, - channel_priority_order=channel_priority_order, - event_metadata=event_metadata, - queue=queue - ) - ) - ) - - -class ConversationTranscoding: - def __init__(self, sinch): - self._sinch = sinch - - def transcode_message( - self, - app_id: str, - app_message: dict, - channels: list, - from_: str = None, - to: str = None - ) -> TranscodeConversationMessageResponse: - return self._sinch.configuration.transport.request( - TranscodeMessageEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=TranscodeConversationMessageRequest( - app_id=app_id, - app_message=app_message, - channels=channels, - from_=from_, - to=to - ) - ) - ) - - -class ConversationOptIn: - def __init__(self, sinch): - self._sinch = sinch - - def register( - self, - app_id: str, - channels: list, - recipient: dict, - request_id: str = None, - processing_strategy: str = None - ) -> RegisterConversationOptInResponse: - return self._sinch.configuration.transport.request( - RegisterOptInEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RegisterConversationOptInRequest( - app_id=app_id, - recipient=recipient, - channels=channels, - request_id=request_id, - processing_strategy=processing_strategy - ) - ) - ) - - -class ConversationOptOut: - def __init__(self, sinch): - self._sinch = sinch - - def register( - self, - app_id: str, - channels: list, - recipient: dict, - request_id: str = None, - processing_strategy: str = None - ) -> RegisterConversationOptOutResponse: - return self._sinch.configuration.transport.request( - RegisterOptOutEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=RegisterConversationOptOutRequest( - app_id=app_id, - recipient=recipient, - channels=channels, - request_id=request_id, - processing_strategy=processing_strategy - ) - ) - ) - - -class ConversationCapability: - def __init__(self, sinch): - self._sinch = sinch - - def query( - self, - app_id: str, - recipient: dict, - request_id: str = None - ) -> QueryConversationCapabilityResponse: - return self._sinch.configuration.transport.request( - CapabilityQueryEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=QueryConversationCapabilityRequest( - app_id=app_id, - recipient=recipient, - request_id=request_id - ) - ) - ) - - -class ConversationTemplate: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - translations: list, - default_translation: str, - channel: str = None, - create_time: str = None, - description: str = None, - id: str = None, - update_time: str = None - ) -> CreateConversationTemplateResponse: - return self._sinch.configuration.transport.request( - CreateTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationTemplateRequest( - channel=channel, - create_time=create_time, - description=description, - id=id, - translations=translations, - default_translation=default_translation, - update_time=update_time - ) - ) - ) - - def list(self) -> ListConversationTemplatesResponse: - return self._sinch.configuration.transport.request( - ListTemplatesEndpoint( - project_id=self._sinch.configuration.project_id - ) - ) - - def get(self, template_id: str) -> GetConversationTemplateResponse: - return self._sinch.configuration.transport.request( - GetTemplatesEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationTemplateRequest( - template_id=template_id - ) - ) - ) - - def update( - self, - template_id: str, - translations: list, - default_translation: str, - id: str = None, - update_mask: str = None, - channel: str = None, - create_time: str = None, - description: str = None, - update_time: str = None - ) -> UpdateConversationTemplateResponse: - return self._sinch.configuration.transport.request( - UpdateTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationTemplateRequest( - channel=channel, - create_time=create_time, - description=description, - id=id, - translations=translations, - default_translation=default_translation, - update_time=update_time, - update_mask=update_mask, - template_id=template_id - ) - ) - ) - - def delete(self, template_id: str) -> DeleteConversationTemplateResponse: - return self._sinch.configuration.transport.request( - DeleteTemplateEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationTemplateRequest( - template_id=template_id - ) - ) - ) - - -class ConversationWebhook: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - app_id: str, - target: str, - triggers: list, - client_credentials: dict = None, - secret: str = None, - target_type: str = None - ) -> CreateWebhookResponse: - return self._sinch.configuration.transport.request( - CreateWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationWebhookRequest( - app_id=app_id, - target=target, - triggers=triggers, - client_credentials=client_credentials, - secret=secret, - target_type=target_type - ) - ) - ) - - def update( - self, - webhook_id: str, - app_id: str, - target: str, - triggers: list, - update_mask: str = None, - client_credentials: dict = None, - secret: str = None, - target_type: str = None - ) -> UpdateWebhookResponse: - return self._sinch.configuration.transport.request( - UpdateWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationWebhookRequest( - app_id=app_id, - target=target, - triggers=triggers, - client_credentials=client_credentials, - secret=secret, - target_type=target_type, - update_mask=update_mask, - webhook_id=webhook_id - ) - ) - ) - - def list(self, app_id: str) -> SinchListWebhooksResponse: - return self._sinch.configuration.transport.request( - ListWebhooksEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationWebhookRequest( - app_id=app_id - ) - ) - ) - - def get(self, webhook_id: str) -> GetWebhookResponse: - return self._sinch.configuration.transport.request( - GetWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationWebhookRequest( - webhook_id=webhook_id - ) - ) - ) - - def delete(self, webhook_id: str) -> SinchDeleteWebhookResponse: - return self._sinch.configuration.transport.request( - DeleteWebhookEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationWebhookRequest( - webhook_id=webhook_id - ) - ) - ) - - -class ConversationConversation: - def __init__(self, sinch): - self._sinch = sinch - - def create( - self, - id: str = None, - metadata: str = None, - conversation_metadata: dict = None, - contact_id: str = None, - app_id: str = None, - active_channel: str = None, - active: bool = None, - ) -> SinchCreateConversationResponse: - return self._sinch.configuration.transport.request( - CreateConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=CreateConversationRequest( - app_id=app_id, - contact_id=contact_id, - id=id, - metadata=metadata, - conversation_metadata=conversation_metadata, - active_channel=active_channel, - active=active - ) - ) - ) - - def list( - self, - only_active: bool, - page_size: int = None, - page_token: str = None, - app_id: str = None, - contact_id: str = None - ) -> SinchListConversationsResponse: - return TokenBasedPaginator._initialize( - sinch=self._sinch, - endpoint=ListConversationsEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=ListConversationsRequest( - only_active=only_active, - page_size=page_size, - page_token=page_token, - app_id=app_id, - contact_id=contact_id - ) - ) - ) - - def get(self, conversation_id: str) -> SinchGetConversationResponse: - return self._sinch.configuration.transport.request( - GetConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=GetConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def delete(self, conversation_id: str) -> SinchDeleteConversationResponse: - return self._sinch.configuration.transport.request( - DeleteConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=DeleteConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def update( - self, - conversation_id: str, - update_mask: str = None, - metadata_update_strategy: str = None, - metadata: str = None, - conversation_metadata: dict = None, - contact_id: str = None, - app_id: str = None, - active_channel: str = None, - active: bool = None - ) -> SinchUpdateConversationResponse: - return self._sinch.configuration.transport.request( - UpdateConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=UpdateConversationRequest( - app_id=app_id, - contact_id=contact_id, - conversation_id=conversation_id, - metadata=metadata, - conversation_metadata=conversation_metadata, - active_channel=active_channel, - active=active, - metadata_update_strategy=metadata_update_strategy, - update_mask=update_mask - ) - ) - ) - - def stop(self, conversation_id: str) -> SinchStopConversationResponse: - return self._sinch.configuration.transport.request( - StopConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=StopConversationRequest( - conversation_id=conversation_id - ) - ) - ) - - def inject_message_to_conversation( - self, - conversation_id: str, - accept_time: str = None, - app_message: dict = None, - channel_identity: dict = None, - contact_id: str = None, - contact_message: dict = None, - direction: str = None, - metadata: str = None - ) -> SinchInjectMessageResponse: - return self._sinch.configuration.transport.request( - InjectMessageToConversationEndpoint( - project_id=self._sinch.configuration.project_id, - request_data=InjectMessageToConversationRequest( - conversation_id=conversation_id, - accept_time=accept_time, - app_message=app_message, - channel_identity=channel_identity, - contact_id=contact_id, - contact_message=contact_message, - direction=direction, - metadata=metadata - ) - ) - ) - - -class ConversationBase: - """ - Documentation for the Conversation API: https://developers.sinch.com/docs/conversation/ - """ - - def __init__(self, sinch): - self._sinch = sinch - - -class Conversation(ConversationBase): - """ - Synchronous version of the Conversation Domain - """ - __doc__ += ConversationBase.__doc__ - - def __init__(self, sinch): - super(Conversation, self).__init__(sinch) - self.message = ConversationMessage(self._sinch) - self.app = ConversationApp(self._sinch) - self.contact = ConversationContact(self._sinch) - self.event = ConversationEvent(self._sinch) - self.transcoding = ConversationTranscoding(self._sinch) - self.opt_in = ConversationOptIn(self._sinch) - self.opt_out = ConversationOptOut(self._sinch) - self.capability = ConversationCapability(self._sinch) - self.template = ConversationTemplate(self._sinch) - self.webhook = ConversationWebhook(self._sinch) - self.conversation = ConversationConversation(self._sinch) +__all__ = ["Conversation"] diff --git a/sinch/domains/conversation/endpoints/__init__.py b/sinch/domains/conversation/api/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/__init__.py rename to sinch/domains/conversation/api/__init__.py diff --git a/sinch/domains/conversation/api/v1/__init__.py b/sinch/domains/conversation/api/v1/__init__.py new file mode 100644 index 00000000..55948540 --- /dev/null +++ b/sinch/domains/conversation/api/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.messages_apis import Messages + +__all__ = [ + "Messages", +] diff --git a/sinch/domains/conversation/api/v1/base/__init__.py b/sinch/domains/conversation/api/v1/base/__init__.py new file mode 100644 index 00000000..5fdfb440 --- /dev/null +++ b/sinch/domains/conversation/api/v1/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.base.base_conversation import ( + BaseConversation, +) + +__all__ = ["BaseConversation"] diff --git a/sinch/domains/conversation/api/v1/base/base_conversation.py b/sinch/domains/conversation/api/v1/base/base_conversation.py new file mode 100644 index 00000000..d194a5a3 --- /dev/null +++ b/sinch/domains/conversation/api/v1/base/base_conversation.py @@ -0,0 +1,23 @@ +class BaseConversation: + """Base class for handling Sinch Conversation operations.""" + + def __init__(self, sinch): + self._sinch = sinch + + def _request(self, endpoint_class, request_data): + """ + A helper method to make requests to endpoints. + + Args: + endpoint_class: The endpoint class to call. + request_data: The request data to pass to the endpoint. + + Returns: + The response from the Sinch transport request. + """ + return self._sinch.configuration.transport.request( + endpoint_class( + project_id=self._sinch.configuration.project_id, + request_data=request_data, + ) + ) diff --git a/sinch/domains/conversation/api/v1/exceptions.py b/sinch/domains/conversation/api/v1/exceptions.py new file mode 100644 index 00000000..08310e9a --- /dev/null +++ b/sinch/domains/conversation/api/v1/exceptions.py @@ -0,0 +1,5 @@ +from sinch.core.exceptions import SinchException + + +class ConversationException(SinchException): + pass diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py new file mode 100644 index 00000000..4d862310 --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.api.v1.internal.messages_endpoints import ( + DeleteMessageEndpoint, + GetMessageEndpoint, + UpdateMessageMetadataEndpoint, +) + +__all__ = [ + "DeleteMessageEndpoint", + "GetMessageEndpoint", + "UpdateMessageMetadataEndpoint", +] diff --git a/sinch/domains/conversation/api/v1/internal/base/__init__.py b/sinch/domains/conversation/api/v1/internal/base/__init__.py new file mode 100644 index 00000000..bb2a6da4 --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/base/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.api.v1.internal.base.conversation_endpoint import ( + ConversationEndpoint, +) + +__all__ = ["ConversationEndpoint"] diff --git a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py new file mode 100644 index 00000000..bf5aaf6b --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py @@ -0,0 +1,114 @@ +import re +from abc import ABC +from typing import Type, Union, get_origin, get_args +from sinch.core.models.http_response import HTTPResponse +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.types import BM +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +class ConversationEndpoint(HTTPEndpoint, ABC): + def __init__(self, project_id: str, request_data: BM): + super().__init__(project_id, request_data) + + def build_url(self, sinch) -> str: + if not self.ENDPOINT_URL: + raise NotImplementedError( + f"ENDPOINT_URL must be defined in the Conversation endpoint subclass " + f"'{self.__class__.__name__}'." + ) + + # TODO: Add support and validation for conversation_region in SinchClient initialization; + + return self.ENDPOINT_URL.format( + origin=sinch.configuration.conversation_origin, + project_id=self.project_id, + **vars(self.request_data), + ) + + def _get_path_params_from_url(self) -> set: + """ + Extracts path parameters from ENDPOINT_URL template. + + Returns: + set: Set of path parameter names that should be excluded from request body and query params. + """ + if not self.ENDPOINT_URL: + return set() + + # Extract all placeholders from the URL template (e.g., {message_id}, {project_id}) + path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL)) + + # Exclude 'origin' and 'project_id' as they are always path params but not from request_data + path_params.discard("origin") + path_params.discard("project_id") + + return path_params + + def build_query_params(self) -> dict: + """ + Constructs the query parameters for the endpoint. + + Returns: + dict: The query parameters to be sent with the API request. + """ + return {} + + def request_body(self) -> str: + """ + Returns the request body as a JSON string. + + Returns: + str: The request body as a JSON string. + """ + return "" + + def process_response_model( + self, response_body: dict, response_model: Type[BM] + ) -> BM: + """ + Processes the response body and maps it to a response model. + + Args: + response_body (dict): The raw response body. + response_model (type): The Pydantic model class or Union type to map the response. + + Returns: + Parsed response object. + """ + try: + origin = get_origin(response_model) + # Check if response_model is a Union type + if origin is Union: + # For Union types, try to validate against each type in the Union sequentially + # This handles cases where TypeAdapter might not be fully defined + union_args = get_args(response_model) + last_error = None + + # Try each type in the Union until one succeeds + for union_type in union_args: + try: + return union_type.model_validate(response_body) + except Exception as e: + last_error = e + continue + + # If all Union types failed, raise an error with the last error details + if last_error is not None: + raise ValueError( + f"Invalid response structure: None of the Union types matched. " + f"Last error: {last_error}" + ) from last_error + + # Use standard model_validate for regular Pydantic models + return response_model.model_validate(response_body) + except Exception as e: + raise ValueError(f"Invalid response structure: {e}") from e + + def handle_response(self, response: HTTPResponse): + if response.status_code >= 400: + raise ConversationException( + message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}", + response=response, + is_from_server=True, + ) diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py new file mode 100644 index 00000000..2027f955 --- /dev/null +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -0,0 +1,126 @@ +import json +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.models.v1.messages.internal.request import ( + MessageIdRequest, + UpdateMessageMetadataRequest, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.api.v1.internal.base import ( + ConversationEndpoint, +) +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +class MessageEndpoint(ConversationEndpoint): + """ + Base class for message-related endpoints that share common query parameter handling. + """ + + QUERY_PARAM_FIELDS = {"messages_source"} + BODY_PARAM_FIELDS = set() + + def build_query_params(self) -> dict: + path_params = self._get_path_params_from_url() + exclude_set = path_params.union(self.BODY_PARAM_FIELDS) + query_params = self.request_data.model_dump( + include=self.QUERY_PARAM_FIELDS, + exclude_none=True, + by_alias=True, + exclude=exclude_set, + ) + return query_params + + +class DeleteMessageEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: MessageIdRequest): + super(DeleteMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse): + try: + super(DeleteMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + + +class GetMessageEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: MessageIdRequest): + super(GetMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response( + self, response: HTTPResponse + ) -> ConversationMessageResponse: + try: + super(GetMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ConversationMessageResponse + ) + + +class UpdateMessageMetadataEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" + HTTP_METHOD = HTTPMethods.PATCH.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + BODY_PARAM_FIELDS = {"metadata"} + + def __init__( + self, project_id: str, request_data: UpdateMessageMetadataRequest + ): + super(UpdateMessageMetadataEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + exclude_set = path_params.union(self.QUERY_PARAM_FIELDS) + request_data = self.request_data.model_dump( + include=self.BODY_PARAM_FIELDS, + by_alias=True, + exclude_none=True, + exclude=exclude_set, + ) + return json.dumps(request_data) + + def handle_response( + self, response: HTTPResponse + ) -> ConversationMessageResponse: + try: + super(UpdateMessageMetadataEndpoint, self).handle_response( + response + ) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model( + response.body, ConversationMessageResponse + ) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py new file mode 100644 index 00000000..41e3c3fc --- /dev/null +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -0,0 +1,116 @@ +from typing import Optional + +from sinch.domains.conversation.models.v1.messages.internal.request import ( + MessageIdRequest, + UpdateMessageMetadataRequest, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.types import ( + MessagesSourceType, +) +from sinch.domains.conversation.api.v1.internal import ( + DeleteMessageEndpoint, + GetMessageEndpoint, + UpdateMessageMetadataEndpoint, +) +from sinch.domains.conversation.api.v1.base import BaseConversation + + +class Messages(BaseConversation): + def delete( + self, + message_id: str, + messages_source: Optional[MessagesSourceType] = None, + **kwargs, + ) -> None: + """ + Delete a specific message by its ID. Note that this operation deletes the message from Conversation API storage; + this operation does not affect messages already delivered to recipients' handsets. Also note that removing all + messages of a conversation will not automatically delete the + conversation. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessagesSource] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = MessageIdRequest( + message_id=message_id, messages_source=messages_source, **kwargs + ) + return self._request(DeleteMessageEndpoint, request_data) + + def get( + self, + message_id: str, + messages_source: Optional[MessagesSourceType] = None, + **kwargs, + ) -> ConversationMessageResponse: + """ + Retrieves a specific message by its ID. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessagesSource] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: ConversationMessageResponse + :rtype: ConversationMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = MessageIdRequest( + message_id=message_id, messages_source=messages_source, **kwargs + ) + return self._request(GetMessageEndpoint, request_data) + + def update( + self, + message_id: str, + metadata: str, + messages_source: Optional[MessagesSourceType] = None, + **kwargs, + ) -> ConversationMessageResponse: + """ + Update a specific message metadata by its ID. + + :param message_id: The unique ID of the message. (required) + :type message_id: str + :param metadata: Metadata that should be associated with the message. (required) + :type metadata: str + :param messages_source: Specifies the message source for which the request will be processed. Used for + operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, + see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). + (optional) + :type messages_source: Optional[MessagesSource] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: ConversationMessageResponse + :rtype: ConversationMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + request_data = UpdateMessageMetadataRequest( + message_id=message_id, + metadata=metadata, + messages_source=messages_source, + **kwargs, + ) + return self._request(UpdateMessageMetadataEndpoint, request_data) diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py new file mode 100644 index 00000000..91599d8b --- /dev/null +++ b/sinch/domains/conversation/conversation.py @@ -0,0 +1,14 @@ +from sinch.domains.conversation.api.v1 import ( + Messages, +) + + +class Conversation: + """ + Documentation for Sinch Conversation is found at + https://developers.sinch.com/docs/conversation/. + """ + + def __init__(self, sinch): + self._sinch = sinch + self.messages = Messages(self._sinch) diff --git a/sinch/domains/conversation/endpoints/app/create_app.py b/sinch/domains/conversation/endpoints/app/create_app.py deleted file mode 100644 index 7e839141..00000000 --- a/sinch/domains/conversation/endpoints/app/create_app.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import CreateConversationAppResponse -from sinch.domains.conversation.models.app.requests import CreateConversationAppRequest - - -class CreateConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationAppRequest): - super(CreateConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationAppResponse: - super(CreateConversationAppEndpoint, self).handle_response(response) - return CreateConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/app/delete_app.py b/sinch/domains/conversation/endpoints/app/delete_app.py deleted file mode 100644 index 09c1933c..00000000 --- a/sinch/domains/conversation/endpoints/app/delete_app.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import DeleteConversationAppResponse -from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest - - -class DeleteConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationAppRequest): - super(DeleteConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationAppResponse: - super(DeleteConversationAppEndpoint, self).handle_response(response) - return DeleteConversationAppResponse() diff --git a/sinch/domains/conversation/endpoints/app/get_app.py b/sinch/domains/conversation/endpoints/app/get_app.py deleted file mode 100644 index d0ff1232..00000000 --- a/sinch/domains/conversation/endpoints/app/get_app.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import GetConversationAppResponse -from sinch.domains.conversation.models.app.requests import GetConversationAppRequest - - -class GetAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationAppRequest): - super(GetAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationAppResponse: - super(GetAppEndpoint, self).handle_response(response) - return GetConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/app/list_apps.py b/sinch/domains/conversation/endpoints/app/list_apps.py deleted file mode 100644 index 5dbe5139..00000000 --- a/sinch/domains/conversation/endpoints/app/list_apps.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import ListConversationAppsResponse -from sinch.domains.conversation.models import SinchConversationApp - - -class ListAppsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str): - super(ListAppsEndpoint, self).__init__(project_id, request_data=None) - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> ListConversationAppsResponse: - super(ListAppsEndpoint, self).handle_response(response) - return ListConversationAppsResponse( - apps=[ - SinchConversationApp( - id=contact["id"], - channel_credentials=contact["channel_credentials"], - processing_mode=contact["processing_mode"], - conversation_metadata_report_view=contact["conversation_metadata_report_view"], - display_name=contact["display_name"], - rate_limits=contact["rate_limits"], - retention_policy=contact["retention_policy"], - dispatch_retention_policy=contact["dispatch_retention_policy"], - smart_conversation=contact["smart_conversation"] - ) for contact in response.body["apps"] - ] - ) diff --git a/sinch/domains/conversation/endpoints/app/update_app.py b/sinch/domains/conversation/endpoints/app/update_app.py deleted file mode 100644 index 9cb7e11a..00000000 --- a/sinch/domains/conversation/endpoints/app/update_app.py +++ /dev/null @@ -1,46 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.app.responses import UpdateConversationAppResponse -from sinch.domains.conversation.models.app.requests import UpdateConversationAppRequest - - -class UpdateConversationAppEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationAppRequest): - super(UpdateConversationAppEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def build_query_params(self): - if self.request_data.update_mask: - return {"update_mask.paths": self.request_data.update_mask} - - def request_body(self): - self.request_data.update_mask = None - self.request_data.app_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationAppResponse: - super(UpdateConversationAppEndpoint, self).handle_response(response) - return UpdateConversationAppResponse( - id=response.body["id"], - channel_credentials=response.body["channel_credentials"], - processing_mode=response.body["processing_mode"], - conversation_metadata_report_view=response.body["conversation_metadata_report_view"], - display_name=response.body["display_name"], - rate_limits=response.body["rate_limits"], - retention_policy=response.body["retention_policy"], - dispatch_retention_policy=response.body["dispatch_retention_policy"], - smart_conversation=response.body["smart_conversation"] - ) diff --git a/sinch/domains/conversation/endpoints/capability.py b/sinch/domains/conversation/endpoints/capability.py deleted file mode 100644 index 46fab13c..00000000 --- a/sinch/domains/conversation/endpoints/capability.py +++ /dev/null @@ -1,33 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.capability.requests import QueryConversationCapabilityRequest -from sinch.domains.conversation.models.capability.responses import QueryConversationCapabilityResponse - - -class CapabilityQueryEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/capability:query" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: QueryConversationCapabilityRequest): - super(CapabilityQueryEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> QueryConversationCapabilityResponse: - super(CapabilityQueryEndpoint, self).handle_response(response) - return QueryConversationCapabilityResponse( - request_id=response.body["request_id"], - app_id=response.body["app_id"], - recipient=response.body["recipient"], - ) diff --git a/sinch/domains/conversation/endpoints/contact/create_contact.py b/sinch/domains/conversation/endpoints/contact/create_contact.py deleted file mode 100644 index c26d6f3f..00000000 --- a/sinch/domains/conversation/endpoints/contact/create_contact.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import CreateConversationContactResponse -from sinch.domains.conversation.models.contact.requests import CreateConversationContactRequest - - -class CreateConversationContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationContactRequest): - super(CreateConversationContactEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationContactResponse: - super(CreateConversationContactEndpoint, self).handle_response(response) - return CreateConversationContactResponse( - id=response.body["id"], - channel_identities=response.body["channel_identities"], - channel_priority=response.body["channel_priority"], - display_name=response.body["display_name"], - email=response.body["email"], - external_id=response.body["external_id"], - metadata=response.body["metadata"], - language=response.body["language"] - ) diff --git a/sinch/domains/conversation/endpoints/contact/delete_contact.py b/sinch/domains/conversation/endpoints/contact/delete_contact.py deleted file mode 100644 index 138dd4bf..00000000 --- a/sinch/domains/conversation/endpoints/contact/delete_contact.py +++ /dev/null @@ -1,26 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import DeleteConversationContactResponse - - -class DeleteContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{contact_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(DeleteContactEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - contact_id=self.request_data.contact_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationContactResponse: - super(DeleteContactEndpoint, self).handle_response(response) - return DeleteConversationContactResponse() diff --git a/sinch/domains/conversation/endpoints/contact/get_channel_profile.py b/sinch/domains/conversation/endpoints/contact/get_channel_profile.py deleted file mode 100644 index e655b42d..00000000 --- a/sinch/domains/conversation/endpoints/contact/get_channel_profile.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import GetConversationChannelProfileRequest -from sinch.domains.conversation.models.contact.responses import GetConversationChannelProfileResponse - - -class GetChannelProfileEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts:getChannelProfile" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationChannelProfileRequest): - super(GetChannelProfileEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> GetConversationChannelProfileResponse: - super(GetChannelProfileEndpoint, self).handle_response(response) - return GetConversationChannelProfileResponse( - profile_name=response.body.get("profile_name") - ) diff --git a/sinch/domains/conversation/endpoints/contact/get_contact.py b/sinch/domains/conversation/endpoints/contact/get_contact.py deleted file mode 100644 index 6d2d915f..00000000 --- a/sinch/domains/conversation/endpoints/contact/get_contact.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import GetConversationContactRequest -from sinch.domains.conversation.models.contact.responses import GetConversationContactResponse - - -class GetContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{contact_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data: GetConversationContactRequest): - super(GetContactEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - contact_id=self.request_data.contact_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationContactResponse: - super(GetContactEndpoint, self).handle_response(response) - return GetConversationContactResponse( - id=response.body["id"], - channel_identities=response.body["channel_identities"], - channel_priority=response.body["channel_priority"], - display_name=response.body["display_name"], - email=response.body["email"], - external_id=response.body["external_id"], - metadata=response.body["metadata"], - language=response.body["language"] - ) diff --git a/sinch/domains/conversation/endpoints/contact/list_contact.py b/sinch/domains/conversation/endpoints/contact/list_contact.py deleted file mode 100644 index b70c6e7f..00000000 --- a/sinch/domains/conversation/endpoints/contact/list_contact.py +++ /dev/null @@ -1,48 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import ListConversationContactsResponse -from sinch.domains.conversation.models import SinchConversationContact - - -class ListContactsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(ListContactsEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - params = {} - if self.request_data.page_size: - params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - params["page_token"] = self.request_data.page_token - - return params - - def handle_response(self, response: HTTPResponse) -> ListConversationContactsResponse: - super(ListContactsEndpoint, self).handle_response(response) - return ListConversationContactsResponse( - contacts=[SinchConversationContact( - id=contact["id"], - channel_identities=contact["channel_identities"], - channel_priority=contact["channel_priority"], - display_name=contact["display_name"], - email=contact["email"], - external_id=contact["external_id"], - metadata=contact["metadata"], - language=contact["language"] - ) for contact in response.body["contacts"]], - next_page_token=response.body.get("next_page_token") - ) diff --git a/sinch/domains/conversation/endpoints/contact/merge_contacts.py b/sinch/domains/conversation/endpoints/contact/merge_contacts.py deleted file mode 100644 index 28780076..00000000 --- a/sinch/domains/conversation/endpoints/contact/merge_contacts.py +++ /dev/null @@ -1,34 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.responses import MergeConversationContactsResponse - - -class MergeConversationContactsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts/{destination_id}:merge" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id, request_data): - super(MergeConversationContactsEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - destination_id=self.request_data.destination_id - ) - - def request_body(self): - return json.dumps({ - "source_id": self.request_data.source_id - }) - - def handle_response(self, response: HTTPResponse) -> MergeConversationContactsResponse: - super(MergeConversationContactsEndpoint, self).handle_response(response) - return MergeConversationContactsResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/contact/update_contact.py b/sinch/domains/conversation/endpoints/contact/update_contact.py deleted file mode 100644 index 73114bf1..00000000 --- a/sinch/domains/conversation/endpoints/contact/update_contact.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.contact.requests import UpdateConversationContactRequest -from sinch.domains.conversation.models.contact.responses import UpdateConversationContactResponse - - -class UpdateConversationContactEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/contacts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationContactRequest): - super(UpdateConversationContactEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationContactResponse: - super(UpdateConversationContactEndpoint, self).handle_response(response) - return UpdateConversationContactResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/conversation/create_conversation.py b/sinch/domains/conversation/endpoints/conversation/create_conversation.py deleted file mode 100644 index 374611c0..00000000 --- a/sinch/domains/conversation/endpoints/conversation/create_conversation.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchCreateConversationResponse -from sinch.domains.conversation.models.conversation.requests import CreateConversationRequest - - -class CreateConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationRequest): - super(CreateConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchCreateConversationResponse: - super(CreateConversationEndpoint, self).handle_response(response) - return SinchCreateConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/delete_conversation.py b/sinch/domains/conversation/endpoints/conversation/delete_conversation.py deleted file mode 100644 index eb4f53a7..00000000 --- a/sinch/domains/conversation/endpoints/conversation/delete_conversation.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchDeleteConversationResponse -from sinch.domains.conversation.models.conversation.requests import DeleteConversationRequest - - -class DeleteConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationRequest): - super(DeleteConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchDeleteConversationResponse: - super(DeleteConversationEndpoint, self).handle_response(response) - return SinchDeleteConversationResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/get_conversation.py b/sinch/domains/conversation/endpoints/conversation/get_conversation.py deleted file mode 100644 index 4f899c87..00000000 --- a/sinch/domains/conversation/endpoints/conversation/get_conversation.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchGetConversationResponse -from sinch.domains.conversation.models.conversation.requests import GetConversationRequest - - -class GetConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationRequest): - super(GetConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchGetConversationResponse: - super(GetConversationEndpoint, self).handle_response(response) - return SinchGetConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py b/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py deleted file mode 100644 index d103e08f..00000000 --- a/sinch/domains/conversation/endpoints/conversation/inject_message_to_conversation.py +++ /dev/null @@ -1,30 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchInjectMessageResponse -from sinch.domains.conversation.models.conversation.requests import InjectMessageToConversationRequest - - -class InjectMessageToConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}:inject-message" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: InjectMessageToConversationRequest): - super(InjectMessageToConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchInjectMessageResponse: - super(InjectMessageToConversationEndpoint, self).handle_response(response) - return SinchInjectMessageResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/list_conversations.py b/sinch/domains/conversation/endpoints/conversation/list_conversations.py deleted file mode 100644 index 9055b640..00000000 --- a/sinch/domains/conversation/endpoints/conversation/list_conversations.py +++ /dev/null @@ -1,58 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.domains.conversation.models.conversation.responses import SinchListConversationsResponse -from sinch.domains.conversation.models.conversation.requests import ListConversationsRequest -from sinch.domains.conversation.models.conversation import Conversation -from sinch.core.enums import HTTPAuthentication, HTTPMethods - - -class ListConversationsEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationsRequest): - super(ListConversationsEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - query_params = {} - if self.request_data.app_id: - query_params["app_id"] = self.request_data.app_id - - if self.request_data.contact_id: - query_params["contact_id"] = self.request_data.contact_id - - if self.request_data.page_size: - query_params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - query_params["page_token"] = self.request_data.page_token - - return query_params - - def handle_response(self, response: HTTPResponse) -> SinchListConversationsResponse: - super(ListConversationsEndpoint, self).handle_response(response) - return SinchListConversationsResponse( - conversations=[ - Conversation( - id=conversation["id"], - app_id=conversation["app_id"], - contact_id=conversation["contact_id"], - last_received=conversation["last_received"], - active_channel=conversation["active_channel"], - active=conversation["active"], - metadata=conversation["metadata"], - metadata_json=conversation["metadata_json"] - ) for conversation in response.body["conversations"] - ], - next_page_token=response.body["next_page_token"], - total_size=response.body["total_size"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation/stop_conversation.py b/sinch/domains/conversation/endpoints/conversation/stop_conversation.py deleted file mode 100644 index b9c7be04..00000000 --- a/sinch/domains/conversation/endpoints/conversation/stop_conversation.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchStopConversationResponse -from sinch.domains.conversation.models.conversation.requests import StopConversationRequest - - -class StopConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}:stop" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: StopConversationRequest): - super(StopConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchStopConversationResponse: - super(StopConversationEndpoint, self).handle_response(response) - return SinchStopConversationResponse() diff --git a/sinch/domains/conversation/endpoints/conversation/update_conversation.py b/sinch/domains/conversation/endpoints/conversation/update_conversation.py deleted file mode 100644 index 31c4748b..00000000 --- a/sinch/domains/conversation/endpoints/conversation/update_conversation.py +++ /dev/null @@ -1,40 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.conversation.responses import SinchUpdateConversationResponse -from sinch.domains.conversation.models.conversation.requests import UpdateConversationRequest - - -class UpdateConversationEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/conversations/{conversation_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationRequest): - super(UpdateConversationEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - conversation_id=self.request_data.conversation_id - ) - - def request_body(self): - self.request_data.conversation_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SinchUpdateConversationResponse: - super(UpdateConversationEndpoint, self).handle_response(response) - return SinchUpdateConversationResponse( - id=response.body["id"], - app_id=response.body["app_id"], - contact_id=response.body["contact_id"], - last_received=response.body["last_received"], - active_channel=response.body["active_channel"], - active=response.body["active"], - metadata=response.body["metadata"], - metadata_json=response.body["metadata_json"] - ) diff --git a/sinch/domains/conversation/endpoints/conversation_endpoint.py b/sinch/domains/conversation/endpoints/conversation_endpoint.py deleted file mode 100644 index 76f9d4bd..00000000 --- a/sinch/domains/conversation/endpoints/conversation_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.conversation.exceptions import ConversationException - - -class ConversationEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise ConversationException( - message=response.body["error"].get("message"), - response=response, - is_from_server=True - ) diff --git a/sinch/domains/conversation/endpoints/events.py b/sinch/domains/conversation/endpoints/events.py deleted file mode 100644 index 90d76ff7..00000000 --- a/sinch/domains/conversation/endpoints/events.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.event.requests import SendConversationEventRequest -from sinch.domains.conversation.models.event.responses import SendConversationEventResponse - - -class SendEventEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/events:send" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: SendConversationEventRequest): - super(SendEventEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SendConversationEventResponse: - super(SendEventEndpoint, self).handle_response(response) - return SendConversationEventResponse( - accepted_time=response.body["accepted_time"], - event_id=response.body["event_id"] - ) diff --git a/sinch/domains/conversation/endpoints/message/delete_message.py b/sinch/domains/conversation/endpoints/message/delete_message.py deleted file mode 100644 index bcff7499..00000000 --- a/sinch/domains/conversation/endpoints/message/delete_message.py +++ /dev/null @@ -1,32 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import DeleteConversationMessageResponse -from sinch.domains.conversation.models.message.requests import DeleteConversationMessageRequest - - -class DeleteConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationMessageRequest): - super(DeleteConversationMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - message_id=self.request_data.message_id - ) - - def build_query_params(self): - if self.request_data.messages_source: - return { - "messages_source": self.request_data.messages_source - } - - def handle_response(self, response: HTTPResponse) -> DeleteConversationMessageResponse: - return DeleteConversationMessageResponse() diff --git a/sinch/domains/conversation/endpoints/message/get_message.py b/sinch/domains/conversation/endpoints/message/get_message.py deleted file mode 100644 index 4a10a083..00000000 --- a/sinch/domains/conversation/endpoints/message/get_message.py +++ /dev/null @@ -1,43 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import GetConversationMessageResponse -from sinch.domains.conversation.models.message.requests import GetConversationMessageRequest - - -class GetConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationMessageRequest): - super(GetConversationMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - message_id=self.request_data.message_id - ) - - def build_query_params(self): - if self.request_data.messages_source: - return { - "messages_source": self.request_data.messages_source - } - - def handle_response(self, response: HTTPResponse) -> GetConversationMessageResponse: - return GetConversationMessageResponse( - id=response.body["id"], - direction=response.body["direction"], - channel_identity=response.body["channel_identity"], - app_message=response.body["app_message"], - conversation_id=response.body["conversation_id"], - contact_id=response.body["contact_id"], - metadata=response.body["metadata"], - accept_time=response.body["accept_time"], - sender_id=response.body["sender_id"], - processing_mode=response.body["processing_mode"], - ) diff --git a/sinch/domains/conversation/endpoints/message/list_message.py b/sinch/domains/conversation/endpoints/message/list_message.py deleted file mode 100644 index b4da35a0..00000000 --- a/sinch/domains/conversation/endpoints/message/list_message.py +++ /dev/null @@ -1,71 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models import SinchConversationMessage -from sinch.domains.conversation.models.message.responses import ListConversationMessagesResponse -from sinch.domains.conversation.models.message.requests import ListConversationMessagesRequest - - -class ListConversationMessagesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationMessagesRequest): - super(ListConversationMessagesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - query_params = {} - if self.request_data.conversation_id: - query_params["conversation_id"] = self.request_data.conversation_id - - if self.request_data.contact_id: - query_params["contact_id"] = self.request_data.contact_id - - if self.request_data.page_size: - query_params["page_size"] = self.request_data.page_size - - if self.request_data.page_token: - query_params["page_token"] = self.request_data.page_token - - if self.request_data.app_id: - query_params["app_id"] = self.request_data.app_id - - if self.request_data.view: - query_params["view"] = self.request_data.view - - if self.request_data.messages_source: - query_params["messages_source"] = self.request_data.messages_source - - if self.request_data.only_recipient_originated: - query_params["only_recipient_originated"] = self.request_data.only_recipient_originated - - return query_params - - def handle_response(self, response: HTTPResponse) -> ListConversationMessagesResponse: - super(ListConversationMessagesEndpoint, self).handle_response(response) - return ListConversationMessagesResponse( - messages=[ - SinchConversationMessage( - id=message["id"], - direction=message["direction"], - channel_identity=message["channel_identity"], - app_message=message["app_message"], - conversation_id=message["conversation_id"], - contact_id=message["contact_id"], - metadata=message["metadata"], - accept_time=message["accept_time"], - sender_id=message["sender_id"], - processing_mode=message["processing_mode"] - ) for message in response.body["messages"] - ], - next_page_token=response.body.get("next_page_token") - ) diff --git a/sinch/domains/conversation/endpoints/message/send_message.py b/sinch/domains/conversation/endpoints/message/send_message.py deleted file mode 100644 index dba2f61e..00000000 --- a/sinch/domains/conversation/endpoints/message/send_message.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.message.responses import SendConversationMessageResponse -from sinch.domains.conversation.models.message.requests import SendConversationMessageRequest - - -class SendConversationMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:send" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: SendConversationMessageRequest): - super(SendConversationMessageEndpoint, self).__init__(project_id, request_data) - self.project_id = project_id - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> SendConversationMessageResponse: - super(SendConversationMessageEndpoint, self).handle_response(response) - return SendConversationMessageResponse( - **response.body - ) diff --git a/sinch/domains/conversation/endpoints/opt_in.py b/sinch/domains/conversation/endpoints/opt_in.py deleted file mode 100644 index 14dde1e7..00000000 --- a/sinch/domains/conversation/endpoints/opt_in.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptInRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptInResponse - - -class RegisterOptInEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/optins:register" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RegisterConversationOptInRequest): - super(RegisterOptInEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - if self.request_data.request_id: - return { - "request_id": self.request_data.request_id - } - - def request_body(self): - self.request_data.request_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> RegisterConversationOptInResponse: - super(RegisterOptInEndpoint, self).handle_response(response) - return RegisterConversationOptInResponse( - response.body["request_id"], - response.body["opt_in"] - ) diff --git a/sinch/domains/conversation/endpoints/opt_out.py b/sinch/domains/conversation/endpoints/opt_out.py deleted file mode 100644 index ade96da8..00000000 --- a/sinch/domains/conversation/endpoints/opt_out.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.opt_in_opt_out.requests import RegisterConversationOptOutRequest -from sinch.domains.conversation.models.opt_in_opt_out.responses import RegisterConversationOptOutResponse - - -class RegisterOptOutEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/optouts:register" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: RegisterConversationOptOutRequest): - super(RegisterOptOutEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def build_query_params(self): - if self.request_data.request_id: - return { - "request_id": self.request_data.request_id - } - - def request_body(self): - self.request_data.request_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> RegisterConversationOptOutResponse: - super(RegisterOptOutEndpoint, self).handle_response(response) - return RegisterConversationOptOutResponse( - response.body["request_id"], - response.body["opt_out"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/create_template.py b/sinch/domains/conversation/endpoints/templates/create_template.py deleted file mode 100644 index 9069243e..00000000 --- a/sinch/domains/conversation/endpoints/templates/create_template.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import CreateConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import CreateConversationTemplateRequest - - -class CreateTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationTemplateRequest): - super(CreateTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateConversationTemplateResponse: - super(CreateTemplateEndpoint, self).handle_response(response) - return CreateConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/delete_template.py b/sinch/domains/conversation/endpoints/templates/delete_template.py deleted file mode 100644 index 5706be16..00000000 --- a/sinch/domains/conversation/endpoints/templates/delete_template.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import DeleteConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import DeleteConversationTemplateRequest - - -class DeleteTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationTemplateRequest): - super(DeleteTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def handle_response(self, response: HTTPResponse) -> DeleteConversationTemplateResponse: - super(DeleteTemplateEndpoint, self).handle_response(response) - return DeleteConversationTemplateResponse() diff --git a/sinch/domains/conversation/endpoints/templates/get_template.py b/sinch/domains/conversation/endpoints/templates/get_template.py deleted file mode 100644 index d7a2a594..00000000 --- a/sinch/domains/conversation/endpoints/templates/get_template.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import GetConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import GetConversationTemplateRequest - - -class GetTemplatesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationTemplateRequest): - super(GetTemplatesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def handle_response(self, response: HTTPResponse) -> GetConversationTemplateResponse: - super(GetTemplatesEndpoint, self).handle_response(response) - return GetConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/templates/list_templates.py b/sinch/domains/conversation/endpoints/templates/list_templates.py deleted file mode 100644 index 19674d34..00000000 --- a/sinch/domains/conversation/endpoints/templates/list_templates.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import ListConversationTemplatesResponse -from sinch.domains.conversation.models.templates import ConversationTemplate - - -class ListTemplatesEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data=None): - super(ListTemplatesEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id - ) - - def handle_response(self, response: HTTPResponse) -> ListConversationTemplatesResponse: - super(ListTemplatesEndpoint, self).handle_response(response) - return ListConversationTemplatesResponse( - templates=[ - ConversationTemplate( - id=template["id"], - description=template["description"], - default_translation=template["default_translation"], - create_time=template["create_time"], - translations=template["translations"], - update_time=template["update_time"], - channel=template["channel"] - ) for template in response.body["templates"] - ] - ) diff --git a/sinch/domains/conversation/endpoints/templates/update_template.py b/sinch/domains/conversation/endpoints/templates/update_template.py deleted file mode 100644 index f4678934..00000000 --- a/sinch/domains/conversation/endpoints/templates/update_template.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.templates.responses import UpdateConversationTemplateResponse -from sinch.domains.conversation.models.templates.requests import UpdateConversationTemplateRequest - - -class UpdateTemplateEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/templates/{template_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationTemplateRequest): - super(UpdateTemplateEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.templates_origin, - project_id=self.project_id, - template_id=self.request_data.template_id - ) - - def request_body(self): - self.request_data.template_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateConversationTemplateResponse: - super(UpdateTemplateEndpoint, self).handle_response(response) - return UpdateConversationTemplateResponse( - id=response.body["id"], - description=response.body["description"], - default_translation=response.body["default_translation"], - create_time=response.body["create_time"], - translations=response.body["translations"], - update_time=response.body["update_time"], - channel=response.body["channel"] - ) diff --git a/sinch/domains/conversation/endpoints/transcode.py b/sinch/domains/conversation/endpoints/transcode.py deleted file mode 100644 index b4e41684..00000000 --- a/sinch/domains/conversation/endpoints/transcode.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.transcoding.requests import TranscodeConversationMessageRequest -from sinch.domains.conversation.models.transcoding.responses import TranscodeConversationMessageResponse - - -class TranscodeMessageEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:transcode" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: TranscodeConversationMessageRequest): - super(TranscodeMessageEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> TranscodeConversationMessageResponse: - super(TranscodeMessageEndpoint, self).handle_response(response) - return TranscodeConversationMessageResponse( - transcoded_message=response.body["transcoded_message"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/create_webhook.py b/sinch/domains/conversation/endpoints/webhooks/create_webhook.py deleted file mode 100644 index 5466ea0e..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/create_webhook.py +++ /dev/null @@ -1,37 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import CreateWebhookResponse -from sinch.domains.conversation.models.webhook.requests import CreateConversationWebhookRequest - - -class CreateWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: CreateConversationWebhookRequest): - super(CreateWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> CreateWebhookResponse: - super(CreateWebhookEndpoint, self).handle_response(response) - return CreateWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py b/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py deleted file mode 100644 index 0c16da71..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/delete_webhook.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import SinchDeleteWebhookResponse -from sinch.domains.conversation.models.webhook.requests import DeleteConversationWebhookRequest - - -class DeleteWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: DeleteConversationWebhookRequest): - super(DeleteWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchDeleteWebhookResponse: - super(DeleteWebhookEndpoint, self).handle_response(response) - return SinchDeleteWebhookResponse() diff --git a/sinch/domains/conversation/endpoints/webhooks/get_webhook.py b/sinch/domains/conversation/endpoints/webhooks/get_webhook.py deleted file mode 100644 index 3942a30b..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/get_webhook.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import GetWebhookResponse -from sinch.domains.conversation.models.webhook.requests import GetConversationWebhookRequest - - -class GetWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: GetConversationWebhookRequest): - super(GetWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def handle_response(self, response: HTTPResponse) -> GetWebhookResponse: - super(GetWebhookEndpoint, self).handle_response(response) - return GetWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py b/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py deleted file mode 100644 index 5f1c6d0a..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/list_webhooks.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import SinchListWebhooksResponse -from sinch.domains.conversation.models.webhook.requests import ListConversationWebhookRequest -from sinch.domains.conversation.models.webhook import ConversationWebhook - - -class ListWebhooksEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/apps/{app_id}/webhooks" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: ListConversationWebhookRequest): - super(ListWebhooksEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - app_id=self.request_data.app_id - ) - - def handle_response(self, response: HTTPResponse) -> SinchListWebhooksResponse: - super(ListWebhooksEndpoint, self).handle_response(response) - return SinchListWebhooksResponse( - webhooks=[ConversationWebhook( - id=webhook["id"], - app_id=webhook["app_id"], - target=webhook["target"], - target_type=webhook["target_type"], - secret=webhook["secret"], - triggers=webhook["triggers"], - client_credentials=webhook["client_credentials"] - ) for webhook in response.body["webhooks"]] - ) diff --git a/sinch/domains/conversation/endpoints/webhooks/update_webhook.py b/sinch/domains/conversation/endpoints/webhooks/update_webhook.py deleted file mode 100644 index 9ba6d372..00000000 --- a/sinch/domains/conversation/endpoints/webhooks/update_webhook.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.conversation.endpoints.conversation_endpoint import ConversationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.conversation.models.webhook.responses import UpdateWebhookResponse -from sinch.domains.conversation.models.webhook.requests import UpdateConversationWebhookRequest - - -class UpdateWebhookEndpoint(ConversationEndpoint): - ENDPOINT_URL = "{origin}/v1/projects/{project_id}/webhooks/{webhook_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value - - def __init__(self, project_id: str, request_data: UpdateConversationWebhookRequest): - super(UpdateWebhookEndpoint, self).__init__(project_id, request_data) - self.request_data = request_data - self.project_id = project_id - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, - project_id=self.project_id, - webhook_id=self.request_data.webhook_id - ) - - def request_body(self): - self.request_data.webhook_id = None - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateWebhookResponse: - super(UpdateWebhookEndpoint, self).handle_response(response) - return UpdateWebhookResponse( - id=response.body["id"], - app_id=response.body["app_id"], - target=response.body["target"], - target_type=response.body["target_type"], - secret=response.body["secret"], - triggers=response.body["triggers"], - client_credentials=response.body["client_credentials"] - ) diff --git a/sinch/domains/conversation/models/__init__.py b/sinch/domains/conversation/models/__init__.py index b82d1b6e..e69de29b 100644 --- a/sinch/domains/conversation/models/__init__.py +++ b/sinch/domains/conversation/models/__init__.py @@ -1,82 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.enums import ( - ConversationChannel, - ConversationRetentionPolicyType -) - - -@dataclass -class SinchConversationRecipient(SinchBaseModel): - contact_id: str - - -@dataclass -class SinchConversationTextMessage(SinchBaseModel): - pass - - -@dataclass -class SinchConversationMessage(SinchBaseModel): - id: str - direction: str - channel_identity: str - app_message: dict - conversation_id: str - contact_id: str - metadata: str - accept_time: str - sender_id: str - processing_mode: str - - -@dataclass -class SinchConversationChannelIdentities(SinchBaseModel): - channel: ConversationChannel - identity: str - app_id: str - - -@dataclass -class SinchConversationContact(SinchBaseModel): - id: str - channel_identities: SinchConversationChannelIdentities - channel_priority: list - display_name: str - email: str - external_id: str - metadata: str - language: str - - -@dataclass -class SinchConversationRetentionPolicy(SinchBaseModel): - retention_type: ConversationRetentionPolicyType - ttl_days: int - - -@dataclass -class SinchConversationTelegramCredentials(SinchBaseModel): # TODO: add more communication channels - token: str - - -@dataclass -class SinchConversationChannelCredentials(SinchBaseModel): - channel: ConversationChannel - callback_secret: Optional[str] = None - telegram_credentials: Optional[SinchConversationTelegramCredentials] = None - - -@dataclass -class SinchConversationApp(SinchBaseModel): - id: str - channel_credentials: dict - processing_mode: str - conversation_metadata_report_view: str - display_name: str - rate_limits: dict - retention_policy: dict - dispatch_retention_policy: dict - smart_conversation: dict diff --git a/sinch/domains/conversation/models/app/requests.py b/sinch/domains/conversation/models/app/requests.py deleted file mode 100644 index 4cd39651..00000000 --- a/sinch/domains/conversation/models/app/requests.py +++ /dev/null @@ -1,36 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.conversation.models import ( - SinchConversationRetentionPolicy -) -from sinch.domains.conversation.enums import ( - ConversationMetadataReportView, - ConversationProcessingMode -) - - -@dataclass -class CreateConversationAppRequest(SinchRequestBaseModel): - display_name: str - channel_credentials: Optional[list] - processing_mode: Optional[ConversationProcessingMode] - conversation_metadata_report_view: Optional[ConversationMetadataReportView] - retention_policy: Optional[SinchConversationRetentionPolicy] - dispatch_retention_policy: Optional[SinchConversationRetentionPolicy] - - -@dataclass -class DeleteConversationAppRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class GetConversationAppRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class UpdateConversationAppRequest(CreateConversationAppRequest): - app_id: str - update_mask: list diff --git a/sinch/domains/conversation/models/app/responses.py b/sinch/domains/conversation/models/app/responses.py deleted file mode 100644 index 6029ef35..00000000 --- a/sinch/domains/conversation/models/app/responses.py +++ /dev/null @@ -1,31 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import ( - SinchConversationApp -) - - -@dataclass -class CreateConversationAppResponse(SinchConversationApp): - pass - - -@dataclass -class DeleteConversationAppResponse(SinchBaseModel): - pass - - -@dataclass -class ListConversationAppsResponse(SinchBaseModel): - apps: List[SinchConversationApp] - - -@dataclass -class GetConversationAppResponse(SinchConversationApp): - pass - - -@dataclass -class UpdateConversationAppResponse(SinchConversationApp): - pass diff --git a/sinch/domains/conversation/models/capability/requests.py b/sinch/domains/conversation/models/capability/requests.py deleted file mode 100644 index 116f5038..00000000 --- a/sinch/domains/conversation/models/capability/requests.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class QueryConversationCapabilityRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - request_id: str diff --git a/sinch/domains/conversation/models/capability/responses.py b/sinch/domains/conversation/models/capability/responses.py deleted file mode 100644 index a5fcab7b..00000000 --- a/sinch/domains/conversation/models/capability/responses.py +++ /dev/null @@ -1,9 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class QueryConversationCapabilityResponse(SinchBaseModel): - request_id: str - app_id: str - recipient: dict diff --git a/sinch/domains/conversation/models/contact/requests.py b/sinch/domains/conversation/models/contact/requests.py deleted file mode 100644 index 04c74f8b..00000000 --- a/sinch/domains/conversation/models/contact/requests.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass -from typing import List, Optional -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.conversation.models import ( - SinchConversationChannelIdentities, - SinchConversationRecipient -) - -from sinch.domains.conversation.enums import ( - ConversationChannel -) - - -@dataclass -class CreateConversationContactRequest(SinchRequestBaseModel): - language: str - channel_identities: Optional[List[SinchConversationChannelIdentities]] - channel_priority: Optional[List[str]] - display_name: Optional[str] - email: Optional[str] - external_id: Optional[str] - metadata: Optional[str] - - -@dataclass -class UpdateConversationContactRequest(CreateConversationContactRequest): - id: str - - -@dataclass -class ListConversationContactRequest(SinchRequestBaseModel): - page_size: int - page_token: str - external_id: str - channel: str - identity: str - - -@dataclass -class DeleteConversationContactRequest(SinchRequestBaseModel): - contact_id: str - - -@dataclass -class GetConversationContactRequest(SinchRequestBaseModel): - contact_id: str - - -@dataclass -class MergeConversationContactsRequest(SinchRequestBaseModel): - destination_id: str - source_id: str - strategy: str - - -@dataclass -class GetConversationChannelProfileRequest(SinchRequestBaseModel): - app_id: str - recipient: SinchConversationRecipient - channel: ConversationChannel diff --git a/sinch/domains/conversation/models/contact/responses.py b/sinch/domains/conversation/models/contact/responses.py deleted file mode 100644 index 496c9a10..00000000 --- a/sinch/domains/conversation/models/contact/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import SinchConversationContact - - -@dataclass -class ListConversationContactsResponse(SinchBaseModel): - contacts: List[SinchConversationContact] - next_page_token: str - - -@dataclass -class CreateConversationContactResponse(SinchConversationContact): - pass - - -@dataclass -class DeleteConversationContactResponse(SinchBaseModel): - pass - - -@dataclass -class GetConversationContactResponse(SinchConversationContact): - pass - - -@dataclass -class MergeConversationContactsResponse(SinchConversationContact): - pass - - -@dataclass -class GetConversationChannelProfileResponse(SinchBaseModel): - profile_name: str - - -@dataclass -class UpdateConversationContactResponse(SinchConversationContact): - pass diff --git a/sinch/domains/conversation/models/conversation/__init__.py b/sinch/domains/conversation/models/conversation/__init__.py deleted file mode 100644 index b34d1d92..00000000 --- a/sinch/domains/conversation/models/conversation/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class Conversation(SinchBaseModel): - id: str - app_id: str - contact_id: str - last_received: str - active_channel: str - active: str - metadata: str - metadata_json: str diff --git a/sinch/domains/conversation/models/conversation/requests.py b/sinch/domains/conversation/models/conversation/requests.py deleted file mode 100644 index cd9ef2b2..00000000 --- a/sinch/domains/conversation/models/conversation/requests.py +++ /dev/null @@ -1,62 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ConversationRequest(SinchRequestBaseModel): - app_id: str - contact_id: str - active: bool - active_channel: str - app_id: str - contact_id: str - metadata: str - conversation_metadata: dict - - -@dataclass -class CreateConversationRequest(ConversationRequest): - id: str - - -@dataclass -class ListConversationsRequest(SinchRequestBaseModel): - app_id: str - contact_id: str - only_active: bool - page_size: int - page_token: str - - -@dataclass -class GetConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class DeleteConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class UpdateConversationRequest(ConversationRequest): - update_mask: str - metadata_update_strategy: str - conversation_id: str - - -@dataclass -class StopConversationRequest(SinchRequestBaseModel): - conversation_id: str - - -@dataclass -class InjectMessageToConversationRequest(SinchRequestBaseModel): - conversation_id: str - accept_time: str - app_message: dict - channel_identity: dict - contact_id: str - contact_message: dict - direction: str - metadata: str diff --git a/sinch/domains/conversation/models/conversation/responses.py b/sinch/domains/conversation/models/conversation/responses.py deleted file mode 100644 index d3add28c..00000000 --- a/sinch/domains/conversation/models/conversation/responses.py +++ /dev/null @@ -1,41 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models.conversation import Conversation - - -@dataclass -class SinchCreateConversationResponse(Conversation): - pass - - -@dataclass -class SinchListConversationsResponse(SinchBaseModel): - conversations: List[Conversation] - next_page_token: str - total_size: int - - -@dataclass -class SinchGetConversationResponse(Conversation): - pass - - -@dataclass -class SinchDeleteConversationResponse(SinchBaseModel): - pass - - -@dataclass -class SinchUpdateConversationResponse(Conversation): - pass - - -@dataclass -class SinchStopConversationResponse(SinchBaseModel): - pass - - -@dataclass -class SinchInjectMessageResponse(SinchBaseModel): - pass diff --git a/sinch/domains/conversation/models/event/requests.py b/sinch/domains/conversation/models/event/requests.py deleted file mode 100644 index c6fc7d0c..00000000 --- a/sinch/domains/conversation/models/event/requests.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class SendConversationEventRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - event: dict - callback_url: str - channel_priority_order: str - event_metadata: str - queue: str diff --git a/sinch/domains/conversation/models/event/responses.py b/sinch/domains/conversation/models/event/responses.py deleted file mode 100644 index 567b2e73..00000000 --- a/sinch/domains/conversation/models/event/responses.py +++ /dev/null @@ -1,8 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class SendConversationEventResponse(SinchBaseModel): - accepted_time: str - event_id: str diff --git a/sinch/domains/conversation/models/message/requests.py b/sinch/domains/conversation/models/message/requests.py deleted file mode 100644 index e353c11b..00000000 --- a/sinch/domains/conversation/models/message/requests.py +++ /dev/null @@ -1,43 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListConversationMessagesRequest(SinchRequestBaseModel): - conversation_id: Optional[str] - contact_id: Optional[str] - app_id: Optional[str] - page_size: Optional[int] - page_token: Optional[str] - view: Optional[str] - messages_source: Optional[str] - only_recipient_originated: Optional[bool] - - -@dataclass -class GetConversationMessageRequest(SinchRequestBaseModel): - message_id: str - messages_source: str - - -@dataclass -class DeleteConversationMessageRequest(SinchRequestBaseModel): - message_id: str - messages_source: str - - -@dataclass -class SendConversationMessageRequest(SinchRequestBaseModel): - app_id: str - recipient: dict - message: dict - callback_url: str - processing_strategy: Optional[str] - channel_priority_order: list - channel_properties: dict - message_metadata: str - conversation_metadata: dict - queue: str - ttl: str - processing_strategy: str diff --git a/sinch/domains/conversation/models/message/responses.py b/sinch/domains/conversation/models/message/responses.py deleted file mode 100644 index ca892152..00000000 --- a/sinch/domains/conversation/models/message/responses.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models import SinchConversationMessage - - -@dataclass -class SendConversationMessageResponse(SinchBaseModel): - accepted_time: str - message_id: str - - -@dataclass -class ListConversationMessagesResponse(SinchBaseModel): - messages: List[SinchConversationMessage] - next_page_token: str - - -@dataclass -class GetConversationMessageResponse(SinchConversationMessage): - pass - - -@dataclass -class DeleteConversationMessageResponse(SinchBaseModel): - pass diff --git a/sinch/domains/conversation/models/opt_in_opt_out/requests.py b/sinch/domains/conversation/models/opt_in_opt_out/requests.py deleted file mode 100644 index 66d9fb90..00000000 --- a/sinch/domains/conversation/models/opt_in_opt_out/requests.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class RegisterConversationOptInRequest(SinchRequestBaseModel): - request_id: str - app_id: str - channels: list - recipient: dict - processing_strategy: str - - -@dataclass -class RegisterConversationOptOutRequest(SinchRequestBaseModel): - request_id: str - app_id: str - channels: list - recipient: dict - processing_strategy: str diff --git a/sinch/domains/conversation/models/opt_in_opt_out/responses.py b/sinch/domains/conversation/models/opt_in_opt_out/responses.py deleted file mode 100644 index 386dbaf6..00000000 --- a/sinch/domains/conversation/models/opt_in_opt_out/responses.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class RegisterConversationOptInResponse(SinchBaseModel): - request_id: str - opt_in: dict - - -@dataclass -class RegisterConversationOptOutResponse(SinchBaseModel): - request_id: str - opt_out: dict diff --git a/sinch/domains/conversation/models/templates/__init__.py b/sinch/domains/conversation/models/templates/__init__.py deleted file mode 100644 index 3b8872e1..00000000 --- a/sinch/domains/conversation/models/templates/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ConversationTemplate(SinchBaseModel): - id: str - description: str - default_translation: str - create_time: str - translations: list - update_time: str - channel: str diff --git a/sinch/domains/conversation/models/templates/requests.py b/sinch/domains/conversation/models/templates/requests.py deleted file mode 100644 index 67e29fbe..00000000 --- a/sinch/domains/conversation/models/templates/requests.py +++ /dev/null @@ -1,29 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class CreateConversationTemplateRequest(SinchRequestBaseModel): - channel: str - create_time: str - description: str - id: str - translations: list - default_translation: str - update_time: str - - -@dataclass -class GetConversationTemplateRequest(SinchRequestBaseModel): - template_id: str - - -@dataclass -class DeleteConversationTemplateRequest(SinchRequestBaseModel): - template_id: str - - -@dataclass -class UpdateConversationTemplateRequest(CreateConversationTemplateRequest): - update_mask: str - template_id: str diff --git a/sinch/domains/conversation/models/templates/responses.py b/sinch/domains/conversation/models/templates/responses.py deleted file mode 100644 index 92dba38e..00000000 --- a/sinch/domains/conversation/models/templates/responses.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.conversation.models.templates import ConversationTemplate - - -@dataclass -class CreateConversationTemplateResponse(ConversationTemplate): - pass - - -@dataclass -class ListConversationTemplatesResponse(SinchBaseModel): - templates: List[ConversationTemplate] - - -@dataclass -class GetConversationTemplateResponse(ConversationTemplate): - pass - - -@dataclass -class DeleteConversationTemplateResponse(SinchBaseModel): - pass - - -@dataclass -class UpdateConversationTemplateResponse(ConversationTemplate): - pass diff --git a/sinch/domains/conversation/models/transcoding/__init__.py b/sinch/domains/conversation/models/transcoding/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/conversation/models/transcoding/requests.py b/sinch/domains/conversation/models/transcoding/requests.py deleted file mode 100644 index 06e54036..00000000 --- a/sinch/domains/conversation/models/transcoding/requests.py +++ /dev/null @@ -1,11 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class TranscodeConversationMessageRequest(SinchRequestBaseModel): - app_id: str - app_message: dict - channels: list - from_: str - to: str diff --git a/sinch/domains/conversation/models/transcoding/responses.py b/sinch/domains/conversation/models/transcoding/responses.py deleted file mode 100644 index 230bd5bd..00000000 --- a/sinch/domains/conversation/models/transcoding/responses.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class TranscodeConversationMessageResponse(SinchBaseModel): - transcoded_message: dict diff --git a/sinch/domains/conversation/endpoints/app/__init__.py b/sinch/domains/conversation/models/v1/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/app/__init__.py rename to sinch/domains/conversation/models/v1/__init__.py diff --git a/sinch/domains/conversation/endpoints/contact/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/contact/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/__init__.py diff --git a/sinch/domains/conversation/endpoints/conversation/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/app/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/conversation/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/app/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py new file mode 100644 index 00000000..23ef0d9c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py @@ -0,0 +1,78 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import ( + AppMessageCommonProps, +) + + +class CardAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): + card_message: Optional[CardMessage] = None + + +class CarouselAppMessage( + AppMessageCommonProps, BaseModelConfigurationResponse +): + carousel_message: Optional[CarouselMessage] = None + + +class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): + choice_message: Optional[ChoiceMessage] = None + + +class LocationAppMessage( + AppMessageCommonProps, BaseModelConfigurationResponse +): + location_message: Optional[LocationMessage] = None + + +class MediaAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): + media_message: Optional[MediaProperties] = None + + +class TemplateAppMessage( + AppMessageCommonProps, BaseModelConfigurationResponse +): + template_message: Optional[TemplateMessage] = None + + +class TextAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): + text_message: Optional[TextMessage] = None + + +class ListAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): + list_message: Optional[ListMessage] = None + + +class ContactInfoAppMessage( + AppMessageCommonProps, BaseModelConfigurationResponse +): + contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py new file mode 100644 index 00000000..0503b780 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/calendar/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "CalendarMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CalendarMessage": + from sinch.domains.conversation.models.v1.messages.categories.calendar.calendar_message import ( + CalendarMessage, + ) + + return CalendarMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py new file mode 100644 index 00000000..8d83bc54 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py @@ -0,0 +1,29 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CalendarMessage(BaseModelConfigurationResponse): + title: StrictStr = Field( + ..., + description="The title is shown close to the button that leads to open a user calendar.", + ) + event_start: datetime = Field( + ..., description="The timestamp defines start of a calendar event." + ) + event_end: datetime = Field( + ..., description="The timestamp defines end of a calendar event." + ) + event_title: StrictStr = Field( + ..., description="Title of a calendar event." + ) + event_description: Optional[StrictStr] = Field( + default=None, description="Description of a calendar event." + ) + fallback_url: StrictStr = Field( + ..., + description="The URL that is opened when the user cannot open a calendar event directly or channel does not have support for this type.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py new file mode 100644 index 00000000..77b7fe4e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/call/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "CallMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CallMessage": + from sinch.domains.conversation.models.v1.messages.categories.call.call_message import ( + CallMessage, + ) + + return CallMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py new file mode 100644 index 00000000..79fbd1b0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py @@ -0,0 +1,14 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CallMessage(BaseModelConfigurationResponse): + phone_number: StrictStr = Field( + default=..., description="Phone number in E.164 with leading +." + ) + title: StrictStr = Field( + default=..., + description="Title shown close to the phone number. The title is clickable in some cases.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py new file mode 100644 index 00000000..ec9792a5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "CardMessage", + "CardMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CardMessage": + from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, + ) + + return CardMessage + if name == "CardMessageField": + from sinch.domains.conversation.models.v1.messages.categories.card.card_message_field import ( + CardMessageField, + ) + + return CardMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py new file mode 100644 index 00000000..3cf1e9ea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py @@ -0,0 +1,36 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import ( + MessageProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CardMessage(BaseModelConfigurationResponse): + choices: Optional[conlist(ChoiceOption)] = Field( + default=None, + description="You may include choices in your Card Message. The number of choices is limited to 10.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="This is an optional description field that is displayed below the title on the card.", + ) + height: Optional[CardHeightType] = None + title: Optional[StrictStr] = Field( + default=None, description="The title of the card message." + ) + media_message: Optional[MediaProperties] = Field( + default=None, description="A message containing a media component." + ) + message_properties: Optional[MessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py new file mode 100644 index 00000000..77e35c77 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CardMessageField(BaseModelConfigurationResponse): + card_message: Optional[CardMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py new file mode 100644 index 00000000..ddff7028 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MessageProperties(BaseModelConfigurationResponse): + whatsapp_header: Optional[StrictStr] = Field( + default=None, + description=( + "Optional. Sets the header text for a WhatsApp reply button message when there is no media. " + "Ignored for other channels or when not transcoded to native WhatsApp reply buttons." + ), + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py new file mode 100644 index 00000000..a33819a0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "CarouselMessage", + "CarouselMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "CarouselMessage": + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, + ) + + return CarouselMessage + if name == "CarouselMessageField": + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message_field import ( + CarouselMessageField, + ) + + return CarouselMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py new file mode 100644 index 00000000..8d4939a5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CarouselMessage(BaseModelConfigurationResponse): + cards: conlist(CardMessage) = Field( + default=..., description="A list of up to 10 cards." + ) + choices: Optional[conlist(ChoiceOption)] = Field( + default=None, + description="Optional. Outer choices on the carousel level. The number of outer choices is limited to 3.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py new file mode 100644 index 00000000..41020788 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class CarouselMessageField(BaseModelConfigurationResponse): + carousel_message: Optional[CarouselMessage] = None diff --git a/sinch/domains/conversation/endpoints/message/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/message/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py new file mode 100644 index 00000000..8f44f48b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply_message import ( + WhatsAppInteractiveNfmReplyMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChannelSpecificContactMessageMessage(BaseModelConfigurationResponse): + message_type: Literal["nfm_reply"] = Field( + ..., description="The message type." + ) + message: WhatsAppInteractiveNfmReplyMessage = Field( + ..., description="The message content." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py new file mode 100644 index 00000000..138f2bf6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py @@ -0,0 +1,17 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import ( + ChannelSpecificMessageType, +) +from sinch.domains.conversation.models.v1.messages.response.types.channel_specific_message_content import ( + ChannelSpecificMessageContent, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChannelSpecificMessage(BaseModelConfigurationResponse): + message_type: ChannelSpecificMessageType = Field( + ..., description="The type of the channel specific message." + ) + message: ChannelSpecificMessageContent = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py new file mode 100644 index 00000000..2a4f7c7a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.flow_action_payload import ( + FlowActionPayload, +) + + +class FlowChannelSpecificMessage(WhatsAppCommonProps): + flow_id: StrictStr = Field(..., description="ID of the Flow.") + flow_cta: StrictStr = Field( + ..., + description="Text which is displayed on the Call To Action button (20 characters maximum, emoji not supported).", + ) + flow_token: Optional[StrictStr] = Field( + default=None, description="Generated token which is an identifier." + ) + flow_mode: Optional[StrictStr] = Field( + default="published", description="The mode in which the flow is." + ) + flow_action: Optional[StrictStr] = Field( + default="navigate", description="The flow action." + ) + flow_action_payload: Optional[FlowActionPayload] = None diff --git a/sinch/domains/conversation/endpoints/templates/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/templates/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py new file mode 100644 index 00000000..638ce4c0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( + KakaoTalkButton, +) + + +class KakaoTalkAppLinkButton(KakaoTalkButton): + type: Literal["AL"] = Field("AL", description="Button type") + scheme_ios: StrictStr = Field( + ..., + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: StrictStr = Field( + ..., + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py new file mode 100644 index 00000000..bfaabe98 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py @@ -0,0 +1,9 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( + KakaoTalkButton, +) + + +class KakaoTalkBotKeywordButton(KakaoTalkButton): + type: Literal["BK"] = Field("BK", description="Button type") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py new file mode 100644 index 00000000..f52cf731 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkButton(BaseModelConfigurationResponse): + name: StrictStr = Field(..., description="Text displayed on the button") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py new file mode 100644 index 00000000..d3517fca --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_head import ( + KakaoTalkCarouselHead, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_tail import ( + KakaoTalkCarouselTail, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_message import ( + KakaoTalkCommerceMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCarousel(BaseModelConfigurationResponse): + head: Optional[KakaoTalkCarouselHead] = Field( + default=None, description="Carousel introduction" + ) + list: conlist(KakaoTalkCommerceMessage) = Field( + ..., description="List of carousel cards" + ) + tail: Optional[KakaoTalkCarouselTail] = Field( + default=None, description="More button" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py new file mode 100644 index 00000000..8cc4305c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py @@ -0,0 +1,13 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_channel_specific_message import ( + KakaoTalkChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel import ( + KakaoTalkCarousel, +) + + +class KakaoTalkCarouselCommerceChannelSpecificMessage( + KakaoTalkChannelSpecificMessage +): + carousel: KakaoTalkCarousel = Field(..., description="Carousel content") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py new file mode 100644 index 00000000..05ed6d5b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCarouselHead(BaseModelConfigurationResponse): + header: StrictStr = Field( + ..., description="Carousel introduction title", max_length=20 + ) + content: StrictStr = Field( + ..., description="Carousel introduction description", max_length=50 + ) + image_url: StrictStr = Field( + ..., description="URL to the image displayed in the introduction" + ) + link_mo: Optional[StrictStr] = Field( + default=None, description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py new file mode 100644 index 00000000..956b3c0e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCarouselTail(BaseModelConfigurationResponse): + link_mo: StrictStr = Field( + ..., description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="App link opened on an iOS device (e.g. `tel://PHONE_NUMBER`)", + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="App link opened on an Android device (e.g. `tel://PHONE_NUMBER`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py new file mode 100644 index 00000000..15d3f7ef --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictBool +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkChannelSpecificMessage(BaseModelConfigurationResponse): + push_alarm: Optional[StrictBool] = Field( + default=True, + description="Set to `true` if a push alarm should be sent to a device.", + ) + adult: Optional[StrictBool] = Field( + default=False, + description="Set to `true` if a message contains adult content. Set to `false` by default.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py new file mode 100644 index 00000000..580532e3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_channel_specific_message import ( + KakaoTalkChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_image import ( + KakaoTalkCommerceImage, +) + + +class KakaoTalkCommerceChannelSpecificMessage(KakaoTalkChannelSpecificMessage): + buttons: conlist(KakaoTalkButton) = Field(..., description="Buttons list") + additional_content: Optional[StrictStr] = Field( + default=None, description="Additional information" + ) + image: KakaoTalkCommerceImage = Field(..., description="Product image") + commerce: KakaoTalkCommerce = Field(..., description="Product information") + coupon: Optional[KakaoTalkCoupon] = Field( + default=None, description="Discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py new file mode 100644 index 00000000..a1c9a486 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCommerceImage(BaseModelConfigurationResponse): + image_url: StrictStr = Field(..., description="URL to the product image") + image_link: Optional[StrictStr] = Field( + default=None, description="URL opened when a user clicks on the image" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py new file mode 100644 index 00000000..4e48ee30 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py @@ -0,0 +1,29 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_image import ( + KakaoTalkCommerceImage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCommerceMessage(BaseModelConfigurationResponse): + buttons: conlist(KakaoTalkButton) = Field(..., description="Buttons list") + additional_content: Optional[StrictStr] = Field( + default=None, description="Additional information", max_length=34 + ) + image: KakaoTalkCommerceImage = Field(..., description="Product image") + commerce: KakaoTalkCommerce = Field(..., description="Product information") + coupon: Optional[KakaoTalkCoupon] = Field( + default=None, description="Discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py new file mode 100644 index 00000000..3b9dc38d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py @@ -0,0 +1,25 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkCoupon(BaseModelConfigurationResponse): + description: Optional[StrictStr] = Field( + default=None, description="Coupon description" + ) + link_mo: Optional[StrictStr] = Field( + default=None, description="Coupon URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="Coupon URL opened on a desktop device" + ) + scheme_android: Optional[StrictStr] = Field( + default=None, + description="Channel coupon URL (format: `alimtalk=coupon://...`)", + ) + scheme_ios: Optional[StrictStr] = Field( + default=None, + description="Channel coupon URL (format: `alimtalk=coupon://...`)", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py new file mode 100644 index 00000000..efc17379 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +class KakaoTalkDiscountFixedCommerce(KakaoTalkRegularPriceCommerce): + type: Literal["FIXED_DISCOUNT_COMMERCE"] = Field( + "FIXED_DISCOUNT_COMMERCE", description="Commerce with fixed discount" + ) + discount_price: StrictInt = Field( + ..., description="Discounted price of the product" + ) + discount_fixed: StrictInt = Field(..., description="Fixed discount") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py new file mode 100644 index 00000000..6947f9ac --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py @@ -0,0 +1,16 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +class KakaoTalkDiscountRateCommerce(KakaoTalkRegularPriceCommerce): + type: Literal["PERCENTAGE_DISCOUNT_COMMERCE"] = Field( + "PERCENTAGE_DISCOUNT_COMMERCE", + description="Commerce with percentage discount", + ) + discount_price: StrictInt = Field( + ..., description="Discounted price of the product" + ) + discount_rate: StrictInt = Field(..., description="Discount rate (%)") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py new file mode 100644 index 00000000..41e05fac --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkDiscountRateCoupon(KakaoTalkCoupon): + type: Literal["PERCENTAGE_DISCOUNT_COUPON"] = Field( + "PERCENTAGE_DISCOUNT_COUPON", description="Percentage discount coupon" + ) + discount_rate: StrictInt = Field(..., description="Discount rate (%)") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py new file mode 100644 index 00000000..2d06d05e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkFixedDiscountCoupon(KakaoTalkCoupon): + type: Literal["FIXED_DISCOUNT_COUPON"] = Field( + "FIXED_DISCOUNT_COUPON", description="Fixed discount coupon" + ) + discount_fixed: StrictInt = Field(..., description="Fixed discount") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py new file mode 100644 index 00000000..c587c9c8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py @@ -0,0 +1,12 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkFreeCoupon(KakaoTalkCoupon): + type: Literal["FREE_COUPON"] = Field( + "FREE_COUPON", description="Free coupon" + ) + title: StrictStr = Field(..., description="Coupon title") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py new file mode 100644 index 00000000..46af8903 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class KakaoTalkRegularPriceCommerce(BaseModelConfigurationResponse): + type: Literal["REGULAR_PRICE_COMMERCE"] = Field( + "REGULAR_PRICE_COMMERCE", description="Commerce with regular price" + ) + title: StrictStr = Field(..., description="Product title") + regular_price: StrictInt = Field( + ..., description="Regular price of the product" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py new file mode 100644 index 00000000..46f27ded --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py @@ -0,0 +1,11 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkShippingDiscountCoupon(KakaoTalkCoupon): + type: Literal["SHIPPING_DISCOUNT_COUPON"] = Field( + "SHIPPING_DISCOUNT_COUPON", description="Shipping discount coupon" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py new file mode 100644 index 00000000..c3783c89 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py @@ -0,0 +1,10 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + + +class KakaoTalkUpCoupon(KakaoTalkCoupon): + type: Literal["UP_COUPON"] = Field("UP_COUPON", description="UP coupon") + title: StrictStr = Field(..., description="Coupon title") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py new file mode 100644 index 00000000..7b297e23 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py @@ -0,0 +1,15 @@ +from typing import Literal, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( + KakaoTalkButton, +) + + +class KakaoTalkWebLinkButton(KakaoTalkButton): + type: Literal["WL"] = Field("WL", description="Button type") + link_mo: StrictStr = Field( + ..., description="URL opened on a mobile device" + ) + link_pc: Optional[StrictStr] = Field( + default=None, description="URL opened on a desktop device" + ) diff --git a/sinch/domains/conversation/endpoints/webhooks/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/__init__.py similarity index 100% rename from sinch/domains/conversation/endpoints/webhooks/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/__init__.py diff --git a/sinch/domains/conversation/models/app/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py similarity index 100% rename from sinch/domains/conversation/models/app/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py new file mode 100644 index 00000000..e3743c8c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py @@ -0,0 +1,15 @@ +from typing import Any, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class FlowActionPayload(BaseModelConfigurationResponse): + screen: Optional[StrictStr] = Field( + default=None, + description="The ID of the screen displayed first. This must be an entry screen.", + ) + data: Optional[Any] = Field( + default=None, description="Data for the first screen." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py new file mode 100644 index 00000000..909e521c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_action_payload import ( + FlowActionPayload, +) + + +class FlowChannelSpecificMessage(WhatsAppCommonProps): + flow_id: StrictStr = Field(..., description="ID of the Flow.") + flow_cta: StrictStr = Field( + ..., + description="Text which is displayed on the Call To Action button (20 characters maximum, emoji not supported).", + ) + flow_token: Optional[StrictStr] = Field( + default=None, description="Generated token which is an identifier." + ) + flow_mode: Optional[StrictStr] = Field( + default="published", description="The mode in which the flow is." + ) + flow_action: Optional[StrictStr] = Field( + default="navigate", description="The flow action." + ) + flow_action_payload: Optional[FlowActionPayload] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py new file mode 100644 index 00000000..4c9f0cc8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveBody(BaseModelConfigurationResponse): + text: StrictStr = Field( + ..., + description="The content of the message (1024 characters maximum). Emojis and Markdown are supported.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py new file mode 100644 index 00000000..7cc87228 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveDocumentHeader(BaseModelConfigurationResponse): + type: Literal["document"] = Field( + ..., description="The document associated with the header." + ) + document: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The document media object." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py new file mode 100644 index 00000000..449c66dd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveFooter(BaseModelConfigurationResponse): + text: StrictStr = Field( + ..., + description="The footer content (60 characters maximum). Emojis, Markdown and links are supported.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py new file mode 100644 index 00000000..7ab870eb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py @@ -0,0 +1,8 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveHeaderMedia(BaseModelConfigurationResponse): + link: StrictStr = Field(..., description="URL for the media.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py new file mode 100644 index 00000000..2c6b9b47 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveImageHeader(BaseModelConfigurationResponse): + type: Literal["image"] = Field( + ..., description="The image associated with the header." + ) + image: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The image media object." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py new file mode 100644 index 00000000..3aa24c5c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py @@ -0,0 +1,13 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveTextHeader(BaseModelConfigurationResponse): + type: Literal["text"] = Field(..., description="The text of the header.") + text: StrictStr = Field( + ..., + description="Text for the header. Formatting allows emojis, but not Markdown.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py new file mode 100644 index 00000000..ab9965dc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( + WhatsAppInteractiveHeaderMedia, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveVideoHeader(BaseModelConfigurationResponse): + type: Literal["video"] = Field( + ..., description="The video associated with the header." + ) + video: WhatsAppInteractiveHeaderMedia = Field( + ..., description="The video media object." + ) diff --git a/sinch/domains/conversation/models/capability/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py similarity index 100% rename from sinch/domains/conversation/models/capability/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py new file mode 100644 index 00000000..4321ce60 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py @@ -0,0 +1,17 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( + WhatsAppInteractiveNfmReplyNameType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveNfmReply(BaseModelConfigurationResponse): + name: WhatsAppInteractiveNfmReplyNameType = Field( + ..., description="The nfm reply message type." + ) + response_json: StrictStr = Field( + ..., description="The JSON specific data." + ) + body: StrictStr = Field(..., description="The message body.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py new file mode 100644 index 00000000..e6c2ab9e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( + WhatsAppInteractiveNfmReply, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveNfmReplyMessage(BaseModelConfigurationResponse): + type: Literal["nfm_reply"] = Field( + description="The interactive message type." + ) + nfm_reply: WhatsAppInteractiveNfmReply = Field( + ..., description="The nfm reply message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py new file mode 100644 index 00000000..e6c2ab9e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py @@ -0,0 +1,17 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( + WhatsAppInteractiveNfmReply, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppInteractiveNfmReplyMessage(BaseModelConfigurationResponse): + type: Literal["nfm_reply"] = Field( + description="The interactive message type." + ) + nfm_reply: WhatsAppInteractiveNfmReply = Field( + ..., description="The nfm reply message." + ) diff --git a/sinch/domains/conversation/models/contact/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py similarity index 100% rename from sinch/domains/conversation/models/contact/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py new file mode 100644 index 00000000..f6353c2c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class Boleto(BaseModelConfigurationResponse): + digitable_line: StrictStr = Field( + ..., + description="The Boleto digitable line which will be copied to the clipboard when the user taps the Boleto button.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py new file mode 100644 index 00000000..1e09f0c2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py @@ -0,0 +1,16 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.pix_key_type import ( + PixKeyType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class DynamicPix(BaseModelConfigurationResponse): + code: StrictStr = Field( + ..., description="The dynamic Pix code to be used by the buyer to pay." + ) + merchant_name: StrictStr = Field(..., description="Account holder name.") + key: StrictStr = Field(..., description="Pix key.") + key_type: PixKeyType = Field(..., description="Pix key type.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py new file mode 100644 index 00000000..59b1afe9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py @@ -0,0 +1,21 @@ +from typing import Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class OrderItem(BaseModelConfigurationResponse): + retailer_id: StrictStr = Field( + ..., description="Unique ID of the retailer." + ) + name: StrictStr = Field( + ..., description="Item's name as displayed to the user." + ) + amount_value: StrictInt = Field(..., description="Price per item.") + quantity: StrictInt = Field( + ..., description="Number of items in this order." + ) + sale_amount_value: Optional[StrictInt] = Field( + default=None, description="Discounted price per item." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py new file mode 100644 index 00000000..a93d5484 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py @@ -0,0 +1,10 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentLink(BaseModelConfigurationResponse): + uri: StrictStr = Field( + ..., description="The payment link to be used by the buyer to pay." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py new file mode 100644 index 00000000..83dc4f0b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.order_item import ( + OrderItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentOrder(BaseModelConfigurationResponse): + items: List[OrderItem] = Field( + ..., description="The items list for this order." + ) + subtotal_value: StrictInt = Field( + ..., + description="Value representing the subtotal amount of this order.", + ) + tax_value: StrictInt = Field( + ..., description="Value representing the tax amount for this order." + ) + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Unique ID of the Facebook catalog being used by the business.", + ) + expiration_time: Optional[datetime] = Field( + default=None, + description="UTC timestamp indicating when the order should expire.", + ) + expiration_description: Optional[StrictStr] = Field( + default=None, description="Description of the expiration." + ) + tax_description: Optional[StrictStr] = Field( + default=None, description="Description of the tax for this order." + ) + shipping_value: Optional[StrictInt] = Field( + default=None, + description="Value representing the shipping amount for this order.", + ) + shipping_description: Optional[StrictStr] = Field( + default=None, description="Shipping description for this order." + ) + discount_value: Optional[StrictInt] = Field( + default=None, description="Value of the discount for this order." + ) + discount_description: Optional[StrictStr] = Field( + default=None, description="Description of the discount for this order." + ) + discount_program_name: Optional[StrictStr] = Field( + default=None, description="Discount program name for this order." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py new file mode 100644 index 00000000..568918b6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py @@ -0,0 +1,13 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_content import ( + PaymentOrderDetailsContent, +) + + +class PaymentOrderDetailsChannelSpecificMessage(WhatsAppCommonProps): + payment: PaymentOrderDetailsContent = Field( + ..., description="The payment order details content." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py new file mode 100644 index 00000000..de3c9d59 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py @@ -0,0 +1,34 @@ +from typing import Optional +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.conversation.models.v1.messages.types.payment_order_type import ( + PaymentOrderType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_goods_type import ( + PaymentOrderGoodsType, +) +from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( + PaymentSettings, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order import ( + PaymentOrder, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentOrderDetailsContent(BaseModelConfigurationResponse): + type: PaymentOrderType = Field( + ..., + description="The country/currency associated with the payment message.", + ) + reference_id: StrictStr = Field(..., description="Unique reference ID.") + type_of_goods: PaymentOrderGoodsType = Field( + ..., description="The type of good associated with this order." + ) + total_amount_value: StrictInt = Field( + ..., + description="Integer representing the total amount of the transaction.", + ) + order: PaymentOrder = Field(..., description="The payment order.") + payment_settings: Optional[PaymentSettings] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py new file mode 100644 index 00000000..b00cda0e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py @@ -0,0 +1,13 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( + WhatsAppCommonProps, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_content import ( + PaymentOrderStatusContent, +) + + +class PaymentOrderStatusChannelSpecificMessage(WhatsAppCommonProps): + payment: PaymentOrderStatusContent = Field( + ..., description="The payment order status message content" + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py new file mode 100644 index 00000000..fc0e5fa7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py @@ -0,0 +1,16 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_order import ( + PaymentOrderStatusOrder, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentOrderStatusContent(BaseModelConfigurationResponse): + reference_id: StrictStr = Field( + ..., description="Unique ID used to query the current payment status." + ) + order: PaymentOrderStatusOrder = Field( + ..., description="The payment order." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py new file mode 100644 index 00000000..14d384ee --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py @@ -0,0 +1,18 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.payment_order_status_type import ( + PaymentOrderStatusType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentOrderStatusOrder(BaseModelConfigurationResponse): + status: PaymentOrderStatusType = Field( + ..., description="The new payment message status." + ) + description: Optional[StrictStr] = Field( + default=None, + description="The description of payment message status update (120 characters maximum).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py new file mode 100644 index 00000000..85b947e1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( + WhatsAppInteractiveHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_body import ( + WhatsAppInteractiveBody, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_footer import ( + WhatsAppInteractiveFooter, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class WhatsAppCommonProps(BaseModelConfigurationResponse): + header: Optional[WhatsAppInteractiveHeader] = Field( + default=None, description="The header of the interactive message." + ) + body: Optional[WhatsAppInteractiveBody] = Field( + default=None, description="Body of the interactive message." + ) + footer: Optional[WhatsAppInteractiveFooter] = Field( + default=None, description="Footer of the interactive message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py new file mode 100644 index 00000000..5ba2c49e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "ChoiceMessage", + "ChoiceMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ChoiceMessage": + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, + ) + + return ChoiceMessage + if name == "ChoiceMessageField": + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_field import ( + ChoiceMessageField, + ) + + return ChoiceMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py new file mode 100644 index 00000000..e45f644f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import ( + MessageProperties, +) + + +class ChoiceMessage(BaseModelConfigurationResponse): + choices: conlist(ChoiceOption) = Field( + default=..., description="The number of choices is limited to 10." + ) + text_message: Optional[TextMessage] = None + message_properties: Optional[MessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py new file mode 100644 index 00000000..5ba83892 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChoiceMessageField(BaseModelConfigurationResponse): + choice_message: Optional[ChoiceMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py new file mode 100644 index 00000000..f9ef0547 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py @@ -0,0 +1,54 @@ +from typing import Any, Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.call.call_message import ( + CallMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.url.url_message import ( + UrlMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.calendar.calendar_message import ( + CalendarMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.sharelocation.share_location_message import ( + ShareLocationMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) + + +class ChoiceMessageWithPostback(BaseModelConfigurationResponse): + postback_data: Optional[Any] = Field( + default=None, + description="An optional field. This data will be returned in the ChoiceResponseMessage. The default is message_id_{text, title}.", + ) + + +class CallChoiceMessage(ChoiceMessageWithPostback): + call_message: Optional[CallMessage] = None + + +class LocationChoiceMessage(ChoiceMessageWithPostback): + location_message: Optional[LocationMessage] = None + + +class TextChoiceMessage(ChoiceMessageWithPostback): + text_message: Optional[TextMessage] = None + + +class UrlChoiceMessage(ChoiceMessageWithPostback): + url_message: Optional[UrlMessage] = None + + +class CalendarChoiceMessage(ChoiceMessageWithPostback): + calendar_message: Optional[CalendarMessage] = None + + +class ShareLocationChoiceMessage(ChoiceMessageWithPostback): + share_location_message: Optional[ShareLocationMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py new file mode 100644 index 00000000..f574170b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.choiceresponse.choice_response_message import ( + ChoiceResponseMessage, +) + +__all__ = [ + "ChoiceResponseMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py new file mode 100644 index 00000000..094b949c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py @@ -0,0 +1,13 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChoiceResponseMessage(BaseModelConfigurationResponse): + message_id: StrictStr = Field( + ..., description="The message id containing the choice." + ) + postback_data: StrictStr = Field( + ..., description="The postback_data defined in the selected choice." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py new file mode 100644 index 00000000..8e548c2c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/common/__init__.py @@ -0,0 +1,15 @@ +from sinch.domains.conversation.models.v1.messages.categories.fallback.fallback_message import ( + FallbackMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.productresponse.product_response_message import ( + ProductResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.common.reply_to import ( + ReplyTo, +) + +__all__ = [ + "FallbackMessage", + "ProductResponseMessage", + "ReplyTo", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py new file mode 100644 index 00000000..b81e0994 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ReplyTo(BaseModelConfigurationResponse): + message_id: StrictStr = Field( + default=..., + description="Required. The Id of the message that this is a response to", + ) diff --git a/sinch/domains/conversation/models/event/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/contact/__init__.py similarity index 100% rename from sinch/domains/conversation/models/event/__init__.py rename to sinch/domains/conversation/models/v1/messages/categories/contact/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py new file mode 100644 index 00000000..f24ad512 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py @@ -0,0 +1,83 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.choiceresponse import ( + ChoiceResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_contact_message_message import ( + ChannelSpecificContactMessageMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.fallback import ( + FallbackMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.mediacard import ( + MediaCardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.productresponse import ( + ProductResponseMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.shared.contact_message_common_props import ( + ContactMessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChannelSpecificContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + channel_specific_message: ChannelSpecificContactMessageMessage = Field( + ..., + description="A contact message containing a channel specific message (not supported by OMNI types).", + ) + + +class ChoiceResponseContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + choice_response_message: Optional[ChoiceResponseMessage] = None + + +class FallbackContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + fallback_message: Optional[FallbackMessage] = None + + +class LocationContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + location_message: Optional[LocationMessage] = None + + +class MediaCardContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + media_card_message: Optional[MediaCardMessage] = None + + +class MediaContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + media_message: Optional[MediaProperties] = None + + +class ProductResponseContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + product_response_message: Optional[ProductResponseMessage] = None + + +class TextContactMessage( + ContactMessageCommonProps, BaseModelConfigurationResponse +): + text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py new file mode 100644 index 00000000..02d9d495 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message_field import ( + ContactInfoMessageField, +) + +__all__ = [ + "ContactInfoMessage", + "ContactInfoMessageField", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py new file mode 100644 index 00000000..e106a3d4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py @@ -0,0 +1,45 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.shared.name_info import ( + NameInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.phone_number_info import ( + PhoneNumberInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.address_info import ( + AddressInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.email_info import ( + EmailInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.organization_info import ( + OrganizationInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.url_info import ( + UrlInfo, +) + + +class ContactInfoMessage(BaseModelConfigurationResponse): + name: NameInfo = Field(..., description="Name information of the contact.") + phone_numbers: conlist(PhoneNumberInfo) = Field( + description="Phone numbers of the contact (at least one required).", + ) + addresses: Optional[conlist(AddressInfo)] = Field( + default=None, description="Physical addresses of the contact." + ) + email_addresses: Optional[conlist(EmailInfo)] = Field( + default=None, description="Email addresses of the contact." + ) + organization: Optional[OrganizationInfo] = Field( + default=None, description="Organization info of the contact." + ) + urls: Optional[conlist(UrlInfo)] = Field( + default=None, description="URLs/websites associated with the contact." + ) + birthday: Optional[str] = Field( + default=None, description="Date of birth in YYYY-MM-DD format." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py new file mode 100644 index 00000000..efe3ef4c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) + + +class ContactInfoMessageField(BaseModelConfigurationResponse): + contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py new file mode 100644 index 00000000..a58da977 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/fallback/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.fallback.fallback_message import ( + FallbackMessage, +) + +__all__ = [ + "FallbackMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py new file mode 100644 index 00000000..83ca3d71 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.reason import Reason +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class FallbackMessage(BaseModelConfigurationResponse): + raw_message: Optional[StrictStr] = Field( + default=None, + description="Optional. The raw fallback message if provided by the channel.", + ) + reason: Optional[Reason] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py new file mode 100644 index 00000000..34b34021 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "ListMessage", + "ListMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ListMessage": + from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, + ) + + return ListMessage + if name == "ListMessageField": + from sinch.domains.conversation.models.v1.messages.categories.list.list_message_field import ( + ListMessageField, + ) + + return ListMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py new file mode 100644 index 00000000..67ebbb2f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py @@ -0,0 +1,11 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.shared.choice_item import ( + ChoiceItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListItemChoice(BaseModelConfigurationResponse): + choice: ChoiceItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py new file mode 100644 index 00000000..110ecb31 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py @@ -0,0 +1,11 @@ +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListItemProduct(BaseModelConfigurationResponse): + product: ProductItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py new file mode 100644 index 00000000..826a8433 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py @@ -0,0 +1,31 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.shared.list_section import ( + ListSection, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message_properties import ( + ListMessageProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListMessage(BaseModelConfigurationResponse): + title: StrictStr = Field( + default=..., + description="A title for the message that is displayed near the products or choices.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="This is an optional field, containing a description for the message.", + ) + media: Optional[MediaProperties] = None + sections: conlist(ListSection) = Field( + default=..., + description="List of ListSection objects containing choices to be presented in the list message.", + ) + message_properties: Optional[ListMessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py new file mode 100644 index 00000000..1fa02b02 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListMessageField(BaseModelConfigurationResponse): + list_message: Optional[ListMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py new file mode 100644 index 00000000..b3f780dc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListMessageProperties(BaseModelConfigurationResponse): + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Required if sending a product list message. The ID of the catalog to which the products belong.", + ) + menu: Optional[StrictStr] = Field( + default=None, + description="Optional. Sets the text for the menu of a choice list message.", + ) + whatsapp_header: Optional[StrictStr] = Field( + default=None, + description="Optional. Sets the text for the header of a WhatsApp choice list message. Ignored for other channels.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py new file mode 100644 index 00000000..9330f8ad --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/__init__.py @@ -0,0 +1,21 @@ +__all__ = [ + "LocationMessage", + "LocationMessageField", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "LocationMessage": + from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, + ) + + return LocationMessage + if name == "LocationMessageField": + from sinch.domains.conversation.models.v1.messages.categories.location.location_message_field import ( + LocationMessageField, + ) + + return LocationMessageField + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py new file mode 100644 index 00000000..c7f1319c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class LocationMessage(BaseModelConfigurationResponse): + coordinates: Coordinates = Field(...) + label: Optional[StrictStr] = Field( + default=None, description="Label or name for the position." + ) + title: StrictStr = Field( + default=..., + description="The title is shown close to the button or link that leads to a map showing the location. The title can be clickable in some cases.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py new file mode 100644 index 00000000..7f3def35 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class LocationMessageField(BaseModelConfigurationResponse): + location_message: Optional[LocationMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py new file mode 100644 index 00000000..e74101f7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.media.media_message_field import ( + MediaMessageField, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) + +__all__ = [ + "MediaMessageField", + "MediaProperties", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py new file mode 100644 index 00000000..4453cde9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaMessageField(BaseModelConfigurationResponse): + media_message: Optional[MediaProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py new file mode 100644 index 00000000..d15041d5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaProperties(BaseModelConfigurationResponse): + thumbnail_url: Optional[StrictStr] = Field( + default=None, + description="An optional parameter. Will be used where it is natively supported.", + ) + url: StrictStr = Field(default=..., description="Url to the media file.") + filename_override: Optional[StrictStr] = Field( + default=None, description="Overrides the media file name." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py new file mode 100644 index 00000000..bc78e410 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/mediacard/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.mediacard.media_card_message import ( + MediaCardMessage, +) + +__all__ = [ + "MediaCardMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py new file mode 100644 index 00000000..1374064e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py @@ -0,0 +1,13 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MediaCardMessage(BaseModelConfigurationResponse): + caption: Optional[StrictStr] = Field( + default=None, + description="Caption for the media on supported channels.", + ) + url: StrictStr = Field(default=..., description="Url to the media file.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py new file mode 100644 index 00000000..abac8b94 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/productresponse/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.productresponse.product_response_message import ( + ProductResponseMessage, +) + +__all__ = [ + "ProductResponseMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py new file mode 100644 index 00000000..8d8a3ebd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py @@ -0,0 +1,22 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ProductResponseMessage(BaseModelConfigurationResponse): + products: Optional[conlist(ProductItem)] = Field( + default=None, description="The selected products." + ) + title: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. Text that may be sent with selected products.", + ) + catalog_id: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. The catalog id that the selected products belong to.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py new file mode 100644 index 00000000..e0f98ed2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "ShareLocationMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "ShareLocationMessage": + from sinch.domains.conversation.models.v1.messages.categories.sharelocation.share_location_message import ( + ShareLocationMessage, + ) + + return ShareLocationMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py new file mode 100644 index 00000000..af9b8f91 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py @@ -0,0 +1,15 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ShareLocationMessage(BaseModelConfigurationResponse): + title: StrictStr = Field( + ..., + description="The title is shown close to the button that leads to open a map to share a location.", + ) + fallback_url: StrictStr = Field( + ..., + description="The URL that is opened when channel does not have support for this type.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py new file mode 100644 index 00000000..6fb43934 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/__init__.py @@ -0,0 +1,35 @@ +__all__ = [ + "TemplateMessage", + "TemplateReferenceChannelSpecific", + "TemplateReferenceField", + "TemplateReferenceOmniChannel", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "TemplateMessage": + from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, + ) + + return TemplateMessage + if name == "TemplateReferenceChannelSpecific": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_channel_specific import ( + TemplateReferenceChannelSpecific, + ) + + return TemplateReferenceChannelSpecific + if name == "TemplateReferenceField": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_field import ( + TemplateReferenceField, + ) + + return TemplateReferenceField + if name == "TemplateReferenceOmniChannel": + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_omni_channel import ( + TemplateReferenceOmniChannel, + ) + + return TemplateReferenceOmniChannel + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py new file mode 100644 index 00000000..40eeb8fe --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py @@ -0,0 +1,19 @@ +from typing import Dict, Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceChannelSpecific, + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TemplateMessage(BaseModelConfigurationResponse): + channel_template: Optional[Dict[str, TemplateReferenceChannelSpecific]] = ( + Field( + default=None, + description="Optional. Channel specific template reference with parameters per channel. The channel template if exists overrides the omnichannel template. At least one of `channel_template` or `omni_template` needs to be present. The key in the map must point to a valid conversation channel as defined by the enum ConversationChannel.", + ) + ) + omni_template: Optional[TemplateReferenceOmniChannel] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py new file mode 100644 index 00000000..93e56cf4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py @@ -0,0 +1,24 @@ +from typing import Dict, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TemplateReferenceChannelSpecific(BaseModelConfigurationResponse): + version: Optional[StrictStr] = Field( + default=None, + description="Used to specify what version of a template to use. Required when using `omni_channel_override` and `omni_template` fields. This will be used in conjunction with `language_code`. Note that, when referencing omni-channel templates using the [Sinch Customer Dashboard](https://dashboard.sinch.com/), the latest version of a given omni-template can be identified by populating this field with `latest`.", + ) + language_code: Optional[StrictStr] = Field( + default=None, + description="The BCP-47 language code, such as `en_US` or `sr_Latn`. For more information, see http://www.unicode.org/reports/tr35/#Unicode_locale_identifier. English is the default `language_code`. Note that, while many API calls involving templates accept either the dashed format (`en-US`) or the underscored format (`en_US`), some channel specific templates (for example, WhatsApp channel-specific templates) only accept the underscored format. Note that this field is required for WhatsApp channel-specific templates.", + ) + parameters: Optional[Dict[str, StrictStr]] = Field( + default=None, + description="Required if the template has parameters. Concrete values must be present for all defined parameters in the template. Parameters can be different for different versions and/or languages of the template.", + ) + template_id: StrictStr = Field( + default=..., + description="The ID of the template. Note that, in the case of WhatsApp channel-specific templates, this field must be populated by the name of the template.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py new file mode 100644 index 00000000..db375b3d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TemplateReferenceField(BaseModelConfigurationResponse): + template_reference: Optional[TemplateReferenceOmniChannel] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py new file mode 100644 index 00000000..97f378cb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_omni_channel.py @@ -0,0 +1,11 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateReferenceChannelSpecific, +) + + +class TemplateReferenceOmniChannel(TemplateReferenceChannelSpecific): + version: StrictStr = Field( + ..., + description="Used to specify what version of a template to use. Required when using `omni_channel_override` and `omni_template` fields. This will be used in conjunction with `language_code`. Note that, when referencing omni-channel templates using the [Sinch Customer Dashboard](https://dashboard.sinch.com/), the latest version of a given omni-template can be identified by populating this field with `latest`.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py new file mode 100644 index 00000000..b60b473e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.categories.text.text_message import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.text.text_message_field import ( + TextMessageField, +) + +__all__ = [ + "TextMessage", + "TextMessageField", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py new file mode 100644 index 00000000..cbcc5adb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py @@ -0,0 +1,10 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class TextMessage(BaseModelConfigurationResponse): + text: StrictStr = Field( + ..., description="The text content of the message." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py new file mode 100644 index 00000000..245e4bfc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) + + +class TextMessageField(BaseModelConfigurationResponse): + text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py new file mode 100644 index 00000000..436869a9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/url/__init__.py @@ -0,0 +1,14 @@ +__all__ = [ + "UrlMessage", +] + + +def __getattr__(name: str): + """Lazy import to avoid circular dependencies.""" + if name == "UrlMessage": + from sinch.domains.conversation.models.v1.messages.categories.url.url_message import ( + UrlMessage, + ) + + return UrlMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py new file mode 100644 index 00000000..a861288b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class UrlMessage(BaseModelConfigurationResponse): + title: StrictStr = Field( + default=..., + description="The title shown close to the URL. The title can be clickable in some cases.", + ) + url: StrictStr = Field(default=..., description="The url to show.") diff --git a/sinch/domains/conversation/models/v1/messages/internal/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/__init__.py new file mode 100644 index 00000000..a9a2c5b3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py new file mode 100644 index 00000000..c9983491 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py @@ -0,0 +1,9 @@ +from sinch.domains.conversation.models.v1.messages.internal.base.base_model_configuration import ( + BaseModelConfigurationRequest, + BaseModelConfigurationResponse, +) + +__all__ = [ + "BaseModelConfigurationRequest", + "BaseModelConfigurationResponse", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py new file mode 100644 index 00000000..204ea49d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py @@ -0,0 +1,44 @@ +import re +from typing import Any +from pydantic import BaseModel, ConfigDict + + +class BaseModelConfigurationRequest(BaseModel): + """ + A base model that allows extra fields and converts snake_case to camelCase. + """ + + model_config = ConfigDict( + # Allows using both alias (camelCase) and field name (snake_case) + populate_by_name=True, + # Allows extra values in input + extra="allow", + ) + + +class BaseModelConfigurationResponse(BaseModel): + """ + A base model that allows extra fields and converts camelCase to snake_case + """ + + @staticmethod + def _to_snake_case(camel_str: str) -> str: + """Helper to convert camelCase string to snake_case.""" + return re.sub(r"(? None: + """Converts unknown fields from camelCase to snake_case.""" + if self.__pydantic_extra__: + converted_extra = { + self._to_snake_case(key): value + for key, value in self.__pydantic_extra__.items() + } + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(converted_extra) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py new file mode 100644 index 00000000..4fc65a1f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -0,0 +1,11 @@ +from sinch.domains.conversation.models.v1.messages.internal.request.message_id_request import ( + MessageIdRequest, +) +from sinch.domains.conversation.models.v1.messages.internal.request.update_message_metadata_request import ( + UpdateMessageMetadataRequest, +) + +__all__ = [ + "MessageIdRequest", + "UpdateMessageMetadataRequest", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py new file mode 100644 index 00000000..86b4a1be --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.types import ( + MessagesSourceType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationRequest, +) + + +class MessageIdRequest(BaseModelConfigurationRequest): + message_id: str = Field(..., description="The unique ID of the message.") + messages_source: Optional[MessagesSourceType] = Field( + default=None, + description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py new file mode 100644 index 00000000..278e9091 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py @@ -0,0 +1,19 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types import ( + MessagesSourceType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationRequest, +) + + +class UpdateMessageMetadataRequest(BaseModelConfigurationRequest): + message_id: str = Field(..., description="The unique ID of the message.") + metadata: StrictStr = Field( + ..., description="Metadata that should be associated with the message." + ) + messages_source: Optional[MessagesSourceType] = Field( + default=None, + description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode.", + ) diff --git a/sinch/domains/conversation/models/message/__init__.py b/sinch/domains/conversation/models/v1/messages/response/__init__.py similarity index 100% rename from sinch/domains/conversation/models/message/__init__.py rename to sinch/domains/conversation/models/v1/messages/response/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/response/message_response.py b/sinch/domains/conversation/models/v1/messages/response/message_response.py new file mode 100644 index 00000000..d62bb794 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/message_response.py @@ -0,0 +1,24 @@ +from sinch.domains.conversation.models.v1.messages.shared import ( + MessageResponseCommonProps, +) +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class AppMessageResponse( + MessageResponseCommonProps, BaseModelConfigurationResponse +): + app_message: AppMessage + + +class ContactMessageResponse( + MessageResponseCommonProps, BaseModelConfigurationResponse +): + contact_message: ContactMessage diff --git a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py new file mode 100644 index 00000000..30e0cd54 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py @@ -0,0 +1,47 @@ +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.channel_specific_message_content import ( + ChannelSpecificMessageContent, +) +from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( + ChoiceOption, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) +from sinch.domains.conversation.models.v1.messages.response.types.conversation_message_response import ( + ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( + KakaoTalkButton, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_commerce import ( + KakaoTalkCommerce, +) +from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( + KakaoTalkCoupon, +) +from sinch.domains.conversation.models.v1.messages.response.types.list_item import ( + ListItem, +) +from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( + PaymentSettings, +) +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( + WhatsAppInteractiveHeader, +) + +__all__ = [ + "AppMessage", + "ChannelSpecificMessageContent", + "ChoiceOption", + "ContactMessage", + "ConversationMessageResponse", + "KakaoTalkButton", + "KakaoTalkCommerce", + "KakaoTalkCoupon", + "ListItem", + "PaymentSettings", + "WhatsAppInteractiveHeader", +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/app_message.py b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py new file mode 100644 index 00000000..396568d5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py @@ -0,0 +1,24 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, + CarouselAppMessage, + ChoiceAppMessage, + ContactInfoAppMessage, + ListAppMessage, + LocationAppMessage, + MediaAppMessage, + TemplateAppMessage, + TextAppMessage, +) + +AppMessage = Union[ + CardAppMessage, + CarouselAppMessage, + ChoiceAppMessage, + LocationAppMessage, + MediaAppMessage, + TemplateAppMessage, + TextAppMessage, + ListAppMessage, + ContactInfoAppMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py b/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py new file mode 100644 index 00000000..90ee43fc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py @@ -0,0 +1,25 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( + FlowChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_channel_specific_message import ( + PaymentOrderDetailsChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( + PaymentOrderStatusChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_channel_specific_message import ( + KakaoTalkCommerceChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_commerce_channel_specific_message import ( + KakaoTalkCarouselCommerceChannelSpecificMessage, +) + + +ChannelSpecificMessageContent = Union[ + FlowChannelSpecificMessage, + PaymentOrderDetailsChannelSpecificMessage, + PaymentOrderStatusChannelSpecificMessage, + KakaoTalkCommerceChannelSpecificMessage, + KakaoTalkCarouselCommerceChannelSpecificMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py b/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py new file mode 100644 index 00000000..d2ddf127 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py @@ -0,0 +1,19 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + CallChoiceMessage, + LocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, + CalendarChoiceMessage, + ShareLocationChoiceMessage, +) + + +ChoiceOption = Union[ + CallChoiceMessage, + LocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, + CalendarChoiceMessage, + ShareLocationChoiceMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py b/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py new file mode 100644 index 00000000..7dfb8f84 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/contact_message.py @@ -0,0 +1,22 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChannelSpecificContactMessage, + ChoiceResponseContactMessage, + FallbackContactMessage, + LocationContactMessage, + MediaCardContactMessage, + MediaContactMessage, + ProductResponseContactMessage, + TextContactMessage, +) + +ContactMessage = Union[ + ChannelSpecificContactMessage, + ChoiceResponseContactMessage, + FallbackContactMessage, + LocationContactMessage, + MediaCardContactMessage, + MediaContactMessage, + ProductResponseContactMessage, + TextContactMessage, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py b/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py new file mode 100644 index 00000000..fd331013 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/conversation_message_response.py @@ -0,0 +1,11 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +ConversationMessageResponse = Union[ + AppMessageResponse, + ContactMessageResponse, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py new file mode 100644 index 00000000..e1d15f36 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py @@ -0,0 +1,17 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_web_link_button import ( + KakaoTalkWebLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_app_link_button import ( + KakaoTalkAppLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_bot_keyword_button import ( + KakaoTalkBotKeywordButton, +) + + +KakaoTalkButton = Union[ + KakaoTalkWebLinkButton, + KakaoTalkAppLinkButton, + KakaoTalkBotKeywordButton, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py new file mode 100644 index 00000000..48a8e02c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py @@ -0,0 +1,17 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_fixed_commerce import ( + KakaoTalkDiscountFixedCommerce, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_rate_commerce import ( + KakaoTalkDiscountRateCommerce, +) + + +KakaoTalkCommerce = Union[ + KakaoTalkRegularPriceCommerce, + KakaoTalkDiscountFixedCommerce, + KakaoTalkDiscountRateCommerce, +] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py new file mode 100644 index 00000000..85256c08 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py @@ -0,0 +1,28 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_fixed_discount_coupon import ( + KakaoTalkFixedDiscountCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_rate_coupon import ( + KakaoTalkDiscountRateCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_shipping_discount_coupon import ( + KakaoTalkShippingDiscountCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_free_coupon import ( + KakaoTalkFreeCoupon, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_up_coupon import ( + KakaoTalkUpCoupon, +) + + +_KakaoTalkCouponUnion = Union[ + KakaoTalkFixedDiscountCoupon, + KakaoTalkDiscountRateCoupon, + KakaoTalkShippingDiscountCoupon, + KakaoTalkFreeCoupon, + KakaoTalkUpCoupon, +] + +KakaoTalkCoupon = Annotated[_KakaoTalkCouponUnion, Field(discriminator="type")] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/list_item.py b/sinch/domains/conversation/models/v1/messages/response/types/list_item.py new file mode 100644 index 00000000..ebc54b0b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/list_item.py @@ -0,0 +1,10 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.list.list_item_choice import ( + ListItemChoice, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_item_product import ( + ListItemProduct, +) + + +ListItem = Union[ListItemChoice, ListItemProduct] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py b/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py new file mode 100644 index 00000000..006c6497 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.dynamic_pix import ( + DynamicPix, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_link import ( + PaymentLink, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.boleto import ( + Boleto, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PaymentSettings(BaseModelConfigurationResponse): + dynamic_pix: Optional[DynamicPix] = Field( + default=None, description="The dynamic Pix payment settings." + ) + payment_link: Optional[PaymentLink] = Field( + default=None, description="The payment link payment settings." + ) + boleto: Optional[Boleto] = Field( + default=None, description="The Boleto payment settings." + ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py new file mode 100644 index 00000000..ccaa44d2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_interactive_header.py @@ -0,0 +1,26 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_text_header import ( + WhatsAppInteractiveTextHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_image_header import ( + WhatsAppInteractiveImageHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_document_header import ( + WhatsAppInteractiveDocumentHeader, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_video_header import ( + WhatsAppInteractiveVideoHeader, +) + + +_WhatsAppInteractiveHeaderUnion = Union[ + WhatsAppInteractiveTextHeader, + WhatsAppInteractiveImageHeader, + WhatsAppInteractiveDocumentHeader, + WhatsAppInteractiveVideoHeader, +] + +WhatsAppInteractiveHeader = Annotated[ + _WhatsAppInteractiveHeaderUnion, Field(discriminator="type") +] diff --git a/sinch/domains/conversation/models/v1/messages/shared/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/__init__.py new file mode 100644 index 00000000..d9cd2eef --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/__init__.py @@ -0,0 +1,61 @@ +from sinch.domains.conversation.models.v1.messages.shared.address_info import ( + AddressInfo, +) +from sinch.domains.conversation.models.v1.messages.shared.agent import Agent +from sinch.domains.conversation.models.v1.messages.shared.channel_identity import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.shared.choice_item import ( + ChoiceItem, +) +from sinch.domains.conversation.models.v1.messages.shared.contact_message_common_props import ( + ContactMessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.shared.message_response_common_props import ( + MessageResponseCommonProps, +) +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) +from sinch.domains.conversation.models.v1.messages.shared.list_section import ( + ListSection, +) +from sinch.domains.conversation.models.v1.messages.shared.product_item import ( + ProductItem, +) +from sinch.domains.conversation.models.v1.messages.shared.reason import Reason +from sinch.domains.conversation.models.v1.messages.shared.reason_sub_code import ( + ReasonSubCode, +) + +__all__ = [ + "AddressInfo", + "Agent", + "AppMessageCommonProps", + "ChannelIdentity", + "ChoiceItem", + "ContactMessageCommonProps", + "MessageResponseCommonProps", + "Coordinates", + "ListSection", + "OmniMessageOverride", + "ProductItem", + "Reason", + "ReasonSubCode", +] + + +def __getattr__(name: str): + if name == "OmniMessageOverride": + from sinch.domains.conversation.models.v1.messages.shared.override.omni_message_override import ( + OmniMessageOverride, + ) + + return OmniMessageOverride + if name == "AppMessageCommonProps": + from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import ( + AppMessageCommonProps, + ) + + return AppMessageCommonProps + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/shared/address_info.py b/sinch/domains/conversation/models/v1/messages/shared/address_info.py new file mode 100644 index 00000000..ff2fed6b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/address_info.py @@ -0,0 +1,24 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class AddressInfo(BaseModelConfigurationResponse): + city: Optional[StrictStr] = Field(default=None, description="City Name") + country: Optional[StrictStr] = Field( + default=None, description="Country Name" + ) + state: Optional[StrictStr] = Field( + default=None, description="Name of a state or region of a country." + ) + zip: Optional[StrictStr] = Field( + default=None, description="Zip/postal code" + ) + type: Optional[StrictStr] = Field( + default=None, description="Address type, e.g. WORK or HOME" + ) + country_code: Optional[StrictStr] = Field( + default=None, description="Two letter country code." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/agent.py b/sinch/domains/conversation/models/v1/messages/shared/agent.py new file mode 100644 index 00000000..62ef947f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/agent.py @@ -0,0 +1,16 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types import AgentType +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class Agent(BaseModelConfigurationResponse): + display_name: Optional[StrictStr] = Field( + default=None, description="Agent's display name" + ) + type: Optional[AgentType] = None + picture_url: Optional[StrictStr] = Field( + default=None, description="The Agent's picture url." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py new file mode 100644 index 00000000..aa60920e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py @@ -0,0 +1,32 @@ +from typing import Dict, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message import ( + ChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.shared import Agent +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.conversation.models.v1.messages.shared.override.omni_message_override import ( + OmniMessageOverride, +) + + +class AppMessageCommonProps(BaseModelConfigurationResponse): + explicit_channel_message: Optional[Dict[str, StrictStr]] = Field( + default=None, + description="Allows you to specify a channel and define a corresponding channel specific message payload that will override the standard Conversation API message types. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`. The message content must be provided in string format. You may use the [transcoding endpoint](https://developers.sinch.com/docs/conversation/api-reference/conversation/tag/Transcoding/) to help create your message. For more information about how to construct an explicit channel message for a particular channel, see that [channel's corresponding documentation](https://developers.sinch.com/docs/conversation/channel-support/) (for example, using explicit channel messages with [the WhatsApp channel](https://developers.sinch.com/docs/conversation/channel-support/whatsapp/message-support/#explicit-channel-messages)).", + ) + explicit_channel_omni_message: Optional[Dict[str, OmniMessageOverride]] = ( + Field( + default=None, + description="Override the message's content for specified channels. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`. The content defined under the specified channel will be sent on that channel.", + ) + ) + channel_specific_message: Optional[Dict[str, ChannelSpecificMessage]] = ( + Field( + default=None, + description="Channel specific messages, overriding any transcoding. The structure of this property is more well-defined than the open structure of the `explicit_channel_message` property, and may be easier to use. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`.", + ) + ) + agent: Optional[Agent] = None diff --git a/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py new file mode 100644 index 00000000..33e411ff --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChannelIdentity(BaseModelConfigurationResponse): + app_id: Optional[StrictStr] = Field( + default=None, + description="Required if using a channel that uses app-scoped channel identities. Currently, FB Messenger, Instagram, LINE, and WeChat use app-scoped channel identities, which means contacts will have different channel identities on different Conversation API apps. These can be thought of as virtual identities that are app-specific and, therefore, the app_id must be included in the API call.", + ) + channel: ConversationChannelType = Field(...) + identity: StrictStr = Field( + default=..., + description="The channel identity. This will differ from channel to channel. For example, a phone number for SMS, WhatsApp, and Viber Business.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/choice_item.py b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py new file mode 100644 index 00000000..9ccd97c8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ChoiceItem(BaseModelConfigurationResponse): + title: StrictStr = Field( + default=..., + description="Required parameter. Title for the choice item.", + ) + description: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. The description (or subtitle) of this choice item.", + ) + media: Optional[MediaProperties] = Field( + default=None, + description="Optional parameter. The media of this choice item.", + ) + postback_data: Optional[StrictStr] = Field( + default=None, + description="Optional parameter. Postback data that will be returned in the MO if the user selects this option.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py new file mode 100644 index 00000000..bdb0f289 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py @@ -0,0 +1,11 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.common.reply_to import ( + ReplyTo, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ContactMessageCommonProps(BaseModelConfigurationResponse): + reply_to: Optional[ReplyTo] = None diff --git a/sinch/domains/conversation/models/v1/messages/shared/coordinates.py b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py new file mode 100644 index 00000000..3c558237 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py @@ -0,0 +1,14 @@ +from typing import Union +from pydantic import Field, StrictFloat, StrictInt +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class Coordinates(BaseModelConfigurationResponse): + latitude: Union[StrictFloat, StrictInt] = Field( + default=..., description="The latitude." + ) + longitude: Union[StrictFloat, StrictInt] = Field( + default=..., description="The longitude." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/email_info.py b/sinch/domains/conversation/models/v1/messages/shared/email_info.py new file mode 100644 index 00000000..bcc0572d --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/email_info.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class EmailInfo(BaseModelConfigurationResponse): + email_address: StrictStr = Field(default=..., description="Email address.") + type: Optional[StrictStr] = Field( + default=None, description="Email address type. e.g. WORK or HOME." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/list_section.py b/sinch/domains/conversation/models/v1/messages/shared/list_section.py new file mode 100644 index 00000000..23b7006e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/list_section.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.response.types.list_item import ( + ListItem, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListSection(BaseModelConfigurationResponse): + title: Optional[StrictStr] = Field( + default=None, description="Optional parameter. Title for list section." + ) + items: conlist(ListItem) = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py new file mode 100644 index 00000000..08107012 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py @@ -0,0 +1,51 @@ +from typing import Optional +from datetime import datetime +from pydantic import Field, StrictBool, StrictStr +from sinch.domains.conversation.models.v1.messages.shared.channel_identity import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( + ConversationDirectionType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MessageResponseCommonProps(BaseModelConfigurationResponse): + accept_time: Optional[datetime] = Field( + default=None, + description="The time Conversation API processed the message.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="A unique identity of message recipient on a particular channel. For example, the channel identity on SMS, WHATSAPP or VIBERBM is a MSISDN phone number.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, description="The ID of the contact." + ) + conversation_id: Optional[StrictStr] = Field( + default=None, description="The ID of the conversation." + ) + direction: Optional[ConversationDirectionType] = None + id: Optional[StrictStr] = Field( + default=None, description="The ID of the message." + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Optional. Metadata associated with the contact. Up to 1024 characters long.", + ) + injected: Optional[StrictBool] = Field( + default=None, description="Flag for whether this message was injected." + ) + sender_id: Optional[StrictStr] = Field( + default=None, + description="For Contact Messages (MO messages), the sender ID represents the recipient to which the message was sent. This may be a phone number (in the case of SMS and MMS) or a unique ID (in the case of WhatsApp). This is field is not supported on all channels, nor is it supported for MT messages.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Whether or not Conversation API should store contacts and conversations for the app. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/name_info.py b/sinch/domains/conversation/models/v1/messages/shared/name_info.py new file mode 100644 index 00000000..337db7c3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/name_info.py @@ -0,0 +1,27 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class NameInfo(BaseModelConfigurationResponse): + full_name: StrictStr = Field( + default=..., description="Full name of the contact" + ) + first_name: Optional[StrictStr] = Field( + default=None, description="First name." + ) + last_name: Optional[StrictStr] = Field( + default=None, description="Last name." + ) + middle_name: Optional[StrictStr] = Field( + default=None, description="Middle name." + ) + prefix: Optional[StrictStr] = Field( + default=None, + description="Prefix before the name. e.g. Mr, Mrs, Dr etc.", + ) + suffix: Optional[StrictStr] = Field( + default=None, description="Suffix after the name." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/organization_info.py b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py new file mode 100644 index 00000000..98158fbb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py @@ -0,0 +1,17 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class OrganizationInfo(BaseModelConfigurationResponse): + company: Optional[StrictStr] = Field( + default=None, description="Company name" + ) + department: Optional[StrictStr] = Field( + default=None, description="Department at the company" + ) + title: Optional[StrictStr] = Field( + default=None, description="Corporate title, e.g. Software engineer" + ) diff --git a/sinch/domains/conversation/models/opt_in_opt_out/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/override/__init__.py similarity index 100% rename from sinch/domains/conversation/models/opt_in_opt_out/__init__.py rename to sinch/domains/conversation/models/v1/messages/shared/override/__init__.py diff --git a/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py b/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py new file mode 100644 index 00000000..c2c43dfa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/override/omni_message_override.py @@ -0,0 +1,50 @@ +from typing import Union + + +def _get_omni_message_override_union(): + """Lazy import to avoid circular dependencies.""" + from sinch.domains.conversation.models.v1.messages.categories.card.card_message_field import ( + CardMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message_field import ( + CarouselMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_field import ( + ChoiceMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message_field import ( + ContactInfoMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.list.list_message_field import ( + ListMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.location.location_message_field import ( + LocationMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.media.media_message_field import ( + MediaMessageField, + ) + from sinch.domains.conversation.models.v1.messages.categories.template.template_reference_field import ( + TemplateReferenceField, + ) + from sinch.domains.conversation.models.v1.messages.categories.text.text_message_field import ( + TextMessageField, + ) + + return Union[ + TextMessageField, + MediaMessageField, + TemplateReferenceField, + ChoiceMessageField, + CardMessageField, + CarouselMessageField, + LocationMessageField, + ContactInfoMessageField, + ListMessageField, + ] + + +def __getattr__(name: str): + if name == "OmniMessageOverride": + return _get_omni_message_override_union() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py new file mode 100644 index 00000000..253301de --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py @@ -0,0 +1,14 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class PhoneNumberInfo(BaseModelConfigurationResponse): + phone_number: StrictStr = Field( + default=..., description="Phone number with country code included." + ) + type: Optional[StrictStr] = Field( + default=None, description="Phone number type, e.g. WORK or HOME." + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/product_item.py b/sinch/domains/conversation/models/v1/messages/shared/product_item.py new file mode 100644 index 00000000..a48feeb4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/product_item.py @@ -0,0 +1,27 @@ +from typing import Optional, Union +from pydantic import Field, StrictFloat, StrictInt, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ProductItem(BaseModelConfigurationResponse): + id: StrictStr = Field( + default=..., description="Required parameter. The ID for the product." + ) + marketplace: StrictStr = Field( + default=..., + description="Required parameter. The marketplace to which the product belongs.", + ) + quantity: Optional[StrictInt] = Field( + default=None, + description="Output only. The quantity of the chosen product.", + ) + item_price: Optional[Union[StrictFloat, StrictInt]] = Field( + default=None, + description="Output only. The price for one unit of the chosen product.", + ) + currency: Optional[StrictStr] = Field( + default=None, + description="Output only. The currency of the item_price.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason.py b/sinch/domains/conversation/models/v1/messages/shared/reason.py new file mode 100644 index 00000000..a62d9c8a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/reason.py @@ -0,0 +1,23 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( + ReasonCodeType, +) +from sinch.domains.conversation.models.v1.messages.shared.reason_sub_code import ( + ReasonSubCode, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class Reason(BaseModelConfigurationResponse): + code: Optional[ReasonCodeType] = None + description: Optional[StrictStr] = Field( + default=None, description="A textual description of the reason." + ) + sub_code: Optional[ReasonSubCode] = None + channel_code: Optional[StrictStr] = Field( + default=None, + description="Error code forwarded directly from the channel. Useful in case of unmapped or channel specific errors. Currently only supported on the WhatsApp channel.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py b/sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py new file mode 100644 index 00000000..05f2cfea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py @@ -0,0 +1,13 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ReasonSubCode = Union[ + Literal[ + "UNSPECIFIED_SUB_CODE", + "ATTACHMENT_REJECTED", + "MEDIA_TYPE_UNDETERMINED", + "INACTIVE_SENDER", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/shared/url_info.py b/sinch/domains/conversation/models/v1/messages/shared/url_info.py new file mode 100644 index 00000000..4fdfd650 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/shared/url_info.py @@ -0,0 +1,12 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfigurationResponse, +) + + +class UrlInfo(BaseModelConfigurationResponse): + url: StrictStr = Field(default=..., description="The URL to be referenced") + type: Optional[StrictStr] = Field( + default=None, description="Optional. URL type, e.g. Org or Social" + ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py new file mode 100644 index 00000000..a7bd086a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -0,0 +1,55 @@ +from sinch.domains.conversation.models.v1.messages.types.agent_type import ( + AgentType, +) +from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import ( + ChannelSpecificMessageType, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( + ConversationDirectionType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.types.messages_source_type import ( + MessagesSourceType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_goods_type import ( + PaymentOrderGoodsType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_status_type import ( + PaymentOrderStatusType, +) +from sinch.domains.conversation.models.v1.messages.types.payment_order_type import ( + PaymentOrderType, +) +from sinch.domains.conversation.models.v1.messages.types.pix_key_type import ( + PixKeyType, +) +from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( + ReasonCodeType, +) +from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( + WhatsAppInteractiveNfmReplyNameType, +) + +__all__ = [ + "AgentType", + "ConversationChannelType", + "ConversationDirectionType", + "ProcessingModeType", + "CardHeightType", + "ChannelSpecificMessageType", + "MessagesSourceType", + "PaymentOrderGoodsType", + "PaymentOrderStatusType", + "PaymentOrderType", + "PixKeyType", + "ReasonCodeType", + "WhatsAppInteractiveNfmReplyNameType", +] diff --git a/sinch/domains/conversation/models/v1/messages/types/agent_type.py b/sinch/domains/conversation/models/v1/messages/types/agent_type.py new file mode 100644 index 00000000..22f685e2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/agent_type.py @@ -0,0 +1,5 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +AgentType = Union[Literal["UNKNOWN_AGENT_TYPE", "HUMAN", "BOT"], StrictStr] diff --git a/sinch/domains/conversation/models/v1/messages/types/card_height_type.py b/sinch/domains/conversation/models/v1/messages/types/card_height_type.py new file mode 100644 index 00000000..22f16af6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/card_height_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +CardHeightType = Union[ + Literal["UNSPECIFIED_HEIGHT", "SHORT", "MEDIUM", "TALL"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py new file mode 100644 index 00000000..62824da8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py @@ -0,0 +1,14 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ChannelSpecificMessageType = Union[ + Literal[ + "FLOWS", + "ORDER_DETAILS", + "ORDER_STATUS", + "COMMERCE", + "CAROUSEL_COMMERCE", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py new file mode 100644 index 00000000..27d46a48 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_channel_type.py @@ -0,0 +1,21 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ConversationChannelType = Union[ + Literal[ + "WHATSAPP", + "RCS", + "SMS", + "MESSENGER", + "VIBERBM", + "MMS", + "INSTAGRAM", + "TELEGRAM", + "KAKAOTALK", + "KAKAOTALKCHAT", + "LINE", + "WECHAT", + "APPLEBC", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py new file mode 100644 index 00000000..9877611c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ConversationDirectionType = Union[ + Literal["UNDEFINED_DIRECTION", "TO_APP", "TO_CONTACT"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py new file mode 100644 index 00000000..023c1cb9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +MessagesSourceType = Union[ + Literal["CONVERSATION_SOURCE", "DISPATCH_SOURCE"], StrictStr +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py new file mode 100644 index 00000000..e6d83ef0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_goods_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PaymentOrderGoodsType = Union[ + Literal["digital-goods", "physical-goods"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py new file mode 100644 index 00000000..cc66258e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_status_type.py @@ -0,0 +1,15 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +PaymentOrderStatusType = Union[ + Literal[ + "pending", + "processing", + "partially-shipped", + "shipped", + "completed", + "canceled", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py b/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py new file mode 100644 index 00000000..43454f75 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/payment_order_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PaymentOrderType = Union[ + Literal["br", "sg"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py b/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py new file mode 100644 index 00000000..14aff004 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/pix_key_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +PixKeyType = Union[ + Literal["CPF", "CNPJ", "EMAIL", "PHONE", "EVP"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py b/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py new file mode 100644 index 00000000..4dd66473 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/processing_mode_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ProcessingModeType = Union[ + Literal["CONVERSATION", "DISPATCH"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py b/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py new file mode 100644 index 00000000..80cd430c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/reason_code_type.py @@ -0,0 +1,36 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ReasonCodeType = Union[ + Literal[ + "UNKNOWN", + "INTERNAL_ERROR", + "RATE_LIMITED", + "RECIPIENT_INVALID_CHANNEL_IDENTITY", + "RECIPIENT_NOT_REACHABLE", + "RECIPIENT_NOT_OPTED_IN", + "OUTSIDE_ALLOWED_SENDING_WINDOW", + "CHANNEL_FAILURE", + "CHANNEL_BAD_CONFIGURATION", + "CHANNEL_CONFIGURATION_MISSING", + "MEDIA_TYPE_UNSUPPORTED", + "MEDIA_TOO_LARGE", + "MEDIA_NOT_REACHABLE", + "NO_CHANNELS_LEFT", + "TEMPLATE_NOT_FOUND", + "TEMPLATE_INSUFFICIENT_PARAMETERS", + "TEMPLATE_NON_EXISTING_LANGUAGE_OR_VERSION", + "DELIVERY_TIMED_OUT", + "DELIVERY_REJECTED_DUE_TO_POLICY", + "CONTACT_NOT_FOUND", + "BAD_REQUEST", + "UNKNOWN_APP", + "NO_CHANNEL_IDENTITY_FOR_CONTACT", + "CHANNEL_REJECT", + "NO_PERMISSION", + "NO_PROFILE_AVAILABLE", + "UNSUPPORTED_OPERATION", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py b/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py new file mode 100644 index 00000000..08ed9f48 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/whatsapp_interactive_nfm_reply_name_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +WhatsAppInteractiveNfmReplyNameType = Union[ + Literal["flow", "address_message"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/webhook/__init__.py b/sinch/domains/conversation/models/webhook/__init__.py deleted file mode 100644 index d9c33544..00000000 --- a/sinch/domains/conversation/models/webhook/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class ConversationWebhook(SinchBaseModel): - id: str - app_id: str - target: str - target_type: str - secret: str - triggers: list - client_credentials: dict diff --git a/sinch/domains/conversation/models/webhook/requests.py b/sinch/domains/conversation/models/webhook/requests.py deleted file mode 100644 index 3195648b..00000000 --- a/sinch/domains/conversation/models/webhook/requests.py +++ /dev/null @@ -1,39 +0,0 @@ -from dataclasses import dataclass - -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class WebhookRequest(SinchRequestBaseModel): - app_id: str - target: str - triggers: list - client_credentials: dict - secret: str - target_type: str - - -@dataclass -class CreateConversationWebhookRequest(WebhookRequest): - pass - - -@dataclass -class ListConversationWebhookRequest(SinchRequestBaseModel): - app_id: str - - -@dataclass -class GetConversationWebhookRequest(SinchRequestBaseModel): - webhook_id: str - - -@dataclass -class DeleteConversationWebhookRequest(SinchRequestBaseModel): - webhook_id: str - - -@dataclass -class UpdateConversationWebhookRequest(WebhookRequest): - update_mask: str - webhook_id: str diff --git a/sinch/domains/conversation/models/webhook/responses.py b/sinch/domains/conversation/models/webhook/responses.py deleted file mode 100644 index 9255bebf..00000000 --- a/sinch/domains/conversation/models/webhook/responses.py +++ /dev/null @@ -1,30 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from sinch.domains.conversation.models.webhook import ConversationWebhook -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class CreateWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class UpdateWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class SinchListWebhooksResponse(SinchBaseModel): - webhooks: List[ConversationWebhook] - - -@dataclass -class GetWebhookResponse(ConversationWebhook): - pass - - -@dataclass -class SinchDeleteWebhookResponse(SinchBaseModel): - pass diff --git a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py index 66d6be7c..43b24862 100644 --- a/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py +++ b/sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py @@ -14,7 +14,7 @@ def __init__(self, project_id: str, request_data: BM): def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: raise NotImplementedError( - "ENDPOINT_URL must be defined in the subclass." + "ENDPOINT_URL must be defined in the Numbers endpoint subclass " ) return self.ENDPOINT_URL.format( diff --git a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py index cca9bf3f..19623f3c 100644 --- a/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py +++ b/sinch/domains/sms/api/v1/internal/base/sms_endpoint.py @@ -25,7 +25,7 @@ def set_authentication_method(self, sinch): def build_url(self, sinch) -> str: if not self.ENDPOINT_URL: raise NotImplementedError( - "ENDPOINT_URL must be defined in the subclass." + "ENDPOINT_URL must be defined in the SMS endpoint subclass " ) # Use the appropriate SMS origin based on authentication method diff --git a/tests/e2e/conversation/features/environment.py b/tests/e2e/conversation/features/environment.py new file mode 100644 index 00000000..db663960 --- /dev/null +++ b/tests/e2e/conversation/features/environment.py @@ -0,0 +1,6 @@ +from tests.e2e.shared_config import create_test_client + + +def before_all(context): + """Initializes the Sinch client""" + context.sinch = create_test_client() diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py new file mode 100644 index 00000000..8aa657ca --- /dev/null +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone +from behave import given, when, then +from sinch.domains.conversation.api.v1.messages_apis import Messages + + +@given('the Conversation service "Messages" is available') +def step_service_is_available(context): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.conversation.messages, Messages), 'Messages service is not available' + context.messages = context.sinch.conversation.messages + + +@when('I send a request to delete a message') +def step_delete_message(context): + context.delete_message_response = context.messages.delete( + message_id='01W4FFL35P4NC4K35MESSAGE001' + ) + + +@then('the delete message response contains no data') +def step_validate_delete_message_response(context): + assert context.delete_message_response is None, 'Delete message response should be None' + + +@when('I send a request to retrieve a message') +def step_retrieve_message(context): + context.message = context.messages.get( + message_id='01W4FFL35P4NC4K35MESSAGE001' + ) + + +@then('the response contains the message details') +def step_validate_message_details(context): + message = context.message + assert message is not None, 'Message should not be None' + assert message.id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message.id to be "01W4FFL35P4NC4K35MESSAGE001", got "{message.id}"' + assert message.direction == 'TO_CONTACT', f'Expected message.direction to be "TO_CONTACT", got "{message.direction}"' + assert message.conversation_id == '01W4FFL35P4NC4K35CONVERSATI', f'Expected message.conversation_id to be "01W4FFL35P4NC4K35CONVERSATI", got "{message.conversation_id}"' + assert message.contact_id == '01W4FFL35P4NC4K35CONTACT001', f'Expected message.contact_id to be "01W4FFL35P4NC4K35CONTACT001", got "{message.contact_id}"' + assert message.metadata == '', f'Expected message.metadata to be "", got "{message.metadata}"' + + expected_accept_time = datetime(2024, 6, 6, 12, 42, 42, tzinfo=timezone.utc) + assert message.accept_time == expected_accept_time, f'Expected message.accept_time to be {expected_accept_time}, got {message.accept_time}' + + assert message.processing_mode == 'CONVERSATION', f'Expected message.processing_mode to be "CONVERSATION", got "{message.processing_mode}"' + assert message.injected is False, f'Expected message.injected to be False, got {message.injected}' + + assert message.channel_identity is not None, 'Message channel_identity should not be None' + assert message.channel_identity.channel == 'SMS', f'Expected channel_identity.channel to be "SMS", got "{message.channel_identity.channel}"' + assert message.channel_identity.identity == '12015555555', f'Expected channel_identity.identity to be "12015555555", got "{message.channel_identity.identity}"' + assert message.channel_identity.app_id == '', f'Expected channel_identity.app_id to be "", got "{message.channel_identity.app_id}"' + + +@when('I send a request to update a message') +def step_update_message(context): + context.update_message_response = context.messages.update( + message_id='01W4FFL35P4NC4K35MESSAGE001', + metadata='Updated metadata' + ) + + +@then('the response contains the message details with updated metadata') +def step_validate_update_message_response(context): + message = context.update_message_response + assert message is not None, 'Update message response should not be None' + assert message.id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message.id to be "01W4FFL35P4NC4K35MESSAGE001", got "{message.id}"' + assert message.metadata == 'Updated metadata', f'Expected message.metadata to be "Updated metadata", got "{message.metadata}"' + + +@when('I send a request to send a message to a contact') +def step_send_message(context): + pass + + +@then('the response contains the id of the message') +def step_validate_send_message_response(context): + pass + + +@when('I send a request to list the existing messages') +def step_list_messages(context): + pass + + +@then('the response contains "{count}" messages') +def step_validate_message_count(context, count): + pass + + +@when('I send a request to list all the messages') +def step_list_all_messages(context): + pass + + +@then('the messages list contains "{count}" messages') +def step_validate_total_message_count(context, count): + pass + + +@when('I iterate manually over the messages pages') +def step_iterate_messages_pages(context): + pass + + +@then('the result contains the data from "{count}" pages') +def step_validate_page_count(context, count): + pass diff --git a/tests/e2e/shared_config.py b/tests/e2e/shared_config.py index a83eb4f1..805e22ae 100644 --- a/tests/e2e/shared_config.py +++ b/tests/e2e/shared_config.py @@ -13,4 +13,5 @@ def create_test_client(): client.configuration.numbers_origin = 'http://localhost:3013' client.configuration.sms_origin = 'http://localhost:3017' client.configuration.number_lookup_origin = 'http://localhost:3022' + client.configuration.conversation_origin = 'http://localhost:3014' return client diff --git a/tests/unit/test_user_agent_header.py b/tests/unit/test_user_agent_header.py index df97e35f..08acfb21 100644 --- a/tests/unit/test_user_agent_header.py +++ b/tests/unit/test_user_agent_header.py @@ -1,9 +1,10 @@ -from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint -from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest - +#from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint +#from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest +# TODO: Reimplement test when DeleteConversationAppEndpoint is functional def test_user_agent_header_creation(sinch_client_sync): - endpoint = DeleteConversationAppRequest(app_id="42") - http_endpoint = DeleteConversationAppEndpoint(sinch_client_sync, endpoint) - http_request = sinch_client_sync.configuration.transport.prepare_request(http_endpoint) - assert "User-Agent" in http_request.headers + pass + # endpoint = DeleteConversationAppRequest(app_id="42") + # http_endpoint = DeleteConversationAppEndpoint(sinch_client_sync, endpoint) + # http_request = sinch_client_sync.configuration.transport.prepare_request(http_endpoint) + # assert "User-Agent" in http_request.headers From ff5d25ed8f32fcdc1569ec112ea87a1022ecebce Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 12 Jan 2026 11:19:05 +0100 Subject: [PATCH 078/106] DEVEXP-794: Add Conversation Region (#110) --- MIGRATION_GUIDE.md | 136 +++++++++++------- .../clients/sinch_client_configuration.py | 27 +++- sinch/core/clients/sinch_client_sync.py | 2 + .../v1/internal/base/conversation_endpoint.py | 4 +- ...p_interactive_nfm_reply_contact_message.py | 17 --- .../whatsapp/payment/payment_order.py | 6 +- .../models/v1/messages/shared/__init__.py | 4 - .../models/v1/messages/shared/reason.py | 6 +- .../models/v1/messages/types/__init__.py | 4 + .../reason_sub_code_type.py} | 2 +- tests/unit/test_client.py | 27 ++++ tests/unit/test_configuration.py | 37 +++-- tests/unit/test_user_agent_header.py | 69 +++++++-- 13 files changed, 240 insertions(+), 101 deletions(-) delete mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py rename sinch/domains/conversation/models/v1/messages/{shared/reason_sub_code.py => types/reason_sub_code_type.py} (89%) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index d1331dcf..430f6a8b 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -6,6 +6,93 @@ This release removes legacy SDK support. This guide lists all removed classes and interfaces from V1 and how to migrate to their V2 equivalents. +## Client Initialization + +### Overview + +In V2, region parameters are required for domain-specific APIs (SMS and Conversation). These parameters must be set explicitly when initializing `SinchClient`, otherwise API calls will fail at runtime. The parameters are exposed directly on `SinchClient` to ensure they are provided. + +### SMS Region + +**In V1:** +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", +) +sinch_client.configuration.sms_region = "eu" + +# Or using SMS token auth +token_client = SinchClient( + service_plan_id='your-service-plan-id', + sms_api_token='your-sms-api-token' +) +token_client.configuration.sms_region_with_service_plan_id = "eu" +``` + +**In V2:** +- The `sms_region` no longer defaults to `us`. Set it explicitly before using the SMS API, otherwise calls will fail at runtime. The parameter is now exposed on `SinchClient` (not just the configuration object) to ensure the region is provided. Note that `sms_region` is only required when using the SMS API endpoints. + +```python +from sinch import SinchClient + +# Using Project auth +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", + sms_region="eu", +) + +# Or using SMS token auth +token_client = SinchClient( + service_plan_id="your-service-plan-id", + sms_api_token="your-sms-api-token", + sms_region="us", +) + +# Note: The code is backward compatible. The sms_region can still be set through the configuration object, +# but you must ensure this setting is done BEFORE any SMS API call: +sinch_client.configuration.sms_region = "eu" +``` + +### Conversation Region + +**In V1:** +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", +) + +sinch_client.configuration.conversation_region = "eu" +``` + +**In V2:** +- The `conversation_region` no longer defaults to `eu`. This parameter is required now when using the Conversation API endpoints. Set it explicitly when initializing `SinchClient`, otherwise calls will fail at runtime. The parameter is exposed on `SinchClient` to ensure the region is provided. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="your-project-id", + key_id="your-key-id", + key_secret="your-key-secret", + conversation_region="eu", +) + +# Note: The conversation_region can also be set through the configuration object, +# but you must ensure this setting is done BEFORE any Conversation API call: +sinch_client.configuration.conversation_region = "eu" +``` + ### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) #### Replacement models @@ -68,52 +155,3 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | - -#### SMS client initialization (with region) -In V1: -```python -from sinch import SinchClient - -# Using Project auth -sinch_client = SinchClient( - project_id="your-project-id", - key_id="your-key-id", - key_secret="your-key-secret", -) -sinch_client.configuration.sms_region = "eu" - -# Or using SMS token auth -token_client = SinchClient( - service_plan_id='your-service-plan-id', - sms_api_token='your-sms-api-token' -) -token_client.configuration.sms_region_with_service_plan_id = "eu" - -``` - - -In V2: -- The `sms_region` no longer defaults to `us`. Set it explicitly before using the SMS API, otherwise calls will fail at runtime. The parameter is now exposed on `SinchClient` (not just the configuration object) to ensure the region is provided. Note that `sms_region` is only required when using the SMS API endpoints. - -```python -from sinch import SinchClient - -# Using Project auth -sinch_client = SinchClient( - project_id="your-project-id", - key_id="your-key-id", - key_secret="your-key-secret", - sms_region="eu", -) - -# or using SMS token auth -token_client = SinchClient( - service_plan_id="your-service-plan-id", - sms_api_token="your-sms-api-token", - sms_region="us", -) - -# Note: The code is backward compatible. The sms_region can still be set through the configuration object, -# but you must ensure this setting is done BEFORE any SMS API call: -sinch_client.configuration.sms_region = "eu" -``` diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 1db8e7d9..756c150a 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -25,6 +25,7 @@ def __init__( service_plan_id: str = None, sms_api_token: str = None, sms_region: str = None, + conversation_region: str = None, ): self.key_id = key_id self.key_secret = key_secret @@ -44,8 +45,8 @@ def __init__( self.number_lookup_origin = "https://lookup.api.sinch.com" self._voice_domain = "https://{}.api.sinch.com" self._voice_region = None - self._conversation_region = "eu" - self._conversation_domain = ".conversation.api.sinch.com" + self._conversation_region = conversation_region + self._conversation_domain = "https://{}.conversation.api.sinch.com/" self._sms_region = sms_region self._sms_region_with_service_plan_id = sms_region self._sms_domain = "https://zt.{}.sms.api.sinch.com" @@ -135,7 +136,10 @@ def _get_sms_domain(self): ) def _set_conversation_origin(self): - self.conversation_origin = self._conversation_region + self._conversation_domain + if self._conversation_region: + self.conversation_origin = self._conversation_domain.format(self._conversation_region) + else: + self.conversation_origin = None def _set_conversation_region(self, region): self._conversation_region = region @@ -284,3 +288,20 @@ def get_sms_origin_for_auth(self): ) return origin + + def get_conversation_origin(self): + """ + Returns the conversation origin. + + Raises: + ValueError: If the conversation region is None (conversation_region not set) + """ + if self.conversation_origin is None: + raise ValueError( + "Conversation region is required. " + "Provide conversation_region when initializing SinchClient " + "Example: SinchClient(project_id='...', key_id='...', key_secret='...', conversation_region='eu')" + "or set it via sinch_client.configuration.conversation_region. " + ) + + return self.conversation_origin diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index 22308c05..1b48e3ef 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -29,6 +29,7 @@ def __init__( service_plan_id: str = None, sms_api_token: str = None, sms_region: str = None, + conversation_region: str = None, ): self.configuration = Configuration( key_id=key_id, @@ -43,6 +44,7 @@ def __init__( service_plan_id=service_plan_id, sms_api_token=sms_api_token, sms_region=sms_region, + conversation_region=conversation_region, ) self.authentication = Authentication(self) diff --git a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py index bf5aaf6b..2cd6f35f 100644 --- a/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py +++ b/sinch/domains/conversation/api/v1/internal/base/conversation_endpoint.py @@ -18,10 +18,10 @@ def build_url(self, sinch) -> str: f"'{self.__class__.__name__}'." ) - # TODO: Add support and validation for conversation_region in SinchClient initialization; + origin = sinch.configuration.get_conversation_origin() return self.ENDPOINT_URL.format( - origin=sinch.configuration.conversation_origin, + origin=origin, project_id=self.project_id, **vars(self.request_data), ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py deleted file mode 100644 index e6c2ab9e..00000000 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_contact_message.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import Literal -from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( - WhatsAppInteractiveNfmReply, -) -from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, -) - - -class WhatsAppInteractiveNfmReplyMessage(BaseModelConfigurationResponse): - type: Literal["nfm_reply"] = Field( - description="The interactive message type." - ) - nfm_reply: WhatsAppInteractiveNfmReply = Field( - ..., description="The nfm reply message." - ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py index 83dc4f0b..ba80e753 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py @@ -1,6 +1,6 @@ from datetime import datetime -from typing import List, Optional -from pydantic import Field, StrictStr, StrictInt +from typing import Optional +from pydantic import Field, StrictStr, StrictInt, conlist from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.order_item import ( OrderItem, ) @@ -10,7 +10,7 @@ class PaymentOrder(BaseModelConfigurationResponse): - items: List[OrderItem] = Field( + items: conlist(OrderItem) = Field( ..., description="The items list for this order." ) subtotal_value: StrictInt = Field( diff --git a/sinch/domains/conversation/models/v1/messages/shared/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/__init__.py index d9cd2eef..44c3b27e 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/shared/__init__.py @@ -24,9 +24,6 @@ ProductItem, ) from sinch.domains.conversation.models.v1.messages.shared.reason import Reason -from sinch.domains.conversation.models.v1.messages.shared.reason_sub_code import ( - ReasonSubCode, -) __all__ = [ "AddressInfo", @@ -41,7 +38,6 @@ "OmniMessageOverride", "ProductItem", "Reason", - "ReasonSubCode", ] diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason.py b/sinch/domains/conversation/models/v1/messages/shared/reason.py index a62d9c8a..f3e03bcc 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/reason.py +++ b/sinch/domains/conversation/models/v1/messages/shared/reason.py @@ -3,8 +3,8 @@ from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( ReasonCodeType, ) -from sinch.domains.conversation.models.v1.messages.shared.reason_sub_code import ( - ReasonSubCode, +from sinch.domains.conversation.models.v1.messages.types.reason_sub_code_type import ( + ReasonSubCodeType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfigurationResponse, @@ -16,7 +16,7 @@ class Reason(BaseModelConfigurationResponse): description: Optional[StrictStr] = Field( default=None, description="A textual description of the reason." ) - sub_code: Optional[ReasonSubCode] = None + sub_code: Optional[ReasonSubCodeType] = None channel_code: Optional[StrictStr] = Field( default=None, description="Error code forwarded directly from the channel. Useful in case of unmapped or channel specific errors. Currently only supported on the WhatsApp channel.", diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index a7bd086a..9eb79ea8 100644 --- a/sinch/domains/conversation/models/v1/messages/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -34,6 +34,9 @@ from sinch.domains.conversation.models.v1.messages.types.reason_code_type import ( ReasonCodeType, ) +from sinch.domains.conversation.models.v1.messages.types.reason_sub_code_type import ( + ReasonSubCodeType, +) from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( WhatsAppInteractiveNfmReplyNameType, ) @@ -51,5 +54,6 @@ "PaymentOrderType", "PixKeyType", "ReasonCodeType", + "ReasonSubCodeType", "WhatsAppInteractiveNfmReplyNameType", ] diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py b/sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py similarity index 89% rename from sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py rename to sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py index 05f2cfea..011402d3 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/reason_sub_code.py +++ b/sinch/domains/conversation/models/v1/messages/types/reason_sub_code_type.py @@ -2,7 +2,7 @@ from pydantic import StrictStr -ReasonSubCode = Union[ +ReasonSubCodeType = Union[ Literal[ "UNSPECIFIED_SUB_CODE", "ATTACHMENT_REJECTED", diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 1ecefe03..a7ad97b9 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -39,3 +39,30 @@ def test_sinch_client_expects_all_attributes(): assert hasattr(sinch_client, "voice") assert hasattr(sinch_client, "configuration") assert isinstance(sinch_client.configuration, Configuration) + + +def test_sinch_client_expects_to_be_initialized_with_conversation_region(): + """ Test that SinchClient can be initialized with conversation_region """ + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id", + conversation_region="eu" + ) + assert sinch_client.configuration.conversation_region == "eu" + assert sinch_client.configuration.conversation_origin == "https://eu.conversation.api.sinch.com/" + + +def test_sinch_client_expects_conversation_region_error_when_not_provided(): + """ Test that get_conversation_origin raises ValueError when SinchClient is initialized without conversation_region """ + sinch_client = SinchClient( + key_id="test_key_id", + key_secret="test_key_secret", + project_id="test_project_id" + ) + + assert sinch_client.configuration.conversation_region is None + assert sinch_client.configuration.conversation_origin is None + + with pytest.raises(ValueError, match="Conversation region is required"): + sinch_client.configuration.get_conversation_origin() diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index dc2935a2..2d92786c 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -19,7 +19,8 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): application_secret="SecretHabitatEntry", service_plan_id="CappyPremiumPlan", sms_api_token="HappyCappyToken", - sms_region="us" + sms_region="us", + conversation_region="eu", ) assert client_configuration.key_id == "CapyKey" @@ -31,6 +32,7 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): assert client_configuration.service_plan_id == "CappyPremiumPlan" assert client_configuration.sms_api_token == "HappyCappyToken" assert client_configuration.sms_region == "us" + assert client_configuration.conversation_region == "eu" assert isinstance(client_configuration.transport, HTTPTransportRequests) assert isinstance(client_configuration.token_manager, TokenManager) @@ -51,16 +53,17 @@ def test_set_sms_region_with_service_plan_id_property_and_check_that_sms_origin_ assert sinch_client_sync.configuration.sms_origin_with_service_plan_id == "https://Herring.sms.api.sinch.com" -def test_set_conversation_region_property_and_check_that_sms_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.conversation_region = "My_brain_hurts" - assert "brain" in sinch_client_sync.configuration.conversation_origin - assert "hurts" in sinch_client_sync.configuration.conversation_origin +def test_set_conversation_region_property_expects_updated_conversation_origin(sinch_client_sync): + """ Test that setting the conversation region property updates the conversation origin """ + sinch_client_sync.configuration.conversation_region = "us" + assert sinch_client_sync.configuration.conversation_origin == "https://us.conversation.api.sinch.com/" -def test_set_conversation_domain_property_and_check_that_sms_origin_was_updated(sinch_client_sync): - sinch_client_sync.configuration.conversation_domain = "My_brain_hurts" - assert "brain" in sinch_client_sync.configuration.conversation_origin - assert "hurts" in sinch_client_sync.configuration.conversation_origin +def test_set_conversation_domain_property_expects_updated_conversation_origin(sinch_client_sync): + """ Test that setting the conversation domain property updates the conversation origin """ + sinch_client_sync.configuration.conversation_region = "eu" + sinch_client_sync.configuration.conversation_domain = "https://{}.test.conversation.api.sinch.com/" + assert sinch_client_sync.configuration.conversation_origin == "https://eu.test.conversation.api.sinch.com/" def test_if_logger_name_was_preserved_correctly(sinch_client_sync): @@ -207,3 +210,19 @@ def test_configuration_expects_get_sms_origin_for_auth_project_authentication(si assert actual_origin == expected_origin assert actual_origin == "https://zt.eu.sms.api.sinch.com" + + +def test_configuration_expects_get_conversation_origin_with_region(sinch_client_sync): + """ Test that get_conversation_origin returns the correct origin when region is set """ + client_configuration = Configuration( + transport=HTTPTransportRequests(sinch_client_sync), + token_manager=TokenManager(sinch_client_sync), + project_id="test_project_id", + conversation_region="us" + ) + + expected_origin = client_configuration.conversation_origin + actual_origin = client_configuration.get_conversation_origin() + + assert actual_origin == expected_origin + assert actual_origin == "https://us.conversation.api.sinch.com/" diff --git a/tests/unit/test_user_agent_header.py b/tests/unit/test_user_agent_header.py index 08acfb21..243ac882 100644 --- a/tests/unit/test_user_agent_header.py +++ b/tests/unit/test_user_agent_header.py @@ -1,10 +1,59 @@ -#from sinch.domains.conversation.endpoints.app.delete_app import DeleteConversationAppEndpoint -#from sinch.domains.conversation.models.app.requests import DeleteConversationAppRequest - -# TODO: Reimplement test when DeleteConversationAppEndpoint is functional -def test_user_agent_header_creation(sinch_client_sync): - pass - # endpoint = DeleteConversationAppRequest(app_id="42") - # http_endpoint = DeleteConversationAppEndpoint(sinch_client_sync, endpoint) - # http_request = sinch_client_sync.configuration.transport.prepare_request(http_endpoint) - # assert "User-Agent" in http_request.headers +from platform import python_version +from sinch import __version__ as sdk_version +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse + + +class DummyEndpoint(HTTPEndpoint): + """Dummy endpoint for testing core functionality""" + + ENDPOINT_URL = "https://capy.sinch.com/v1/test" + + @property + def HTTP_METHOD(self) -> str: + return HTTPMethods.GET.value + + @property + def HTTP_AUTHENTICATION(self) -> str: + return HTTPAuthentication.OAUTH.value + + def build_url(self, sinch): + return self.ENDPOINT_URL + + def build_query_params(self): + return {} + + def request_body(self): + return "" + + def handle_response(self, response: HTTPResponse): + return response + + +def test_user_agent_header_creation_expects_to_be_included(sinch_client_sync): + """ + Test that User-Agent header is created with the correct format. + + Expected format: sinch-sdk/{sdk_version} (Python/{python_version}; {implementation_type}; {auxiliary_flag}) + Note: auxiliary_flag is currently always empty in the implementation. + """ + endpoint = DummyEndpoint("dummy_project_id", {}) + http_request = sinch_client_sync.configuration.transport.prepare_request(endpoint) + + assert "User-Agent" in http_request.headers + + user_agent = http_request.headers["User-Agent"] + transport_class_name = sinch_client_sync.configuration.transport.__class__.__name__ + + # Parse the User-Agent string + prefix, info_section = user_agent.split(" (", 1) + info_section = info_section.rstrip(")") + components = [c.strip() for c in info_section.split(";")] + + # Validate structure + assert prefix == f"sinch-sdk/{sdk_version}", f"Expected prefix 'sinch-sdk/{sdk_version}', got '{prefix}'" + assert len(components) == 3, f"Expected 3 components, got {len(components)}: {components}" + assert components[0] == f"Python/{python_version()}", f"Expected 'Python/{python_version()}', got '{components[0]}'" + assert components[1] == transport_class_name, f"Expected '{transport_class_name}', got '{components[1]}'" + assert components[2] == "", f"Auxiliary flag should be empty (not implemented yet), got '{components[2]}'" From 2adcabc12e9fa6cc4f24b30bbc6d78cee0a0f6a2 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 15 Jan 2026 09:16:01 +0100 Subject: [PATCH 079/106] DEVEXP-794: Update Channel specific directory structure (#111) --- .../flow_channel_specific_message.py | 26 ---------- .../kakaotalk/buttons/__init__.py | 7 +++ .../kakaotalk_app_link_button.py | 2 +- .../kakaotalk_bot_keyword_button.py | 2 +- .../{ => buttons}/kakaotalk_button.py | 0 .../kakaotalk_web_link_button.py | 2 +- .../kakaotalk/commerce/__init__.py | 42 +++++++++++++++ .../{ => commerce}/kakaotalk_carousel.py | 6 +-- ...ousel_commerce_channel_specific_message.py | 6 +-- .../{ => commerce}/kakaotalk_carousel_head.py | 0 .../{ => commerce}/kakaotalk_carousel_tail.py | 0 .../kakaotalk_channel_specific_message.py | 0 ...otalk_commerce_channel_specific_message.py | 6 +-- .../kakaotalk_commerce_image.py | 0 .../kakaotalk_commerce_message.py | 2 +- .../kakaotalk_discount_fixed_commerce.py | 2 +- .../kakaotalk_discount_rate_commerce.py | 2 +- .../kakaotalk_regular_price_commerce.py | 0 .../kakaotalk/coupons/__init__.py | 7 +++ .../{ => coupons}/kakaotalk_coupon.py | 0 .../kakaotalk_discount_rate_coupon.py | 2 +- .../kakaotalk_fixed_discount_coupon.py | 2 +- .../{ => coupons}/kakaotalk_free_coupon.py | 2 +- .../kakaotalk_shipping_discount_coupon.py | 2 +- .../{ => coupons}/kakaotalk_up_coupon.py | 2 +- .../whatsapp/flows/__init__.py | 31 +++++++++++ .../flows/flow_channel_specific_message.py | 2 +- .../whatsapp_interactive_document_header.py | 2 +- .../whatsapp_interactive_image_header.py | 2 +- .../whatsapp_interactive_video_header.py | 2 +- .../whatsapp/nfmreply/__init__.py | 7 +++ .../whatsapp_interactive_nfm_reply_message.py | 2 +- .../whatsapp/payment/__init__.py | 51 +++++++++++++++++++ .../whatsapp/payment/payment_order.py | 2 +- ..._order_details_channel_specific_message.py | 2 +- .../payment/payment_order_details_content.py | 2 +- ...t_order_status_channel_specific_message.py | 2 +- .../payment/payment_order_status_content.py | 2 +- .../whatsapp/whatsapp_common_props.py | 4 +- .../types/channel_specific_message_content.py | 4 +- .../response/types/kakaotalk_button.py | 6 +-- .../response/types/kakaotalk_commerce.py | 6 +-- .../response/types/kakaotalk_coupon.py | 10 ++-- 43 files changed, 185 insertions(+), 76 deletions(-) delete mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => buttons}/kakaotalk_app_link_button.py (91%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => buttons}/kakaotalk_bot_keyword_button.py (83%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => buttons}/kakaotalk_button.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => buttons}/kakaotalk_web_link_button.py (90%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_carousel.py (70%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_carousel_commerce_channel_specific_message.py (63%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_carousel_head.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_carousel_tail.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_channel_specific_message.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_commerce_channel_specific_message.py (85%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_commerce_image.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_commerce_message.py (95%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_discount_fixed_commerce.py (89%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_discount_rate_commerce.py (89%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => commerce}/kakaotalk_regular_price_commerce.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_coupon.py (100%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_discount_rate_coupon.py (89%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_fixed_discount_coupon.py (89%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_free_coupon.py (87%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_shipping_discount_coupon.py (87%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/{ => coupons}/kakaotalk_up_coupon.py (87%) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py deleted file mode 100644 index 2a4f7c7a..00000000 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/flow_channel_specific_message.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Optional -from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( - WhatsAppCommonProps, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.flow_action_payload import ( - FlowActionPayload, -) - - -class FlowChannelSpecificMessage(WhatsAppCommonProps): - flow_id: StrictStr = Field(..., description="ID of the Flow.") - flow_cta: StrictStr = Field( - ..., - description="Text which is displayed on the Call To Action button (20 characters maximum, emoji not supported).", - ) - flow_token: Optional[StrictStr] = Field( - default=None, description="Generated token which is an identifier." - ) - flow_mode: Optional[StrictStr] = Field( - default="published", description="The mode in which the flow is." - ) - flow_action: Optional[StrictStr] = Field( - default="navigate", description="The flow action." - ) - flow_action_payload: Optional[FlowActionPayload] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py new file mode 100644 index 00000000..6fe3454b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_button import ( + KakaoTalkButton, +) + +__all__ = [ + "KakaoTalkButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py similarity index 91% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py index 638ce4c0..675c3dfc 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_app_link_button.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_app_link_button.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( KakaoTalkButton, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py similarity index 83% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py index bfaabe98..1cac0dec 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_bot_keyword_button.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_bot_keyword_button.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( KakaoTalkButton, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_button.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py similarity index 90% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py index 7b297e23..e49e8e85 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_web_link_button.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_web_link_button.py @@ -1,6 +1,6 @@ from typing import Literal, Optional from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons import ( KakaoTalkButton, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py new file mode 100644 index 00000000..84ddbabd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/__init__.py @@ -0,0 +1,42 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_channel_specific_message import ( + KakaoTalkChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_image import ( + KakaoTalkCommerceImage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_head import ( + KakaoTalkCarouselHead, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_tail import ( + KakaoTalkCarouselTail, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_regular_price_commerce import ( + KakaoTalkRegularPriceCommerce, +) + + +def __getattr__(name: str): + if name == "KakaoTalkCommerceMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_message import ( + KakaoTalkCommerceMessage, + ) + + return KakaoTalkCommerceMessage + if name == "KakaoTalkCarousel": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel import ( + KakaoTalkCarousel, + ) + + return KakaoTalkCarousel + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "KakaoTalkChannelSpecificMessage", + "KakaoTalkCommerceImage", + "KakaoTalkCarouselHead", + "KakaoTalkCarouselTail", + "KakaoTalkRegularPriceCommerce", + "KakaoTalkCommerceMessage", + "KakaoTalkCarousel", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py similarity index 70% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py index d3517fca..6fac4cc7 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py @@ -1,12 +1,8 @@ from typing import Optional from pydantic import Field, conlist -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_head import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkCarouselHead, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_tail import ( KakaoTalkCarouselTail, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_message import ( KakaoTalkCommerceMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py similarity index 63% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py index 8cc4305c..9a2d45f7 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_commerce_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_commerce_channel_specific_message.py @@ -1,9 +1,7 @@ from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_channel_specific_message import ( - KakaoTalkChannelSpecificMessage, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkCarousel, + KakaoTalkChannelSpecificMessage, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_head.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_carousel_tail.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_channel_specific_message.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py similarity index 85% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py index 580532e3..6b8a0eca 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_channel_specific_message.py @@ -1,7 +1,8 @@ from typing import Optional from pydantic import Field, StrictStr, conlist -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_channel_specific_message import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkChannelSpecificMessage, + KakaoTalkCommerceImage, ) from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_button import ( KakaoTalkButton, @@ -12,9 +13,6 @@ from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( KakaoTalkCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_image import ( - KakaoTalkCommerceImage, -) class KakaoTalkCommerceChannelSpecificMessage(KakaoTalkChannelSpecificMessage): diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_image.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py similarity index 95% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py index 4e48ee30..5ae37bad 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_commerce_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py @@ -9,7 +9,7 @@ from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( KakaoTalkCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_image import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkCommerceImage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py similarity index 89% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py index efc17379..38e9cdf4 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_fixed_commerce.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_fixed_commerce.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictInt -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkRegularPriceCommerce, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py similarity index 89% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py index 6947f9ac..0975dc0a 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_commerce.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_discount_rate_commerce.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictInt -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce import ( KakaoTalkRegularPriceCommerce, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_regular_price_commerce.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py new file mode 100644 index 00000000..73992ad2 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_coupon import ( + KakaoTalkCoupon, +) + +__all__ = [ + "KakaoTalkCoupon", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py similarity index 89% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py index 41e05fac..687a45bb 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_discount_rate_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_discount_rate_coupon.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictInt -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( KakaoTalkCoupon, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py similarity index 89% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py index 2d06d05e..05f9c28f 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_fixed_discount_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_fixed_discount_coupon.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictInt -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( KakaoTalkCoupon, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py similarity index 87% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py index c587c9c8..1feefca6 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_free_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_free_coupon.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( KakaoTalkCoupon, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py similarity index 87% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py index 46f27ded..517e0bac 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_shipping_discount_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_shipping_discount_coupon.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( KakaoTalkCoupon, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py similarity index 87% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py index c3783c89..62d87fad 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/kakaotalk_up_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_up_coupon.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons import ( KakaoTalkCoupon, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py index e69de29b..92044534 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/__init__.py @@ -0,0 +1,31 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_action_payload import ( + FlowActionPayload, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_body import ( + WhatsAppInteractiveBody, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_footer import ( + WhatsAppInteractiveFooter, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( + WhatsAppInteractiveHeaderMedia, +) + + +def __getattr__(name: str): + if name == "FlowChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( + FlowChannelSpecificMessage, + ) + + return FlowChannelSpecificMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "FlowActionPayload", + "WhatsAppInteractiveBody", + "WhatsAppInteractiveFooter", + "WhatsAppInteractiveHeaderMedia", + "FlowChannelSpecificMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py index 909e521c..b54a0672 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_channel_specific_message.py @@ -3,7 +3,7 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( WhatsAppCommonProps, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_action_payload import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( FlowActionPayload, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py index 7cc87228..059c22eb 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py index 2c6b9b47..f1887210 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py index ab9965dc..d5d76785 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_header_media import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py index e69de29b..cd920029 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( + WhatsAppInteractiveNfmReply, +) + +__all__ = [ + "WhatsAppInteractiveNfmReply", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py index e6c2ab9e..4068cd89 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py @@ -1,6 +1,6 @@ from typing import Literal from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply.whatsapp_interactive_nfm_reply import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.nfmreply import ( WhatsAppInteractiveNfmReply, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py index e69de29b..71559f06 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py @@ -0,0 +1,51 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.order_item import ( + OrderItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_order import ( + PaymentOrderStatusOrder, +) + + +def __getattr__(name: str): + if name == "PaymentOrder": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order import ( + PaymentOrder, + ) + + return PaymentOrder + if name == "PaymentOrderDetailsContent": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_content import ( + PaymentOrderDetailsContent, + ) + + return PaymentOrderDetailsContent + if name == "PaymentOrderStatusContent": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_content import ( + PaymentOrderStatusContent, + ) + + return PaymentOrderStatusContent + if name == "PaymentOrderDetailsChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_channel_specific_message import ( + PaymentOrderDetailsChannelSpecificMessage, + ) + + return PaymentOrderDetailsChannelSpecificMessage + if name == "PaymentOrderStatusChannelSpecificMessage": + from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( + PaymentOrderStatusChannelSpecificMessage, + ) + + return PaymentOrderStatusChannelSpecificMessage + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "OrderItem", + "PaymentOrderStatusOrder", + "PaymentOrder", + "PaymentOrderDetailsContent", + "PaymentOrderStatusContent", + "PaymentOrderDetailsChannelSpecificMessage", + "PaymentOrderStatusChannelSpecificMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py index ba80e753..30bbcb30 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Optional from pydantic import Field, StrictStr, StrictInt, conlist -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.order_item import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( OrderItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py index 568918b6..66271782 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_channel_specific_message.py @@ -2,7 +2,7 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( WhatsAppCommonProps, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_details_content import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( PaymentOrderDetailsContent, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py index de3c9d59..3cbdfac6 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py @@ -9,7 +9,7 @@ from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( PaymentSettings, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( PaymentOrder, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py index b00cda0e..3d31c01e 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_channel_specific_message.py @@ -2,7 +2,7 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.whatsapp_common_props import ( WhatsAppCommonProps, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_content import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( PaymentOrderStatusContent, ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py index fc0e5fa7..8ef61f11 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py @@ -1,5 +1,5 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_order import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( PaymentOrderStatusOrder, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py index 85b947e1..1d340106 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py @@ -3,10 +3,8 @@ from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( WhatsAppInteractiveHeader, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_body import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows import ( WhatsAppInteractiveBody, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.whatsapp_interactive_footer import ( WhatsAppInteractiveFooter, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( diff --git a/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py b/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py index 90ee43fc..4bb50086 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py @@ -8,10 +8,10 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( PaymentOrderStatusChannelSpecificMessage, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_commerce_channel_specific_message import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_channel_specific_message import ( KakaoTalkCommerceChannelSpecificMessage, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_carousel_commerce_channel_specific_message import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_commerce_channel_specific_message import ( KakaoTalkCarouselCommerceChannelSpecificMessage, ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py index e1d15f36..d84a85db 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_button.py @@ -1,11 +1,11 @@ from typing import Union -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_web_link_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_web_link_button import ( KakaoTalkWebLinkButton, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_app_link_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_app_link_button import ( KakaoTalkAppLinkButton, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_bot_keyword_button import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.buttons.kakaotalk_bot_keyword_button import ( KakaoTalkBotKeywordButton, ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py index 48a8e02c..2c8593e4 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_commerce.py @@ -1,11 +1,11 @@ from typing import Union -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_regular_price_commerce import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_regular_price_commerce import ( KakaoTalkRegularPriceCommerce, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_fixed_commerce import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_discount_fixed_commerce import ( KakaoTalkDiscountFixedCommerce, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_rate_commerce import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_discount_rate_commerce import ( KakaoTalkDiscountRateCommerce, ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py index 85256c08..6331efbc 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/kakaotalk_coupon.py @@ -1,18 +1,18 @@ from typing import Annotated, Union from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_fixed_discount_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_fixed_discount_coupon import ( KakaoTalkFixedDiscountCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_discount_rate_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_discount_rate_coupon import ( KakaoTalkDiscountRateCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_shipping_discount_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_shipping_discount_coupon import ( KakaoTalkShippingDiscountCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_free_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_free_coupon import ( KakaoTalkFreeCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.kakaotalk_up_coupon import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.coupons.kakaotalk_up_coupon import ( KakaoTalkUpCoupon, ) From 794d4c8e545e8a7aa856f64a955a386c1eea68d9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 19 Jan 2026 10:23:25 +0100 Subject: [PATCH 080/106] DEVEXP-794: Conversation - Messages (Endpoint Unit Tests) (#112) --- .../clients/sinch_client_configuration.py | 2 +- tests/conftest.py | 42 ++- .../v1/endpoints/messages/__init__.py | 0 .../messages/test_delete_message_endpoint.py | 83 +++++ .../messages/test_get_message_endpoint.py | 270 +++++++++++++++++ .../messages/test_update_message_endpoint.py | 285 ++++++++++++++++++ tests/unit/test_client.py | 2 +- tests/unit/test_configuration.py | 8 +- 8 files changed, 673 insertions(+), 19 deletions(-) create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/__init__.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 756c150a..da0551f3 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -46,7 +46,7 @@ def __init__( self._voice_domain = "https://{}.api.sinch.com" self._voice_region = None self._conversation_region = conversation_region - self._conversation_domain = "https://{}.conversation.api.sinch.com/" + self._conversation_domain = "https://{}.conversation.api.sinch.com" self._sms_region = sms_region self._sms_region_with_service_plan_id = sms_region self._sms_domain = "https://zt.{}.sms.api.sinch.com" diff --git a/tests/conftest.py b/tests/conftest.py index 424073bb..a8a8f164 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,11 @@ ListDeliveryReportsRequest, ListDeliveryReportsResponse, ) -from sinch.domains.sms.models.v1.response import RecipientDeliveryReport import pytest from sinch import SinchClient -from sinch.core.models.base_model import SinchBaseModel, SinchRequestBaseModel +from sinch.core.models.base_model import SinchRequestBaseModel from sinch.core.models.http_response import HTTPResponse from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.domains.numbers.models.v1.response import ActiveNumber @@ -266,8 +265,10 @@ class MockSinchClient: return MockSinchClient() -@pytest.fixture -def mock_sinch_client_sms(): +def _create_mock_sinch_client(**config_kwargs): + """ + Helper function to create a mock Sinch client with the given configuration. + """ from sinch.core.clients.sinch_client_configuration import Configuration from sinch.core.ports.http_transport import HTTPTransport from sinch.core.token_manager import TokenManager @@ -277,16 +278,16 @@ def mock_sinch_client_sms(): mock_token_manager = MagicMock(spec=TokenManager) - config = Configuration( - transport=mock_transport, - token_manager=mock_token_manager, - project_id="test_project_id", - key_id="test_key_id", - key_secret="test_key_secret", - service_plan_id="test_service_plan_id", - sms_region="eu" - ) + default_config = { + "transport": mock_transport, + "token_manager": mock_token_manager, + "project_id": "test_project_id", + "key_id": "test_key_id", + "key_secret": "test_key_secret", + } + default_config.update(config_kwargs) + config = Configuration(**default_config) config._authentication_method = "project_auth" class MockSinchClient: @@ -295,6 +296,21 @@ class MockSinchClient: return MockSinchClient() +@pytest.fixture +def mock_sinch_client_sms(): + return _create_mock_sinch_client( + service_plan_id="test_service_plan_id", + sms_region="eu" + ) + + +@pytest.fixture +def mock_sinch_client_conversation(): + return _create_mock_sinch_client( + conversation_region="us" + ) + + @pytest.fixture def mock_pagination_active_number_responses(): return [ diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/__init__.py b/tests/unit/domains/conversation/v1/endpoints/messages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py new file mode 100644 index 00000000..6ed477b9 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_delete_message_endpoint.py @@ -0,0 +1,83 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import DeleteMessageEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import MessageIdRequest +from sinch.domains.conversation.api.v1.exceptions import ConversationException + + +@pytest.fixture +def request_data(): + return MessageIdRequest(message_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=204, + body=None, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "error": { + "message": "Message not found", + "status": "NotFound" + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return DeleteMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """ + Test that the URL is built correctly. + """ + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_messages_source_query_param_expects_parsed_params(): + """ + Test that the messages_source query parameter is parsed correctly. + """ + request_data = MessageIdRequest( + message_id="01FC66621XXXXX119Z8PMV1QPQ", + messages_source="CONVERSATION_SOURCE" + ) + endpoint = DeleteMessageEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert query_params["messages_source"] == "CONVERSATION_SOURCE" + + +def test_handle_response_expects_success(endpoint, mock_response): + """ + Test that a successful delete response (204 No Content) is handled correctly. + """ + result = endpoint.handle_response(mock_response) + assert result is None + + +def test_handle_response_expects_conversation_exception_on_error( + endpoint, mock_error_response +): + """ + Test that ConversationException is raised when server returns an error. + """ + with pytest.raises(ConversationException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py new file mode 100644 index 00000000..59a1f3a6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py @@ -0,0 +1,270 @@ +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import GetMessageEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import MessageIdRequest +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +@pytest.fixture +def request_data(): + return MessageIdRequest(message_id="CAPY123456789ABCDEFGHIJKLMNOP") + + +@pytest.fixture +def mock_contact_message_response(): + """Mock response for ContactMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body={ + "id": "CAPY123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "contact_message": { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "flow", + "response_json": "{\"key\": \"value\"}", + "body": "Message body text" + } + } + }, + "reply_to": { + "message_id": "REPLY_TO_MSG123456789ABCDEF" + } + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_app_message_response(): + """Mock response for AppMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body={ + "id": "APP123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "app_message": { + "card_message": { + "choices": [ + { + "call_message": { + "phone_number": "+15551231234", + "title": "Message text" + }, + "postback_data": None + } + ], + "description": "Card description text", + "height": "UNSPECIFIED_HEIGHT", + "title": "Card title", + "media_message": { + "thumbnail_url": "https://example.com/thumbnail.jpg", + "url": "https://example.com/media.jpg", + "filename_override": "custom_filename.jpg" + }, + "message_properties": { + "whatsapp_header": "WhatsApp header text" + } + }, + "explicit_channel_message": { + "property1": "string", + "property2": "string" + }, + "explicit_channel_omni_message": { + "property1": { + "text_message": { + "text": "string" + } + }, + "property2": { + "text_message": { + "text": "string" + } + } + }, + "channel_specific_message": { + "property1": { + "message_type": "FLOWS", + "message": { + "header": { + "type": "text", + "text": "string" + }, + "body": { + "text": "string" + }, + "footer": { + "text": "string" + }, + "flow_id": "string", + "flow_token": "string", + "flow_mode": "draft", + "flow_cta": "string", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "string", + "data": {} + } + } + } + }, + "agent": { + "display_name": "Agent Name", + "type": "UNKNOWN_AGENT_TYPE", + "picture_url": "https://example.com/agent.jpg" + } + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """" + Test that the URL is built correctly. + """ + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/CAPY123456789ABCDEFGHIJKLMNOP" + ) + + +def test_messages_source_query_param_expects_parsed_params(): + """ + Test that the messages_source query parameter is parsed correctly. + """ + request_data = MessageIdRequest( + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + messages_source="CONVERSATION_SOURCE" + ) + endpoint = GetMessageEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert query_params["messages_source"] == "CONVERSATION_SOURCE" + + +def test_handle_response_expects_contact_message_response(endpoint, mock_contact_message_response): + """ + Test that contact message response is handled correctly and mapped to the appropriate fields. + """ + parsed_response = endpoint.handle_response(mock_contact_message_response) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect a ContactMessageResponse + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.contact_message is not None + assert parsed_response.contact_message.channel_specific_message is not None + assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Message body text" + assert parsed_response.contact_message.reply_to is not None + assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_app_message_response(mock_app_message_response): + """ + Test that the app message response is handled correctly and mapped to the appropriate fields. + """ + request_data = MessageIdRequest(message_id="APP123456789ABCDEFGHIJKLMNOP") + endpoint = GetMessageEndpoint("test_project_id", request_data) + + parsed_response = endpoint.handle_response(mock_app_message_response) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect an AppMessageResponse + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) + + assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.app_message is not None + assert parsed_response.app_message.card_message is not None + assert parsed_response.app_message.card_message.title == "Card title" + assert parsed_response.app_message.card_message.description == "Card description text" + assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" + assert parsed_response.app_message.card_message.choices is not None + assert len(parsed_response.app_message.card_message.choices) == 1 + assert parsed_response.app_message.card_message.choices[0].call_message is not None + assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" + assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" + assert parsed_response.app_message.card_message.media_message is not None + assert parsed_response.app_message.card_message.media_message.url == "https://example.com/media.jpg" + assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://example.com/thumbnail.jpg" + assert parsed_response.app_message.card_message.media_message.filename_override == "custom_filename.jpg" + assert parsed_response.app_message.card_message.message_properties is not None + assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" + assert parsed_response.app_message.agent is not None + assert parsed_response.app_message.agent.display_name == "Agent Name" + assert parsed_response.app_message.agent.type == "UNKNOWN_AGENT_TYPE" + assert parsed_response.app_message.agent.picture_url == "https://example.com/agent.jpg" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py new file mode 100644 index 00000000..9f64d833 --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py @@ -0,0 +1,285 @@ +import json +import pytest +from datetime import datetime, timezone +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import UpdateMessageMetadataEndpoint +from sinch.domains.conversation.models.v1.messages.internal.request import UpdateMessageMetadataRequest +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +@pytest.fixture +def request_data(): + return UpdateMessageMetadataRequest( + message_id="UPDATE123456789ABCDEFGHIJKLMNOP", + metadata="updated_metadata_value", + ) + + +@pytest.fixture +def mock_contact_message_response(): + """Mock response for ContactMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body={ + "id": "UPDATE123456789ABCDEFGHIJKLMNOP", + "conversation_id": "UPDATE_CONV987654321ZYXWVUTSRQP", + "contact_id": "UPDATE_CONTACT456789ABCDEFGHIJK", + "direction": "TO_CONTACT", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "updated_metadata_value", + "accept_time": "2026-01-15T17:19:12.000Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "contact_message": { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "flow", + "response_json": "{\"key\": \"value\"}", + "body": "Updated message content" + } + } + }, + "reply_to": { + "message_id": "REPLY_TO_MSG123456789ABCDEF" + } + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_app_message_response(): + """Mock response for AppMessageResponse (Union type test).""" + return HTTPResponse( + status_code=200, + body={ + "id": "UPDATE_APP123456789ABCDEFGHIJK", + "conversation_id": "UPDATE_CONV987654321ZYXWVUTSRQP", + "contact_id": "UPDATE_CONTACT456789ABCDEFGHIJK", + "direction": "TO_CONTACT", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "updated_metadata_value", + "accept_time": "2026-01-15T17:19:12.000Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "app_message": { + "card_message": { + "choices": [ + { + "call_message": { + "phone_number": "+15551231234", + "title": "Message text" + }, + "postback_data": None + } + ], + "description": "Card description text", + "height": "UNSPECIFIED_HEIGHT", + "title": "Card title", + "media_message": { + "thumbnail_url": "https://update.example.com/thumb.jpg", + "url": "https://update.example.com/image.jpg", + "filename_override": "updated_image.jpg" + }, + "message_properties": { + "whatsapp_header": "WhatsApp header text" + } + }, + "explicit_channel_message": { + "property1": "string", + "property2": "string" + }, + "explicit_channel_omni_message": { + "property1": { + "text_message": { + "text": "string" + } + }, + "property2": { + "text_message": { + "text": "string" + } + } + }, + "channel_specific_message": { + "property1": { + "message_type": "FLOWS", + "message": { + "header": { + "type": "text", + "text": "string" + }, + "body": { + "text": "string" + }, + "footer": { + "text": "string" + }, + "flow_id": "string", + "flow_token": "string", + "flow_mode": "draft", + "flow_cta": "string", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "string", + "data": {} + } + } + } + }, + "agent": { + "display_name": "Updated Agent", + "type": "HUMAN_AGENT", + "picture_url": "https://update.example.com/agent_photo.jpg" + } + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return UpdateMessageMetadataEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/UPDATE123456789ABCDEFGHIJKLMNOP" + ) + + +def test_messages_source_query_param_expects_parsed_params(request_data): + """ + Test that the URL is built correctly with messages_source query parameter. + metadata is from body application/json, so it should not be in query params. + """ + request_data.messages_source = "DISPATCH_SOURCE" + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + assert "metadata" not in query_params + assert query_params["messages_source"] == "DISPATCH_SOURCE" + + +def test_request_body_expects_excludes_message_id_and_query_params(request_data): + """ + Test that message_id and messages_source are excluded from request body. + metadata should always be included in the request body. + """ + request_data.messages_source = "CONVERSATION_SOURCE" + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "messages_source" not in body + assert "message_id" not in body + assert "metadata" in body + assert body["metadata"] == "updated_metadata_value" + + +def test_handle_response_expects_contact_message_mapping(endpoint, mock_contact_message_response): + """ + Test that the response handles ContactMessageResponse correctly (Union type test). + """ + parsed_response = endpoint.handle_response(mock_contact_message_response) + + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + assert parsed_response.id == "UPDATE123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "UPDATE_CONV987654321ZYXWVUTSRQP" + assert parsed_response.contact_id == "UPDATE_CONTACT456789ABCDEFGHIJK" + assert parsed_response.direction == "TO_CONTACT" + assert parsed_response.metadata == "updated_metadata_value" + assert parsed_response.contact_message is not None + assert parsed_response.contact_message.channel_specific_message is not None + assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Updated message content" + assert parsed_response.contact_message.reply_to is not None + assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 15, 17, 19, 12, 0, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_app_message_mapping(mock_app_message_response): + """ + Test that the response handles AppMessageResponse correctly (Union type test). + """ + request_data = UpdateMessageMetadataRequest( + message_id="UPDATE_APP123456789ABCDEFGHIJK", + metadata="updated_metadata_value", + ) + endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) + + parsed_response = endpoint.handle_response(mock_app_message_response) + + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) + + assert parsed_response.id == "UPDATE_APP123456789ABCDEFGHIJK" + assert parsed_response.conversation_id == "UPDATE_CONV987654321ZYXWVUTSRQP" + assert parsed_response.contact_id == "UPDATE_CONTACT456789ABCDEFGHIJK" + assert parsed_response.direction == "TO_CONTACT" + assert parsed_response.metadata == "updated_metadata_value" + assert parsed_response.app_message is not None + assert parsed_response.app_message.card_message is not None + assert parsed_response.app_message.card_message.title == "Card title" + assert parsed_response.app_message.card_message.description == "Card description text" + assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" + assert parsed_response.app_message.card_message.choices is not None + assert len(parsed_response.app_message.card_message.choices) == 1 + assert parsed_response.app_message.card_message.choices[0].call_message is not None + assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" + assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" + assert parsed_response.app_message.card_message.media_message is not None + assert parsed_response.app_message.card_message.media_message.url == "https://update.example.com/image.jpg" + assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://update.example.com/thumb.jpg" + assert parsed_response.app_message.card_message.media_message.filename_override == "updated_image.jpg" + assert parsed_response.app_message.card_message.message_properties is not None + assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" + assert parsed_response.app_message.agent is not None + assert parsed_response.app_message.agent.display_name == "Updated Agent" + assert parsed_response.app_message.agent.type == "HUMAN_AGENT" + assert parsed_response.app_message.agent.picture_url == "https://update.example.com/agent_photo.jpg" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 15, 17, 19, 12, 0, tzinfo=timezone.utc + ) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index a7ad97b9..de7efd87 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -50,7 +50,7 @@ def test_sinch_client_expects_to_be_initialized_with_conversation_region(): conversation_region="eu" ) assert sinch_client.configuration.conversation_region == "eu" - assert sinch_client.configuration.conversation_origin == "https://eu.conversation.api.sinch.com/" + assert sinch_client.configuration.conversation_origin == "https://eu.conversation.api.sinch.com" def test_sinch_client_expects_conversation_region_error_when_not_provided(): diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 2d92786c..571f9c5f 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -56,14 +56,14 @@ def test_set_sms_region_with_service_plan_id_property_and_check_that_sms_origin_ def test_set_conversation_region_property_expects_updated_conversation_origin(sinch_client_sync): """ Test that setting the conversation region property updates the conversation origin """ sinch_client_sync.configuration.conversation_region = "us" - assert sinch_client_sync.configuration.conversation_origin == "https://us.conversation.api.sinch.com/" + assert sinch_client_sync.configuration.conversation_origin == "https://us.conversation.api.sinch.com" def test_set_conversation_domain_property_expects_updated_conversation_origin(sinch_client_sync): """ Test that setting the conversation domain property updates the conversation origin """ sinch_client_sync.configuration.conversation_region = "eu" - sinch_client_sync.configuration.conversation_domain = "https://{}.test.conversation.api.sinch.com/" - assert sinch_client_sync.configuration.conversation_origin == "https://eu.test.conversation.api.sinch.com/" + sinch_client_sync.configuration.conversation_domain = "https://{}.test.conversation.api.sinch.com" + assert sinch_client_sync.configuration.conversation_origin == "https://eu.test.conversation.api.sinch.com" def test_if_logger_name_was_preserved_correctly(sinch_client_sync): @@ -225,4 +225,4 @@ def test_configuration_expects_get_conversation_origin_with_region(sinch_client_ actual_origin = client_configuration.get_conversation_origin() assert actual_origin == expected_origin - assert actual_origin == "https://us.conversation.api.sinch.com/" + assert actual_origin == "https://us.conversation.api.sinch.com" From 99744de949abd3f324f27e2937d2bee1b2901f34 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 20 Jan 2026 10:00:34 +0100 Subject: [PATCH 081/106] DEVEXP-794: Conversation Messages - Unit Tests I (#113) Co-authored-by: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> --- .../contactinfo/contact_info_message.py | 3 +- .../v1/messages/response/types/app_message.py | 4 +- .../messages/test_get_message_endpoint.py | 193 +--------------- .../messages/test_update_message_endpoint.py | 207 ++---------------- .../app_message/test_card_app_message.py | 148 +++++++++++++ .../app_message/test_carousel_app_message.py | 118 ++++++++++ .../app_message/test_choice_app_message.py | 141 ++++++++++++ .../test_contact_info_app_message.py | 139 ++++++++++++ .../app_message/test_list_app_message.py | 123 +++++++++++ .../app_message/test_location_app_message.py | 91 ++++++++ .../app_message/test_media_app_message.py | 87 ++++++++ .../app_message/test_template_app_message.py | 104 +++++++++ .../app_message/test_text_app_message.py | 83 +++++++ ...est_conversation_message_response_model.py | 176 +++++++++++++++ 14 files changed, 1240 insertions(+), 377 deletions(-) create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py index e106a3d4..e2483c65 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py @@ -1,4 +1,5 @@ from typing import Optional +from datetime import date from pydantic import Field, conlist from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfigurationResponse, @@ -40,6 +41,6 @@ class ContactInfoMessage(BaseModelConfigurationResponse): urls: Optional[conlist(UrlInfo)] = Field( default=None, description="URLs/websites associated with the contact." ) - birthday: Optional[str] = Field( + birthday: Optional[date] = Field( default=None, description="Date of birth in YYYY-MM-DD format." ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/app_message.py b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py index 396568d5..60564b66 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/app_message.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/app_message.py @@ -15,10 +15,10 @@ CardAppMessage, CarouselAppMessage, ChoiceAppMessage, + ContactInfoAppMessage, + ListAppMessage, LocationAppMessage, MediaAppMessage, TemplateAppMessage, TextAppMessage, - ListAppMessage, - ContactInfoAppMessage, ] diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py index 59a1f3a6..177dab0a 100644 --- a/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_get_message_endpoint.py @@ -1,5 +1,4 @@ import pytest -from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse from sinch.domains.conversation.api.v1.internal import GetMessageEndpoint from sinch.domains.conversation.models.v1.messages.internal.request import MessageIdRequest @@ -7,6 +6,10 @@ AppMessageResponse, ContactMessageResponse, ) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) @pytest.fixture @@ -15,138 +18,21 @@ def request_data(): @pytest.fixture -def mock_contact_message_response(): +def mock_contact_message_response(contact_message_response_data): """Mock response for ContactMessageResponse (Union type test).""" return HTTPResponse( status_code=200, - body={ - "id": "CAPY123456789ABCDEFGHIJKLMNOP", - "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", - "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", - "direction": "UNDEFINED_DIRECTION", - "channel_identity": { - "app_id": "APP123456789ABCDEFGHIJK", - "channel": "WHATSAPP", - "identity": "+46701234567" - }, - "metadata": "test_metadata", - "accept_time": "2026-01-14T20:32:31.147Z", - "injected": True, - "sender_id": "SENDER123456789ABCDEFGHIJK", - "processing_mode": "CONVERSATION", - "contact_message": { - "channel_specific_message": { - "message_type": "nfm_reply", - "message": { - "type": "nfm_reply", - "nfm_reply": { - "name": "flow", - "response_json": "{\"key\": \"value\"}", - "body": "Message body text" - } - } - }, - "reply_to": { - "message_id": "REPLY_TO_MSG123456789ABCDEF" - } - } - }, + body=contact_message_response_data, headers={"Content-Type": "application/json"}, ) @pytest.fixture -def mock_app_message_response(): +def mock_app_message_response(app_message_response_data): """Mock response for AppMessageResponse (Union type test).""" return HTTPResponse( status_code=200, - body={ - "id": "APP123456789ABCDEFGHIJKLMNOP", - "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", - "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", - "direction": "UNDEFINED_DIRECTION", - "channel_identity": { - "app_id": "APP123456789ABCDEFGHIJK", - "channel": "WHATSAPP", - "identity": "+46701234567" - }, - "metadata": "test_metadata", - "accept_time": "2026-01-14T20:32:31.147Z", - "injected": True, - "sender_id": "SENDER123456789ABCDEFGHIJK", - "processing_mode": "CONVERSATION", - "app_message": { - "card_message": { - "choices": [ - { - "call_message": { - "phone_number": "+15551231234", - "title": "Message text" - }, - "postback_data": None - } - ], - "description": "Card description text", - "height": "UNSPECIFIED_HEIGHT", - "title": "Card title", - "media_message": { - "thumbnail_url": "https://example.com/thumbnail.jpg", - "url": "https://example.com/media.jpg", - "filename_override": "custom_filename.jpg" - }, - "message_properties": { - "whatsapp_header": "WhatsApp header text" - } - }, - "explicit_channel_message": { - "property1": "string", - "property2": "string" - }, - "explicit_channel_omni_message": { - "property1": { - "text_message": { - "text": "string" - } - }, - "property2": { - "text_message": { - "text": "string" - } - } - }, - "channel_specific_message": { - "property1": { - "message_type": "FLOWS", - "message": { - "header": { - "type": "text", - "text": "string" - }, - "body": { - "text": "string" - }, - "footer": { - "text": "string" - }, - "flow_id": "string", - "flow_token": "string", - "flow_mode": "draft", - "flow_cta": "string", - "flow_action": "navigate", - "flow_action_payload": { - "screen": "string", - "data": {} - } - } - } - }, - "agent": { - "display_name": "Agent Name", - "type": "UNKNOWN_AGENT_TYPE", - "picture_url": "https://example.com/agent.jpg" - } - } - }, + body=app_message_response_data, headers={"Content-Type": "application/json"}, ) @@ -191,32 +77,6 @@ def test_handle_response_expects_contact_message_response(endpoint, mock_contact assert isinstance(parsed_response, ContactMessageResponse) assert not isinstance(parsed_response, AppMessageResponse) - assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" - assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" - assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" - assert parsed_response.direction == "UNDEFINED_DIRECTION" - assert parsed_response.metadata == "test_metadata" - assert parsed_response.contact_message is not None - assert parsed_response.contact_message.channel_specific_message is not None - assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" - assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Message body text" - assert parsed_response.contact_message.reply_to is not None - assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" - assert parsed_response.channel_identity is not None - assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" - assert parsed_response.channel_identity.channel == "WHATSAPP" - assert parsed_response.channel_identity.identity == "+46701234567" - assert parsed_response.injected is True - assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" - assert parsed_response.processing_mode == "CONVERSATION" - - assert parsed_response.accept_time == datetime( - 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc - ) - def test_handle_response_expects_app_message_response(mock_app_message_response): """ @@ -231,40 +91,3 @@ def test_handle_response_expects_app_message_response(mock_app_message_response) # In this test case, we expect an AppMessageResponse assert isinstance(parsed_response, AppMessageResponse) assert not isinstance(parsed_response, ContactMessageResponse) - - assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" - assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" - assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" - assert parsed_response.direction == "UNDEFINED_DIRECTION" - assert parsed_response.metadata == "test_metadata" - assert parsed_response.app_message is not None - assert parsed_response.app_message.card_message is not None - assert parsed_response.app_message.card_message.title == "Card title" - assert parsed_response.app_message.card_message.description == "Card description text" - assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" - assert parsed_response.app_message.card_message.choices is not None - assert len(parsed_response.app_message.card_message.choices) == 1 - assert parsed_response.app_message.card_message.choices[0].call_message is not None - assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" - assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" - assert parsed_response.app_message.card_message.media_message is not None - assert parsed_response.app_message.card_message.media_message.url == "https://example.com/media.jpg" - assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://example.com/thumbnail.jpg" - assert parsed_response.app_message.card_message.media_message.filename_override == "custom_filename.jpg" - assert parsed_response.app_message.card_message.message_properties is not None - assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" - assert parsed_response.app_message.agent is not None - assert parsed_response.app_message.agent.display_name == "Agent Name" - assert parsed_response.app_message.agent.type == "UNKNOWN_AGENT_TYPE" - assert parsed_response.app_message.agent.picture_url == "https://example.com/agent.jpg" - assert parsed_response.channel_identity is not None - assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" - assert parsed_response.channel_identity.channel == "WHATSAPP" - assert parsed_response.channel_identity.identity == "+46701234567" - assert parsed_response.injected is True - assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" - assert parsed_response.processing_mode == "CONVERSATION" - - assert parsed_response.accept_time == datetime( - 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc - ) diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py index 9f64d833..8a3ccd29 100644 --- a/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_update_message_endpoint.py @@ -1,6 +1,5 @@ import json import pytest -from datetime import datetime, timezone from sinch.core.models.http_response import HTTPResponse from sinch.domains.conversation.api.v1.internal import UpdateMessageMetadataEndpoint from sinch.domains.conversation.models.v1.messages.internal.request import UpdateMessageMetadataRequest @@ -8,149 +7,36 @@ AppMessageResponse, ContactMessageResponse, ) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) @pytest.fixture def request_data(): return UpdateMessageMetadataRequest( - message_id="UPDATE123456789ABCDEFGHIJKLMNOP", - metadata="updated_metadata_value", + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", ) @pytest.fixture -def mock_contact_message_response(): +def mock_contact_message_response(contact_message_response_data): """Mock response for ContactMessageResponse (Union type test).""" return HTTPResponse( status_code=200, - body={ - "id": "UPDATE123456789ABCDEFGHIJKLMNOP", - "conversation_id": "UPDATE_CONV987654321ZYXWVUTSRQP", - "contact_id": "UPDATE_CONTACT456789ABCDEFGHIJK", - "direction": "TO_CONTACT", - "channel_identity": { - "app_id": "APP123456789ABCDEFGHIJK", - "channel": "WHATSAPP", - "identity": "+46701234567" - }, - "metadata": "updated_metadata_value", - "accept_time": "2026-01-15T17:19:12.000Z", - "injected": True, - "sender_id": "SENDER123456789ABCDEFGHIJK", - "processing_mode": "CONVERSATION", - "contact_message": { - "channel_specific_message": { - "message_type": "nfm_reply", - "message": { - "type": "nfm_reply", - "nfm_reply": { - "name": "flow", - "response_json": "{\"key\": \"value\"}", - "body": "Updated message content" - } - } - }, - "reply_to": { - "message_id": "REPLY_TO_MSG123456789ABCDEF" - } - } - }, + body=contact_message_response_data, headers={"Content-Type": "application/json"}, ) @pytest.fixture -def mock_app_message_response(): +def mock_app_message_response(app_message_response_data): """Mock response for AppMessageResponse (Union type test).""" return HTTPResponse( status_code=200, - body={ - "id": "UPDATE_APP123456789ABCDEFGHIJK", - "conversation_id": "UPDATE_CONV987654321ZYXWVUTSRQP", - "contact_id": "UPDATE_CONTACT456789ABCDEFGHIJK", - "direction": "TO_CONTACT", - "channel_identity": { - "app_id": "APP123456789ABCDEFGHIJK", - "channel": "WHATSAPP", - "identity": "+46701234567" - }, - "metadata": "updated_metadata_value", - "accept_time": "2026-01-15T17:19:12.000Z", - "injected": True, - "sender_id": "SENDER123456789ABCDEFGHIJK", - "processing_mode": "CONVERSATION", - "app_message": { - "card_message": { - "choices": [ - { - "call_message": { - "phone_number": "+15551231234", - "title": "Message text" - }, - "postback_data": None - } - ], - "description": "Card description text", - "height": "UNSPECIFIED_HEIGHT", - "title": "Card title", - "media_message": { - "thumbnail_url": "https://update.example.com/thumb.jpg", - "url": "https://update.example.com/image.jpg", - "filename_override": "updated_image.jpg" - }, - "message_properties": { - "whatsapp_header": "WhatsApp header text" - } - }, - "explicit_channel_message": { - "property1": "string", - "property2": "string" - }, - "explicit_channel_omni_message": { - "property1": { - "text_message": { - "text": "string" - } - }, - "property2": { - "text_message": { - "text": "string" - } - } - }, - "channel_specific_message": { - "property1": { - "message_type": "FLOWS", - "message": { - "header": { - "type": "text", - "text": "string" - }, - "body": { - "text": "string" - }, - "footer": { - "text": "string" - }, - "flow_id": "string", - "flow_token": "string", - "flow_mode": "draft", - "flow_cta": "string", - "flow_action": "navigate", - "flow_action_payload": { - "screen": "string", - "data": {} - } - } - } - }, - "agent": { - "display_name": "Updated Agent", - "type": "HUMAN_AGENT", - "picture_url": "https://update.example.com/agent_photo.jpg" - } - } - }, + body=app_message_response_data, headers={"Content-Type": "application/json"}, ) @@ -164,7 +50,7 @@ def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation) """Test that the URL is built correctly.""" assert ( endpoint.build_url(mock_sinch_client_conversation) - == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/UPDATE123456789ABCDEFGHIJKLMNOP" + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages/CAPY123456789ABCDEFGHIJKLMNOP" ) @@ -193,7 +79,7 @@ def test_request_body_expects_excludes_message_id_and_query_params(request_data) assert "messages_source" not in body assert "message_id" not in body assert "metadata" in body - assert body["metadata"] == "updated_metadata_value" + assert body["metadata"] == "test_metadata" def test_handle_response_expects_contact_message_mapping(endpoint, mock_contact_message_response): @@ -204,32 +90,9 @@ def test_handle_response_expects_contact_message_mapping(endpoint, mock_contact_ assert isinstance(parsed_response, ContactMessageResponse) assert not isinstance(parsed_response, AppMessageResponse) - - assert parsed_response.id == "UPDATE123456789ABCDEFGHIJKLMNOP" - assert parsed_response.conversation_id == "UPDATE_CONV987654321ZYXWVUTSRQP" - assert parsed_response.contact_id == "UPDATE_CONTACT456789ABCDEFGHIJK" - assert parsed_response.direction == "TO_CONTACT" - assert parsed_response.metadata == "updated_metadata_value" - assert parsed_response.contact_message is not None - assert parsed_response.contact_message.channel_specific_message is not None - assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" - assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" - assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Updated message content" - assert parsed_response.contact_message.reply_to is not None - assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" - assert parsed_response.channel_identity is not None - assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" - assert parsed_response.channel_identity.channel == "WHATSAPP" - assert parsed_response.channel_identity.identity == "+46701234567" - assert parsed_response.injected is True - assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" - assert parsed_response.processing_mode == "CONVERSATION" - assert parsed_response.accept_time == datetime( - 2026, 1, 15, 17, 19, 12, 0, tzinfo=timezone.utc - ) + assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert parsed_response.metadata == "test_metadata" def test_handle_response_expects_app_message_mapping(mock_app_message_response): @@ -237,8 +100,8 @@ def test_handle_response_expects_app_message_mapping(mock_app_message_response): Test that the response handles AppMessageResponse correctly (Union type test). """ request_data = UpdateMessageMetadataRequest( - message_id="UPDATE_APP123456789ABCDEFGHIJK", - metadata="updated_metadata_value", + message_id="APP123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", ) endpoint = UpdateMessageMetadataEndpoint("test_project_id", request_data) @@ -246,40 +109,6 @@ def test_handle_response_expects_app_message_mapping(mock_app_message_response): assert isinstance(parsed_response, AppMessageResponse) assert not isinstance(parsed_response, ContactMessageResponse) - - assert parsed_response.id == "UPDATE_APP123456789ABCDEFGHIJK" - assert parsed_response.conversation_id == "UPDATE_CONV987654321ZYXWVUTSRQP" - assert parsed_response.contact_id == "UPDATE_CONTACT456789ABCDEFGHIJK" - assert parsed_response.direction == "TO_CONTACT" - assert parsed_response.metadata == "updated_metadata_value" - assert parsed_response.app_message is not None - assert parsed_response.app_message.card_message is not None - assert parsed_response.app_message.card_message.title == "Card title" - assert parsed_response.app_message.card_message.description == "Card description text" - assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" - assert parsed_response.app_message.card_message.choices is not None - assert len(parsed_response.app_message.card_message.choices) == 1 - assert parsed_response.app_message.card_message.choices[0].call_message is not None - assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" - assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" - assert parsed_response.app_message.card_message.media_message is not None - assert parsed_response.app_message.card_message.media_message.url == "https://update.example.com/image.jpg" - assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://update.example.com/thumb.jpg" - assert parsed_response.app_message.card_message.media_message.filename_override == "updated_image.jpg" - assert parsed_response.app_message.card_message.message_properties is not None - assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" - assert parsed_response.app_message.agent is not None - assert parsed_response.app_message.agent.display_name == "Updated Agent" - assert parsed_response.app_message.agent.type == "HUMAN_AGENT" - assert parsed_response.app_message.agent.picture_url == "https://update.example.com/agent_photo.jpg" - assert parsed_response.channel_identity is not None - assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" - assert parsed_response.channel_identity.channel == "WHATSAPP" - assert parsed_response.channel_identity.identity == "+46701234567" - assert parsed_response.injected is True - assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" - assert parsed_response.processing_mode == "CONVERSATION" - assert parsed_response.accept_time == datetime( - 2026, 1, 15, 17, 19, 12, 0, tzinfo=timezone.utc - ) + assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" + assert parsed_response.metadata == "test_metadata" diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py new file mode 100644 index 00000000..56660608 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py @@ -0,0 +1,148 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, +) + + +@pytest.fixture +def card_app_message_data(): + """Test data for CardAppMessage from Java SDK.""" + return { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_card_app_message_expects_correct_fields(card_app_message_data): + """Test that CardAppMessage is parsed correctly with all fields.""" + parsed_response = CardAppMessage.model_validate(card_app_message_data) + + assert isinstance(parsed_response, CardAppMessage) + assert parsed_response.card_message is not None + assert parsed_response.card_message.title == "title value" + assert parsed_response.card_message.description == "description value" + assert parsed_response.card_message.height == "MEDIUM" + assert parsed_response.card_message.media_message is not None + assert parsed_response.card_message.media_message.url == "an url value" + assert parsed_response.card_message.media_message.thumbnail_url == "another url" + assert parsed_response.card_message.media_message.filename_override == "filename override value" + assert len(parsed_response.card_message.choices) == 6 + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py new file mode 100644 index 00000000..9fc37981 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py @@ -0,0 +1,118 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CarouselAppMessage, +) + + +@pytest.fixture +def carousel_app_message_data(): + """Test data for CarouselAppMessage from Java SDK.""" + return { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_carousel_app_message_expects_correct_fields(carousel_app_message_data): + """Test that CarouselAppMessage is parsed correctly with all fields.""" + parsed_response = CarouselAppMessage.model_validate(carousel_app_message_data) + + assert isinstance(parsed_response, CarouselAppMessage) + assert parsed_response.carousel_message is not None + assert len(parsed_response.carousel_message.cards) == 1 + assert parsed_response.carousel_message.cards[0].title == "title value" + assert parsed_response.carousel_message.cards[0].description == "description value" + assert parsed_response.carousel_message.cards[0].height == "MEDIUM" + assert len(parsed_response.carousel_message.choices) == 1 + assert parsed_response.carousel_message.choices[0].call_message.title == "title value" + assert parsed_response.carousel_message.choices[0].call_message.phone_number == "phone number value" + assert parsed_response.carousel_message.choices[0].postback_data == "postback call_message data value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py new file mode 100644 index 00000000..4b88081a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py @@ -0,0 +1,141 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ChoiceAppMessage, +) + + +@pytest.fixture +def choice_app_message_data(): + """Test data for ChoiceAppMessage from Java SDK.""" + return { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_choice_app_message_expects_correct_fields(choice_app_message_data): + """Test that ChoiceAppMessage is parsed correctly with all fields.""" + parsed_response = ChoiceAppMessage.model_validate(choice_app_message_data) + + assert isinstance(parsed_response, ChoiceAppMessage) + assert parsed_response.choice_message is not None + assert parsed_response.choice_message.text_message is not None + assert parsed_response.choice_message.text_message.text == "This is a text message." + assert len(parsed_response.choice_message.choices) == 6 + assert parsed_response.choice_message.choices[0].call_message.title == "title value" + assert parsed_response.choice_message.choices[0].call_message.phone_number == "phone number value" + assert parsed_response.choice_message.choices[0].postback_data == "postback call_message data value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py new file mode 100644 index 00000000..3205060d --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py @@ -0,0 +1,139 @@ +import pytest +from datetime import date +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ContactInfoAppMessage, +) + + +@pytest.fixture +def contact_info_app_message_data(): + """Test data for ContactInfoAppMessage from Java SDK.""" + return { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value", + "middle_name": "middle_name value", + "prefix": "prefix value", + "suffix": "suffix value" + }, + "phone_numbers": [ + { + "phone_number": "phone_number value", + "type": "type value" + } + ], + "addresses": [ + { + "city": "city value", + "country": "country value", + "state": "state va@lue", + "zip": "zip value", + "country_code": "country_code value" + } + ], + "email_addresses": [ + { + "email_address": "email_address value", + "type": "type value" + } + ], + "organization": { + "company": "company value", + "department": "department value", + "title": "title value" + }, + "urls": [ + { + "url": "url value", + "type": "type value" + } + ], + "birthday": "1968-07-07" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_contact_info_app_message_expects_correct_fields(contact_info_app_message_data): + """Test that ContactInfoAppMessage is parsed correctly with all fields.""" + parsed_response = ContactInfoAppMessage.model_validate(contact_info_app_message_data) + + assert isinstance(parsed_response, ContactInfoAppMessage) + assert parsed_response.contact_info_message is not None + assert parsed_response.contact_info_message.name is not None + assert parsed_response.contact_info_message.name.full_name == "full_name value" + assert parsed_response.contact_info_message.name.first_name == "first_name value" + assert parsed_response.contact_info_message.name.last_name == "last_name value" + assert parsed_response.contact_info_message.name.middle_name == "middle_name value" + assert parsed_response.contact_info_message.name.prefix == "prefix value" + assert parsed_response.contact_info_message.name.suffix == "suffix value" + assert len(parsed_response.contact_info_message.phone_numbers) == 1 + assert parsed_response.contact_info_message.phone_numbers[0].phone_number == "phone_number value" + assert parsed_response.contact_info_message.phone_numbers[0].type == "type value" + assert len(parsed_response.contact_info_message.addresses) == 1 + assert len(parsed_response.contact_info_message.email_addresses) == 1 + assert parsed_response.contact_info_message.organization is not None + assert len(parsed_response.contact_info_message.urls) == 1 + assert isinstance(parsed_response.contact_info_message.birthday, date) + assert parsed_response.contact_info_message.birthday == date(1968, 7, 7) + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py new file mode 100644 index 00000000..9cf9bad6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py @@ -0,0 +1,123 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ListAppMessage, +) + + +@pytest.fixture +def list_app_message_data(): + """Test data for ListAppMessage from Java SDK.""" + return { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "choice": { + "title": "choice title", + "description": "description value", + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "postback_data": "postback value" + } + } + ] + } + ], + "description": "description value", + "message_properties": { + "catalog_id": "catalog ID value", + "menu": "menu value" + }, + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_list_app_message_expects_correct_fields(list_app_message_data): + """Test that ListAppMessage is parsed correctly with all fields.""" + parsed_response = ListAppMessage.model_validate(list_app_message_data) + + assert isinstance(parsed_response, ListAppMessage) + assert parsed_response.list_message is not None + assert parsed_response.list_message.title == "a list message title value" + assert parsed_response.list_message.description == "description value" + assert len(parsed_response.list_message.sections) == 1 + assert parsed_response.list_message.sections[0].title == "a list section title value" + assert len(parsed_response.list_message.sections[0].items) == 1 + assert parsed_response.list_message.sections[0].items[0].choice.title == "choice title" + assert parsed_response.list_message.sections[0].items[0].choice.description == "description value" + assert parsed_response.list_message.message_properties is not None + assert parsed_response.list_message.message_properties.catalog_id == "catalog ID value" + assert parsed_response.list_message.message_properties.menu == "menu value" + assert parsed_response.list_message.media is not None + assert parsed_response.list_message.media.url == "an url value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py new file mode 100644 index 00000000..27340e2c --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py @@ -0,0 +1,91 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + LocationAppMessage, +) + + +@pytest.fixture +def location_app_message_data(): + """Test data for LocationAppMessage from Java SDK.""" + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "label": "label value", + "title": "title value" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "choices": [ + { + "call_message": { + "phone_number": "phone number value", + "title": "title value" + }, + "postback_data": "postback call_message data value" + } + ], + "text_message": { + "text": "This is a text message." + } + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_id": "1", + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_cta": "Book!", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_price": 100, + "product_description": "description", + "product_name": "name" + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_location_app_message_expects_correct_fields(location_app_message_data): + """Test that LocationAppMessage is parsed correctly with all fields.""" + parsed_response = LocationAppMessage.model_validate(location_app_message_data) + + assert isinstance(parsed_response, LocationAppMessage) + assert parsed_response.location_message is not None + assert parsed_response.location_message.title == "title value" + assert parsed_response.location_message.label == "label value" + assert parsed_response.location_message.coordinates.latitude == 47.6279809 + assert parsed_response.location_message.coordinates.longitude == -2.8229159 + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py new file mode 100644 index 00000000..51b18435 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py @@ -0,0 +1,87 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + MediaAppMessage, +) + + +@pytest.fixture +def media_app_message_data(): + """Test data for MediaAppMessage from Java SDK.""" + return { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_media_app_message_expects_correct_fields(media_app_message_data): + """Test that MediaAppMessage is parsed correctly with all fields.""" + parsed_response = MediaAppMessage.model_validate(media_app_message_data) + + assert isinstance(parsed_response, MediaAppMessage) + assert parsed_response.media_message is not None + assert parsed_response.media_message.url == "an url value" + assert parsed_response.media_message.thumbnail_url == "another url" + assert parsed_response.media_message.filename_override == "filename override value" + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py new file mode 100644 index 00000000..47c6677f --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py @@ -0,0 +1,104 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TemplateAppMessage, +) + + +@pytest.fixture +def template_app_message_data(): + """Test data for TemplateAppMessage from Java SDK.""" + return { + "template_message": { + "channel_template": { + "KAKAOTALK": { + "template_id": "my template ID value", + "language_code": "en-US" + } + }, + "omni_template": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_template_app_message_expects_correct_fields(template_app_message_data): + """Test that TemplateAppMessage is parsed correctly with all fields.""" + parsed_response = TemplateAppMessage.model_validate(template_app_message_data) + + assert isinstance(parsed_response, TemplateAppMessage) + assert parsed_response.template_message is not None + assert parsed_response.template_message.channel_template is not None + assert "KAKAOTALK" in parsed_response.template_message.channel_template + assert parsed_response.template_message.channel_template["KAKAOTALK"].template_id == "my template ID value" + assert parsed_response.template_message.channel_template["KAKAOTALK"].language_code == "en-US" + assert parsed_response.template_message.omni_template is not None + assert parsed_response.template_message.omni_template.template_id == "another template ID" + assert parsed_response.template_message.omni_template.version == "another version" + assert parsed_response.template_message.omni_template.language_code == "another language" + assert parsed_response.template_message.omni_template.parameters is not None + assert parsed_response.explicit_channel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py new file mode 100644 index 00000000..88972885 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py @@ -0,0 +1,83 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TextAppMessage, +) + + +@pytest.fixture +def text_app_message_data(): + """Test data for TextAppMessage from Java SDK.""" + return { + "text_message": { + "text": "This is a text message." + }, + "explicit_channel_message": { + "KAKAOTALK": "foo value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + } + } + }, + "channel_specific_message": { + "MESSENGER": { + "message_type": "FLOWS", + "message": { + "flow_id": "1", + "flow_cta": "Book!", + "header": { + "type": "text", + "text": "text header value" + }, + "body": { + "text": "Flow message body" + }, + "footer": { + "text": "Flow message footer" + }, + "flow_token": "AQAAAAACS5FpgQ_cAAAAAD0QI3s.", + "flow_mode": "draft", + "flow_action": "navigate", + "flow_action_payload": { + "screen": "", + "data": { + "product_name": "name", + "product_description": "description", + "product_price": 100 + } + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_text_app_message_expects_correct_fields(text_app_message_data): + """Test that TextAppMessage is parsed correctly with all fields.""" + parsed_response = TextAppMessage.model_validate(text_app_message_data) + + assert isinstance(parsed_response, TextAppMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.explicit_channel_message is not None + assert parsed_response.channel_specific_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py b/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py new file mode 100644 index 00000000..ebbdecff --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/test_conversation_message_response_model.py @@ -0,0 +1,176 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.response.message_response import ( + AppMessageResponse, + ContactMessageResponse, +) + + +@pytest.fixture +def contact_message_response_data(): + """Test data for ContactMessageResponse.""" + return { + "id": "CAPY123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "contact_message": { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "flow", + "response_json": "{\"key\": \"value\"}", + "body": "Message body text" + } + } + }, + "reply_to": { + "message_id": "REPLY_TO_MSG123456789ABCDEF" + } + } + } + + +@pytest.fixture +def app_message_response_data(): + """Test data for AppMessageResponse.""" + return { + "id": "APP123456789ABCDEFGHIJKLMNOP", + "conversation_id": "CONV987654321ZYXWVUTSRQPONMLK", + "contact_id": "CONTACT456789ABCDEFGHIJKLMNOPQR", + "direction": "UNDEFINED_DIRECTION", + "channel_identity": { + "app_id": "APP123456789ABCDEFGHIJK", + "channel": "WHATSAPP", + "identity": "+46701234567" + }, + "metadata": "test_metadata", + "accept_time": "2026-01-14T20:32:31.147Z", + "injected": True, + "sender_id": "SENDER123456789ABCDEFGHIJK", + "processing_mode": "CONVERSATION", + "app_message": { + "card_message": { + "choices": [ + { + "call_message": { + "phone_number": "+15551231234", + "title": "Message text" + }, + "postback_data": None + } + ], + "description": "Card description text", + "height": "UNSPECIFIED_HEIGHT", + "title": "Card title", + "media_message": { + "thumbnail_url": "https://example.com/thumbnail.jpg", + "url": "https://example.com/media.jpg", + "filename_override": "custom_filename.jpg" + }, + "message_properties": { + "whatsapp_header": "WhatsApp header text" + } + }, + "agent": { + "display_name": "Agent Name", + "type": "UNKNOWN_AGENT_TYPE", + "picture_url": "https://example.com/agent.jpg" + } + } + } + + +def test_parsing_contact_message_response_expects_correct_fields(contact_message_response_data): + """Test that ContactMessageResponse is parsed correctly with all fields.""" + parsed_response = ContactMessageResponse.model_validate(contact_message_response_data) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect a ContactMessageResponse + assert isinstance(parsed_response, ContactMessageResponse) + assert not isinstance(parsed_response, AppMessageResponse) + + assert parsed_response.id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.contact_message is not None + assert parsed_response.contact_message.channel_specific_message is not None + assert parsed_response.contact_message.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.name == "flow" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.contact_message.channel_specific_message.message.nfm_reply.body == "Message body text" + assert parsed_response.contact_message.reply_to is not None + assert parsed_response.contact_message.reply_to.message_id == "REPLY_TO_MSG123456789ABCDEF" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_parsing_app_message_response_expects_correct_fields(app_message_response_data): + """Test that AppMessageResponse is parsed correctly with all fields.""" + parsed_response = AppMessageResponse.model_validate(app_message_response_data) + + # ConversationMessageResponse is a Union of AppMessageResponse and ContactMessageResponse + # In this test case, we expect an AppMessageResponse + assert isinstance(parsed_response, AppMessageResponse) + assert not isinstance(parsed_response, ContactMessageResponse) + + assert parsed_response.id == "APP123456789ABCDEFGHIJKLMNOP" + assert parsed_response.conversation_id == "CONV987654321ZYXWVUTSRQPONMLK" + assert parsed_response.contact_id == "CONTACT456789ABCDEFGHIJKLMNOPQR" + assert parsed_response.direction == "UNDEFINED_DIRECTION" + assert parsed_response.metadata == "test_metadata" + assert parsed_response.app_message is not None + assert parsed_response.app_message.card_message is not None + assert parsed_response.app_message.card_message.title == "Card title" + assert parsed_response.app_message.card_message.description == "Card description text" + assert parsed_response.app_message.card_message.height == "UNSPECIFIED_HEIGHT" + assert parsed_response.app_message.card_message.choices is not None + assert len(parsed_response.app_message.card_message.choices) == 1 + assert parsed_response.app_message.card_message.choices[0].call_message is not None + assert parsed_response.app_message.card_message.choices[0].call_message.phone_number == "+15551231234" + assert parsed_response.app_message.card_message.choices[0].call_message.title == "Message text" + assert parsed_response.app_message.card_message.media_message is not None + assert parsed_response.app_message.card_message.media_message.url == "https://example.com/media.jpg" + assert parsed_response.app_message.card_message.media_message.thumbnail_url == "https://example.com/thumbnail.jpg" + assert parsed_response.app_message.card_message.media_message.filename_override == "custom_filename.jpg" + assert parsed_response.app_message.card_message.message_properties is not None + assert parsed_response.app_message.card_message.message_properties.whatsapp_header == "WhatsApp header text" + assert parsed_response.app_message.agent is not None + assert parsed_response.app_message.agent.display_name == "Agent Name" + assert parsed_response.app_message.agent.type == "UNKNOWN_AGENT_TYPE" + assert parsed_response.app_message.agent.picture_url == "https://example.com/agent.jpg" + assert parsed_response.channel_identity is not None + assert parsed_response.channel_identity.app_id == "APP123456789ABCDEFGHIJK" + assert parsed_response.channel_identity.channel == "WHATSAPP" + assert parsed_response.channel_identity.identity == "+46701234567" + assert parsed_response.injected is True + assert parsed_response.sender_id == "SENDER123456789ABCDEFGHIJK" + assert parsed_response.processing_mode == "CONVERSATION" + + assert parsed_response.accept_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) From 1a8143eed5b3c6a5c9b641de26843c2f24b96e79 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 22 Jan 2026 12:28:36 +0100 Subject: [PATCH 082/106] DEVEXP-794: Conversation Messages - Snippets (#115) --- .github/workflows/ci.yml | 2 ++ .../conversation/messages/delete/snippet.py | 26 +++++++++++++++++ .../conversation/messages/get/snippet.py | 26 +++++++++++++++++ .../conversation/messages/update/snippet.py | 29 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 examples/snippets/conversation/messages/delete/snippet.py create mode 100644 examples/snippets/conversation/messages/get/snippet.py create mode 100644 examples/snippets/conversation/messages/update/snippet.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb2eba9c..49967804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,3 +96,5 @@ jobs: run: | python -m behave tests/e2e/numbers/features python -m behave tests/e2e/sms/features + python -m behave tests/e2e/conversation/features + python -m behave tests/e2e/number-lookup/features diff --git a/examples/snippets/conversation/messages/delete/snippet.py b/examples/snippets/conversation/messages/delete/snippet.py new file mode 100644 index 00000000..c520eb7c --- /dev/null +++ b/examples/snippets/conversation/messages/delete/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to delete +message_id = "MESSAGE_ID" + +sinch_client.conversation.messages.delete(message_id=message_id) + +print("Message deleted successfully") diff --git a/examples/snippets/conversation/messages/get/snippet.py b/examples/snippets/conversation/messages/get/snippet.py new file mode 100644 index 00000000..4c3d343c --- /dev/null +++ b/examples/snippets/conversation/messages/get/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to retrieve +message_id = "MESSAGE_ID" + +response = sinch_client.conversation.messages.get(message_id=message_id) + +print(f"Message details:\n{response}") diff --git a/examples/snippets/conversation/messages/update/snippet.py b/examples/snippets/conversation/messages/update/snippet.py new file mode 100644 index 00000000..72d31d32 --- /dev/null +++ b/examples/snippets/conversation/messages/update/snippet.py @@ -0,0 +1,29 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the message to update +message_id = "MESSAGE_ID" + +response = sinch_client.conversation.messages.update( + message_id=message_id, + metadata="metadata value set from Python SDK snippet" +) + +print(f"Updated message:\n{response}") From 3b637831486abd429b269fab9b8e9534664bc308 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 22 Jan 2026 17:48:03 +0100 Subject: [PATCH 083/106] DEVEXP-794: Conversation Messages - Unit Tests II (#114) --- .../request/test_message_id_request.py | 43 ++++ .../test_update_message_metadata_request.py | 55 ++++++ .../app_message/test_card_app_message.py | 1 - .../app_message/test_carousel_app_message.py | 1 - .../app_message/test_choice_app_message.py | 1 - .../test_contact_info_app_message.py | 1 - .../app_message/test_list_app_message.py | 1 - .../app_message/test_location_app_message.py | 1 - .../app_message/test_media_app_message.py | 1 - .../test_omni_message_override_card.py | 119 +++++++++++ .../test_omni_message_override_carousel.py | 185 ++++++++++++++++++ .../test_omni_message_override_choice.py | 114 +++++++++++ ...test_omni_message_override_contact_info.py | 101 ++++++++++ .../test_omni_message_override_list.py | 91 +++++++++ .../test_omni_message_override_location.py | 56 ++++++ .../test_omni_message_override_media.py | 49 +++++ ...mni_message_override_template_reference.py | 65 ++++++ .../test_omni_message_override_text.py | 43 ++++ .../app_message/test_template_app_message.py | 1 - .../app_message/test_text_app_message.py | 1 - .../test_channel_specific_contact_message.py | 45 +++++ .../test_choice_response_contact_message.py | 32 +++ .../test_fallback_contact_message.py | 39 ++++ .../test_location_contact_message.py | 38 ++++ .../test_media_card_contact_message.py | 32 +++ .../test_media_contact_message.py | 30 +++ .../test_product_response_contact_message.py | 48 +++++ .../test_text_contact_message.py | 59 ++++++ 28 files changed, 1244 insertions(+), 9 deletions(-) create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py create mode 100644 tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py create mode 100644 tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py new file mode 100644 index 00000000..af5049b7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_message_id_request.py @@ -0,0 +1,43 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.internal.request import ( + MessageIdRequest, +) + + +def test_message_id_request_expects_accepts_snake_case_input(): + """ + Test that the model accepts snake_case input when allow_population_by_field_name is True. + """ + request = MessageIdRequest(message_id="CAPYLAKE123456789ABCDEFGHIJKL") + + assert request.message_id == "CAPYLAKE123456789ABCDEFGHIJKL" + + +@pytest.mark.parametrize("messages_source", ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"]) +def test_message_id_request_expects_accepts_messages_source(messages_source): + """ + Test that the model accepts messages_source with different values. + """ + request = MessageIdRequest( + message_id="CAPYPOUND123456789ABCDEFGHIJKLM", + messages_source=messages_source + ) + + assert request.message_id == "CAPYPOUND123456789ABCDEFGHIJKLM" + assert request.messages_source == messages_source + + +def test_message_id_request_expects_validation_error_for_missing_field(): + """ + Test that the model raises a ValidationError when a required field is missing. + """ + data = {} + + with pytest.raises(ValidationError) as excinfo: + MessageIdRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "messageId" in error_message or "message_id" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py new file mode 100644 index 00000000..01df0e0c --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_update_message_metadata_request.py @@ -0,0 +1,55 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.internal.request import ( + UpdateMessageMetadataRequest, +) + + +@pytest.mark.parametrize("messages_source", ["CONVERSATION_SOURCE", "DISPATCH_SOURCE"]) +def test_update_message_metadata_request_expects_accepts_messages_source(messages_source): + """ + Test that the model accepts messages_source with different values. + """ + request = UpdateMessageMetadataRequest( + message_id="CAPY123456789ABCDEFGHIJKLMNOP", + metadata="test_metadata", + messages_source=messages_source + ) + + assert request.message_id == "CAPY123456789ABCDEFGHIJKLMNOP" + assert request.metadata == "test_metadata" + assert request.messages_source == messages_source + + +def test_update_message_metadata_request_expects_validation_error_for_missing_message_id(): + """ + Test that the model raises a ValidationError when message_id field is missing. + """ + data = { + "metadata": "test_metadata" + } + + with pytest.raises(ValidationError) as excinfo: + UpdateMessageMetadataRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "messageId" in error_message or "message_id" in error_message + + +def test_update_message_metadata_request_expects_validation_error_for_missing_metadata(): + """ + Test that the model raises a ValidationError when metadata field is missing. + """ + data = { + "message_id": "CAPY123456789ABCDEFGHIJKLMNOP" + } + + with pytest.raises(ValidationError) as excinfo: + UpdateMessageMetadataRequest(**data) + + error_message = str(excinfo.value) + + assert "Field required" in error_message or "field required" in error_message + assert "metadata" in error_message diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py index 56660608..9f2fe2bd 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_card_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def card_app_message_data(): - """Test data for CardAppMessage from Java SDK.""" return { "card_message": { "title": "title value", diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py index 9fc37981..075d1b8b 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_carousel_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def carousel_app_message_data(): - """Test data for CarouselAppMessage from Java SDK.""" return { "carousel_message": { "cards": [ diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py index 4b88081a..7bc2e118 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_choice_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def choice_app_message_data(): - """Test data for ChoiceAppMessage from Java SDK.""" return { "choice_message": { "text_message": { diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py index 3205060d..862491d3 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_contact_info_app_message.py @@ -7,7 +7,6 @@ @pytest.fixture def contact_info_app_message_data(): - """Test data for ContactInfoAppMessage from Java SDK.""" return { "contact_info_message": { "name": { diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py index 9cf9bad6..28d7f0de 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_list_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def list_app_message_data(): - """Test data for ListAppMessage from Java SDK.""" return { "list_message": { "title": "a list message title value", diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py index 27340e2c..b7ff9055 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_location_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def location_app_message_data(): - """Test data for LocationAppMessage from Java SDK.""" return { "location_message": { "coordinates": { diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py index 51b18435..3316e9d4 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_media_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def media_app_message_data(): - """Test data for MediaAppMessage from Java SDK.""" return { "media_message": { "url": "an url value", diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py new file mode 100644 index 00000000..3292b266 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_card.py @@ -0,0 +1,119 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CardAppMessage, +) + + +@pytest.fixture +def card_app_message_with_omni_override_card_data(): + return { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "card_message": { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_card_app_message_with_omni_override_card_expects_correct_fields( + card_app_message_with_omni_override_card_data, +): + """Test that CardAppMessage with OmniMessageOverrideCard is parsed correctly.""" + parsed_response = CardAppMessage.model_validate( + card_app_message_with_omni_override_card_data + ) + + assert isinstance(parsed_response, CardAppMessage) + assert parsed_response.card_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.card_message is not None + assert omni_override.card_message.title == "title value" + assert omni_override.card_message.description == "description value" + assert omni_override.card_message.height == "MEDIUM" + assert len(omni_override.card_message.choices) == 6 + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py new file mode 100644 index 00000000..629cdb2f --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_carousel.py @@ -0,0 +1,185 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + CarouselAppMessage, +) + + +@pytest.fixture +def carousel_app_message_with_omni_override_carousel_data(): + return { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "carousel_message": { + "cards": [ + { + "title": "title value", + "description": "description value", + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "height": "MEDIUM", + "choices": [ + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback_data text" + }, + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback_data call" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback_data location" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback_data url" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + ], + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_carousel_app_message_with_omni_override_carousel_expects_correct_fields( + carousel_app_message_with_omni_override_carousel_data, +): + """Test that CarouselAppMessage with OmniMessageOverrideCarousel is parsed correctly.""" + parsed_response = CarouselAppMessage.model_validate( + carousel_app_message_with_omni_override_carousel_data + ) + + assert isinstance(parsed_response, CarouselAppMessage) + assert parsed_response.carousel_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.carousel_message is not None + assert len(omni_override.carousel_message.cards) == 1 + assert len(omni_override.carousel_message.choices) == 6 + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py new file mode 100644 index 00000000..57a73ef4 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_choice.py @@ -0,0 +1,114 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ChoiceAppMessage, +) + + +@pytest.fixture +def choice_app_message_with_omni_override_choice_data(): + return { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "choice_message": { + "text_message": { + "text": "This is a text message." + }, + "choices": [ + { + "call_message": { + "title": "title value", + "phone_number": "phone number value" + }, + "postback_data": "postback call_message data value" + }, + { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "postback_data": "postback location_message data value" + }, + { + "text_message": { + "text": "This is a text message." + }, + "postback_data": "postback text_message data value" + }, + { + "url_message": { + "title": "title value", + "url": "an url value" + }, + "postback_data": "postback url_message data value" + }, + { + "calendar_message": { + "title": "Calendar Message Example", + "event_start": "2023-10-01T10:00:00Z", + "event_end": "2023-10-01T11:00:00Z", + "event_title": "Team Meeting", + "event_description": "Monthly team sync-up", + "fallback_url": "https://calendar.example.com/event/12345" + }, + "postback_data": "postback calendar_message data value" + }, + { + "share_location_message": { + "title": "Share Location Example", + "fallback_url": "https://maps.example.com/?q=37.7749,-122.4194" + }, + "postback_data": "postback share_location_message data value" + } + ] + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_choice_app_message_with_omni_override_choice_expects_correct_fields( + choice_app_message_with_omni_override_choice_data, +): + """Test that ChoiceAppMessage with OmniMessageOverrideChoice is parsed correctly.""" + parsed_response = ChoiceAppMessage.model_validate( + choice_app_message_with_omni_override_choice_data + ) + + assert isinstance(parsed_response, ChoiceAppMessage) + assert parsed_response.choice_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.choice_message is not None + assert omni_override.choice_message.text_message is not None + assert len(omni_override.choice_message.choices) == 6 + assert omni_override.choice_message.choices[0].call_message is not None + assert omni_override.choice_message.choices[1].location_message is not None + assert omni_override.choice_message.choices[2].text_message is not None + assert omni_override.choice_message.choices[3].url_message is not None + assert omni_override.choice_message.choices[4].calendar_message is not None + assert omni_override.choice_message.choices[5].share_location_message is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py new file mode 100644 index 00000000..9cc83df6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_contact_info.py @@ -0,0 +1,101 @@ +import pytest +from datetime import date +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ContactInfoAppMessage, +) + + +@pytest.fixture +def contact_info_app_message_with_omni_override_contact_info_data(): + return { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value" + }, + "phone_numbers": [] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "contact_info_message": { + "name": { + "full_name": "full_name value", + "first_name": "first_name value", + "last_name": "last_name value", + "middle_name": "middle_name value", + "prefix": "prefix value", + "suffix": "suffix value" + }, + "phone_numbers": [ + { + "phone_number": "phone_number value", + "type": "type value" + } + ], + "addresses": [ + { + "city": "city value", + "country": "country value", + "state": "state va@lue", + "zip": "zip value", + "country_code": "country_code value" + } + ], + "email_addresses": [ + { + "email_address": "email_address value", + "type": "type value" + } + ], + "organization": { + "company": "company value", + "department": "department value", + "title": "title value" + }, + "urls": [ + { + "url": "url value", + "type": "type value" + } + ], + "birthday": "1968-07-07" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_contact_info_app_message_with_omni_override_contact_info_expects_correct_fields( + contact_info_app_message_with_omni_override_contact_info_data, +): + """Test that ContactInfoAppMessage with OmniMessageOverrideContactInfo is parsed correctly.""" + parsed_response = ContactInfoAppMessage.model_validate( + contact_info_app_message_with_omni_override_contact_info_data + ) + + assert isinstance(parsed_response, ContactInfoAppMessage) + assert parsed_response.contact_info_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.contact_info_message is not None + assert omni_override.contact_info_message.name.full_name == "full_name value" + assert omni_override.contact_info_message.name.first_name == "first_name value" + assert omni_override.contact_info_message.name.last_name == "last_name value" + assert omni_override.contact_info_message.name.middle_name == "middle_name value" + assert omni_override.contact_info_message.name.prefix == "prefix value" + assert omni_override.contact_info_message.name.suffix == "suffix value" + assert len(omni_override.contact_info_message.phone_numbers) == 1 + assert len(omni_override.contact_info_message.addresses) == 1 + assert len(omni_override.contact_info_message.email_addresses) == 1 + assert omni_override.contact_info_message.organization is not None + assert len(omni_override.contact_info_message.urls) == 1 + assert isinstance(omni_override.contact_info_message.birthday, date) + assert omni_override.contact_info_message.birthday == date(1968, 7, 7) + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py new file mode 100644 index 00000000..34aa95cd --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_list.py @@ -0,0 +1,91 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + ListAppMessage, +) + + +@pytest.fixture +def list_app_message_with_omni_override_list_data(): + return { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "product": { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + } + ] + } + ] + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "list_message": { + "title": "a list message title value", + "sections": [ + { + "title": "a list section title value", + "items": [ + { + "product": { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + } + ] + } + ], + "description": "description value", + "message_properties": { + "catalog_id": "catalog ID value", + "menu": "menu value" + }, + "media": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_list_app_message_with_omni_override_list_expects_correct_fields( + list_app_message_with_omni_override_list_data, +): + """Test that ListAppMessage with OmniMessageOverrideList is parsed correctly.""" + parsed_response = ListAppMessage.model_validate( + list_app_message_with_omni_override_list_data + ) + + assert isinstance(parsed_response, ListAppMessage) + assert parsed_response.list_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.list_message is not None + assert omni_override.list_message.title == "a list message title value" + assert len(omni_override.list_message.sections) == 1 + assert omni_override.list_message.description == "description value" + assert omni_override.list_message.message_properties is not None + assert omni_override.list_message.message_properties.catalog_id == "catalog ID value" + assert omni_override.list_message.message_properties.menu == "menu value" + assert omni_override.list_message.media is not None + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py new file mode 100644 index 00000000..2ad82be8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_location.py @@ -0,0 +1,56 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + LocationAppMessage, +) + + +@pytest.fixture +def location_app_message_with_omni_override_location_data(): + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "title": "title value", + "label": "label value" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_location_app_message_with_omni_override_location_expects_correct_fields( + location_app_message_with_omni_override_location_data, +): + """Test that LocationAppMessage with OmniMessageOverrideLocation is parsed correctly.""" + parsed_response = LocationAppMessage.model_validate( + location_app_message_with_omni_override_location_data + ) + + assert isinstance(parsed_response, LocationAppMessage) + assert parsed_response.location_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.location_message is not None + assert omni_override.location_message.coordinates.latitude == 47.6279809 + assert omni_override.location_message.coordinates.longitude == -2.8229159 + assert omni_override.location_message.title == "title value" + assert omni_override.location_message.label == "label value" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py new file mode 100644 index 00000000..d6e40946 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_media.py @@ -0,0 +1,49 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + MediaAppMessage, +) + + +@pytest.fixture +def media_app_message_with_omni_override_media_data(): + return { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "media_message": { + "url": "an url value", + "thumbnail_url": "another url", + "filename_override": "filename override value" + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_media_app_message_with_omni_override_media_expects_correct_fields( + media_app_message_with_omni_override_media_data, +): + """Test that MediaAppMessage with OmniMessageOverrideMedia is parsed correctly.""" + parsed_response = MediaAppMessage.model_validate( + media_app_message_with_omni_override_media_data + ) + + assert isinstance(parsed_response, MediaAppMessage) + assert parsed_response.media_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.media_message is not None + assert omni_override.media_message.url == "an url value" + assert omni_override.media_message.thumbnail_url == "another url" + assert omni_override.media_message.filename_override == "filename override value" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py new file mode 100644 index 00000000..b5368c54 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_template_reference.py @@ -0,0 +1,65 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TemplateAppMessage, +) + + +@pytest.fixture +def template_app_message_with_omni_override_template_reference_data(): + return { + "template_message": { + "channel_template": { + "KAKAOTALK": { + "template_id": "my template ID value", + "language_code": "en-US" + } + }, + "omni_template": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "template_reference": { + "template_id": "another template ID", + "version": "another version", + "language_code": "another language", + "parameters": { + "name": "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + } + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_template_app_message_with_omni_override_template_reference_expects_correct_fields( + template_app_message_with_omni_override_template_reference_data, +): + """Test that TemplateAppMessage with OmniMessageOverrideTemplateReference is parsed correctly.""" + parsed_response = TemplateAppMessage.model_validate( + template_app_message_with_omni_override_template_reference_data + ) + + assert isinstance(parsed_response, TemplateAppMessage) + assert parsed_response.template_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.template_reference is not None + assert omni_override.template_reference.template_id == "another template ID" + assert omni_override.template_reference.version == "another version" + assert omni_override.template_reference.language_code == "another language" + assert omni_override.template_reference.parameters is not None + assert omni_override.template_reference.parameters["name"] == "Value for the name parameter used in the version 1 and language \"en-US\" of the template" + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py new file mode 100644 index 00000000..99d424f6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_omni_message_override_text.py @@ -0,0 +1,43 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.app.app_message import ( + TextAppMessage, +) + + +@pytest.fixture +def text_app_message_with_omni_override_text_data(): + return { + "text_message": { + "text": "This is a text message." + }, + "explicit_channel_omni_message": { + "KAKAOTALK": { + "text_message": { + "text": "This is a text message." + } + } + }, + "agent": { + "display_name": "display_name value", + "type": "BOT", + "picture_url": "picture_url value" + } + } + + +def test_parsing_text_app_message_with_omni_override_text_expects_correct_fields( + text_app_message_with_omni_override_text_data, +): + """Test that TextAppMessage with OmniMessageOverrideText is parsed correctly.""" + parsed_response = TextAppMessage.model_validate( + text_app_message_with_omni_override_text_data + ) + + assert isinstance(parsed_response, TextAppMessage) + assert parsed_response.text_message is not None + assert parsed_response.explicit_channel_omni_message is not None + assert "KAKAOTALK" in parsed_response.explicit_channel_omni_message + omni_override = parsed_response.explicit_channel_omni_message["KAKAOTALK"] + assert omni_override.text_message is not None + assert omni_override.text_message.text == "This is a text message." + assert parsed_response.agent is not None diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py index 47c6677f..e62616cb 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_template_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def template_app_message_data(): - """Test data for TemplateAppMessage from Java SDK.""" return { "template_message": { "channel_template": { diff --git a/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py index 88972885..ed453387 100644 --- a/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py +++ b/tests/unit/domains/conversation/v1/models/response/app_message/test_text_app_message.py @@ -6,7 +6,6 @@ @pytest.fixture def text_app_message_data(): - """Test data for TextAppMessage from Java SDK.""" return { "text_message": { "text": "This is a text message." diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py new file mode 100644 index 00000000..403ece7a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_channel_specific_contact_message.py @@ -0,0 +1,45 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChannelSpecificContactMessage, +) + + +@pytest.fixture +def channel_specific_contact_message_data(): + return { + "channel_specific_message": { + "message_type": "nfm_reply", + "message": { + "type": "nfm_reply", + "nfm_reply": { + "name": "address_message", + "response_json": "{\"key\": \"value\"}", + "body": "nfm reply body value" + } + } + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_channel_specific_contact_message_expects_correct_fields( + channel_specific_contact_message_data, +): + """Test that ChannelSpecificContactMessage is parsed correctly with all fields.""" + parsed_response = ChannelSpecificContactMessage.model_validate( + channel_specific_contact_message_data + ) + + assert isinstance(parsed_response, ChannelSpecificContactMessage) + assert parsed_response.channel_specific_message is not None + assert parsed_response.channel_specific_message.message_type == "nfm_reply" + assert parsed_response.channel_specific_message.message is not None + assert parsed_response.channel_specific_message.message.type == "nfm_reply" + assert parsed_response.channel_specific_message.message.nfm_reply is not None + assert parsed_response.channel_specific_message.message.nfm_reply.name == "address_message" + assert parsed_response.channel_specific_message.message.nfm_reply.response_json == "{\"key\": \"value\"}" + assert parsed_response.channel_specific_message.message.nfm_reply.body == "nfm reply body value" + assert parsed_response.reply_to is not None + assert parsed_response.reply_to.message_id == "message id value" diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py new file mode 100644 index 00000000..8340cac9 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_choice_response_contact_message.py @@ -0,0 +1,32 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ChoiceResponseContactMessage, +) + + +@pytest.fixture +def choice_response_contact_message_data(): + return { + "choice_response_message": { + "message_id": "message id value", + "postback_data": "postback data value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_choice_response_contact_message_expects_correct_fields( + choice_response_contact_message_data, +): + """Test that ChoiceResponseContactMessage is parsed correctly with all fields.""" + parsed_response = ChoiceResponseContactMessage.model_validate( + choice_response_contact_message_data + ) + + assert isinstance(parsed_response, ChoiceResponseContactMessage) + assert parsed_response.choice_response_message is not None + assert parsed_response.choice_response_message.message_id == "message id value" + assert parsed_response.choice_response_message.postback_data == "postback data value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py new file mode 100644 index 00000000..1ad6ae81 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_fallback_contact_message.py @@ -0,0 +1,39 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + FallbackContactMessage, +) + + +@pytest.fixture +def fallback_contact_message_data(): + return { + "fallback_message": { + "raw_message": "raw message value", + "reason": { + "code": "RECIPIENT_NOT_OPTED_IN", + "description": "reason description", + "sub_code": "UNSPECIFIED_SUB_CODE", + "channel_code": "a channel code" + } + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_fallback_contact_message_expects_correct_fields( + fallback_contact_message_data, +): + """Test that FallbackContactMessage is parsed correctly with all fields.""" + parsed_response = FallbackContactMessage.model_validate(fallback_contact_message_data) + + assert isinstance(parsed_response, FallbackContactMessage) + assert parsed_response.fallback_message is not None + assert parsed_response.fallback_message.raw_message == "raw message value" + assert parsed_response.fallback_message.reason is not None + assert parsed_response.fallback_message.reason.code == "RECIPIENT_NOT_OPTED_IN" + assert parsed_response.fallback_message.reason.description == "reason description" + assert parsed_response.fallback_message.reason.sub_code == "UNSPECIFIED_SUB_CODE" + assert parsed_response.fallback_message.reason.channel_code == "a channel code" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py new file mode 100644 index 00000000..4baf19d5 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_location_contact_message.py @@ -0,0 +1,38 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + LocationContactMessage, +) + + +@pytest.fixture +def location_contact_message_data(): + return { + "location_message": { + "coordinates": { + "latitude": 47.6279809, + "longitude": -2.8229159 + }, + "label": "label value", + "title": "title value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_location_contact_message_expects_correct_fields( + location_contact_message_data, +): + """Test that LocationContactMessage is parsed correctly with all fields.""" + parsed_response = LocationContactMessage.model_validate( + location_contact_message_data + ) + + assert isinstance(parsed_response, LocationContactMessage) + assert parsed_response.location_message is not None + assert parsed_response.location_message.coordinates.latitude == 47.6279809 + assert parsed_response.location_message.coordinates.longitude == -2.8229159 + assert parsed_response.location_message.label == "label value" + assert parsed_response.location_message.title == "title value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py new file mode 100644 index 00000000..f63ed465 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_card_contact_message.py @@ -0,0 +1,32 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + MediaCardContactMessage, +) + + +@pytest.fixture +def media_card_contact_message_data(): + return { + "media_card_message": { + "caption": "caption value", + "url": "an url value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_media_card_contact_message_expects_correct_fields( + media_card_contact_message_data, +): + """Test that MediaCardContactMessage is parsed correctly with all fields.""" + parsed_response = MediaCardContactMessage.model_validate( + media_card_contact_message_data + ) + + assert isinstance(parsed_response, MediaCardContactMessage) + assert parsed_response.media_card_message is not None + assert parsed_response.media_card_message.caption == "caption value" + assert parsed_response.media_card_message.url == "an url value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py new file mode 100644 index 00000000..880306d7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_media_contact_message.py @@ -0,0 +1,30 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + MediaContactMessage, +) + + +@pytest.fixture +def media_contact_message_data(): + return { + "media_message": { + "thumbnail_url": "another url", + "url": "an url value", + "filename_override": "filename override value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_media_contact_message_expects_correct_fields(media_contact_message_data): + """Test that MediaContactMessage is parsed correctly with all fields.""" + parsed_response = MediaContactMessage.model_validate(media_contact_message_data) + + assert isinstance(parsed_response, MediaContactMessage) + assert parsed_response.media_message is not None + assert parsed_response.media_message.thumbnail_url == "another url" + assert parsed_response.media_message.url == "an url value" + assert parsed_response.media_message.filename_override == "filename override value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py new file mode 100644 index 00000000..1ef65819 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_product_response_contact_message.py @@ -0,0 +1,48 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + ProductResponseContactMessage, +) + + +@pytest.fixture +def product_response_contact_message_data(): + return { + "product_response_message": { + "products": [ + { + "id": "product ID value", + "marketplace": "marketplace value", + "quantity": 4, + "item_price": 3.14159, + "currency": "currency value" + } + ], + "title": "a product response message title value", + "catalog_id": "catalog id value" + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_product_response_contact_message_expects_correct_fields( + product_response_contact_message_data, +): + """Test that ProductResponseContactMessage is parsed correctly with all fields.""" + parsed_response = ProductResponseContactMessage.model_validate( + product_response_contact_message_data + ) + + assert isinstance(parsed_response, ProductResponseContactMessage) + assert parsed_response.product_response_message is not None + assert len(parsed_response.product_response_message.products) == 1 + product = parsed_response.product_response_message.products[0] + assert product.id == "product ID value" + assert product.marketplace == "marketplace value" + assert product.quantity == 4 + assert product.item_price == 3.14159 + assert product.currency == "currency value" + assert parsed_response.product_response_message.title == "a product response message title value" + assert parsed_response.product_response_message.catalog_id == "catalog id value" + assert parsed_response.reply_to is not None diff --git a/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py b/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py new file mode 100644 index 00000000..b67b2fa4 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/contact_message/test_text_contact_message.py @@ -0,0 +1,59 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.contact.contact_message import ( + TextContactMessage, +) + + +@pytest.fixture +def text_contact_message_data(): + return { + "text_message": { + "text": "This is a text message." + }, + "reply_to": { + "message_id": "message id value" + } + } + + +def test_parsing_text_contact_message_expects_correct_fields(text_contact_message_data): + """Test that TextContactMessage is parsed correctly with reply_to present.""" + parsed_response = TextContactMessage.model_validate(text_contact_message_data) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is not None + + +def test_parsing_text_contact_message_allows_missing_reply_to(): + """Test that TextContactMessage accepts payloads without reply_to.""" + parsed_response = TextContactMessage.model_validate( + { + "text_message": { + "text": "This is a text message." + } + } + ) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is None + + +def test_parsing_text_contact_message_allows_null_reply_to(): + """Test that TextContactMessage accepts payloads with reply_to set to null.""" + parsed_response = TextContactMessage.model_validate( + { + "text_message": { + "text": "This is a text message." + }, + "reply_to": None + } + ) + + assert isinstance(parsed_response, TextContactMessage) + assert parsed_response.text_message is not None + assert parsed_response.text_message.text == "This is a text message." + assert parsed_response.reply_to is None From f84bb18e55ccd73c001e732edd72adf357109442 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 29 Jan 2026 09:25:47 +0100 Subject: [PATCH 084/106] DEVEXP-794: Conversation Messages - Send (E2E tests) (#116) --- .../conversation/api/v1/internal/__init__.py | 2 + .../api/v1/internal/messages_endpoints.py | 31 + .../conversation/api/v1/messages_apis.py | 1004 ++++++++++++++++- .../conversation/api/v1/utils/__init__.py | 15 + .../api/v1/utils/message_helpers.py | 122 ++ .../v1/messages/categories/app/app_message.py | 28 +- .../categories/calendar/calendar_message.py | 4 +- .../messages/categories/call/call_message.py | 4 +- .../messages/categories/card/card_message.py | 6 +- .../categories/card/card_message_field.py | 4 +- .../categories/card/message_properties.py | 4 +- .../categories/carousel/carousel_message.py | 6 +- .../carousel/carousel_message_field.py | 4 +- ...hannel_specific_contact_message_message.py | 4 +- .../channel_specific_message.py | 6 +- .../channel_specific_message_content.py | 14 +- .../kakaotalk/buttons/kakaotalk_button.py | 4 +- .../kakaotalk/commerce/kakaotalk_carousel.py | 4 +- .../commerce/kakaotalk_carousel_head.py | 4 +- .../commerce/kakaotalk_carousel_tail.py | 4 +- .../kakaotalk_channel_specific_message.py | 4 +- .../commerce/kakaotalk_commerce_image.py | 4 +- .../commerce/kakaotalk_commerce_message.py | 4 +- .../kakaotalk_regular_price_commerce.py | 4 +- .../kakaotalk/coupons/kakaotalk_coupon.py | 4 +- .../whatsapp/flows/flow_action_payload.py | 4 +- .../flows/whatsapp_interactive_body.py | 4 +- .../whatsapp_interactive_document_header.py | 4 +- .../flows/whatsapp_interactive_footer.py | 4 +- .../whatsapp_interactive_header_media.py | 4 +- .../whatsapp_interactive_image_header.py | 4 +- .../flows/whatsapp_interactive_text_header.py | 4 +- .../whatsapp_interactive_video_header.py | 4 +- .../whatsapp_interactive_nfm_reply.py | 4 +- .../whatsapp_interactive_nfm_reply_message.py | 4 +- .../whatsapp/payment/boleto.py | 4 +- .../whatsapp/payment/dynamic_pix.py | 4 +- .../whatsapp/payment/order_item.py | 4 +- .../whatsapp/payment/payment_link.py | 4 +- .../whatsapp/payment/payment_order.py | 4 +- .../payment/payment_order_details_content.py | 4 +- .../payment/payment_order_status_content.py | 4 +- .../payment/payment_order_status_order.py | 4 +- .../whatsapp/whatsapp_common_props.py | 4 +- .../categories/choice/choice_message.py | 14 +- .../categories/choice/choice_message_field.py | 4 +- .../choice/choice_message_properties.py | 15 + .../categories/choice/choice_option.py | 52 + .../categories/choice/choice_options.py | 4 +- .../choiceresponse/choice_response_message.py | 4 +- .../v1/messages/categories/common/reply_to.py | 4 +- .../categories/contact/contact_message.py | 22 +- .../contactinfo/contact_info_message.py | 4 +- .../contactinfo/contact_info_message_field.py | 4 +- .../categories/fallback/fallback_message.py | 4 +- .../types => categories/list}/list_item.py | 2 +- .../categories/list/list_item_choice.py | 4 +- .../categories/list/list_item_product.py | 4 +- .../messages/categories/list/list_message.py | 4 +- .../categories/list/list_message_field.py | 4 +- .../list/list_message_properties.py | 4 +- .../categories/location/location_message.py | 4 +- .../location/location_message_field.py | 4 +- .../categories/media/media_message_field.py | 4 +- .../categories/media/media_properties.py | 4 +- .../mediacard/media_card_message.py | 4 +- .../product_response_message.py | 4 +- .../sharelocation/share_location_message.py | 4 +- .../categories/template/template_message.py | 4 +- .../template_reference_channel_specific.py | 4 +- .../template/template_reference_field.py | 4 +- .../messages/categories/text/text_message.py | 4 +- .../categories/text/text_message_field.py | 4 +- .../v1/messages/categories/url/url_message.py | 4 +- .../v1/messages/internal/base/__init__.py | 6 +- .../internal/base/base_model_configuration.py | 18 +- .../v1/messages/internal/request/__init__.py | 16 + .../internal/request/message_id_request.py | 4 +- .../v1/messages/internal/request/recipient.py | 38 + .../internal/request/send_message_request.py | 104 ++ .../request/send_message_request_body.py | 43 + .../update_message_metadata_request.py | 4 +- .../v1/messages/response/message_response.py | 12 +- .../v1/messages/response/types/__init__.py | 10 +- .../messages/response/types/choice_option.py | 19 - .../response/types/payment_settings.py | 4 +- .../response/types/send_message_response.py | 17 + .../models/v1/messages/shared/__init__.py | 10 +- .../models/v1/messages/shared/address_info.py | 4 +- .../models/v1/messages/shared/agent.py | 4 +- .../shared/app_message_common_props.py | 4 +- .../v1/messages/shared/channel_identity.py | 4 +- .../models/v1/messages/shared/choice_item.py | 4 +- .../shared/contact_message_common_props.py | 4 +- .../models/v1/messages/shared/coordinates.py | 4 +- .../models/v1/messages/shared/email_info.py | 4 +- .../models/v1/messages/shared/list_section.py | 6 +- ...ommon_props.py => message_common_props.py} | 4 +- .../models/v1/messages/shared/name_info.py | 4 +- .../v1/messages/shared/organization_info.py | 4 +- .../v1/messages/shared/phone_number_info.py | 4 +- .../models/v1/messages/shared/product_item.py | 4 +- .../models/v1/messages/shared/reason.py | 4 +- .../models/v1/messages/shared/url_info.py | 4 +- .../models/v1/messages/types/__init__.py | 58 + .../messages/types/calendar_message_dict.py | 12 + .../v1/messages/types/call_message_dict.py | 6 + .../v1/messages/types/card_message_dict.py | 24 + .../messages/types/carousel_message_dict.py | 14 + .../v1/messages/types/choice_message_dict.py | 18 + .../types/choice_message_properties_dict.py | 11 + .../v1/messages/types/choice_option_dict.py | 34 + .../types/contact_info_message_dict.py | 52 + .../v1/messages/types/coordinates_dict.py | 6 + .../v1/messages/types/list_message_dict.py | 51 + .../messages/types/location_message_dict.py | 12 + .../messages/types/media_properties_dict.py | 8 + .../v1/messages/types/message_content_type.py | 7 + .../messages/types/message_properties_dict.py | 6 + .../v1/messages/types/message_queue_type.py | 7 + .../types/metadata_update_strategy_type.py | 7 + .../types/processing_strategy_type.py | 7 + .../v1/messages/types/recipient_dict.py | 20 + .../types/send_message_request_body_dict.py | 46 + .../types/share_location_message_dict.py | 6 + .../messages/types/template_message_dict.py | 20 + .../v1/messages/types/text_message_dict.py | 5 + .../v1/messages/types/url_message_dict.py | 6 + sinch/domains/sms/api/v1/batches_apis.py | 19 +- sinch/domains/sms/models/v1/types/__init__.py | 2 + .../sms/models/v1/types/media_body_dict.py | 8 + .../features/steps/conversation.steps.py | 10 +- 132 files changed, 2147 insertions(+), 281 deletions(-) create mode 100644 sinch/domains/conversation/api/v1/utils/__init__.py create mode 100644 sinch/domains/conversation/api/v1/utils/message_helpers.py rename sinch/domains/conversation/models/v1/messages/{response/types => categories/channelspecific}/channel_specific_message_content.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py rename sinch/domains/conversation/models/v1/messages/{response/types => categories/list}/list_item.py (100%) create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/recipient.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py delete mode 100644 sinch/domains/conversation/models/v1/messages/response/types/choice_option.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py rename sinch/domains/conversation/models/v1/messages/shared/{message_response_common_props.py => message_common_props.py} (95%) create mode 100644 sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/call_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/card_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/list_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/location_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/message_content_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/message_queue_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/recipient_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/template_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/text_message_dict.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/url_message_dict.py create mode 100644 sinch/domains/sms/models/v1/types/media_body_dict.py diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py index 4d862310..bc4a7083 100644 --- a/sinch/domains/conversation/api/v1/internal/__init__.py +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -2,10 +2,12 @@ DeleteMessageEndpoint, GetMessageEndpoint, UpdateMessageMetadataEndpoint, + SendMessageEndpoint, ) __all__ = [ "DeleteMessageEndpoint", "GetMessageEndpoint", "UpdateMessageMetadataEndpoint", + "SendMessageEndpoint", ] diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py index 2027f955..d28bcd4c 100644 --- a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -4,9 +4,11 @@ from sinch.domains.conversation.models.v1.messages.internal.request import ( MessageIdRequest, UpdateMessageMetadataRequest, + SendMessageRequest, ) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, + SendMessageResponse, ) from sinch.domains.conversation.api.v1.internal.base import ( ConversationEndpoint, @@ -124,3 +126,32 @@ def handle_response( return self.process_response_model( response.body, ConversationMessageResponse ) + + +class SendMessageEndpoint(ConversationEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages:send" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: SendMessageRequest): + super(SendMessageEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data_dict = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True, exclude=path_params + ) + return json.dumps(request_data_dict) + + def handle_response(self, response: HTTPResponse) -> SendMessageResponse: + try: + super(SendMessageEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, SendMessageResponse) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 41e3c3fc..90bc7b84 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -1,21 +1,72 @@ -from typing import Optional +from typing import Any, Dict, List, Optional, Union from sinch.domains.conversation.models.v1.messages.internal.request import ( MessageIdRequest, UpdateMessageMetadataRequest, + SendMessageRequest, + SendMessageRequestBody, ) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, + SendMessageResponse, ) from sinch.domains.conversation.models.v1.messages.types import ( MessagesSourceType, + ConversationChannelType, + ProcessingStrategyType, + MetadataUpdateStrategyType, + MessageQueueType, + MessageContentType, + CardMessageDict, + CarouselMessageDict, + ChoiceMessageDict, + ContactInfoMessageDict, + ListMessageDict, + LocationMessageDict, + MediaPropertiesDict, + TemplateMessageDict, + ChannelRecipientIdentityDict, + SendMessageRequestBodyDict, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.card import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateMessage, ) from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, UpdateMessageMetadataEndpoint, + SendMessageEndpoint, ) from sinch.domains.conversation.api.v1.base import BaseConversation +from sinch.domains.conversation.api.v1.utils import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) class Messages(BaseConversation): @@ -114,3 +165,954 @@ def update( **kwargs, ) return self._request(UpdateMessageMetadataEndpoint, request_data) + + def _send_message_variant( + self, + app_id: str, + contact_id: Optional[str], + recipient_identities: Optional[List[ChannelRecipientIdentityDict]], + message_field: str, + message: object, + message_cls: type, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + - Builds Recipient Dictionary from contact_id or recipient_identities + - Normalizes recipient dict -> Recipient model + - Normalizes message dict -> message_cls(**message) + - Builds SendMessageRequest(message=..., recipient=..., app_id=...) and sends the request + """ + recipient_dict = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + recipient_model = coerce_recipient(recipient_dict) + if isinstance(message, dict): + message = message_cls(**message) + + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + send_message_request_body = SendMessageRequestBody( + **{message_field: message}, + **message_kwargs, + ) + request_data = SendMessageRequest( + app_id=app_id, + recipient=recipient_model, + message=send_message_request_body, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **request_kwargs, + ) + return self._request(SendMessageEndpoint, request_data) + + def send( + self, + app_id: str, + message: Union[SendMessageRequestBodyDict, dict], + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param message: The message content to send. Can be a SendMessageRequestBodyDict or a dict. + :type message: Union[SendMessageRequestBodyDict, dict] + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + recipient_dict = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + recipient = coerce_recipient(recipient_dict) + # Coerce message to SendMessageRequestBody if it's a dict + if isinstance(message, dict): + message = SendMessageRequestBody(**message) + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + # message kwargs are applied directly to the message model (if provided as dict) + if message_kwargs: + message = SendMessageRequestBody( + **message.model_dump(), **message_kwargs + ) + request_data = SendMessageRequest( + app_id=app_id, + recipient=recipient, + message=message, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **request_kwargs, + ) + return self._request(SendMessageEndpoint, request_data) + + def send_text_message( + self, + app_id: str, + text: str, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a text message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param text: The text content of the message. + :type text: str + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="text_message", + message=TextMessage(text=text), + message_cls=TextMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_card_message( + self, + app_id: str, + card_message: CardMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a card message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param card_message: The card message content. + :type card_message: CardMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="card_message", + message=card_message, + message_cls=CardMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_carousel_message( + self, + app_id: str, + carousel_message: CarouselMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a carousel message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param carousel_message: The carousel message content. + :type carousel_message: CarouselMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="carousel_message", + message=carousel_message, + message_cls=CarouselMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_choice_message( + self, + app_id: str, + choice_message: ChoiceMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a choice message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param choice_message: The choice message content. + :type choice_message: ChoiceMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="choice_message", + message=choice_message, + message_cls=ChoiceMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_contact_info_message( + self, + app_id: str, + contact_info_message: ContactInfoMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a contact info message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param contact_info_message: The contact info message content. + :type contact_info_message: ContactInfoMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="contact_info_message", + message=contact_info_message, + message_cls=ContactInfoMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_list_message( + self, + app_id: str, + list_message: ListMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a list message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param list_message: The list message content. + :type list_message: ListMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="list_message", + message=list_message, + message_cls=ListMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_location_message( + self, + app_id: str, + location_message: LocationMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a location message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param location_message: The location message content. + :type location_message: LocationMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="location_message", + message=location_message, + message_cls=LocationMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_media_message( + self, + app_id: str, + media_message: MediaPropertiesDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a media message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param media_message: The media message content. + :type media_message: MediaPropertiesDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="media_message", + message=media_message, + message_cls=MediaProperties, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) + + def send_template_message( + self, + app_id: str, + template_message: TemplateMessageDict, + contact_id: Optional[str] = None, + recipient_identities: Optional[ + List[ChannelRecipientIdentityDict] + ] = None, + ttl: Optional[Union[str, int]] = None, + callback_url: Optional[str] = None, + channel_priority_order: Optional[List[ConversationChannelType]] = None, + channel_properties: Optional[Dict[str, str]] = None, + message_metadata: Optional[str] = None, + conversation_metadata: Optional[Dict[str, Any]] = None, + queue: Optional[MessageQueueType] = None, + processing_strategy: Optional[ProcessingStrategyType] = None, + correlation_id: Optional[str] = None, + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = None, + message_content_type: Optional[MessageContentType] = None, + **kwargs, + ) -> SendMessageResponse: + """ + Send a template message from a Conversation app to a contact associated with that app. + If the recipient is not associated with an existing contact, a new contact will be created. + The message is added to the active conversation with the contact if a conversation already exists. + If no active conversation exists a new one is started automatically. + + :param app_id: The ID of the Conversation API app sending the message. + :type app_id: str + :param contact_id: The contact ID of the recipient. Either contact_id or recipient_identities must be provided. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. Either contact_id or recipient_identities must be provided. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + :param template_message: The template message content. + :type template_message: TemplateMessageDict + :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. + :type ttl: Optional[Union[str, int]] + :param callback_url: Overwrites the default callback url for delivery receipts for this message. + :type callback_url: Optional[str] + :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. + :type channel_priority_order: Optional[List[ConversationChannelType]] + :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. + :type channel_properties: Optional[Dict[str, str]] + :param message_metadata: Metadata that should be associated with the message. Up to 1024 characters long. + :type message_metadata: Optional[str] + :param conversation_metadata: Metadata that will be associated with the conversation. Up to 2048 characters long. + :type conversation_metadata: Optional[Dict[str, Any]] + :param queue: Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'. + :type queue: Optional[MessageQueueType] + :param processing_strategy: Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'. + :type processing_strategy: Optional[ProcessingStrategyType] + :param correlation_id: An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long. + :type correlation_id: Optional[str] + :param conversation_metadata_update_strategy: Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'. + :type conversation_metadata_update_strategy: Optional[MetadataUpdateStrategyType] + :param message_content_type: Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'. + :type message_content_type: Optional[MessageContentType] + :param **kwargs: Additional parameters for the message body (e.g., agent, etc.). + :type **kwargs: dict + + :returns: SendMessageResponse + :rtype: SendMessageResponse + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return self._send_message_variant( + app_id=app_id, + contact_id=contact_id, + recipient_identities=recipient_identities, + message_field="template_message", + message=template_message, + message_cls=TemplateMessage, + ttl=ttl, + callback_url=callback_url, + channel_priority_order=channel_priority_order, + channel_properties=channel_properties, + message_metadata=message_metadata, + conversation_metadata=conversation_metadata, + queue=queue, + processing_strategy=processing_strategy, + correlation_id=correlation_id, + conversation_metadata_update_strategy=conversation_metadata_update_strategy, + message_content_type=message_content_type, + **kwargs, + ) diff --git a/sinch/domains/conversation/api/v1/utils/__init__.py b/sinch/domains/conversation/api/v1/utils/__init__.py new file mode 100644 index 00000000..ef5df4d6 --- /dev/null +++ b/sinch/domains/conversation/api/v1/utils/__init__.py @@ -0,0 +1,15 @@ +""" +Utility functions for Conversation API message operations. +""" + +from sinch.domains.conversation.api.v1.utils.message_helpers import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) + +__all__ = [ + "build_recipient_dict", + "coerce_recipient", + "split_send_kwargs", +] diff --git a/sinch/domains/conversation/api/v1/utils/message_helpers.py b/sinch/domains/conversation/api/v1/utils/message_helpers.py new file mode 100644 index 00000000..f0f62601 --- /dev/null +++ b/sinch/domains/conversation/api/v1/utils/message_helpers.py @@ -0,0 +1,122 @@ +""" +Helper functions for building and processing message requests. + +This module contains pure utility functions that handle common operations +for message sending, such as recipient validation, type coercion, and +parameter splitting. +""" + +from typing import List, Optional, Union + +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + ChannelRecipientIdentity, + IdentifiedBy, + Recipient, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ChannelRecipientIdentityDict, + RecipientDict, +) + + +def build_recipient_dict( + contact_id: Optional[str] = None, + recipient_identities: Optional[List[ChannelRecipientIdentityDict]] = None, +) -> RecipientDict: + """ + Build a RecipientDict from optional contact_id or recipient_identities. + + Validates that exactly one of the parameters is provided and returns + the appropriate dictionary structure. + + :param contact_id: The contact ID of the recipient. + :type contact_id: Optional[str] + :param recipient_identities: List of channel identities for the recipient. + :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] + + :returns: A RecipientDict with either contact_id or channel_identities. + :rtype: RecipientDict + + :raises ValueError: If both or neither parameters are provided. + """ + has_contact_id = contact_id is not None + has_identities = recipient_identities is not None + + if has_contact_id and has_identities: + raise ValueError( + "Cannot specify both 'contact_id' and 'recipient_identities'. " + "Provide exactly one." + ) + if not has_contact_id and not has_identities: + raise ValueError( + "Must provide either 'contact_id' or 'recipient_identities'." + ) + + return ( + {"contact_id": contact_id} + if has_contact_id + else {"channel_identities": recipient_identities} + ) + + +def coerce_recipient(recipient: Union[Recipient, dict]) -> Recipient: + """ + Coerce a recipient input to a Recipient model instance. + + Handles multiple input formats: + - Recipient model instance (returns as-is) + - Simplified dict: {"channel_identities": [...]} + - Simplified dict: {"contact_id": "..."} + - Full form dict: {"identified_by": {"channel_identities": [...]}} + + :param recipient: The recipient as a Recipient model or dict. + :type recipient: Union[Recipient, dict] + + :returns: A Recipient model instance. + :rtype: Recipient + """ + if isinstance(recipient, dict): + # Allow passing recipient dict in simplified form: + # - {"channel_identities": [...]} -> converts to {"identified_by": {"channel_identities": [...]}} + # - {"contact_id": "..."} + # - Or full form: {"identified_by": {"channel_identities": [...]}} + if ( + "channel_identities" in recipient + and "identified_by" not in recipient + ): + channel_identities = [ + ChannelRecipientIdentity(**ci) if isinstance(ci, dict) else ci + for ci in recipient["channel_identities"] + ] + return Recipient( + identified_by=IdentifiedBy( + channel_identities=channel_identities + ) + ) + return Recipient(**recipient) + return recipient + + +def split_send_kwargs(kwargs: dict) -> tuple[dict, dict]: + """ + Split kwargs into message-level and request-level parameters. + + Separates keyword arguments into two groups: + - message_kwargs: Fields that belong under the `message` field + - request_kwargs: Fields that belong on the SendMessageRequest itself + + :param kwargs: Dictionary of keyword arguments to split. + :type kwargs: dict + + :returns: A tuple of (message_kwargs, request_kwargs). + :rtype: tuple[dict, dict] + """ + message_fields = set(SendMessageRequestBody.model_fields.keys()) + message_kwargs = {k: v for k, v in kwargs.items() if k in message_fields} + request_kwargs = { + k: v for k, v in kwargs.items() if k not in message_fields + } + return message_kwargs, request_kwargs diff --git a/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py index 23ef0d9c..4e0cc5ed 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/app/app_message.py @@ -27,52 +27,44 @@ TextMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.shared.app_message_common_props import ( AppMessageCommonProps, ) -class CardAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): +class CardAppMessage(AppMessageCommonProps, BaseModelConfiguration): card_message: Optional[CardMessage] = None -class CarouselAppMessage( - AppMessageCommonProps, BaseModelConfigurationResponse -): +class CarouselAppMessage(AppMessageCommonProps, BaseModelConfiguration): carousel_message: Optional[CarouselMessage] = None -class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): +class ChoiceAppMessage(AppMessageCommonProps, BaseModelConfiguration): choice_message: Optional[ChoiceMessage] = None -class LocationAppMessage( - AppMessageCommonProps, BaseModelConfigurationResponse -): +class LocationAppMessage(AppMessageCommonProps, BaseModelConfiguration): location_message: Optional[LocationMessage] = None -class MediaAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): +class MediaAppMessage(AppMessageCommonProps, BaseModelConfiguration): media_message: Optional[MediaProperties] = None -class TemplateAppMessage( - AppMessageCommonProps, BaseModelConfigurationResponse -): +class TemplateAppMessage(AppMessageCommonProps, BaseModelConfiguration): template_message: Optional[TemplateMessage] = None -class TextAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): +class TextAppMessage(AppMessageCommonProps, BaseModelConfiguration): text_message: Optional[TextMessage] = None -class ListAppMessage(AppMessageCommonProps, BaseModelConfigurationResponse): +class ListAppMessage(AppMessageCommonProps, BaseModelConfiguration): list_message: Optional[ListMessage] = None -class ContactInfoAppMessage( - AppMessageCommonProps, BaseModelConfigurationResponse -): +class ContactInfoAppMessage(AppMessageCommonProps, BaseModelConfiguration): contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py index 8d83bc54..36e119de 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/calendar/calendar_message.py @@ -2,11 +2,11 @@ from datetime import datetime from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CalendarMessage(BaseModelConfigurationResponse): +class CalendarMessage(BaseModelConfiguration): title: StrictStr = Field( ..., description="The title is shown close to the button that leads to open a user calendar.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py index 79fbd1b0..0969866f 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/call/call_message.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CallMessage(BaseModelConfigurationResponse): +class CallMessage(BaseModelConfiguration): phone_number: StrictStr = Field( default=..., description="Phone number in E.164 with leading +." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py index 3cf1e9ea..c8de5a25 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message.py @@ -6,18 +6,18 @@ from sinch.domains.conversation.models.v1.messages.categories.media import ( MediaProperties, ) -from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( ChoiceOption, ) from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import ( MessageProperties, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CardMessage(BaseModelConfigurationResponse): +class CardMessage(BaseModelConfiguration): choices: Optional[conlist(ChoiceOption)] = Field( default=None, description="You may include choices in your Card Message. The number of choices is limited to 10.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py index 77e35c77..0e80ad30 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/card/card_message_field.py @@ -3,9 +3,9 @@ CardMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CardMessageField(BaseModelConfigurationResponse): +class CardMessageField(BaseModelConfiguration): card_message: Optional[CardMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py index ddff7028..78912593 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py +++ b/sinch/domains/conversation/models/v1/messages/categories/card/message_properties.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class MessageProperties(BaseModelConfigurationResponse): +class MessageProperties(BaseModelConfiguration): whatsapp_header: Optional[StrictStr] = Field( default=None, description=( diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py index 8d4939a5..7026560d 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message.py @@ -3,15 +3,15 @@ from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( CardMessage, ) -from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( ChoiceOption, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CarouselMessage(BaseModelConfigurationResponse): +class CarouselMessage(BaseModelConfiguration): cards: conlist(CardMessage) = Field( default=..., description="A list of up to 10 cards." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py index 41020788..4f671d1d 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/carousel/carousel_message_field.py @@ -3,9 +3,9 @@ CarouselMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class CarouselMessageField(BaseModelConfigurationResponse): +class CarouselMessageField(BaseModelConfiguration): carousel_message: Optional[CarouselMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py index 8f44f48b..3131c8df 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_contact_message_message.py @@ -4,11 +4,11 @@ WhatsAppInteractiveNfmReplyMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChannelSpecificContactMessageMessage(BaseModelConfigurationResponse): +class ChannelSpecificContactMessageMessage(BaseModelConfiguration): message_type: Literal["nfm_reply"] = Field( ..., description="The message type." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py index 138f2bf6..fd939112 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message.py @@ -2,15 +2,15 @@ from sinch.domains.conversation.models.v1.messages.types.channel_specific_message_type import ( ChannelSpecificMessageType, ) -from sinch.domains.conversation.models.v1.messages.response.types.channel_specific_message_content import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message_content import ( ChannelSpecificMessageContent, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChannelSpecificMessage(BaseModelConfigurationResponse): +class ChannelSpecificMessage(BaseModelConfiguration): message_type: ChannelSpecificMessageType = Field( ..., description="The type of the channel specific message." ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py index 4bb50086..d7e5eac7 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/channel_specific_message_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py @@ -1,4 +1,11 @@ from typing import Union + +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_commerce_channel_specific_message import ( + KakaoTalkCarouselCommerceChannelSpecificMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_channel_specific_message import ( + KakaoTalkCommerceChannelSpecificMessage, +) from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( FlowChannelSpecificMessage, ) @@ -8,13 +15,6 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_order_status_channel_specific_message import ( PaymentOrderStatusChannelSpecificMessage, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_channel_specific_message import ( - KakaoTalkCommerceChannelSpecificMessage, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_carousel_commerce_channel_specific_message import ( - KakaoTalkCarouselCommerceChannelSpecificMessage, -) - ChannelSpecificMessageContent = Union[ FlowChannelSpecificMessage, diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py index f52cf731..4e898ef4 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/buttons/kakaotalk_button.py @@ -1,8 +1,8 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkButton(BaseModelConfigurationResponse): +class KakaoTalkButton(BaseModelConfiguration): name: StrictStr = Field(..., description="Text displayed on the button") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py index 6fac4cc7..2b6fd81c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel.py @@ -6,11 +6,11 @@ KakaoTalkCommerceMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCarousel(BaseModelConfigurationResponse): +class KakaoTalkCarousel(BaseModelConfiguration): head: Optional[KakaoTalkCarouselHead] = Field( default=None, description="Carousel introduction" ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py index 05ed6d5b..7870c02b 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_head.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCarouselHead(BaseModelConfigurationResponse): +class KakaoTalkCarouselHead(BaseModelConfiguration): header: StrictStr = Field( ..., description="Carousel introduction title", max_length=20 ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py index 956b3c0e..5a02ecda 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_carousel_tail.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCarouselTail(BaseModelConfigurationResponse): +class KakaoTalkCarouselTail(BaseModelConfiguration): link_mo: StrictStr = Field( ..., description="URL opened on a mobile device" ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py index 15d3f7ef..674e7fdf 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_channel_specific_message.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictBool from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkChannelSpecificMessage(BaseModelConfigurationResponse): +class KakaoTalkChannelSpecificMessage(BaseModelConfiguration): push_alarm: Optional[StrictBool] = Field( default=True, description="Set to `true` if a push alarm should be sent to a device.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py index a1c9a486..ca762987 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_image.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCommerceImage(BaseModelConfigurationResponse): +class KakaoTalkCommerceImage(BaseModelConfiguration): image_url: StrictStr = Field(..., description="URL to the product image") image_link: Optional[StrictStr] = Field( default=None, description="URL opened when a user clicks on the image" diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py index 5ae37bad..fe386706 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_commerce_message.py @@ -13,11 +13,11 @@ KakaoTalkCommerceImage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCommerceMessage(BaseModelConfigurationResponse): +class KakaoTalkCommerceMessage(BaseModelConfiguration): buttons: conlist(KakaoTalkButton) = Field(..., description="Buttons list") additional_content: Optional[StrictStr] = Field( default=None, description="Additional information", max_length=34 diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py index 46af8903..4fd71fe3 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/commerce/kakaotalk_regular_price_commerce.py @@ -1,11 +1,11 @@ from typing import Literal from pydantic import Field, StrictStr, StrictInt from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkRegularPriceCommerce(BaseModelConfigurationResponse): +class KakaoTalkRegularPriceCommerce(BaseModelConfiguration): type: Literal["REGULAR_PRICE_COMMERCE"] = Field( "REGULAR_PRICE_COMMERCE", description="Commerce with regular price" ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py index 3b9dc38d..e9db07ae 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/kakaotalk/coupons/kakaotalk_coupon.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class KakaoTalkCoupon(BaseModelConfigurationResponse): +class KakaoTalkCoupon(BaseModelConfiguration): description: Optional[StrictStr] = Field( default=None, description="Coupon description" ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py index e3743c8c..0c4c624c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/flow_action_payload.py @@ -1,11 +1,11 @@ from typing import Any, Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class FlowActionPayload(BaseModelConfigurationResponse): +class FlowActionPayload(BaseModelConfiguration): screen: Optional[StrictStr] = Field( default=None, description="The ID of the screen displayed first. This must be an entry screen.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py index 4c9f0cc8..50ab1ff9 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_body.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveBody(BaseModelConfigurationResponse): +class WhatsAppInteractiveBody(BaseModelConfiguration): text: StrictStr = Field( ..., description="The content of the message (1024 characters maximum). Emojis and Markdown are supported.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py index 059c22eb..11d8be41 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_document_header.py @@ -4,11 +4,11 @@ WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveDocumentHeader(BaseModelConfigurationResponse): +class WhatsAppInteractiveDocumentHeader(BaseModelConfiguration): type: Literal["document"] = Field( ..., description="The document associated with the header." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py index 449c66dd..0c7f570a 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_footer.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveFooter(BaseModelConfigurationResponse): +class WhatsAppInteractiveFooter(BaseModelConfiguration): text: StrictStr = Field( ..., description="The footer content (60 characters maximum). Emojis, Markdown and links are supported.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py index 7ab870eb..a16d83b8 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_header_media.py @@ -1,8 +1,8 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveHeaderMedia(BaseModelConfigurationResponse): +class WhatsAppInteractiveHeaderMedia(BaseModelConfiguration): link: StrictStr = Field(..., description="URL for the media.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py index f1887210..2c9c45d1 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_image_header.py @@ -4,11 +4,11 @@ WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveImageHeader(BaseModelConfigurationResponse): +class WhatsAppInteractiveImageHeader(BaseModelConfiguration): type: Literal["image"] = Field( ..., description="The image associated with the header." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py index 3aa24c5c..994dcc71 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_text_header.py @@ -1,11 +1,11 @@ from typing import Literal from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveTextHeader(BaseModelConfigurationResponse): +class WhatsAppInteractiveTextHeader(BaseModelConfiguration): type: Literal["text"] = Field(..., description="The text of the header.") text: StrictStr = Field( ..., diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py index d5d76785..de16a9c1 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/flows/whatsapp_interactive_video_header.py @@ -4,11 +4,11 @@ WhatsAppInteractiveHeaderMedia, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveVideoHeader(BaseModelConfigurationResponse): +class WhatsAppInteractiveVideoHeader(BaseModelConfiguration): type: Literal["video"] = Field( ..., description="The video associated with the header." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py index 4321ce60..225115d7 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply.py @@ -3,11 +3,11 @@ WhatsAppInteractiveNfmReplyNameType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveNfmReply(BaseModelConfigurationResponse): +class WhatsAppInteractiveNfmReply(BaseModelConfiguration): name: WhatsAppInteractiveNfmReplyNameType = Field( ..., description="The nfm reply message type." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py index 4068cd89..9f6b72c1 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/nfmreply/whatsapp_interactive_nfm_reply_message.py @@ -4,11 +4,11 @@ WhatsAppInteractiveNfmReply, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppInteractiveNfmReplyMessage(BaseModelConfigurationResponse): +class WhatsAppInteractiveNfmReplyMessage(BaseModelConfiguration): type: Literal["nfm_reply"] = Field( description="The interactive message type." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py index f6353c2c..ef46a9a8 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class Boleto(BaseModelConfigurationResponse): +class Boleto(BaseModelConfiguration): digitable_line: StrictStr = Field( ..., description="The Boleto digitable line which will be copied to the clipboard when the user taps the Boleto button.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py index 1e09f0c2..8e43f16d 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py @@ -3,11 +3,11 @@ PixKeyType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class DynamicPix(BaseModelConfigurationResponse): +class DynamicPix(BaseModelConfiguration): code: StrictStr = Field( ..., description="The dynamic Pix code to be used by the buyer to pay." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py index 59b1afe9..a3d9a732 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/order_item.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr, StrictInt from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class OrderItem(BaseModelConfigurationResponse): +class OrderItem(BaseModelConfiguration): retailer_id: StrictStr = Field( ..., description="Unique ID of the retailer." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py index a93d5484..c621eb66 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentLink(BaseModelConfigurationResponse): +class PaymentLink(BaseModelConfiguration): uri: StrictStr = Field( ..., description="The payment link to be used by the buyer to pay." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py index 30bbcb30..96401378 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order.py @@ -5,11 +5,11 @@ OrderItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentOrder(BaseModelConfigurationResponse): +class PaymentOrder(BaseModelConfiguration): items: conlist(OrderItem) = Field( ..., description="The items list for this order." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py index 3cbdfac6..67beb888 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py @@ -13,11 +13,11 @@ PaymentOrder, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentOrderDetailsContent(BaseModelConfigurationResponse): +class PaymentOrderDetailsContent(BaseModelConfiguration): type: PaymentOrderType = Field( ..., description="The country/currency associated with the payment message.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py index 8ef61f11..544a62a2 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_content.py @@ -3,11 +3,11 @@ PaymentOrderStatusOrder, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentOrderStatusContent(BaseModelConfigurationResponse): +class PaymentOrderStatusContent(BaseModelConfiguration): reference_id: StrictStr = Field( ..., description="Unique ID used to query the current payment status." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py index 14d384ee..ee91a90a 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_status_order.py @@ -4,11 +4,11 @@ PaymentOrderStatusType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentOrderStatusOrder(BaseModelConfigurationResponse): +class PaymentOrderStatusOrder(BaseModelConfiguration): status: PaymentOrderStatusType = Field( ..., description="The new payment message status." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py index 1d340106..6433db6b 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/whatsapp_common_props.py @@ -8,11 +8,11 @@ WhatsAppInteractiveFooter, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class WhatsAppCommonProps(BaseModelConfigurationResponse): +class WhatsAppCommonProps(BaseModelConfiguration): header: Optional[WhatsAppInteractiveHeader] = Field( default=None, description="The header of the interactive message." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py index e45f644f..789c3af7 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message.py @@ -1,22 +1,22 @@ from typing import Optional from pydantic import Field, conlist -from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message_properties import ( + ChoiceMessageProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( ChoiceOption, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.categories.text import ( TextMessage, ) -from sinch.domains.conversation.models.v1.messages.categories.card.message_properties import ( - MessageProperties, -) -class ChoiceMessage(BaseModelConfigurationResponse): +class ChoiceMessage(BaseModelConfiguration): choices: conlist(ChoiceOption) = Field( default=..., description="The number of choices is limited to 10." ) text_message: Optional[TextMessage] = None - message_properties: Optional[MessageProperties] = None + message_properties: Optional[ChoiceMessageProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py index 5ba83892..0ed3fc0c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_field.py @@ -3,9 +3,9 @@ ChoiceMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChoiceMessageField(BaseModelConfigurationResponse): +class ChoiceMessageField(BaseModelConfiguration): choice_message: Optional[ChoiceMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py new file mode 100644 index 00000000..14e61940 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_message_properties.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class ChoiceMessageProperties(BaseModelConfiguration): + whatsapp_footer: Optional[StrictStr] = Field( + default=None, + description=( + "Optional. Sets the text for the footer of a WhatsApp reply button or URL button message. " + "Ignored for other channels." + ), + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py new file mode 100644 index 00000000..0110db24 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_option.py @@ -0,0 +1,52 @@ +from typing import Annotated, Union, get_args +from pydantic import BeforeValidator + +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + CalendarChoiceMessage, + CallChoiceMessage, + ChoiceMessageWithPostback, + LocationChoiceMessage, + ShareLocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, +) + +ChoiceOptionUnion = Union[ + CallChoiceMessage, + LocationChoiceMessage, + TextChoiceMessage, + UrlChoiceMessage, + CalendarChoiceMessage, + ShareLocationChoiceMessage, +] + + +def _choice_message_type_keys() -> frozenset[str]: + """Message-type keys derived from Union members (spec: choiceTypes oneOf).""" + base_fields = set(ChoiceMessageWithPostback.model_fields) + keys = set() + for model in get_args(ChoiceOptionUnion): + keys.update(model.model_fields.keys() - base_fields) + return frozenset(keys) + + +_CHOICE_MESSAGE_TYPE_KEYS = _choice_message_type_keys() + + +def _validate_exactly_one_choice_message_key(value: object) -> object: + """Ensure each choice dict has exactly one message-type key.""" + if not isinstance(value, dict): + return value + keys = _CHOICE_MESSAGE_TYPE_KEYS + count = sum(1 for k in keys if value.get(k) is not None) + if count != 1: + raise ValueError( + f"Each choice must have exactly one of: {', '.join(sorted(keys))}." + ) + return value + + +ChoiceOption = Annotated[ + ChoiceOptionUnion, + BeforeValidator(_validate_exactly_one_choice_message_key), +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py index f9ef0547..a8c7f0b1 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py +++ b/sinch/domains/conversation/models/v1/messages/categories/choice/choice_options.py @@ -16,14 +16,14 @@ ShareLocationMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.categories.text import ( TextMessage, ) -class ChoiceMessageWithPostback(BaseModelConfigurationResponse): +class ChoiceMessageWithPostback(BaseModelConfiguration): postback_data: Optional[Any] = Field( default=None, description="An optional field. This data will be returned in the ChoiceResponseMessage. The default is message_id_{text, title}.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py index 094b949c..4b447a63 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/choiceresponse/choice_response_message.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChoiceResponseMessage(BaseModelConfigurationResponse): +class ChoiceResponseMessage(BaseModelConfiguration): message_id: StrictStr = Field( ..., description="The message id containing the choice." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py index b81e0994..f3a6c582 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py +++ b/sinch/domains/conversation/models/v1/messages/categories/common/reply_to.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ReplyTo(BaseModelConfigurationResponse): +class ReplyTo(BaseModelConfiguration): message_id: StrictStr = Field( default=..., description="Required. The Id of the message that this is a response to", diff --git a/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py index f24ad512..f427fe2f 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/contact/contact_message.py @@ -28,12 +28,12 @@ ContactMessageCommonProps, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) class ChannelSpecificContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): channel_specific_message: ChannelSpecificContactMessageMessage = Field( ..., @@ -42,42 +42,38 @@ class ChannelSpecificContactMessage( class ChoiceResponseContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): choice_response_message: Optional[ChoiceResponseMessage] = None class FallbackContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): fallback_message: Optional[FallbackMessage] = None class LocationContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): location_message: Optional[LocationMessage] = None class MediaCardContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): media_card_message: Optional[MediaCardMessage] = None -class MediaContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse -): +class MediaContactMessage(ContactMessageCommonProps, BaseModelConfiguration): media_message: Optional[MediaProperties] = None class ProductResponseContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse + ContactMessageCommonProps, BaseModelConfiguration ): product_response_message: Optional[ProductResponseMessage] = None -class TextContactMessage( - ContactMessageCommonProps, BaseModelConfigurationResponse -): +class TextContactMessage(ContactMessageCommonProps, BaseModelConfiguration): text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py index e2483c65..66bf447c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message.py @@ -2,7 +2,7 @@ from datetime import date from pydantic import Field, conlist from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.shared.name_info import ( NameInfo, @@ -24,7 +24,7 @@ ) -class ContactInfoMessage(BaseModelConfigurationResponse): +class ContactInfoMessage(BaseModelConfiguration): name: NameInfo = Field(..., description="Name information of the contact.") phone_numbers: conlist(PhoneNumberInfo) = Field( description="Phone numbers of the contact (at least one required).", diff --git a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py index efe3ef4c..9c25f070 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/contactinfo/contact_info_message_field.py @@ -1,11 +1,11 @@ from typing import Optional from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( ContactInfoMessage, ) -class ContactInfoMessageField(BaseModelConfigurationResponse): +class ContactInfoMessageField(BaseModelConfiguration): contact_info_message: Optional[ContactInfoMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py index 83ca3d71..ab556e9c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/fallback/fallback_message.py @@ -2,11 +2,11 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.shared.reason import Reason from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class FallbackMessage(BaseModelConfigurationResponse): +class FallbackMessage(BaseModelConfiguration): raw_message: Optional[StrictStr] = Field( default=None, description="Optional. The raw fallback message if provided by the channel.", diff --git a/sinch/domains/conversation/models/v1/messages/response/types/list_item.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/response/types/list_item.py rename to sinch/domains/conversation/models/v1/messages/categories/list/list_item.py index ebc54b0b..51455a49 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/list_item.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item.py @@ -1,4 +1,5 @@ from typing import Union + from sinch.domains.conversation.models.v1.messages.categories.list.list_item_choice import ( ListItemChoice, ) @@ -6,5 +7,4 @@ ListItemProduct, ) - ListItem = Union[ListItemChoice, ListItemProduct] diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py index 67ebbb2f..33d99d28 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_choice.py @@ -3,9 +3,9 @@ ChoiceItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListItemChoice(BaseModelConfigurationResponse): +class ListItemChoice(BaseModelConfiguration): choice: ChoiceItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py index 110ecb31..4322937f 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_item_product.py @@ -3,9 +3,9 @@ ProductItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListItemProduct(BaseModelConfigurationResponse): +class ListItemProduct(BaseModelConfiguration): product: ProductItem = Field(...) diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py index 826a8433..eff78dd2 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message.py @@ -10,11 +10,11 @@ ListMessageProperties, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListMessage(BaseModelConfigurationResponse): +class ListMessage(BaseModelConfiguration): title: StrictStr = Field( default=..., description="A title for the message that is displayed near the products or choices.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py index 1fa02b02..27d0ee84 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_field.py @@ -3,9 +3,9 @@ ListMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListMessageField(BaseModelConfigurationResponse): +class ListMessageField(BaseModelConfiguration): list_message: Optional[ListMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py index b3f780dc..4066c000 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py +++ b/sinch/domains/conversation/models/v1/messages/categories/list/list_message_properties.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListMessageProperties(BaseModelConfigurationResponse): +class ListMessageProperties(BaseModelConfiguration): catalog_id: Optional[StrictStr] = Field( default=None, description="Required if sending a product list message. The ID of the catalog to which the products belong.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py index c7f1319c..f8b71a3b 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message.py @@ -4,11 +4,11 @@ Coordinates, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class LocationMessage(BaseModelConfigurationResponse): +class LocationMessage(BaseModelConfiguration): coordinates: Coordinates = Field(...) label: Optional[StrictStr] = Field( default=None, description="Label or name for the position." diff --git a/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py index 7f3def35..3e18afaa 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/location/location_message_field.py @@ -3,9 +3,9 @@ LocationMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class LocationMessageField(BaseModelConfigurationResponse): +class LocationMessageField(BaseModelConfiguration): location_message: Optional[LocationMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py index 4453cde9..fb4653b7 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_message_field.py @@ -3,9 +3,9 @@ MediaProperties, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class MediaMessageField(BaseModelConfigurationResponse): +class MediaMessageField(BaseModelConfiguration): media_message: Optional[MediaProperties] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py index d15041d5..298ca6aa 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py +++ b/sinch/domains/conversation/models/v1/messages/categories/media/media_properties.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class MediaProperties(BaseModelConfigurationResponse): +class MediaProperties(BaseModelConfiguration): thumbnail_url: Optional[StrictStr] = Field( default=None, description="An optional parameter. Will be used where it is natively supported.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py index 1374064e..411b2ec6 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/mediacard/media_card_message.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class MediaCardMessage(BaseModelConfigurationResponse): +class MediaCardMessage(BaseModelConfiguration): caption: Optional[StrictStr] = Field( default=None, description="Caption for the media on supported channels.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py index 8d8a3ebd..c93ec77a 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/productresponse/product_response_message.py @@ -4,11 +4,11 @@ ProductItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ProductResponseMessage(BaseModelConfigurationResponse): +class ProductResponseMessage(BaseModelConfiguration): products: Optional[conlist(ProductItem)] = Field( default=None, description="The selected products." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py index af9b8f91..58a366f5 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/sharelocation/share_location_message.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ShareLocationMessage(BaseModelConfigurationResponse): +class ShareLocationMessage(BaseModelConfiguration): title: StrictStr = Field( ..., description="The title is shown close to the button that leads to open a map to share a location.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py index 40eeb8fe..fe003f71 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_message.py @@ -5,11 +5,11 @@ TemplateReferenceOmniChannel, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class TemplateMessage(BaseModelConfigurationResponse): +class TemplateMessage(BaseModelConfiguration): channel_template: Optional[Dict[str, TemplateReferenceChannelSpecific]] = ( Field( default=None, diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py index 93e56cf4..404f39e0 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_channel_specific.py @@ -1,11 +1,11 @@ from typing import Dict, Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class TemplateReferenceChannelSpecific(BaseModelConfigurationResponse): +class TemplateReferenceChannelSpecific(BaseModelConfiguration): version: Optional[StrictStr] = Field( default=None, description="Used to specify what version of a template to use. Required when using `omni_channel_override` and `omni_template` fields. This will be used in conjunction with `language_code`. Note that, when referencing omni-channel templates using the [Sinch Customer Dashboard](https://dashboard.sinch.com/), the latest version of a given omni-template can be identified by populating this field with `latest`.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py index db375b3d..35fb765c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/template/template_reference_field.py @@ -3,9 +3,9 @@ TemplateReferenceOmniChannel, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class TemplateReferenceField(BaseModelConfigurationResponse): +class TemplateReferenceField(BaseModelConfiguration): template_reference: Optional[TemplateReferenceOmniChannel] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py index cbcc5adb..fb8a266b 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class TextMessage(BaseModelConfigurationResponse): +class TextMessage(BaseModelConfiguration): text: StrictStr = Field( ..., description="The text content of the message." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py index 245e4bfc..f85f620c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py +++ b/sinch/domains/conversation/models/v1/messages/categories/text/text_message_field.py @@ -1,11 +1,11 @@ from typing import Optional from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.categories.text import ( TextMessage, ) -class TextMessageField(BaseModelConfigurationResponse): +class TextMessageField(BaseModelConfiguration): text_message: Optional[TextMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py index a861288b..a6ca73d0 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py +++ b/sinch/domains/conversation/models/v1/messages/categories/url/url_message.py @@ -1,10 +1,10 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class UrlMessage(BaseModelConfigurationResponse): +class UrlMessage(BaseModelConfiguration): title: StrictStr = Field( default=..., description="The title shown close to the URL. The title can be clickable in some cases.", diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py index c9983491..c6908a4e 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/base/__init__.py @@ -1,9 +1,7 @@ from sinch.domains.conversation.models.v1.messages.internal.base.base_model_configuration import ( - BaseModelConfigurationRequest, - BaseModelConfigurationResponse, + BaseModelConfiguration, ) __all__ = [ - "BaseModelConfigurationRequest", - "BaseModelConfigurationResponse", + "BaseModelConfiguration", ] diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py index 204ea49d..200cf35e 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py +++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py @@ -3,9 +3,10 @@ from pydantic import BaseModel, ConfigDict -class BaseModelConfigurationRequest(BaseModel): +class BaseModelConfiguration(BaseModel): """ - A base model that allows extra fields and converts snake_case to camelCase. + Base model for all conversation message models. + Both request and response use snake_case in the Conversation API. """ model_config = ConfigDict( @@ -15,24 +16,11 @@ class BaseModelConfigurationRequest(BaseModel): extra="allow", ) - -class BaseModelConfigurationResponse(BaseModel): - """ - A base model that allows extra fields and converts camelCase to snake_case - """ - @staticmethod def _to_snake_case(camel_str: str) -> str: """Helper to convert camelCase string to snake_case.""" return re.sub(r"(? None: """Converts unknown fields from camelCase to snake_case.""" if self.__pydantic_extra__: diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py index 4fc65a1f..da524ea8 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -4,8 +4,24 @@ from sinch.domains.conversation.models.v1.messages.internal.request.update_message_metadata_request import ( UpdateMessageMetadataRequest, ) +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, + IdentifiedBy, + ChannelRecipientIdentity, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request import ( + SendMessageRequest, +) __all__ = [ "MessageIdRequest", "UpdateMessageMetadataRequest", + "Recipient", + "IdentifiedBy", + "ChannelRecipientIdentity", + "SendMessageRequestBody", + "SendMessageRequest", ] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py index 86b4a1be..7823c8c1 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py @@ -4,11 +4,11 @@ MessagesSourceType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationRequest, + BaseModelConfiguration, ) -class MessageIdRequest(BaseModelConfigurationRequest): +class MessageIdRequest(BaseModelConfiguration): message_id: str = Field(..., description="The unique ID of the message.") messages_source: Optional[MessagesSourceType] = Field( default=None, diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py b/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py new file mode 100644 index 00000000..3b42eadc --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/recipient.py @@ -0,0 +1,38 @@ +from typing import List, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) + + +class ChannelRecipientIdentity(BaseModelConfiguration): + channel: ConversationChannelType = Field( + ..., description="The conversation channel." + ) + identity: StrictStr = Field( + ..., description="The channel recipient identity." + ) + + +class IdentifiedBy(BaseModelConfiguration): + channel_identities: List[ChannelRecipientIdentity] = Field( + ..., + description=( + "A list of specific channel identities. " + "The API will use these identities when sending to specific channels." + ), + ) + + +class Recipient(BaseModelConfiguration): + identified_by: Optional[IdentifiedBy] = Field( + default=None, + description="The identity as specified by the channel. Required if using Dispatch Mode.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py new file mode 100644 index 00000000..accf6f61 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py @@ -0,0 +1,104 @@ +from typing import Any, Dict, List, Optional, Union + +from pydantic import Field, StrictInt, StrictStr, field_serializer +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, +) +from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request_body import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) +from sinch.domains.conversation.models.v1.messages.types.processing_strategy_type import ( + ProcessingStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.metadata_update_strategy_type import ( + MetadataUpdateStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.message_queue_type import ( + MessageQueueType, +) +from sinch.domains.conversation.models.v1.messages.types.message_content_type import ( + MessageContentType, +) + + +class SendMessageRequest(BaseModelConfiguration): + app_id: StrictStr = Field( + ..., + description="The ID of the Conversation API app sending the message.", + ) + recipient: Recipient = Field( + ..., + description="The recipient of the message.", + ) + message: SendMessageRequestBody = Field( + ..., + description="The message content to send.", + ) + ttl: Optional[Union[StrictStr, StrictInt]] = Field( + default=None, + description="The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'.", + ) + callback_url: Optional[StrictStr] = Field( + default=None, + description="Overwrites the default callback url for delivery receipts for this message.", + ) + channel_priority_order: Optional[List[ConversationChannelType]] = Field( + default=None, + description="Explicitly define the channels and order in which they are tried when sending the message.", + ) + channel_properties: Optional[Dict[str, str]] = Field( + default=None, + description="Channel-specific properties. The key in the map must point to a valid channel property key.", + ) + message_metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata that should be associated with the message. Up to 1024 characters long.", + ) + conversation_metadata: Optional[Dict[str, Any]] = Field( + default=None, + description="Metadata that will be associated with the conversation. Up to 2048 characters long.", + ) + queue: Optional[MessageQueueType] = Field( + default=None, + description="Select the priority type for the message. Can be 'NORMAL_PRIORITY' or 'HIGH_PRIORITY'.", + ) + processing_strategy: Optional[ProcessingStrategyType] = Field( + default=None, + description="Overrides the app's Processing Mode. Can be 'DEFAULT' or 'DISPATCH_ONLY'.", + ) + correlation_id: Optional[StrictStr] = Field( + default=None, + description="An arbitrary identifier that will be propagated to callbacks related to this message. Up to 128 characters long.", + ) + conversation_metadata_update_strategy: Optional[ + MetadataUpdateStrategyType + ] = Field( + default=None, + description="Update strategy for the conversation_metadata field. Can be 'REPLACE' or 'MERGE_PATCH'.", + ) + message_content_type: Optional[MessageContentType] = Field( + default=None, + description="Classifies the message content for use with consent management. Can be 'CONTENT_UNKNOWN', 'CONTENT_MARKETING', or 'CONTENT_NOTIFICATION'.", + ) + + @field_serializer("ttl") + def serialize_ttl( + self, value: Optional[Union[StrictStr, StrictInt]] + ) -> Optional[str]: + """ + Serialize ttl field to the format expected by the API (string with 's' suffix). + Converts int to string with 's' suffix, or ensures string has 's' suffix. + """ + if value is None: + return None + if isinstance(value, int): + return f"{value}s" + if isinstance(value, str) and not value.endswith("s"): + return f"{value}s" + return value diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py new file mode 100644 index 00000000..b5bb2698 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py @@ -0,0 +1,43 @@ +from typing import Optional +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.contactinfo.contact_info_message import ( + ContactInfoMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.list.list_message import ( + ListMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media.media_properties import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template.template_message import ( + TemplateMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class SendMessageRequestBody(BaseModelConfiguration): + text_message: Optional[TextMessage] = None + card_message: Optional[CardMessage] = None + carousel_message: Optional[CarouselMessage] = None + choice_message: Optional[ChoiceMessage] = None + contact_info_message: Optional[ContactInfoMessage] = None + list_message: Optional[ListMessage] = None + location_message: Optional[LocationMessage] = None + media_message: Optional[MediaProperties] = None + template_message: Optional[TemplateMessage] = None diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py index 278e9091..f454ff7e 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py @@ -4,11 +4,11 @@ MessagesSourceType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationRequest, + BaseModelConfiguration, ) -class UpdateMessageMetadataRequest(BaseModelConfigurationRequest): +class UpdateMessageMetadataRequest(BaseModelConfiguration): message_id: str = Field(..., description="The unique ID of the message.") metadata: StrictStr = Field( ..., description="Metadata that should be associated with the message." diff --git a/sinch/domains/conversation/models/v1/messages/response/message_response.py b/sinch/domains/conversation/models/v1/messages/response/message_response.py index d62bb794..75393428 100644 --- a/sinch/domains/conversation/models/v1/messages/response/message_response.py +++ b/sinch/domains/conversation/models/v1/messages/response/message_response.py @@ -1,5 +1,5 @@ from sinch.domains.conversation.models.v1.messages.shared import ( - MessageResponseCommonProps, + MessageCommonProps, ) from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( AppMessage, @@ -8,17 +8,13 @@ ContactMessage, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class AppMessageResponse( - MessageResponseCommonProps, BaseModelConfigurationResponse -): +class AppMessageResponse(MessageCommonProps, BaseModelConfiguration): app_message: AppMessage -class ContactMessageResponse( - MessageResponseCommonProps, BaseModelConfigurationResponse -): +class ContactMessageResponse(MessageCommonProps, BaseModelConfiguration): contact_message: ContactMessage diff --git a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py index 30e0cd54..767b4d0f 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py @@ -1,10 +1,10 @@ from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( AppMessage, ) -from sinch.domains.conversation.models.v1.messages.response.types.channel_specific_message_content import ( +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message_content import ( ChannelSpecificMessageContent, ) -from sinch.domains.conversation.models.v1.messages.response.types.choice_option import ( +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( ChoiceOption, ) from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( @@ -22,7 +22,7 @@ from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( KakaoTalkCoupon, ) -from sinch.domains.conversation.models.v1.messages.response.types.list_item import ( +from sinch.domains.conversation.models.v1.messages.categories.list.list_item import ( ListItem, ) from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( @@ -31,6 +31,9 @@ from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( WhatsAppInteractiveHeader, ) +from sinch.domains.conversation.models.v1.messages.response.types.send_message_response import ( + SendMessageResponse, +) __all__ = [ "AppMessage", @@ -43,5 +46,6 @@ "KakaoTalkCoupon", "ListItem", "PaymentSettings", + "SendMessageResponse", "WhatsAppInteractiveHeader", ] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py b/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py deleted file mode 100644 index d2ddf127..00000000 --- a/sinch/domains/conversation/models/v1/messages/response/types/choice_option.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Union -from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( - CallChoiceMessage, - LocationChoiceMessage, - TextChoiceMessage, - UrlChoiceMessage, - CalendarChoiceMessage, - ShareLocationChoiceMessage, -) - - -ChoiceOption = Union[ - CallChoiceMessage, - LocationChoiceMessage, - TextChoiceMessage, - UrlChoiceMessage, - CalendarChoiceMessage, - ShareLocationChoiceMessage, -] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py b/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py index 006c6497..83c6991f 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py @@ -10,11 +10,11 @@ Boleto, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PaymentSettings(BaseModelConfigurationResponse): +class PaymentSettings(BaseModelConfiguration): dynamic_pix: Optional[DynamicPix] = Field( default=None, description="The dynamic Pix payment settings." ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py b/sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py new file mode 100644 index 00000000..ae727e69 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py @@ -0,0 +1,17 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class SendMessageResponse(BaseModelConfiguration): + accepted_time: Optional[datetime] = Field( + default=None, + description="Timestamp when the Conversation API accepted the message for delivery to the referenced contact.", + ) + message_id: StrictStr = Field( + ..., + description="The ID of the sent message.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/__init__.py b/sinch/domains/conversation/models/v1/messages/shared/__init__.py index 44c3b27e..2bf6844a 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/shared/__init__.py @@ -11,15 +11,12 @@ from sinch.domains.conversation.models.v1.messages.shared.contact_message_common_props import ( ContactMessageCommonProps, ) -from sinch.domains.conversation.models.v1.messages.shared.message_response_common_props import ( - MessageResponseCommonProps, +from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( + MessageCommonProps, ) from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( Coordinates, ) -from sinch.domains.conversation.models.v1.messages.shared.list_section import ( - ListSection, -) from sinch.domains.conversation.models.v1.messages.shared.product_item import ( ProductItem, ) @@ -32,9 +29,8 @@ "ChannelIdentity", "ChoiceItem", "ContactMessageCommonProps", - "MessageResponseCommonProps", + "MessageCommonProps", "Coordinates", - "ListSection", "OmniMessageOverride", "ProductItem", "Reason", diff --git a/sinch/domains/conversation/models/v1/messages/shared/address_info.py b/sinch/domains/conversation/models/v1/messages/shared/address_info.py index ff2fed6b..a7bbc8e0 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/address_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/address_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class AddressInfo(BaseModelConfigurationResponse): +class AddressInfo(BaseModelConfiguration): city: Optional[StrictStr] = Field(default=None, description="City Name") country: Optional[StrictStr] = Field( default=None, description="Country Name" diff --git a/sinch/domains/conversation/models/v1/messages/shared/agent.py b/sinch/domains/conversation/models/v1/messages/shared/agent.py index 62ef947f..f18a3c7b 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/agent.py +++ b/sinch/domains/conversation/models/v1/messages/shared/agent.py @@ -2,11 +2,11 @@ from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.types import AgentType from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class Agent(BaseModelConfigurationResponse): +class Agent(BaseModelConfiguration): display_name: Optional[StrictStr] = Field( default=None, description="Agent's display name" ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py index aa60920e..17091a95 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py +++ b/sinch/domains/conversation/models/v1/messages/shared/app_message_common_props.py @@ -5,14 +5,14 @@ ) from sinch.domains.conversation.models.v1.messages.shared import Agent from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) from sinch.domains.conversation.models.v1.messages.shared.override.omni_message_override import ( OmniMessageOverride, ) -class AppMessageCommonProps(BaseModelConfigurationResponse): +class AppMessageCommonProps(BaseModelConfiguration): explicit_channel_message: Optional[Dict[str, StrictStr]] = Field( default=None, description="Allows you to specify a channel and define a corresponding channel specific message payload that will override the standard Conversation API message types. The key in the map must point to a valid conversation channel as defined in the enum `ConversationChannel`. The message content must be provided in string format. You may use the [transcoding endpoint](https://developers.sinch.com/docs/conversation/api-reference/conversation/tag/Transcoding/) to help create your message. For more information about how to construct an explicit channel message for a particular channel, see that [channel's corresponding documentation](https://developers.sinch.com/docs/conversation/channel-support/) (for example, using explicit channel messages with [the WhatsApp channel](https://developers.sinch.com/docs/conversation/channel-support/whatsapp/message-support/#explicit-channel-messages)).", diff --git a/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py index 33e411ff..ecf4daa1 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py +++ b/sinch/domains/conversation/models/v1/messages/shared/channel_identity.py @@ -4,11 +4,11 @@ ConversationChannelType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChannelIdentity(BaseModelConfigurationResponse): +class ChannelIdentity(BaseModelConfiguration): app_id: Optional[StrictStr] = Field( default=None, description="Required if using a channel that uses app-scoped channel identities. Currently, FB Messenger, Instagram, LINE, and WeChat use app-scoped channel identities, which means contacts will have different channel identities on different Conversation API apps. These can be thought of as virtual identities that are app-specific and, therefore, the app_id must be included in the API call.", diff --git a/sinch/domains/conversation/models/v1/messages/shared/choice_item.py b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py index 9ccd97c8..9e5c0459 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/choice_item.py +++ b/sinch/domains/conversation/models/v1/messages/shared/choice_item.py @@ -4,11 +4,11 @@ MediaProperties, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ChoiceItem(BaseModelConfigurationResponse): +class ChoiceItem(BaseModelConfiguration): title: StrictStr = Field( default=..., description="Required parameter. Title for the choice item.", diff --git a/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py index bdb0f289..e67161c8 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py +++ b/sinch/domains/conversation/models/v1/messages/shared/contact_message_common_props.py @@ -3,9 +3,9 @@ ReplyTo, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ContactMessageCommonProps(BaseModelConfigurationResponse): +class ContactMessageCommonProps(BaseModelConfiguration): reply_to: Optional[ReplyTo] = None diff --git a/sinch/domains/conversation/models/v1/messages/shared/coordinates.py b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py index 3c558237..94dbbccb 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/coordinates.py +++ b/sinch/domains/conversation/models/v1/messages/shared/coordinates.py @@ -1,11 +1,11 @@ from typing import Union from pydantic import Field, StrictFloat, StrictInt from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class Coordinates(BaseModelConfigurationResponse): +class Coordinates(BaseModelConfiguration): latitude: Union[StrictFloat, StrictInt] = Field( default=..., description="The latitude." ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/email_info.py b/sinch/domains/conversation/models/v1/messages/shared/email_info.py index bcc0572d..ea83866e 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/email_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/email_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class EmailInfo(BaseModelConfigurationResponse): +class EmailInfo(BaseModelConfiguration): email_address: StrictStr = Field(default=..., description="Email address.") type: Optional[StrictStr] = Field( default=None, description="Email address type. e.g. WORK or HOME." diff --git a/sinch/domains/conversation/models/v1/messages/shared/list_section.py b/sinch/domains/conversation/models/v1/messages/shared/list_section.py index 23b7006e..e6163fd9 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/list_section.py +++ b/sinch/domains/conversation/models/v1/messages/shared/list_section.py @@ -1,14 +1,14 @@ from typing import Optional from pydantic import Field, StrictStr, conlist -from sinch.domains.conversation.models.v1.messages.response.types.list_item import ( +from sinch.domains.conversation.models.v1.messages.categories.list.list_item import ( ListItem, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ListSection(BaseModelConfigurationResponse): +class ListSection(BaseModelConfiguration): title: Optional[StrictStr] = Field( default=None, description="Optional parameter. Title for list section." ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py b/sinch/domains/conversation/models/v1/messages/shared/message_common_props.py similarity index 95% rename from sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py rename to sinch/domains/conversation/models/v1/messages/shared/message_common_props.py index 08107012..b79df300 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/message_response_common_props.py +++ b/sinch/domains/conversation/models/v1/messages/shared/message_common_props.py @@ -11,11 +11,11 @@ ProcessingModeType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class MessageResponseCommonProps(BaseModelConfigurationResponse): +class MessageCommonProps(BaseModelConfiguration): accept_time: Optional[datetime] = Field( default=None, description="The time Conversation API processed the message.", diff --git a/sinch/domains/conversation/models/v1/messages/shared/name_info.py b/sinch/domains/conversation/models/v1/messages/shared/name_info.py index 337db7c3..006e7137 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/name_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/name_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class NameInfo(BaseModelConfigurationResponse): +class NameInfo(BaseModelConfiguration): full_name: StrictStr = Field( default=..., description="Full name of the contact" ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/organization_info.py b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py index 98158fbb..39deed8f 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/organization_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/organization_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class OrganizationInfo(BaseModelConfigurationResponse): +class OrganizationInfo(BaseModelConfiguration): company: Optional[StrictStr] = Field( default=None, description="Company name" ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py index 253301de..c5cb58f2 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/phone_number_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class PhoneNumberInfo(BaseModelConfigurationResponse): +class PhoneNumberInfo(BaseModelConfiguration): phone_number: StrictStr = Field( default=..., description="Phone number with country code included." ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/product_item.py b/sinch/domains/conversation/models/v1/messages/shared/product_item.py index a48feeb4..3a5ed876 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/product_item.py +++ b/sinch/domains/conversation/models/v1/messages/shared/product_item.py @@ -1,11 +1,11 @@ from typing import Optional, Union from pydantic import Field, StrictFloat, StrictInt, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class ProductItem(BaseModelConfigurationResponse): +class ProductItem(BaseModelConfiguration): id: StrictStr = Field( default=..., description="Required parameter. The ID for the product." ) diff --git a/sinch/domains/conversation/models/v1/messages/shared/reason.py b/sinch/domains/conversation/models/v1/messages/shared/reason.py index f3e03bcc..66048aea 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/reason.py +++ b/sinch/domains/conversation/models/v1/messages/shared/reason.py @@ -7,11 +7,11 @@ ReasonSubCodeType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class Reason(BaseModelConfigurationResponse): +class Reason(BaseModelConfiguration): code: Optional[ReasonCodeType] = None description: Optional[StrictStr] = Field( default=None, description="A textual description of the reason." diff --git a/sinch/domains/conversation/models/v1/messages/shared/url_info.py b/sinch/domains/conversation/models/v1/messages/shared/url_info.py index 4fdfd650..d0c425b5 100644 --- a/sinch/domains/conversation/models/v1/messages/shared/url_info.py +++ b/sinch/domains/conversation/models/v1/messages/shared/url_info.py @@ -1,11 +1,11 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfigurationResponse, + BaseModelConfiguration, ) -class UrlInfo(BaseModelConfigurationResponse): +class UrlInfo(BaseModelConfiguration): url: StrictStr = Field(default=..., description="The URL to be referenced") type: Optional[StrictStr] = Field( default=None, description="Optional. URL type, e.g. Org or Social" diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index 9eb79ea8..fb34138a 100644 --- a/sinch/domains/conversation/models/v1/messages/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -40,6 +40,49 @@ from sinch.domains.conversation.models.v1.messages.types.whatsapp_interactive_nfm_reply_name_type import ( WhatsAppInteractiveNfmReplyNameType, ) +from sinch.domains.conversation.models.v1.messages.types.processing_strategy_type import ( + ProcessingStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.metadata_update_strategy_type import ( + MetadataUpdateStrategyType, +) +from sinch.domains.conversation.models.v1.messages.types.message_queue_type import ( + MessageQueueType, +) +from sinch.domains.conversation.models.v1.messages.types.message_content_type import ( + MessageContentType, +) +from sinch.domains.conversation.models.v1.messages.types.list_message_dict import ( + ListMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.carousel_message_dict import ( + CarouselMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_message_dict import ( + ChoiceMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.contact_info_message_dict import ( + ContactInfoMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.template_message_dict import ( + TemplateMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.recipient_dict import ( + RecipientDict, + ChannelRecipientIdentityDict, +) +from sinch.domains.conversation.models.v1.messages.types.send_message_request_body_dict import ( + SendMessageRequestBodyDict, +) __all__ = [ "AgentType", @@ -48,6 +91,17 @@ "ProcessingModeType", "CardHeightType", "ChannelSpecificMessageType", + "ListMessageDict", + "MediaPropertiesDict", + "CardMessageDict", + "CarouselMessageDict", + "ChoiceMessageDict", + "ContactInfoMessageDict", + "LocationMessageDict", + "TemplateMessageDict", + "RecipientDict", + "ChannelRecipientIdentityDict", + "SendMessageRequestBodyDict", "MessagesSourceType", "PaymentOrderGoodsType", "PaymentOrderStatusType", @@ -56,4 +110,8 @@ "ReasonCodeType", "ReasonSubCodeType", "WhatsAppInteractiveNfmReplyNameType", + "ProcessingStrategyType", + "MetadataUpdateStrategyType", + "MessageQueueType", + "MessageContentType", ] diff --git a/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py new file mode 100644 index 00000000..77e67dc4 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/calendar_message_dict.py @@ -0,0 +1,12 @@ +from datetime import datetime +from typing import TypedDict +from typing_extensions import NotRequired + + +class CalendarMessageDict(TypedDict): + title: str + event_start: datetime + event_end: datetime + event_title: str + fallback_url: str + event_description: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py new file mode 100644 index 00000000..977bfd5a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/call_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class CallMessageDict(TypedDict): + phone_number: str + title: str diff --git a/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py new file mode 100644 index 00000000..0a9803e7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/card_message_dict.py @@ -0,0 +1,24 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.card_height_type import ( + CardHeightType, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.message_properties_dict import ( + MessagePropertiesDict, +) + + +class CardMessageDict(TypedDict): + choices: NotRequired[List[ChoiceOptionDict]] + description: NotRequired[str] + height: NotRequired[CardHeightType] + title: NotRequired[str] + media_message: NotRequired[MediaPropertiesDict] + message_properties: NotRequired[MessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py new file mode 100644 index 00000000..39613be5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/carousel_message_dict.py @@ -0,0 +1,14 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) + + +class CarouselMessageDict(TypedDict): + cards: List[CardMessageDict] + choices: NotRequired[List[ChoiceOptionDict]] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py new file mode 100644 index 00000000..8bf24db8 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_message_dict.py @@ -0,0 +1,18 @@ +from typing import List, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.choice_message_properties_dict import ( + ChoiceMessagePropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_option_dict import ( + ChoiceOptionDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) + + +class ChoiceMessageDict(TypedDict): + choices: List[ChoiceOptionDict] + text_message: NotRequired[TextMessageDict] + message_properties: NotRequired[ChoiceMessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py new file mode 100644 index 00000000..db9d6ddd --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_message_properties_dict.py @@ -0,0 +1,11 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class ChoiceMessagePropertiesDict(TypedDict): + """ + Additional properties for ChoiceMessage (whatsapp_footer). + CardMessage uses MessagePropertiesDict with whatsapp_header. + """ + + whatsapp_footer: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py b/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py new file mode 100644 index 00000000..cd957119 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/choice_option_dict.py @@ -0,0 +1,34 @@ +from typing import Any, TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.calendar_message_dict import ( + CalendarMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.call_message_dict import ( + CallMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.share_location_message_dict import ( + ShareLocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.url_message_dict import ( + UrlMessageDict, +) + + +class ChoiceOptionDict(TypedDict): + # Optional metadata returned back to you as postback + postback_data: NotRequired[Any] + + # Exactly one of the following keys is expected per choice: + call_message: NotRequired[CallMessageDict] + location_message: NotRequired[LocationMessageDict] + text_message: NotRequired[TextMessageDict] + url_message: NotRequired[UrlMessageDict] + calendar_message: NotRequired[CalendarMessageDict] + share_location_message: NotRequired[ShareLocationMessageDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py new file mode 100644 index 00000000..e98b0c96 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/contact_info_message_dict.py @@ -0,0 +1,52 @@ +from datetime import date +from typing import List, TypedDict +from typing_extensions import NotRequired + + +class NameInfoDict(TypedDict): + full_name: str + first_name: NotRequired[str] + last_name: NotRequired[str] + middle_name: NotRequired[str] + prefix: NotRequired[str] + suffix: NotRequired[str] + + +class PhoneNumberInfoDict(TypedDict): + phone_number: str + type: NotRequired[str] + + +class AddressInfoDict(TypedDict): + city: NotRequired[str] + country: NotRequired[str] + state: NotRequired[str] + zip: NotRequired[str] + type: NotRequired[str] + country_code: NotRequired[str] + + +class EmailInfoDict(TypedDict): + email_address: str + type: NotRequired[str] + + +class OrganizationInfoDict(TypedDict): + company: NotRequired[str] + department: NotRequired[str] + title: NotRequired[str] + + +class UrlInfoDict(TypedDict): + url: str + type: NotRequired[str] + + +class ContactInfoMessageDict(TypedDict): + name: NameInfoDict + phone_numbers: List[PhoneNumberInfoDict] + addresses: NotRequired[List[AddressInfoDict]] + email_addresses: NotRequired[List[EmailInfoDict]] + organization: NotRequired[OrganizationInfoDict] + urls: NotRequired[List[UrlInfoDict]] + birthday: NotRequired[date] diff --git a/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py b/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py new file mode 100644 index 00000000..99eb060f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/coordinates_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict, Union + + +class CoordinatesDict(TypedDict): + latitude: Union[int, float] + longitude: Union[int, float] diff --git a/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py new file mode 100644 index 00000000..0783d4bb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/list_message_dict.py @@ -0,0 +1,51 @@ +from typing import List, TypedDict, Union +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) + + +class ChoiceItemDict(TypedDict): + title: str + description: NotRequired[str] + media: NotRequired[MediaPropertiesDict] + postback_data: NotRequired[str] + + +class ProductItemDict(TypedDict): + id: str + marketplace: str + quantity: NotRequired[int] + item_price: NotRequired[Union[int, float]] + currency: NotRequired[str] + + +class ListItemChoiceDict(TypedDict): + choice: ChoiceItemDict + + +class ListItemProductDict(TypedDict): + product: ProductItemDict + + +ListItemDict = Union[ListItemChoiceDict, ListItemProductDict] + + +class ListSectionDict(TypedDict): + items: List[ListItemDict] + title: NotRequired[str] + + +class ListMessagePropertiesDict(TypedDict): + catalog_id: NotRequired[str] + menu: NotRequired[str] + whatsapp_header: NotRequired[str] + + +class ListMessageDict(TypedDict): + title: str + sections: List[ListSectionDict] + description: NotRequired[str] + media: NotRequired[MediaPropertiesDict] + message_properties: NotRequired[ListMessagePropertiesDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py new file mode 100644 index 00000000..38e945fb --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/location_message_dict.py @@ -0,0 +1,12 @@ +from typing import TypedDict +from typing_extensions import NotRequired + +from sinch.domains.conversation.models.v1.messages.types.coordinates_dict import ( + CoordinatesDict, +) + + +class LocationMessageDict(TypedDict): + coordinates: CoordinatesDict + title: str + label: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py new file mode 100644 index 00000000..b55181aa --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/media_properties_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MediaPropertiesDict(TypedDict): + url: str + thumbnail_url: NotRequired[str] + filename_override: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_content_type.py b/sinch/domains/conversation/models/v1/messages/types/message_content_type.py new file mode 100644 index 00000000..1b7058ea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_content_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MessageContentType = Union[ + Literal["CONTENT_UNKNOWN", "CONTENT_MARKETING", "CONTENT_NOTIFICATION"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py b/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py new file mode 100644 index 00000000..f7ab8036 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_properties_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MessagePropertiesDict(TypedDict): + whatsapp_header: NotRequired[str] diff --git a/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py b/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py new file mode 100644 index 00000000..f7f4a28f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/message_queue_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MessageQueueType = Union[ + Literal["NORMAL_PRIORITY", "HIGH_PRIORITY"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py b/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py new file mode 100644 index 00000000..94fb09b1 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/metadata_update_strategy_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +MetadataUpdateStrategyType = Union[ + Literal["REPLACE", "MERGE_PATCH"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py b/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py new file mode 100644 index 00000000..8bb2311e --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/processing_strategy_type.py @@ -0,0 +1,7 @@ +from typing import Literal, Union +from pydantic import StrictStr + +ProcessingStrategyType = Union[ + Literal["DEFAULT", "DISPATCH_ONLY"], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py b/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py new file mode 100644 index 00000000..936c2187 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/recipient_dict.py @@ -0,0 +1,20 @@ +from typing import List, TypedDict, Union +from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( + ConversationChannelType, +) + + +class ChannelRecipientIdentityDict(TypedDict): + channel: ConversationChannelType + identity: str + + +class RecipientIdentifiedByDict(TypedDict): + channel_identities: List[ChannelRecipientIdentityDict] + + +class RecipientContactIdDict(TypedDict): + contact_id: str + + +RecipientDict = Union[RecipientIdentifiedByDict, RecipientContactIdDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py b/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py new file mode 100644 index 00000000..1f0de272 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py @@ -0,0 +1,46 @@ +from typing import TypedDict +from typing_extensions import NotRequired +from sinch.domains.conversation.models.v1.messages.types.card_message_dict import ( + CardMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.carousel_message_dict import ( + CarouselMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.choice_message_dict import ( + ChoiceMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.contact_info_message_dict import ( + ContactInfoMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.list_message_dict import ( + ListMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.location_message_dict import ( + LocationMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.media_properties_dict import ( + MediaPropertiesDict, +) +from sinch.domains.conversation.models.v1.messages.types.template_message_dict import ( + TemplateMessageDict, +) +from sinch.domains.conversation.models.v1.messages.types.text_message_dict import ( + TextMessageDict, +) + + +class SendMessageRequestBodyDict(TypedDict, total=False): + """ + TypedDict for the message body in send message requests. + At least one message type must be provided. + """ + + text_message: NotRequired[TextMessageDict] + card_message: NotRequired[CardMessageDict] + carousel_message: NotRequired[CarouselMessageDict] + choice_message: NotRequired[ChoiceMessageDict] + contact_info_message: NotRequired[ContactInfoMessageDict] + list_message: NotRequired[ListMessageDict] + location_message: NotRequired[LocationMessageDict] + media_message: NotRequired[MediaPropertiesDict] + template_message: NotRequired[TemplateMessageDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py new file mode 100644 index 00000000..5c4b975c --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/share_location_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class ShareLocationMessageDict(TypedDict): + title: str + fallback_url: str diff --git a/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py new file mode 100644 index 00000000..2782a6ea --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/template_message_dict.py @@ -0,0 +1,20 @@ +from typing import Dict, TypedDict +from typing_extensions import NotRequired + + +class TemplateReferenceChannelSpecificDict(TypedDict): + template_id: str + version: NotRequired[str] + language_code: NotRequired[str] + parameters: NotRequired[Dict[str, str]] + + +class TemplateReferenceOmniChannelDict(TemplateReferenceChannelSpecificDict): + version: str + + +class TemplateMessageDict(TypedDict): + channel_template: NotRequired[ + Dict[str, TemplateReferenceChannelSpecificDict] + ] + omni_template: NotRequired[TemplateReferenceOmniChannelDict] diff --git a/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py new file mode 100644 index 00000000..f3c3330a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/text_message_dict.py @@ -0,0 +1,5 @@ +from typing import TypedDict + + +class TextMessageDict(TypedDict): + text: str diff --git a/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py b/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py new file mode 100644 index 00000000..cb289c25 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/url_message_dict.py @@ -0,0 +1,6 @@ +from typing import TypedDict + + +class UrlMessageDict(TypedDict): + title: str + url: str diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py index 87616819..843ad482 100644 --- a/sinch/domains/sms/api/v1/batches_apis.py +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -30,12 +30,11 @@ ReplaceMediaRequest, ) from sinch.domains.sms.models.v1.shared import ( - MediaBody, TextRequest, BinaryRequest, MediaRequest, ) -from sinch.domains.sms.models.v1.types import DeliveryReportType +from sinch.domains.sms.models.v1.types import DeliveryReportType, MediaBodyDict from sinch.domains.sms.api.v1.internal import ( CancelBatchMessageEndpoint, DryRunEndpoint, @@ -303,7 +302,7 @@ def dry_run_mms( self, to: List[str], from_: str, - body: MediaBody, + body: MediaBodyDict, per_recipient: Optional[bool] = None, number_of_recipients: Optional[int] = None, parameters: Optional[Dict[str, Dict[str, str]]] = None, @@ -325,7 +324,7 @@ def dry_run_mms( :param from_: The sender phone number. (required) :type from_: str :param body: The message body. (required) - :type body: MediaBody + :type body: MediaBodyDict :param per_recipient: Whether to include per recipient details in the response (optional) :type per_recipient: Optional[bool] :param number_of_recipients: Max number of recipients to include per recipient details for in the response (optional) @@ -642,7 +641,7 @@ def replace_mms( batch_id: str, to: List[str], from_: str, - body: MediaBody, + body: MediaBodyDict, delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, @@ -664,7 +663,7 @@ def replace_mms( :param from_: The sender phone number. (required) :type from_: str :param body: The message body. (required) - :type body: MediaBody + :type body: MediaBodyDict :param delivery_report: The delivery report type. (optional) :type delivery_report: Optional[DeliveryReportType] :param send_at: The time to send the message at. (optional) @@ -910,7 +909,7 @@ def send_mms( self, to: List[str], from_: str, - body: MediaBody, + body: MediaBodyDict, delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, @@ -936,7 +935,7 @@ def send_mms( :param from_: The sender phone number. (required) :type from_: str :param body: The message body. (required) - :type body: MediaBody + :type body: MediaBodyDict :param delivery_report: The delivery report type. (optional) :type delivery_report: Optional[DeliveryReportType] :param send_at: The time to send the message at. (optional) @@ -1218,7 +1217,7 @@ def update_mms( from_: Optional[str] = None, to_add: Optional[List[str]] = None, to_remove: Optional[List[str]] = None, - body: Optional[MediaBody] = None, + body: Optional[MediaBodyDict] = None, delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, @@ -1241,7 +1240,7 @@ def update_mms( :param to_remove: The list of phone numbers to remove from the batch. (optional) :type to_remove: Optional[List[str]] :param body: The message body. (optional) - :type body: Optional[MediaBody] + :type body: Optional[MediaBodyDict] :param delivery_report: The delivery report type. (optional) :type delivery_report: Optional[DeliveryReportType] :param send_at: The time to send the message at. (optional) diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py index f30dd14a..a52cfcc2 100644 --- a/sinch/domains/sms/models/v1/types/__init__.py +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -8,6 +8,7 @@ DeliveryStatusType, ) from sinch.domains.sms.models.v1.types.encoding_type import EncodingType +from sinch.domains.sms.models.v1.types.media_body_dict import MediaBodyDict from sinch.domains.sms.models.v1.types.recipient_delivery_report_type import ( RecipientDeliveryReportType, ) @@ -18,6 +19,7 @@ "DeliveryReportType", "DeliveryStatusType", "EncodingType", + "MediaBodyDict", "RecipientDeliveryReportType", ] diff --git a/sinch/domains/sms/models/v1/types/media_body_dict.py b/sinch/domains/sms/models/v1/types/media_body_dict.py new file mode 100644 index 00000000..5a3f9b8c --- /dev/null +++ b/sinch/domains/sms/models/v1/types/media_body_dict.py @@ -0,0 +1,8 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class MediaBodyDict(TypedDict): + url: str + subject: NotRequired[str] + message: NotRequired[str] diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index 8aa657ca..e330ca95 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -69,12 +69,18 @@ def step_validate_update_message_response(context): @when('I send a request to send a message to a contact') def step_send_message(context): - pass + context.message_response = context.messages.send_text_message( + app_id='01W4FFL35P4NC4K35CONVAPP001', + text='Hello', + contact_id='01W4FFL35P4NC4K35CONTACT001' + ) @then('the response contains the id of the message') def step_validate_send_message_response(context): - pass + assert context.message_response is not None, 'Message response should not be None' + assert hasattr(context.message_response, 'message_id'), 'Message response should have message_id attribute' + assert context.message_response.message_id == '01W4FFL35P4NC4K35MESSAGE001', f'Expected message_id to be "01W4FFL35P4NC4K35MESSAGE001", got "{context.message_response.message_id}"' @when('I send a request to list the existing messages') From 8da74b9fbe9da03b2373baafdea7efd1a230fcac Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 30 Jan 2026 16:48:45 +0100 Subject: [PATCH 085/106] DEVEXP-794: Conversation Messages - Send Unit Tests (#117) --- .../messages/test_send_message_endpoint.py | 94 +++++++++ .../request/test_send_message_request.py | 94 +++++++++ .../request/test_send_message_request_body.py | 191 ++++++++++++++++++ .../response/test_send_message_response.py | 35 ++++ .../v1/utils/test_message_helpers.py | 147 ++++++++++++++ 5 files changed, 561 insertions(+) create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py create mode 100644 tests/unit/domains/conversation/v1/models/response/test_send_message_response.py create mode 100644 tests/unit/domains/conversation/v1/utils/test_message_helpers.py diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py new file mode 100644 index 00000000..99468c2a --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py @@ -0,0 +1,94 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import SendMessageEndpoint +from sinch.domains.conversation.api.v1.exceptions import ConversationException +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequest, + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.internal.request.recipient import ( + Recipient, +) +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.response.types import SendMessageResponse + + +@pytest.fixture +def request_data(): + return SendMessageRequest( + app_id="my app ID", + recipient=Recipient(contact_id="my contact ID"), + message=SendMessageRequestBody( + text_message=TextMessage(text="This is a text message.") + ), + ) + + +@pytest.fixture +def mock_send_message_response(): + """Mock response for SendMessageResponse.""" + return HTTPResponse( + status_code=200, + body={"message_id": "01FC66621XXXXX119Z8PMV1QPQ"}, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + """Mock error response for send message endpoint.""" + return HTTPResponse( + status_code=400, + body={ + "error": { + "code": 400, + "message": "Invalid argument", + "status": "INVALID_ARGUMENT" + } + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return SendMessageEndpoint("test_project_id", request_data) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages:send" + ) + + +def test_request_body_expects_valid_json_with_app_id_recipient_message(request_data): + """Test that the endpoint produces a JSON body with app_id, recipient, and message.""" + endpoint = SendMessageEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["app_id"] == "my app ID" + assert body["recipient"]["contact_id"] == "my contact ID" + assert "text_message" in body["message"] + assert "project_id" not in body + + +def test_handle_response_expects_send_message_response(endpoint, mock_send_message_response): + """Test that SendMessageResponse is handled correctly.""" + parsed_response = endpoint.handle_response(mock_send_message_response) + + assert isinstance(parsed_response, SendMessageResponse) + assert parsed_response.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_handle_response_expects_conversation_exception_on_error( + endpoint, mock_error_response +): + """Test that ConversationException is raised when server returns an error.""" + with pytest.raises(ConversationException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py new file mode 100644 index 00000000..e60dea81 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py @@ -0,0 +1,94 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, + SendMessageRequestBody, + SendMessageRequest, +) + + +def test_send_message_request_expects_parsed_input(): + """ + Test that the model parses input correctly. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ) + + assert request.app_id == "my-app-id" + assert request.recipient.contact_id == "my-contact-id" + assert request.message.text_message is not None + assert request.message.text_message.text == "Hello" + + +@pytest.mark.parametrize("processing_strategy", ["DEFAULT", "DISPATCH_ONLY"]) +def test_send_message_request_expects_accepts_processing_strategy(processing_strategy): + """ + Test that the model accepts processing_strategy with different values. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + processing_strategy=processing_strategy, + ) + + assert request.processing_strategy == processing_strategy + + +@pytest.mark.parametrize("ttl_input,expected_serialized", [(10, "10s"), ("10s", "10s"), ("10", "10s"), (None, None)]) +def test_send_message_request_expects_ttl_serialized_to_backend(ttl_input, expected_serialized): + """ + Test that ttl is serialized as "10s" when sent to the backend (int/str normalized to string with 's' suffix). + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + ttl=ttl_input, + ) + + payload = request.model_dump(mode="json", exclude_none=True) + if expected_serialized is None: + assert "ttl" not in payload + else: + assert payload["ttl"] == expected_serialized + + +def test_send_message_request_expects_validation_error_for_missing_app_id(): + """ + Test that the model raises a ValidationError when app_id field is missing. + """ + data = { + "recipient": Recipient(contact_id="my-contact-id"), + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "app_id" in error_message + + +def test_send_message_request_expects_validation_error_for_missing_recipient(): + """ + Test that the model raises a ValidationError when recipient field is missing. + """ + data = { + "app_id": "my-app-id", + "message": SendMessageRequestBody(text_message=TextMessage(text="Hello")), + } + + with pytest.raises(ValidationError) as excinfo: + SendMessageRequest(**data) + + error_message = str(excinfo.value) + + assert "field required" in error_message.casefold() + assert "recipient" in error_message diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py new file mode 100644 index 00000000..013b70d8 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request_body.py @@ -0,0 +1,191 @@ +import pytest +from sinch.domains.conversation.models.v1.messages.categories.card.card_message import ( + CardMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.carousel.carousel_message import ( + CarouselMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_message import ( + ChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.choice.choice_options import ( + TextChoiceMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.location.location_message import ( + LocationMessage, +) +from sinch.domains.conversation.models.v1.messages.categories.media import ( + MediaProperties, +) +from sinch.domains.conversation.models.v1.messages.categories.template import ( + TemplateMessage, + TemplateReferenceOmniChannel, +) +from sinch.domains.conversation.models.v1.messages.categories.text import ( + TextMessage, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + SendMessageRequestBody, +) +from sinch.domains.conversation.models.v1.messages.shared.coordinates import ( + Coordinates, +) + + +def test_send_message_request_body_expects_accepts_text_message(): + """ + Test that the model accepts text_message with valid content. + """ + body = SendMessageRequestBody(text_message=TextMessage(text="Test message content")) + + assert body.text_message.text == "Test message content" + + +def test_send_message_request_body_expects_accepts_card_message(): + """ + Test that the model accepts card_message. + """ + body = SendMessageRequestBody(card_message=CardMessage(title="Card title")) + + assert body.card_message is not None + assert body.card_message.title == "Card title" + + +def test_send_message_request_body_expects_accepts_carousel_message(): + """ + Test that the model accepts carousel_message with a list of cards. + """ + body = SendMessageRequestBody( + carousel_message=CarouselMessage(cards=[CardMessage(title="Card 1")]) + ) + + assert body.carousel_message is not None + assert len(body.carousel_message.cards) == 1 + assert body.carousel_message.cards[0].title == "Card 1" + + +def test_send_message_request_body_expects_accepts_choice_message(): + """ + Test that the model accepts choice_message with choices. + """ + body = SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[TextChoiceMessage(text_message=TextMessage(text="Option 1"))] + ) + ) + + assert body.choice_message is not None + assert len(body.choice_message.choices) == 1 + assert body.choice_message.choices[0].text_message.text == "Option 1" + + +def test_send_message_request_body_expects_accepts_location_message(): + """ + Test that the model accepts location_message with coordinates and title. + """ + body = SendMessageRequestBody( + location_message=LocationMessage( + coordinates=Coordinates(latitude=59.3293, longitude=18.0686), + title="Stockholm", + ) + ) + + assert body.location_message is not None + assert body.location_message.title == "Stockholm" + assert body.location_message.coordinates.latitude == 59.3293 + assert body.location_message.coordinates.longitude == 18.0686 + + +def test_send_message_request_body_expects_accepts_media_message(): + """ + Test that the model accepts media_message with url. + """ + body = SendMessageRequestBody( + media_message=MediaProperties(url="https://example.com/image.jpg") + ) + + assert body.media_message is not None + assert body.media_message.url == "https://example.com/image.jpg" + + +def test_send_message_request_body_expects_accepts_template_message(): + """ + Test that the model accepts template_message with omni_template. + """ + body = SendMessageRequestBody( + template_message=TemplateMessage( + omni_template=TemplateReferenceOmniChannel( + template_id="tpl_123", version="latest" + ) + ) + ) + + assert body.template_message is not None + assert body.template_message.omni_template is not None + assert body.template_message.omni_template.template_id == "tpl_123" + assert body.template_message.omni_template.version == "latest" + + +def test_send_message_request_body_expects_accepts_choice_with_one_message_key(): + """ + Parsing from dict: each choice with exactly one message-type key is valid. + Choices array can include Call, Location, Text, URL, Calendar, Request location + (number limited to 10 per spec). + """ + choices = [ + {"text_message": {"text": "Option 1"}}, + {"call_message": {"title": "Call us", "phone_number": "+46732000000"}}, + {"url_message": {"title": "Website", "url": "https://example.com"}}, + { + "location_message": { + "title": "Show map", + "coordinates": {"latitude": 59.33, "longitude": 18.07}, + } + }, + { + "share_location_message": { + "title": "Share location", + "fallback_url": "https://example.com", + } + }, + ] + body = SendMessageRequestBody( + choice_message=ChoiceMessage(choices=choices) + ) + assert body.choice_message is not None + assert len(body.choice_message.choices) == 5 + assert body.choice_message.choices[0].text_message.text == "Option 1" + assert body.choice_message.choices[1].call_message.phone_number == "+46732000000" + assert body.choice_message.choices[2].url_message.url == "https://example.com" + assert body.choice_message.choices[3].location_message.title == "Show map" + assert ( + body.choice_message.choices[4].share_location_message.title + == "Share location" + ) + + +def test_send_message_request_body_expects_rejects_choice_with_zero_message_keys(): + """ + Parsing from dict: choice with no message-type key raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage(choices=[{"postback_data": "x"}]) + ) + + +def test_send_message_request_body_expects_rejects_choice_with_two_message_keys(): + """ + Parsing from dict: choice with two message-type keys raises. + """ + with pytest.raises(ValueError, match="exactly one of"): + SendMessageRequestBody( + choice_message=ChoiceMessage( + choices=[ + { + "text_message": {"text": "A"}, + "call_message": {"title": "Call", "phone_number": "1"}, + } + ] + ) + ) diff --git a/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py new file mode 100644 index 00000000..2f6b542a --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py @@ -0,0 +1,35 @@ +import pytest +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.response.types import ( + SendMessageResponse, +) + + +def test_parsing_send_message_response_expects_message_id_only(): + """Test that SendMessageResponse parses with required message_id only.""" + data = {"message_id": "01FC66621XXXXX119Z8PMV1QPQ"} + parsed = SendMessageResponse.model_validate(data) + + assert isinstance(parsed, SendMessageResponse) + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time is None + + +def test_parsing_send_message_response_expects_accepted_time(): + """Test that SendMessageResponse parses accepted_time from ISO string.""" + data = { + "message_id": "01FC66621XXXXX119Z8PMV1QPQ", + "accepted_time": "2026-01-14T20:32:31.147Z", + } + parsed = SendMessageResponse.model_validate(data) + + assert parsed.message_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.accepted_time == datetime( + 2026, 1, 14, 20, 32, 31, 147000, tzinfo=timezone.utc + ) + + +def test_send_message_response_expects_message_id_required(): + """Test that SendMessageResponse requires message_id.""" + with pytest.raises(ValueError): + SendMessageResponse.model_validate({"accepted_time": "2026-01-14T20:32:31.147Z"}) diff --git a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py new file mode 100644 index 00000000..ad6875f6 --- /dev/null +++ b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py @@ -0,0 +1,147 @@ +import pytest +from sinch.domains.conversation.api.v1.utils import ( + build_recipient_dict, + coerce_recipient, + split_send_kwargs, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + Recipient, +) + + +class TestBuildRecipientDict: + + @pytest.mark.parametrize( + "contact_id,recipient_identities,expected", + [ + ("contact-123", None, {"contact_id": "contact-123"}), + ( + None, + [{"channel": "RCS", "identity": "+46701234567"}], + {"channel_identities": [{"channel": "RCS", "identity": "+46701234567"}]}, + ), + ], + ) + def test_build_recipient_dict_expects_valid_input_returns_recipient_dict( + self, contact_id, recipient_identities, expected + ): + """Test that providing contact_id or recipient_identities returns the expected dict.""" + result = build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert result == expected + + @pytest.mark.parametrize( + "contact_id,recipient_identities,error_substring", + [ + ( + "contact-123", + [{"channel": "RCS", "identity": "+46701234567"}], + "Cannot specify both", + ), + (None, None, "Must provide either"), + ], + ) + def test_build_recipient_dict_expects_value_error_when_invalid( + self, contact_id, recipient_identities, error_substring + ): + """Test that invalid combinations raise ValueError with expected message.""" + with pytest.raises(ValueError) as excinfo: + build_recipient_dict( + contact_id=contact_id, recipient_identities=recipient_identities + ) + assert error_substring in str(excinfo.value) + + +class TestCoerceRecipient: + + def test_coerce_recipient_expects_recipient_instance_returned_unchanged(self): + """Passing a Recipient returns the same instance with contact_id preserved.""" + recipient_input = Recipient(contact_id="contact-123") + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is recipient_input + assert result.contact_id == "contact-123" + + def test_coerce_recipient_expects_dict_with_contact_id_converted_to_recipient( + self, + ): + """Passing a dict with contact_id returns a new Recipient with that contact_id.""" + recipient_input = {"contact_id": "contact-456"} + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result is not recipient_input + assert result.contact_id == "contact-456" + + def test_coerce_recipient_expects_dict_with_channel_identities_converted_to_recipient( + self, + ): + """Passing a dict with channel_identities returns Recipient with identified_by.""" + recipient_input = { + "channel_identities": [{"channel": "RCS", "identity": "+46701234567"}] + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + def test_coerce_recipient_expects_dict_with_identified_by_converted_to_recipient( + self, + ): + """Passing a dict with identified_by.channel_identities returns Recipient.""" + recipient_input = { + "identified_by": { + "channel_identities": [ + {"channel": "RCS", "identity": "+46701234567"}, + ] + } + } + result = coerce_recipient(recipient_input) + assert isinstance(result, Recipient) + assert result.identified_by is not None + assert len(result.identified_by.channel_identities) == 1 + assert ( + result.identified_by.channel_identities[0].identity == "+46701234567" + ) + + +class TestSplitSendKwargs: + + @pytest.mark.parametrize( + "kwargs,expected_message_kwargs,expected_request_kwargs", + [ + ({}, {}, {}), + ( + {"text_message": {"text": "Hello"}}, + {"text_message": {"text": "Hello"}}, + {}, + ), + ( + {"ttl": 10, "callback_url": "https://example.com/callback"}, + {}, + {"ttl": 10, "callback_url": "https://example.com/callback"}, + ), + ( + { + "text_message": {"text": "Hi"}, + "ttl": 30, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + { + "text_message": {"text": "Hi"}, + "media_message": {"url": "https://example.com/image.jpg"}, + }, + {"ttl": 30}, + ), + ], + ) + def test_split_send_kwargs_expects_kwargs_split_into_message_and_request( + self, kwargs, expected_message_kwargs, expected_request_kwargs + ): + """Test that kwargs are split into message_kwargs and request_kwargs.""" + message_kwargs, request_kwargs = split_send_kwargs(kwargs) + assert message_kwargs == expected_message_kwargs + assert request_kwargs == expected_request_kwargs From 70a1247371aec4e9d03be2cc9745e60c9008b9ba Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 30 Jan 2026 18:00:32 +0100 Subject: [PATCH 086/106] DEVEXP-794: Conversation Messages Send - Snippets (#118) --- MIGRATION_GUIDE.md | 33 ++ .../conversation/messages/send/snippet.py | 41 +++ .../messages/send_card_message/snippet.py | 46 +++ .../messages/send_carousel_message/snippet.py | 52 +++ .../messages/send_choice_message/snippet.py | 45 +++ .../send_contact_info_message/snippet.py | 42 +++ .../messages/send_list_message/snippet.py | 51 +++ .../messages/send_location_message/snippet.py | 42 +++ .../messages/send_media_message/snippet.py | 41 +++ .../messages/send_template_message/snippet.py | 44 +++ .../messages/send_text_message/snippet.py | 37 +++ .../v1/test_conversation_messages.py | 306 ++++++++++++++++++ 12 files changed, 780 insertions(+) create mode 100644 examples/snippets/conversation/messages/send/snippet.py create mode 100644 examples/snippets/conversation/messages/send_card_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_carousel_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_choice_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_contact_info_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_list_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_location_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_media_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_template_message/snippet.py create mode 100644 examples/snippets/conversation/messages/send_text_message/snippet.py create mode 100644 tests/unit/domains/conversation/v1/test_conversation_messages.py diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 430f6a8b..b8259588 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -93,6 +93,39 @@ sinch_client = SinchClient( sinch_client.configuration.conversation_region = "eu" ``` +### [`Conversation`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/conversation) + +#### Replacement models + +##### Messages (send, get, delete, list) + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` | `send()`: pass `app_id`, `message` (dict or [`SendMessageRequestBodyDict`](sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py)), and either `contact_id` or `recipient_identities`. Internally uses [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py), [`SendMessageRequestBody`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py). For typed payloads use `send_text_message()`, `send_card_message()`, etc. +| `sinch.domains.conversation.models.message.responses.SendConversationMessageResponse` | [`SendMessageResponse`](sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py) (`message_id`, optional `accepted_time` as `datetime`) | +| `sinch.domains.conversation.models.message.requests.GetConversationMessageRequest` | `get(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | +| `sinch.domains.conversation.models.message.responses.GetConversationMessageResponse` | [`ConversationMessageResponse`](sinch/domains/conversation/models/v1/messages/response/types/__init__.py) (Union of app/contact message response types) | +| `sinch.domains.conversation.models.message.requests.DeleteConversationMessageRequest` | `delete(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | +| `sinch.domains.conversation.models.message.responses.DeleteConversationMessageResponse` | `None` (method returns `None`) | +| `sinch.domains.conversation.models.message.requests.ListConversationMessagesRequest` | `list()` with individual parameters: `conversation_id`, `contact_id`, `app_id`, `page_size`, `page_token`, `view`, `messages_source`, `only_recipient_originated` (signature aligned with V1 where available) | +| `sinch.domains.conversation.models.message.responses.ListConversationMessagesResponse` | Response type for `list()` (messages list, next_page_token) | + +#### Replacement APIs + +The Conversation domain API access remains `sinch_client.conversation`; message operations are under `sinch_client.conversation.messages`. Recipient is specified with exactly one of `contact_id` or `recipient_identities` (list of `{channel, identity}`). + +##### Messages API + +| Old method | New method in `conversation.messages` | +|------------|----------------------------------------| +| `send()` with `SendConversationMessageRequest` | Use convenience methods: `send_text_message()`, `send_card_message()`, `send_carousel_message()`, `send_choice_message()`, `send_contact_info_message()`, `send_list_message()`, `send_location_message()`, `send_media_message()`, `send_template_message()`
Or `send()` with `app_id`, `message` (dict or `SendMessageRequestBodyDict`), and either `contact_id` or `recipient_identities` | +| `get()` with `GetConversationMessageRequest` | `get()` with `message_id: str` parameter | +| `delete()` with `DeleteConversationMessageRequest` | `delete()` with `message_id: str` parameter | +| `list()` with `ListConversationMessagesRequest` | In Progress | +| — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source`| + +
+ ### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) #### Replacement models diff --git a/examples/snippets/conversation/messages/send/snippet.py b/examples/snippets/conversation/messages/send/snippet.py new file mode 100644 index 00000000..970c9c99 --- /dev/null +++ b/examples/snippets/conversation/messages/send/snippet.py @@ -0,0 +1,41 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +response = sinch_client.conversation.messages.send( + app_id=app_id, + message={ + "text_message": { + "text": "[Python SDK: Conversation Message] Sample text message" + } + }, + recipient_identities=recipient_identities +) + +print(f"Successfully sent message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_card_message/snippet.py b/examples/snippets/conversation/messages/send_card_message/snippet.py new file mode 100644 index 00000000..5300eaf2 --- /dev/null +++ b/examples/snippets/conversation/messages/send_card_message/snippet.py @@ -0,0 +1,46 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +card_message = { + "title": "Card title", + "description": "Optional card description", + "choices": [ + {"text_message": {"text": "Yes"}, "postback_data": "yes"}, + {"text_message": {"text": "No"}, "postback_data": "no"}, + ] +} + +response = sinch_client.conversation.messages.send_card_message( + app_id=app_id, + card_message=card_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent card message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_carousel_message/snippet.py b/examples/snippets/conversation/messages/send_carousel_message/snippet.py new file mode 100644 index 00000000..bb8ecb4f --- /dev/null +++ b/examples/snippets/conversation/messages/send_carousel_message/snippet.py @@ -0,0 +1,52 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +carousel_message = { + "cards": [ + { + "title": "Card 1", + "description": "First card description", + "choices": [{"text_message": {"text": "Option 1"}}], + }, + { + "title": "Card 2", + "description": "Second card description", + "choices": [{"url_message": {"title": "Link", "url": "https://example.com"}}], + }, + ], +} + +response = sinch_client.conversation.messages.send_carousel_message( + app_id=app_id, + carousel_message=carousel_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent carousel message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_choice_message/snippet.py b/examples/snippets/conversation/messages/send_choice_message/snippet.py new file mode 100644 index 00000000..315e13d4 --- /dev/null +++ b/examples/snippets/conversation/messages/send_choice_message/snippet.py @@ -0,0 +1,45 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +choice_message = { + "text_message": {"text": "Choose an option:"}, + "choices": [ + {"text_message": {"text": "Option A"}, "postback_data": "option_a"}, + {"text_message": {"text": "Option B"}, "postback_data": "option_b"}, + ], +} + +response = sinch_client.conversation.messages.send_choice_message( + app_id=app_id, + choice_message=choice_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent choice message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_contact_info_message/snippet.py b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py new file mode 100644 index 00000000..70f84c57 --- /dev/null +++ b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py @@ -0,0 +1,42 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +contact_info_message = { + "name": {"full_name": "John Doe"}, + "phone_numbers": [{"phone_number": "+1234567890"}], +} + +response = sinch_client.conversation.messages.send_contact_info_message( + app_id=app_id, + contact_info_message=contact_info_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent contact info message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_list_message/snippet.py b/examples/snippets/conversation/messages/send_list_message/snippet.py new file mode 100644 index 00000000..3b7010ac --- /dev/null +++ b/examples/snippets/conversation/messages/send_list_message/snippet.py @@ -0,0 +1,51 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +list_message = { + "title": "Choose an option", + "description": "Select from the list below", + "sections": [ + { + "title": "Section 1", + "items": [ + {"choice": {"title": "Option A", "postback_data": "option_a"}}, + {"choice": {"title": "Option B", "postback_data": "option_b"}}, + ], + }, + ], +} + +response = sinch_client.conversation.messages.send_list_message( + app_id=app_id, + list_message=list_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent list message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_location_message/snippet.py b/examples/snippets/conversation/messages/send_location_message/snippet.py new file mode 100644 index 00000000..451d0d83 --- /dev/null +++ b/examples/snippets/conversation/messages/send_location_message/snippet.py @@ -0,0 +1,42 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +location_message = { + "title": "Our office", + "coordinates": {"latitude": 59.3293, "longitude": 18.0686}, +} + +response = sinch_client.conversation.messages.send_location_message( + app_id=app_id, + location_message=location_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent location message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_media_message/snippet.py b/examples/snippets/conversation/messages/send_media_message/snippet.py new file mode 100644 index 00000000..df7aa970 --- /dev/null +++ b/examples/snippets/conversation/messages/send_media_message/snippet.py @@ -0,0 +1,41 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +media_message = { + "url": "https://example.com/image.jpg", +} + +response = sinch_client.conversation.messages.send_media_message( + app_id=app_id, + media_message=media_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent media message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_template_message/snippet.py b/examples/snippets/conversation/messages/send_template_message/snippet.py new file mode 100644 index 00000000..cb604a74 --- /dev/null +++ b/examples/snippets/conversation/messages/send_template_message/snippet.py @@ -0,0 +1,44 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "RCS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +template_message = { + "omni_template": { + "template_id": "TEMPLATE_ID", + "version": "1", + }, +} + +response = sinch_client.conversation.messages.send_template_message( + app_id=app_id, + template_message=template_message, + recipient_identities=recipient_identities +) + +print(f"Successfully sent template message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_text_message/snippet.py b/examples/snippets/conversation/messages/send_text_message/snippet.py new file mode 100644 index 00000000..6a5bebbe --- /dev/null +++ b/examples/snippets/conversation/messages/send_text_message/snippet.py @@ -0,0 +1,37 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to send the message from +app_id = "CONVERSATION_APP_ID" +# The phone number of the recipient in E.164 format (e.g. +46701234567) +recipient_identities = [ + { + "channel": "SMS", + "identity": "RECIPIENT_PHONE_NUMBER" + } +] + +response = sinch_client.conversation.messages.send_text_message( + app_id=app_id, + text="[Python SDK: Conversation] Sample text message", + recipient_identities=recipient_identities +) + +print(f"Successfully sent text message.\n{response}") diff --git a/tests/unit/domains/conversation/v1/test_conversation_messages.py b/tests/unit/domains/conversation/v1/test_conversation_messages.py new file mode 100644 index 00000000..144d30dd --- /dev/null +++ b/tests/unit/domains/conversation/v1/test_conversation_messages.py @@ -0,0 +1,306 @@ +""" +Unit tests for Conversation Messages API +""" +from unittest.mock import MagicMock +import pytest +from sinch.domains.conversation.conversation import Conversation +from sinch.domains.conversation.api.v1 import Messages +from sinch.domains.conversation.api.v1.internal import ( + DeleteMessageEndpoint, + GetMessageEndpoint, + SendMessageEndpoint, + UpdateMessageMetadataEndpoint, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + MessageIdRequest, + UpdateMessageMetadataRequest, + SendMessageRequest, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + SendMessageResponse, +) + + +@pytest.fixture +def mock_send_message_response(): + return SendMessageResponse( + message_id="01FC66621SND04119Z8PMV1QPQ", + ) + + +@pytest.fixture +def mock_conversation_message_response(): + response = MagicMock() + response.id = "01FC66621GET02119Z8PMV1QPQ" + return response + + +def test_conversation_expects_messages_attribute(mock_sinch_client_conversation): + """Test that Conversation exposes .messages as Messages instance.""" + conversation = Conversation(mock_sinch_client_conversation) + assert isinstance(conversation.messages, Messages) + + +def test_messages_delete_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """Test that delete sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + None + ) + spy_endpoint = mocker.spy(DeleteMessageEndpoint, "__init__") + + message_id = "01FC66621DEL01119Z8PMV1QPQ" + conversation = Conversation(mock_sinch_client_conversation) + conversation.messages.delete(message_id=message_id) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MessageIdRequest) + assert kwargs["request_data"].message_id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_delete_with_messages_source_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """Test that delete with messages_source sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + None + ) + spy_endpoint = mocker.spy(DeleteMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + message_id = "01FC66621DL205119Z8PMV1QPQ" + conversation.messages.delete( + message_id=message_id, + messages_source="DISPATCH_SOURCE", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["request_data"].message_id == message_id + assert kwargs["request_data"].messages_source == "DISPATCH_SOURCE" + + +def test_messages_get_expects_correct_request( + mock_sinch_client_conversation, mock_conversation_message_response, mocker +): + """Test that get sends the correct request and returns the response.""" + message_id = "01FC66621GET02119Z8PMV1QPQ" + mock_conversation_message_response.id = message_id + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_conversation_message_response + ) + spy_endpoint = mocker.spy(GetMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.get(message_id=message_id) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], MessageIdRequest) + assert kwargs["request_data"].message_id == message_id + assert response.id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_update_expects_correct_request( + mock_sinch_client_conversation, mock_conversation_message_response, mocker +): + """Test that update sends the correct request and returns the response.""" + message_id = "01FC66621UPD03119Z8PMV1QPQ" + mock_conversation_message_response.id = message_id + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_conversation_message_response + ) + spy_endpoint = mocker.spy(UpdateMessageMetadataEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.update( + message_id=message_id, + metadata="updated-metadata", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], UpdateMessageMetadataRequest) + assert kwargs["request_data"].message_id == message_id + assert kwargs["request_data"].metadata == "updated-metadata" + assert response.id == message_id + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send sends the correct request and returns SendMessageResponse.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send( + app_id="APP_ID", + message={"text_message": {"text": "Hello"}}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["project_id"] == "test_project_id" + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.text_message is not None + assert kwargs["request_data"].message.text_message.text == "Hello" + assert isinstance(response, SendMessageResponse) + assert response.message_id == "01FC66621SND04119Z8PMV1QPQ" + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_with_contact_id_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send with contact_id builds recipient correctly.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send( + app_id="APP_ID", + message={"text_message": {"text": "Hi"}}, + contact_id="CONTACT_123", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].recipient.contact_id == "CONTACT_123" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_text_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_text_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_text_message( + app_id="APP_ID", + text="Hello", + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.text_message is not None + assert kwargs["request_data"].message.text_message.text == "Hello" + assert isinstance(response, SendMessageResponse) + assert response.message_id == "01FC66621SND04119Z8PMV1QPQ" + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_card_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_card_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_card_message( + app_id="APP_ID", + card_message={"title": "Card title", "description": "Description"}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.card_message is not None + assert kwargs["request_data"].message.card_message.title == "Card title" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_choice_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_choice_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_choice_message( + app_id="APP_ID", + choice_message={ + "text_message": {"text": "Choose:"}, + "choices": [ + {"text_message": {"text": "Option A"}, "postback_data": "a"}, + ], + }, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert isinstance(kwargs["request_data"], SendMessageRequest) + assert kwargs["request_data"].app_id == "APP_ID" + assert kwargs["request_data"].message.choice_message is not None + assert kwargs["request_data"].message.choice_message.text_message.text == "Choose:" + assert len(kwargs["request_data"].message.choice_message.choices) == 1 + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + +def test_messages_send_media_message_expects_correct_request( + mock_sinch_client_conversation, mock_send_message_response, mocker +): + """Test that send_media_message sends the correct request.""" + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_send_message_response + ) + spy_endpoint = mocker.spy(SendMessageEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.send_media_message( + app_id="APP_ID", + media_message={"url": "https://example.com/image.jpg"}, + recipient_identities=[ + {"channel": "RCS", "identity": "+46701234567"}, + ], + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + assert kwargs["request_data"].message.media_message is not None + assert kwargs["request_data"].message.media_message.url == "https://example.com/image.jpg" + assert isinstance(response, SendMessageResponse) + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() From 70417d36143de891e23611f4734ca0b253f1fcf1 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Feb 2026 18:35:01 +0100 Subject: [PATCH 087/106] DEVEXP-1241: Conversation Messages - List (E2E) (#119) --- .../conversation/api/v1/internal/__init__.py | 2 + .../api/v1/internal/messages_endpoints.py | 42 +++++++++ .../conversation/api/v1/messages_apis.py | 93 ++++++++++++++++++- .../models/v1/messages/internal/__init__.py | 8 +- .../internal/list_messages_response.py | 24 +++++ .../v1/messages/internal/request/__init__.py | 4 + .../internal/request/list_messages_request.py | 69 ++++++++++++++ .../models/v1/messages/types/__init__.py | 4 + .../types/conversation_messages_view_type.py | 8 ++ .../features/steps/conversation.steps.py | 43 +++++++-- .../v1/test_conversation_messages.py | 36 +++++++ 11 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py create mode 100644 sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py index bc4a7083..3fbd813d 100644 --- a/sinch/domains/conversation/api/v1/internal/__init__.py +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -1,6 +1,7 @@ from sinch.domains.conversation.api.v1.internal.messages_endpoints import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, UpdateMessageMetadataEndpoint, SendMessageEndpoint, ) @@ -8,6 +9,7 @@ __all__ = [ "DeleteMessageEndpoint", "GetMessageEndpoint", + "ListMessagesEndpoint", "UpdateMessageMetadataEndpoint", "SendMessageEndpoint", ] diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py index d28bcd4c..d371c95e 100644 --- a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -2,10 +2,14 @@ from sinch.core.enums import HTTPAuthentication, HTTPMethods from sinch.core.models.http_response import HTTPResponse from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, ) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, SendMessageResponse, @@ -36,6 +40,44 @@ def build_query_params(self) -> dict: return query_params +class ListMessagesEndpoint(MessageEndpoint): + ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + QUERY_PARAM_FIELDS = { + "app_id", + "channel", + "channel_identity", + "contact_id", + "conversation_id", + "direction", + "end_time", + "messages_source", + "only_recipient_originated", + "page_size", + "page_token", + "start_time", + "view", + } + + def __init__(self, project_id: str, request_data: ListMessagesRequest): + super(ListMessagesEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> ListMessagesResponse: + try: + super(ListMessagesEndpoint, self).handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListMessagesResponse) + + class DeleteMessageEndpoint(MessageEndpoint): ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}" HTTP_METHOD = HTTPMethods.DELETE.value diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 90bc7b84..36222476 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -1,6 +1,8 @@ +from datetime import datetime from typing import Any, Dict, List, Optional, Union - +from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, @@ -11,12 +13,14 @@ SendMessageResponse, ) from sinch.domains.conversation.models.v1.messages.types import ( - MessagesSourceType, ConversationChannelType, - ProcessingStrategyType, - MetadataUpdateStrategyType, - MessageQueueType, + ConversationDirectionType, + ConversationMessagesViewType, MessageContentType, + MessageQueueType, + MessagesSourceType, + MetadataUpdateStrategyType, + ProcessingStrategyType, CardMessageDict, CarouselMessageDict, ChoiceMessageDict, @@ -58,6 +62,7 @@ from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, UpdateMessageMetadataEndpoint, SendMessageEndpoint, ) @@ -131,6 +136,84 @@ def get( ) return self._request(GetMessageEndpoint, request_data) + def list( + self, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + conversation_id: Optional[str] = None, + contact_id: Optional[str] = None, + app_id: Optional[str] = None, + channel_identity: Optional[str] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + view: Optional[ConversationMessagesViewType] = None, + messages_source: Optional[MessagesSourceType] = None, + only_recipient_originated: Optional[bool] = None, + channel: Optional[ConversationChannelType] = None, + direction: Optional[ConversationDirectionType] = None, + **kwargs, + ) -> Paginator[ConversationMessageResponse]: + """ + List messages sent or received via particular Processing Modes. + The messages are ordered by their accept_time property in descending order. + + :param page_size: Maximum number of messages to fetch. Defaults to 10, maximum is 1000. + :type page_size: Optional[int] + :param page_token: Next page token previously returned if any. + :type page_token: Optional[str] + :param conversation_id: Filter messages by conversation ID. + :type conversation_id: Optional[str] + :param contact_id: Filter messages by contact ID. + :type contact_id: Optional[str] + :param app_id: Filter messages by app ID. + :type app_id: Optional[str] + :param channel_identity: Channel identity of the contact. + :type channel_identity: Optional[str] + :param start_time: Filter messages with accept_time after this timestamp. + :type start_time: Optional[datetime] + :param end_time: Filter messages with accept_time before this timestamp. + :type end_time: Optional[datetime] + :param view: Messages view type. WITH_METADATA or WITHOUT_METADATA. + :type view: Optional[ConversationMessagesViewType] + :param messages_source: Specifies the message source for the request. + :type messages_source: Optional[MessagesSourceType] + :param only_recipient_originated: Only fetch recipient-originated messages. + :type only_recipient_originated: Optional[bool] + :param channel: Only fetch messages from the specified channel. + :type channel: Optional[ConversationChannelType] + :param direction: Only fetch messages with the specified direction. TO_APP or TO_CONTACT. + :type direction: Optional[ConversationDirectionType] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: TokenBasedPaginator with ConversationMessageResponse items + :rtype: Paginator[ConversationMessageResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListMessagesEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListMessagesRequest( + page_size=page_size, + page_token=page_token, + conversation_id=conversation_id, + contact_id=contact_id, + app_id=app_id, + channel_identity=channel_identity, + start_time=start_time, + end_time=end_time, + view=view, + messages_source=messages_source, + only_recipient_originated=only_recipient_originated, + channel=channel, + direction=direction, + **kwargs, + ), + ), + ) + def update( self, message_id: str, diff --git a/sinch/domains/conversation/models/v1/messages/internal/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/__init__.py index a9a2c5b3..56c121c5 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/__init__.py @@ -1 +1,7 @@ -__all__ = [] +from sinch.domains.conversation.models.v1.messages.internal.list_messages_response import ( + ListMessagesResponse, +) + +__all__ = [ + "ListMessagesResponse", +] diff --git a/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py new file mode 100644 index 00000000..d9604750 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/list_messages_response.py @@ -0,0 +1,24 @@ +from typing import List, Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.response.types import ( + ConversationMessageResponse, +) + + +class ListMessagesResponse(BaseModelConfiguration): + messages: Optional[List[ConversationMessageResponse]] = Field( + default=None, + description="List of messages associated to the referenced conversation.", + ) + next_page_token: Optional[StrictStr] = Field( + default=None, + description="Token that should be included in the next request to fetch the next page.", + ) + + @property + def content(self): + """Returns the messages as part of the response object for pagination compatibility.""" + return self.messages or [] diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py index da524ea8..43b2a215 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -1,3 +1,6 @@ +from sinch.domains.conversation.models.v1.messages.internal.request.list_messages_request import ( + ListMessagesRequest, +) from sinch.domains.conversation.models.v1.messages.internal.request.message_id_request import ( MessageIdRequest, ) @@ -17,6 +20,7 @@ ) __all__ = [ + "ListMessagesRequest", "MessageIdRequest", "UpdateMessageMetadataRequest", "Recipient", diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py new file mode 100644 index 00000000..7e5c8507 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -0,0 +1,69 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationDirectionType, + ConversationMessagesViewType, + MessagesSourceType, +) + + +class ListMessagesRequest(BaseModelConfiguration): + """Request model for listing messages.""" + + conversation_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by conversation ID.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by contact ID.", + ) + app_id: Optional[StrictStr] = Field( + default=None, + description="Filter messages by app ID.", + ) + channel_identity: Optional[StrictStr] = Field( + default=None, + description="Channel identity of the contact.", + ) + start_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time after this timestamp.", + ) + end_time: Optional[datetime] = Field( + default=None, + description="Filter messages with accept_time before this timestamp.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="Maximum number of messages to fetch. Defaults to 10, maximum is 1000.", + ) + page_token: Optional[StrictStr] = Field( + default=None, + description="Next page token previously returned if any.", + ) + view: Optional[ConversationMessagesViewType] = Field( + default=None, + description="Messages view type. WITH_METADATA or WITHOUT_METADATA.", + ) + messages_source: Optional[MessagesSourceType] = Field( + default=None, + description="Specifies the message source for the request.", + ) + only_recipient_originated: Optional[bool] = Field( + default=None, + description="Only fetch recipient-originated messages.", + ) + channel: Optional[ConversationChannelType] = Field( + default=None, + description="Only fetch messages from the specified channel.", + ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Only fetch messages with the specified direction. TO_APP or TO_CONTACT.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index fb34138a..5834cd05 100644 --- a/sinch/domains/conversation/models/v1/messages/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -7,6 +7,9 @@ from sinch.domains.conversation.models.v1.messages.types.conversation_channel_type import ( ConversationChannelType, ) +from sinch.domains.conversation.models.v1.messages.types.conversation_messages_view_type import ( + ConversationMessagesViewType, +) from sinch.domains.conversation.models.v1.messages.types.conversation_direction_type import ( ConversationDirectionType, ) @@ -87,6 +90,7 @@ __all__ = [ "AgentType", "ConversationChannelType", + "ConversationMessagesViewType", "ConversationDirectionType", "ProcessingModeType", "CardHeightType", diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py new file mode 100644 index 00000000..643df25f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_messages_view_type.py @@ -0,0 +1,8 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +ConversationMessagesViewType = Union[ + Literal["WITH_METADATA", "WITHOUT_METADATA"], + StrictStr, +] diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index e330ca95..db251a59 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -85,29 +85,60 @@ def step_validate_send_message_response(context): @when('I send a request to list the existing messages') def step_list_messages(context): - pass + context.list_response = context.messages.list(page_size=2) @then('the response contains "{count}" messages') def step_validate_message_count(context, count): - pass + expected_messages_count = int(count) + assert len(context.list_response.content()) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.list_response.content())}' + ) @when('I send a request to list all the messages') def step_list_all_messages(context): - pass + """List all messages using iterator""" + response = context.messages.list(page_size=2) + messages_list = [] + + for message in response.iterator(): + messages_list.append(message) + + context.messages_list = messages_list @then('the messages list contains "{count}" messages') def step_validate_total_message_count(context, count): - pass + expected_messages_count = int(count) + assert len(context.messages_list) == expected_messages_count, ( + f'Expected {expected_messages_count} messages, got {len(context.messages_list)}' + ) @when('I iterate manually over the messages pages') def step_iterate_messages_pages(context): - pass + """Manually iterate over messages pages""" + context.list_response = context.messages.list( + page_size=2, + ) + + context.messages_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True @then('the result contains the data from "{count}" pages') def step_validate_page_count(context, count): - pass + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, ( + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + ) diff --git a/tests/unit/domains/conversation/v1/test_conversation_messages.py b/tests/unit/domains/conversation/v1/test_conversation_messages.py index 144d30dd..f8d7f9d2 100644 --- a/tests/unit/domains/conversation/v1/test_conversation_messages.py +++ b/tests/unit/domains/conversation/v1/test_conversation_messages.py @@ -5,13 +5,19 @@ import pytest from sinch.domains.conversation.conversation import Conversation from sinch.domains.conversation.api.v1 import Messages +from sinch.core.pagination import TokenBasedPaginator from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListMessagesEndpoint, SendMessageEndpoint, UpdateMessageMetadataEndpoint, ) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, @@ -84,6 +90,36 @@ def test_messages_delete_with_messages_source_expects_correct_request( assert kwargs["request_data"].messages_source == "DISPATCH_SOURCE" +def test_messages_list_expects_correct_request( + mock_sinch_client_conversation, mocker +): + """ + Test that the Messages.list() method sends the correct request + and handles the response properly. + """ + mock_response = ListMessagesResponse(messages=[], next_page_token=None) + mock_sinch_client_conversation.configuration.transport.request.return_value = ( + mock_response + ) + + # Spy on the ListMessagesEndpoint to capture calls + spy_endpoint = mocker.spy(ListMessagesEndpoint, "__init__") + + conversation = Conversation(mock_sinch_client_conversation) + response = conversation.messages.list(page_size=10) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"] == ListMessagesRequest(page_size=10) + + assert isinstance(response, TokenBasedPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_conversation.configuration.transport.request.assert_called_once() + + def test_messages_get_expects_correct_request( mock_sinch_client_conversation, mock_conversation_message_response, mocker ): From 1c6114ca8b7a1697207d1bfad7d74d0797e62dd7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 5 Feb 2026 15:56:02 +0100 Subject: [PATCH 088/106] DEVEXP-1241: Conversation Messages - List (Unit Tests & Snippet) (#120) --- .../conversation/messages/list/snippet.py | 37 ++++++ .../messages/test_list_messages_endpoint.py | 115 ++++++++++++++++++ .../request/test_list_messages_request.py | 41 +++++++ .../internal/test_list_messages_response.py | 39 ++++++ 4 files changed, 232 insertions(+) create mode 100644 examples/snippets/conversation/messages/list/snippet.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py new file mode 100644 index 00000000..7f81d1dc --- /dev/null +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -0,0 +1,37 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# The ID of the Conversation App to list messages from +app_id = "CONVERSATION_APP_ID" + +messages = sinch_client.conversation.messages.list( + app_id=app_id, + page_size=10 +) + +page_counter = 1 +while True: + print(f"Page {page_counter} List of Messages: {messages}") + + if not messages.has_next_page: + break + + messages = messages.next_page() + page_counter += 1 diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py new file mode 100644 index 00000000..e6eb805e --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_messages_endpoint.py @@ -0,0 +1,115 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import ListMessagesEndpoint +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, +) + + +@pytest.fixture +def request_data(): + return ListMessagesRequest(page_size=10) + + +@pytest.fixture +def endpoint(request_data): + return ListMessagesEndpoint("test_project_id", request_data) + + +@pytest.fixture +def mock_list_messages_response(contact_message_response_data): + return HTTPResponse( + status_code=200, + body={ + "messages": [contact_message_response_data], + "next_page_token": "token_next_page_abc", + }, + headers={"Content-Type": "application/json"}, + ) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly (no path params beyond project_id).""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages" + ) + + +def test_build_query_params_expects_excludes_unset_fields(): + """Test that query params only include non-None fields.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["page_size"] == 10 + assert "conversation_id" not in query_params + + +def test_build_query_params_expects_parsed_params(): + """Test that all query param fields are serialized when set.""" + request_data = ListMessagesRequest( + conversation_id="CONV123", + contact_id="CONTACT456", + app_id="APP789", + channel_identity="+46701234567", + page_size=20, + page_token="token_xyz", + view="WITH_METADATA", + messages_source="DISPATCH_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_APP", + ) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + + query_params = endpoint.build_query_params() + + assert query_params["conversation_id"] == "CONV123" + assert query_params["contact_id"] == "CONTACT456" + assert query_params["app_id"] == "APP789" + assert query_params["channel_identity"] == "+46701234567" + assert query_params["page_size"] == 20 + assert query_params["page_token"] == "token_xyz" + assert query_params["view"] == "WITH_METADATA" + assert query_params["messages_source"] == "DISPATCH_SOURCE" + assert query_params["only_recipient_originated"] is True + assert query_params["channel"] == "WHATSAPP" + assert query_params["direction"] == "TO_APP" + + +def test_handle_response_expects_list_messages_response( + endpoint, mock_list_messages_response +): + """Test that a successful response is parsed to ListMessagesResponse.""" + result = endpoint.handle_response(mock_list_messages_response) + + assert isinstance(result, ListMessagesResponse) + assert result.next_page_token == "token_next_page_abc" + assert result.messages is not None + assert len(result.messages) == 1 + assert result.messages[0].id == "CAPY123456789ABCDEFGHIJKLMNOP" + + +def test_handle_response_expects_empty_messages_list(): + """Test that response with empty messages list is handled correctly.""" + request_data = ListMessagesRequest(page_size=10) + endpoint = ListMessagesEndpoint("test_project_id", request_data) + mock_response = HTTPResponse( + status_code=200, + body={"messages": [], "next_page_token": None}, + headers={"Content-Type": "application/json"}, + ) + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListMessagesResponse) + assert result.messages == [] + assert result.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py new file mode 100644 index 00000000..03fb01b7 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_messages_request.py @@ -0,0 +1,41 @@ +from datetime import datetime, timezone + +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListMessagesRequest, +) + + +def test_list_messages_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + request = ListMessagesRequest( + conversation_id="CONV123456789ABCDEFGHIJKLM", + contact_id="CONTACT456789ABCDEFGHIJKLMNOP", + app_id="APP123456789ABCDEFGHIJK", + channel_identity="+46701234567", + start_time=start, + end_time=end, + page_size=50, + page_token="next_page_token_abc", + view="WITH_METADATA", + messages_source="CONVERSATION_SOURCE", + only_recipient_originated=True, + channel="WHATSAPP", + direction="TO_CONTACT", + ) + + assert request.conversation_id == "CONV123456789ABCDEFGHIJKLM" + assert request.contact_id == "CONTACT456789ABCDEFGHIJKLMNOP" + assert request.app_id == "APP123456789ABCDEFGHIJK" + assert request.channel_identity == "+46701234567" + assert request.start_time == start + assert request.end_time == end + assert request.page_size == 50 + assert request.page_token == "next_page_token_abc" + assert request.view == "WITH_METADATA" + assert request.messages_source == "CONVERSATION_SOURCE" + assert request.only_recipient_originated is True + assert request.channel == "WHATSAPP" + assert request.direction == "TO_CONTACT" diff --git a/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py new file mode 100644 index 00000000..8ecc2d5e --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/test_list_messages_response.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, + app_message_response_data, +) + + +def test_list_messages_response_expects_correct_mapping( + contact_message_response_data, + app_message_response_data, +): + """ + Test that response is correctly parsed from dict and + content property returns messages. + """ + data = { + "messages": [contact_message_response_data, app_message_response_data], + "next_page_token": "token_abc", + } + response = ListMessagesResponse.model_validate(data) + + assert response.next_page_token == "token_abc" + assert response.messages is not None + assert len(response.messages) == 2 + assert response.messages[0].id == contact_message_response_data["id"] + assert response.messages[1].id == app_message_response_data["id"] + assert response.content == response.messages + assert len(response.content) == 2 + + +def test_list_messages_response_expects_empty_messages_list(): + """Test that response with empty messages list has content as empty list.""" + response = ListMessagesResponse(messages=[], next_page_token=None) + + assert response.messages == [] + assert response.content == [] + assert response.next_page_token is None From afdcd1750c0d071a004d5af50c3b4f57d92510e5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 16 Feb 2026 10:04:06 +0100 Subject: [PATCH 089/106] DEVEXP-1266: Update CI (#121) Co-authored-by: Antoine SEIN <142824551+asein-sinch@users.noreply.github.com> --- .github/workflows/ci.yml | 16 ++ .../available_numbers/rent_any/snippet.py | 2 +- scripts/check_snippet_coverage.py | 101 ++++++++++++ tests/unit/test_check_snippet_coverage.py | 147 ++++++++++++++++++ 4 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 scripts/check_snippet_coverage.py create mode 100644 tests/unit/test_check_snippet_coverage.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49967804..388b22b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,8 +35,22 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip + pip install -e . pip install -r requirements-dev.txt + - name: Compile all examples + run: | + for file in $(find examples -name "*.py"); do + echo "Compiling $file..." + python -m py_compile "$file" || exit 1 + done + echo "All examples compiled successfully." + + - name: Check snippet coverage + run: | + pip install python-dotenv + python scripts/check_snippet_coverage.py + - name: Lint and format check with Ruff run: | ruff check sinch/domains/numbers --statistics @@ -98,3 +112,5 @@ jobs: python -m behave tests/e2e/sms/features python -m behave tests/e2e/conversation/features python -m behave tests/e2e/number-lookup/features + + \ No newline at end of file diff --git a/examples/snippets/numbers/available_numbers/rent_any/snippet.py b/examples/snippets/numbers/available_numbers/rent_any/snippet.py index b2d57247..a7ce7a60 100644 --- a/examples/snippets/numbers/available_numbers/rent_any/snippet.py +++ b/examples/snippets/numbers/available_numbers/rent_any/snippet.py @@ -25,7 +25,7 @@ response = sinch_client.numbers.rent_any( region_code="US", - type_="LOCAL", + number_type="LOCAL", capabilities=["SMS", "VOICE"], sms_configuration=sms_configuration ) diff --git a/scripts/check_snippet_coverage.py b/scripts/check_snippet_coverage.py new file mode 100644 index 00000000..98167e05 --- /dev/null +++ b/scripts/check_snippet_coverage.py @@ -0,0 +1,101 @@ +""" +Validate that snippets have valid syntax, working imports, and reference existing SDK methods by executing them until the first outbound API call. +""" +import argparse +import os +import sys +from pathlib import Path +from unittest.mock import patch + +for var in [ + "SINCH_PROJECT_ID", "SINCH_KEY_ID", "SINCH_KEY_SECRET", + "SINCH_SMS_REGION", "SINCH_CONVERSATION_REGION", + "SINCH_PHONE_NUMBER", "SINCH_SERVICE_PLAN_ID", +]: + os.environ.setdefault(var, "test") + + +class SnippetValidationComplete(Exception): + """Raised when snippet successfully reaches first API call.""" + + +def validate_snippet(snippet_path: Path, quiet: bool = True) -> tuple[bool, str]: + """Run snippet; success when it reaches the first API call.""" + def mock_request(self, endpoint): + raise SnippetValidationComplete() + + try: + with patch( + "sinch.core.adapters.requests_http_transport.HTTPTransportRequests.request", + mock_request, + ): + with open(snippet_path) as f: + source = f.read() + if quiet: + with open(os.devnull, "w") as devnull: + old_stdout, old_stderr = sys.stdout, sys.stderr + sys.stdout, sys.stderr = devnull, devnull + try: + exec(source, {"__name__": "__main__"}) + finally: + sys.stdout, sys.stderr = old_stdout, old_stderr + else: + exec(source, {"__name__": "__main__"}) + return False, "Snippet ran without making API call" + except SnippetValidationComplete: + return True, "" + except ModuleNotFoundError as e: + return False, f"Broken import: {e}" + except ImportError as e: + return False, f"Broken import: {e}" + except AttributeError as e: + return False, f"Method/attribute does not exist: {e}" + except SyntaxError as e: + return False, f"Syntax error: {e}" + except Exception as e: + return False, f"{type(e).__name__}: {e}" + + +def main(): + parser = argparse.ArgumentParser( + description="Validate snippets (imports, syntax, SDK method names)" + ) + parser.add_argument("-q", "--quiet", action="store_true", help="Only print failures") + args = parser.parse_args() + + root = Path(__file__).parent.parent + os.chdir(root) + + snippets_dir = root / "examples" / "snippets" + if not snippets_dir.exists(): + print("ERROR: examples/snippets directory not found") + return 1 + + snippet_files = list(snippets_dir.rglob("snippet.py")) + if not snippet_files: + print("ERROR: No snippet.py files found") + return 1 + + failed = [] + for snippet_path in sorted(snippet_files): + rel_path = snippet_path.relative_to(root) + success, error = validate_snippet(snippet_path, quiet=args.quiet) + if success: + if not args.quiet: + print(f" OK {rel_path}") + else: + print(f" FAIL {rel_path}\n {error}") + failed.append((rel_path, error)) + + if failed: + print(f"\n{len(failed)} snippet(s) failed validation:") + for path, err in failed: + print(f" - {path}: {err}") + return 1 + + print(f"\nAll {len(snippet_files)} snippets validated successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_check_snippet_coverage.py b/tests/unit/test_check_snippet_coverage.py new file mode 100644 index 00000000..602bacc1 --- /dev/null +++ b/tests/unit/test_check_snippet_coverage.py @@ -0,0 +1,147 @@ +import importlib.util +from pathlib import Path +from textwrap import dedent +import pytest + +ROOT = Path(__file__).parent.parent.parent +_SPEC = importlib.util.spec_from_file_location( + "check_snippet_coverage", + ROOT / "scripts" / "check_snippet_coverage.py", +) +_CHECK_SNIPPET_MOD = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_CHECK_SNIPPET_MOD) +validate_snippet = _CHECK_SNIPPET_MOD.validate_snippet + + +@pytest.fixture +def temp_snippet_dir(tmp_path): + """Temporary directory for snippet files.""" + return tmp_path + + +def test_nonexistent_module_import_expects_failure_with_broken_import_message( + temp_snippet_dir, +): + """Test that importing a nonexistent module returns failure with broken import message.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from nonexistent_module_xyz import foo") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error + assert "nonexistent_module_xyz" in error + + +def test_missing_name_import_from_sinch_expects_failure(temp_snippet_dir): + """Test that importing a missing name from sinch returns failure.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("from sinch import NonExistentClass") + + success, error = validate_snippet(path) + + assert success is False + assert "Broken import" in error or "ImportError" in error + + +def test_nonexistent_sdk_method_expects_attribute_error(temp_snippet_dir): + """Test that calling a nonexistent SDK method returns failure with attribute error.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_nonexistent_method( + to=["+1"], from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "Method/attribute does not exist" in error + assert "send_nonexistent_method" in error + + +def test_invalid_syntax_expects_syntax_error(temp_snippet_dir): + """Test that invalid Python syntax returns failure with syntax error.""" + path = temp_snippet_dir / "snippet.py" + path.write_text("def foo()\n return 42") + + success, error = validate_snippet(path) + + assert success is False + assert "Syntax error" in error + + +def test_snippet_without_api_call_expects_failure(temp_snippet_dir): + """Test that a snippet that does not make an API call returns failure.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + print("no api call") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "without making API call" in error + + +def test_invalid__args_expects_failure(temp_snippet_dir): + """Test that invalid arguments return failure (TypeError or similar).""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms( + to="not_a_list", from_="+1", body="hi" + ) + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is False + assert "TypeError" in error or "AttributeError" in error or len(error) > 0 + + +def test_valid_snippet_expects_success(temp_snippet_dir): + """Test that a valid snippet (inline string) passes validation.""" + snippet = """ + from sinch import SinchClient + + sinch_client = SinchClient( + project_id="my-project-id", + key_id="my-key-id", + key_secret="my-key-secret", + sms_region="us", + ) + sinch_client.sms.batches.send_sms(to=["+1"], from_="+1", body="hi") + """ + path = temp_snippet_dir / "snippet.py" + path.write_text(dedent(snippet)) + + success, error = validate_snippet(path) + + assert success is True, f"Snippet failed: {error}" From 73be4c40e86ded059c090a5d209d50853195bfb7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 23 Feb 2026 19:50:06 +0100 Subject: [PATCH 090/106] DEVEXP-795: Conversation Webhooks (#122) --- .github/workflows/ci.yml | 1 + examples/webhooks/.env.example | 4 +- examples/webhooks/README.md | 18 +- .../webhooks/conversation_api/__init__.py | 0 .../webhooks/conversation_api/controller.py | 30 ++ .../conversation_api/server_business_logic.py | 81 ++++ examples/webhooks/numbers_api/controller.py | 6 +- examples/webhooks/server.py | 4 + examples/webhooks/sms_api/controller.py | 7 +- .../webhooks/v1/webhook_utils.py | 38 +- sinch/domains/conversation/conversation.py | 12 + .../models/v1/webhooks/__init__.py | 39 ++ .../events/conversation_webhook_event.py | 22 + .../events/conversation_webhook_event_base.py | 35 ++ .../webhooks/events/delivery_status_type.py | 14 + .../v1/webhooks/events/inbound_message.py | 20 + .../events/message_delivery_receipt_event.py | 17 + .../events/message_delivery_report.py | 53 +++ .../webhooks/events/message_inbound_event.py | 16 + .../webhooks/events/message_submit_event.py | 16 + .../events/message_submit_notification.py | 47 +++ .../conversation/webhooks/v1/__init__.py | 5 + .../webhooks/v1/conversation_webhooks.py | 124 ++++++ .../webhooks/v1/internal/__init__.py | 5 + .../webhooks/v1/internal/webhook_event.py | 9 + .../numbers/webhooks/v1/numbers_webhooks.py | 32 +- sinch/domains/sms/webhooks/v1/sms_webhooks.py | 30 +- .../features/steps/conversation.steps.py | 30 ++ .../features/steps/webhooks-events.steps.py | 382 ++++++++++++++++++ tests/e2e/helpers.py | 16 + .../numbers/features/steps/webhooks.steps.py | 12 +- .../e2e/sms/features/steps/webhooks.steps.py | 16 +- .../test_conversation_webhooks_event_model.py | 82 ++++ .../v1/webhooks/test_conversation_webhooks.py | 129 ++++++ 34 files changed, 1303 insertions(+), 49 deletions(-) create mode 100644 examples/webhooks/conversation_api/__init__.py create mode 100644 examples/webhooks/conversation_api/controller.py create mode 100644 examples/webhooks/conversation_api/server_business_logic.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/__init__.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py create mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py create mode 100644 sinch/domains/conversation/webhooks/v1/__init__.py create mode 100644 sinch/domains/conversation/webhooks/v1/conversation_webhooks.py create mode 100644 sinch/domains/conversation/webhooks/v1/internal/__init__.py create mode 100644 sinch/domains/conversation/webhooks/v1/internal/webhook_event.py create mode 100644 tests/e2e/conversation/features/steps/webhooks-events.steps.py create mode 100644 tests/e2e/helpers.py create mode 100644 tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py create mode 100644 tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b22b2..afac0688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,7 @@ jobs: cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ + cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/ - name: Wait for mock server run: .github/scripts/wait-for-mockserver.sh diff --git a/examples/webhooks/.env.example b/examples/webhooks/.env.example index 02e98c4b..13561254 100644 --- a/examples/webhooks/.env.example +++ b/examples/webhooks/.env.example @@ -6,4 +6,6 @@ SERVER_PORT = # See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/ NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET # See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks -SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET \ No newline at end of file +SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET +# See https://developers.sinch.com/docs/conversation/callbacks +CONVERSATION_WEBHOOKS_SECRET = CONVERSATION_WEBHOOKS_SECRET \ No newline at end of file diff --git a/examples/webhooks/README.md b/examples/webhooks/README.md index 8d9674c5..c2f88a45 100644 --- a/examples/webhooks/README.md +++ b/examples/webhooks/README.md @@ -6,6 +6,7 @@ to process incoming webhooks from Sinch services. The webhook handlers are organized by service: - **SMS**: Handlers for SMS webhook events (`sms_api/`) - **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`) +- **Conversation**: Handlers for Conversation API webhook events (`conversation_api/`) This directory contains both the webhook handlers and the server application (`server.py`) that uses them. @@ -39,6 +40,10 @@ This directory contains both the webhook handlers and the server application (`s ``` SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret ``` + - Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): + ``` + CONVERSATION_WEBHOOKS_SECRET=Your Conversation Webhook Secret + ``` ## Usage @@ -69,10 +74,11 @@ The server will start on the port specified in your `.env` file (default: 3001). The server exposes the following endpoints: -| Service | Endpoint | -|--------------|--------------------| -| Numbers | /NumbersEvent | -| SMS | /SmsEvent | +| Service | Endpoint | +|--------------|----------------------| +| Numbers | /NumbersEvent | +| SMS | /SmsEvent | +| Conversation | /ConversationEvent | ## Using ngrok to expose your local server @@ -93,10 +99,12 @@ Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http Use the `https` forwarding URL in your callback configuration. For example: - Numbers: https://adbd-79-148-170-158.ngrok-free.app/NumbersEvent - SMS: https://adbd-79-148-170-158.ngrok-free.app/SmsEvent + - Conversation: https://adbd-79-148-170-158.ngrok-free.app/ConversationEvent Use this value to configure the callback URLs: - **Numbers**: Set the `callback_url` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)). -- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L147), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. +- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L146), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. +- **Conversation**: Set the `callback_url` parameter when sending a message via the SDK (see `messages_apis` example: [send_text_message](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/conversation/api/v1/messages_apis.py#L420)). You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there. diff --git a/examples/webhooks/conversation_api/__init__.py b/examples/webhooks/conversation_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/webhooks/conversation_api/controller.py b/examples/webhooks/conversation_api/controller.py new file mode 100644 index 00000000..74e47888 --- /dev/null +++ b/examples/webhooks/conversation_api/controller.py @@ -0,0 +1,30 @@ +from flask import request, Response +from webhooks.conversation_api.server_business_logic import handle_conversation_event + + +class ConversationController: + def __init__(self, sinch_client, webhooks_secret): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = request.raw_body if request.raw_body else b"" + + webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret) + + # Set to True to enforce signature validation (recommended in production) + ensure_valid_signature = False + if ensure_valid_signature: + valid = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + if not valid: + return Response(status=401) + + event = webhooks_service.parse_event(raw_body, headers) + handle_conversation_event(event=event, logger=self.logger) + + return Response(status=200) diff --git a/examples/webhooks/conversation_api/server_business_logic.py b/examples/webhooks/conversation_api/server_business_logic.py new file mode 100644 index 00000000..03ef74e9 --- /dev/null +++ b/examples/webhooks/conversation_api/server_business_logic.py @@ -0,0 +1,81 @@ +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +def handle_conversation_event(event: ConversationWebhookEventBase, logger): + """ + Dispatch a Conversation webhook event to the appropriate handler by trigger type. + + :param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). + :param logger: Logger instance for output. + """ + if isinstance(event, MessageInboundEvent): + _handle_message_inbound(event, logger) + elif isinstance(event, MessageDeliveryReceiptEvent): + _handle_message_delivery(event, logger) + elif isinstance(event, MessageSubmitEvent): + _handle_message_submit(event, logger) + else: + logger.debug("Event: %s", event.model_dump_json(indent=2) if hasattr(event, "model_dump_json") else event) + + +def _handle_message_inbound(event: MessageInboundEvent, logger): + """Handle MESSAGE_INBOUND: log inbound message.""" + logger.info("## MESSAGE_INBOUND") + msg = event.message + contact_msg = msg.contact_message + channel_identity = msg.channel_identity + contact_id = msg.contact_id + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "A new message has been received on the channel '%s' (identity: %s) from the contact ID '%s'", + channel, + identity, + contact_id, + ) + if contact_msg: + if hasattr(contact_msg, "text_message") and contact_msg.text_message: + logger.info("Text: %s", contact_msg.text_message.text) + elif hasattr(contact_msg, "media_message") and contact_msg.media_message: + logger.info("Media: %s", getattr(contact_msg.media_message, "url", contact_msg.media_message)) + elif hasattr(contact_msg, "fallback_message") and contact_msg.fallback_message: + logger.info("Fallback: %s", contact_msg.fallback_message) + else: + logger.info("Contact message: %s", contact_msg) + + +def _handle_message_delivery(event: MessageDeliveryReceiptEvent, logger): + """Handle MESSAGE_DELIVERY: log delivery status and failure reason if failed.""" + logger.info("## MESSAGE_DELIVERY") + report = event.message_delivery_report + status = report.status + logger.info("Message delivery status: '%s'", status) + if status == "FAILED" and report.reason: + logger.info( + "Reason: %s (%s) - %s", + report.reason.code, + getattr(report.reason, "sub_code", ""), + report.reason.description, + ) + + +def _handle_message_submit(event: MessageSubmitEvent, logger): + """Handle MESSAGE_SUBMIT: log that the message was submitted to the channel.""" + logger.info("## MESSAGE_SUBMIT") + submit_notification = event.message_submit_notification + channel_identity = submit_notification.channel_identity + channel = channel_identity.channel if channel_identity else "?" + identity = channel_identity.identity if channel_identity else "?" + logger.info( + "The following message has been submitted on the channel '%s' (identity: %s) to the contact ID '%s'", + channel, + identity, + submit_notification.contact_id, + ) + if submit_notification.submitted_message: + logger.debug("Submitted message: %s", submit_notification.submitted_message) diff --git a/examples/webhooks/numbers_api/controller.py b/examples/webhooks/numbers_api/controller.py index afe5666a..2380eda4 100644 --- a/examples/webhooks/numbers_api/controller.py +++ b/examples/webhooks/numbers_api/controller.py @@ -10,7 +10,7 @@ def __init__(self, sinch_client, webhooks_secret): def numbers_event(self): headers = dict(request.headers) - body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + raw_body = request.raw_body if request.raw_body else b"" webhooks_service = self.sinch_client.numbers.webhooks(self.webhooks_secret) @@ -18,13 +18,13 @@ def numbers_event(self): if ensure_valid_authentication: valid_auth = webhooks_service.validate_authentication_header( headers=headers, - json_payload=body_str + json_payload=raw_body, ) if not valid_auth: return Response(status=401) - event = webhooks_service.parse_event(body_str) + event = webhooks_service.parse_event(raw_body, headers) handle_numbers_event(numbers_event=event, logger=self.logger) diff --git a/examples/webhooks/server.py b/examples/webhooks/server.py index d7f6f1ca..98caa89a 100644 --- a/examples/webhooks/server.py +++ b/examples/webhooks/server.py @@ -10,6 +10,7 @@ from flask import Flask, request from webhooks.numbers_api.controller import NumbersController from webhooks.sms_api.controller import SmsController +from webhooks.conversation_api.controller import ConversationController from webhooks.sinch_client_helper import get_sinch_client, load_config app = Flask(__name__) @@ -18,6 +19,7 @@ port = int(config.get('SERVER_PORT') or 3001) numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET') sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET') +conversation_webhooks_secret = config.get('CONVERSATION_WEBHOOKS_SECRET') sinch_client = get_sinch_client(config) # Set up logging at the INFO level @@ -26,6 +28,7 @@ numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret) sms_controller = SmsController(sinch_client, sms_webhooks_secret) +conversation_controller = ConversationController(sinch_client, conversation_webhooks_secret or '') # Middleware to capture raw body @@ -36,6 +39,7 @@ def before_request(): app.add_url_rule('/NumbersEvent', methods=['POST'], view_func=numbers_controller.numbers_event) app.add_url_rule('/SmsEvent', methods=['POST'], view_func=sms_controller.sms_event) +app.add_url_rule('/ConversationEvent', methods=['POST'], view_func=conversation_controller.conversation_event) if __name__ == '__main__': app.run(port=port) diff --git a/examples/webhooks/sms_api/controller.py b/examples/webhooks/sms_api/controller.py index dbebd1de..b5bd50f5 100644 --- a/examples/webhooks/sms_api/controller.py +++ b/examples/webhooks/sms_api/controller.py @@ -12,8 +12,7 @@ def __init__(self, sinch_client, webhooks_secret): def sms_event(self): headers = dict(request.headers) - - body_str = request.raw_body.decode('utf-8') if request.raw_body else '' + raw_body = request.raw_body if request.raw_body else b"" webhooks_service = self.sinch_client.sms.webhooks(self.webhooks_secret) @@ -24,13 +23,13 @@ def sms_event(self): if ensure_valid_authentication: valid_auth = webhooks_service.validate_authentication_header( headers=headers, - json_payload=body_str + json_payload=raw_body, ) if not valid_auth: return Response(status=401) - event = webhooks_service.parse_event(body_str) + event = webhooks_service.parse_event(raw_body, headers) handle_sms_event(sms_event=event, logger=self.logger) diff --git a/sinch/domains/authentication/webhooks/v1/webhook_utils.py b/sinch/domains/authentication/webhooks/v1/webhook_utils.py index 5d05b88a..c65441aa 100644 --- a/sinch/domains/authentication/webhooks/v1/webhook_utils.py +++ b/sinch/domains/authentication/webhooks/v1/webhook_utils.py @@ -1,7 +1,43 @@ import json import re from datetime import datetime -from typing import Any, Dict +from typing import Any, Dict, Optional, Union + + +def _content_type_from_headers(headers: Optional[Dict[str, str]]) -> str: + """Get Content-Type from headers dict (case-insensitive).""" + if not headers: + return "" + return headers.get("content-type") or headers.get("Content-Type") or "" + + +def _charset_from_content_type(content_type: str) -> str: + """Extract charset from Content-Type header; default to utf-8 if missing.""" + if not content_type: + return "utf-8" + match = re.search(r"charset\s*=\s*([^\s;]+)", content_type, re.I) + return match.group(1).strip("'\"").lower() if match else "utf-8" + + +def decode_payload( + payload: Union[str, bytes], headers: Optional[Dict[str, str]] = None +) -> str: + """ + Decode request body to str using Content-Type charset when payload is bytes. + + When payload is str, return as-is. When bytes, use charset from headers + (default utf-8); + """ + if isinstance(payload, str): + return payload + if not payload: + return "" + content_type = _content_type_from_headers(headers) + charset = _charset_from_content_type(content_type) + try: + return payload.decode(charset) + except (LookupError, UnicodeDecodeError): + raise def parse_json(payload: str) -> Dict[str, Any]: diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py index 91599d8b..b85cff21 100644 --- a/sinch/domains/conversation/conversation.py +++ b/sinch/domains/conversation/conversation.py @@ -1,6 +1,7 @@ from sinch.domains.conversation.api.v1 import ( Messages, ) +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks class Conversation: @@ -12,3 +13,14 @@ class Conversation: def __init__(self, sinch): self._sinch = sinch self.messages = Messages(self._sinch) + + def webhooks(self, callback_secret: str) -> ConversationWebhooks: + """ + Create a Conversation API webhooks handler with the given webhook secret. + + :param callback_secret: Secret used for webhook signature validation. + :type callback_secret: str + :returns: A configured webhooks handler. + :rtype: ConversationWebhooks + """ + return ConversationWebhooks(callback_secret) diff --git a/sinch/domains/conversation/models/v1/webhooks/__init__.py b/sinch/domains/conversation/models/v1/webhooks/__init__.py new file mode 100644 index 00000000..bf06e529 --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/__init__.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event import ( + ConversationWebhookEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( + InboundMessage, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( + MessageDeliveryReport, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( + MessageSubmitEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( + MessageSubmitNotification, +) + +__all__ = [ + "ConversationWebhookEvent", + "ConversationWebhookEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "DeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py new file mode 100644 index 00000000..8a7d07dd --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py @@ -0,0 +1,22 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( + MessageSubmitEvent, +) + + +ConversationWebhookEvent = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationWebhookEventBase, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py new file mode 100644 index 00000000..26cf3eea --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent + + +class ConversationWebhookEventBase(WebhookEvent): + """Base fields present on every Conversation API webhook payload.""" + + app_id: Optional[StrictStr] = Field( + default=None, + description="Id of the subscribed app.", + ) + project_id: Optional[StrictStr] = Field( + default=None, + description="The project ID of the app which has subscribed for the callback.", + ) + accepted_time: Optional[datetime] = Field( + default=None, + description="Timestamp when the channel callback was accepted by the Conversation API.", + ) + event_time: Optional[datetime] = Field( + default=None, + description="Timestamp of the event as provided by the underlying channels.", + ) + message_metadata: Optional[StrictStr] = Field( + default=None, + description="Context-dependent metadata.", + ) + correlation_id: Optional[StrictStr] = Field( + default=None, + description="Value from correlation_id of the send message request.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py b/sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py new file mode 100644 index 00000000..a220270b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py @@ -0,0 +1,14 @@ +from typing import Literal, Union +from pydantic import StrictStr + + +DeliveryStatusType = Union[ + Literal[ + "QUEUED_ON_CHANNEL", + "DELIVERED", + "READ", + "FAILED", + "SWITCHING_CHANNEL", + ], + StrictStr, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py b/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py new file mode 100644 index 00000000..2b4d45ec --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py @@ -0,0 +1,20 @@ +from typing import Optional + +from pydantic import Field + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( + MessageCommonProps, +) +from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( + ContactMessage, +) + + +class InboundMessage(MessageCommonProps, WebhookEvent): + """Inbound message container (contact message + channel/contact info).""" + + contact_message: Optional[ContactMessage] = Field( + default=None, + description="The contact (inbound) message content.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py new file mode 100644 index 00000000..79ef1a9b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( + MessageDeliveryReport, +) + + +class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" + + message_delivery_report: MessageDeliveryReport = Field( + default=None, + description="The delivery report payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py new file mode 100644 index 00000000..222e3bba --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py @@ -0,0 +1,53 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared import ( + ChannelIdentity, + Reason, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) + +from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( + DeliveryStatusType, +) + + +class MessageDeliveryReport(WebhookEvent): + """Delivery report for an app message (MESSAGE_DELIVERY trigger).""" + + message_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the message.", + ) + conversation_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the conversation.", + ) + status: Optional[DeliveryStatusType] = Field( + default=None, + description="Shows the status of the message or event delivery.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="Channel identity of the recipient.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact.", + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata associated with the message.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Processing mode (CONVERSATION or DISPATCH).", + ) + reason: Optional[Reason] = Field( + default=None, + description="Reason when status is FAILED.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py new file mode 100644 index 00000000..89732cb7 --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( + InboundMessage, +) + + +class MessageInboundEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_INBOUND (inbound message from user).""" + + message: InboundMessage = Field( + description="The inbound message payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py new file mode 100644 index 00000000..6e539c9b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( + ConversationWebhookEventBase, +) +from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( + MessageSubmitNotification, +) + + +class MessageSubmitEvent(ConversationWebhookEventBase): + """Webhook event for MESSAGE_SUBMIT (message submission notification).""" + + message_submit_notification: MessageSubmitNotification = Field( + description="The message submit notification payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py new file mode 100644 index 00000000..69ac499b --- /dev/null +++ b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py @@ -0,0 +1,47 @@ +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.models.v1.messages.shared import ( + ChannelIdentity, +) +from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( + AppMessage, +) +from sinch.domains.conversation.models.v1.messages.types.processing_mode_type import ( + ProcessingModeType, +) + + +class MessageSubmitNotification(WebhookEvent): + """Notification that an app message was submitted (MESSAGE_SUBMIT trigger).""" + + message_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the app message.", + ) + conversation_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the conversation. Empty if processing_mode is DISPATCH.", + ) + channel_identity: Optional[ChannelIdentity] = Field( + default=None, + description="Channel identity of the recipient.", + ) + contact_id: Optional[StrictStr] = Field( + default=None, + description="The ID of the contact. Empty if processing_mode is DISPATCH.", + ) + submitted_message: Optional[AppMessage] = Field( + default=None, + description="The submitted app message content (AppMessage).", + ) + metadata: Optional[StrictStr] = Field( + default=None, + description="Metadata from message_metadata of the Send Message request.", + ) + processing_mode: Optional[ProcessingModeType] = Field( + default=None, + description="Processing mode (CONVERSATION or DISPATCH).", + ) diff --git a/sinch/domains/conversation/webhooks/v1/__init__.py b/sinch/domains/conversation/webhooks/v1/__init__.py new file mode 100644 index 00000000..37b2f39f --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.webhooks.v1.conversation_webhooks import ( + ConversationWebhooks, +) + +__all__ = ["ConversationWebhooks"] diff --git a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py new file mode 100644 index 00000000..2acadc98 --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py @@ -0,0 +1,124 @@ +import logging +from typing import Any, Dict, Union, Optional +from sinch.domains.authentication.webhooks.v1.authentication_validation import ( + validate_webhook_signature_with_nonce, +) +from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + decode_payload, + parse_json, + normalize_iso_timestamp, +) +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +logger = logging.getLogger(__name__) + + +ConversationWebhookCallback = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationWebhookEventBase, +] + + +class ConversationWebhooks: + """ + Handler for Conversation API webhooks: validate signature and parse events. + """ + + def __init__(self, webhook_secret: Optional[str] = None): + """ + :param webhook_secret: Secret configured for the webhook (used for HMAC validation). + """ + self.webhook_secret = webhook_secret + + def _validate_signature( + self, + payload: Union[str, bytes], + headers: Dict[str, str], + webhook_secret: Optional[str] = None, + ) -> bool: + """ + Validate the webhook signature using the request body and headers. + + Uses x-sinch-webhook-signature, x-sinch-webhook-signature-nonce, and + x-sinch-webhook-signature-timestamp. Returns True only if the signature + is valid. + + :param payload: Raw request body (string or bytes). + :param headers: Incoming request headers (key case is normalized to lower). + :param webhook_secret: Secret for this webhook; defaults to the secret passed to __init__. + :returns: True if the signature is valid, False otherwise. + """ + secret = ( + webhook_secret + if webhook_secret is not None + else self.webhook_secret + ) + if not secret: + return False + payload_str = decode_payload(payload, headers) + return validate_webhook_signature_with_nonce( + secret, headers, payload_str + ) + + def validate_authentication_header( + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], + ) -> bool: + """ + Validate the webhook signature (convenience wrapper around internal validation). + + :param headers: Incoming request's headers. + :param json_payload: Incoming request's raw body (str or bytes). + :returns: True if the X-Sinch-Webhook-Signature header is valid. + """ + return self._validate_signature(json_payload, headers) + + def parse_event( + self, + event_body: Union[str, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, + ) -> ConversationWebhookCallback: + """ + Parse the webhook payload into a typed event. + + Parses by key: message_delivery_report → MessageDeliveryReceiptEvent, + message → MessageInboundEvent, message_submit_notification → MessageSubmitEvent. + Normalizes accepted_time and event_time. Injects trigger on the returned event. + + :param event_body: JSON string, raw bytes, or dict of the webhook body. + :param headers: Request headers (used to decode charset when event_body is bytes). + :returns: Parsed event model. + :raises ValueError: If JSON parsing fails or the payload is invalid. + """ + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): + event_body = parse_json(event_body) + + # Normalize timestamp fields + for key in ("accepted_time", "event_time"): + if key in event_body and isinstance(event_body[key], str): + event_body[key] = normalize_iso_timestamp(event_body[key]) + + # Type is determined by which key is present (message_delivery_report, message, + # message_submit_notification). + if "message_delivery_report" in event_body: + return MessageDeliveryReceiptEvent(**event_body) + if "message" in event_body: + return MessageInboundEvent(**event_body) + if "message_submit_notification" in event_body: + return MessageSubmitEvent(**event_body) + + logger.warning( + "Conversation webhook: unknown event type; returning base event." + ) + return ConversationWebhookEventBase(**event_body) diff --git a/sinch/domains/conversation/webhooks/v1/internal/__init__.py b/sinch/domains/conversation/webhooks/v1/internal/__init__.py new file mode 100644 index 00000000..37ec3ff5 --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.webhooks.v1.internal.webhook_event import ( + WebhookEvent, +) + +__all__ = ["WebhookEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py b/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py new file mode 100644 index 00000000..3c2e597e --- /dev/null +++ b/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py @@ -0,0 +1,9 @@ +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class WebhookEvent(BaseModelConfiguration): + """Base model for Conversation API webhook events.""" + + pass diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py index b324ceeb..b804fb74 100644 --- a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py +++ b/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py @@ -1,8 +1,9 @@ -from typing import Any, Dict, Union +from typing import Any, Dict, Optional, Union from sinch.domains.authentication.webhooks.v1.authentication_validation import ( validate_signature_header, ) from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + decode_payload, parse_json, normalize_iso_timestamp, ) @@ -14,24 +15,33 @@ def __init__(self, callback_secret: str): self.callback_secret = callback_secret def validate_authentication_header( - self, headers: Dict[str, str], json_payload: str + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], ) -> bool: """ Validate the authorization header for a callback request :param headers: Incoming request's headers :type headers: Dict[str, str] - :param json_payload: Incoming request's raw body - :type json_payload: str + :param json_payload: Incoming request's raw body (str or bytes) + :type json_payload: Union[str, bytes] :returns: True if the X-Sinch-Signature header is valid :rtype: bool """ + payload_str = ( + decode_payload(json_payload, headers) + if isinstance(json_payload, bytes) + else json_payload + ) return validate_signature_header( - self.callback_secret, headers, json_payload + self.callback_secret, headers, payload_str ) def parse_event( - self, event_body: Union[str, Dict[str, Any]] + self, + event_body: Union[str, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, ) -> NumbersWebhooksEvent: """ Parses the event payload into a NumbersWebhooksEvent object. @@ -40,12 +50,16 @@ def parse_event( the ``timestamp`` field. If the timezone is missing, the method assumes UTC and returns a timezone-aware ``datetime`` object. - :param event_body: The event payload. - :type event_body: Union[str, Dict[str, Any]] + :param event_body: The event payload (JSON string, raw bytes, or dict). + :type event_body: Union[str, bytes, Dict[str, Any]] + :param headers: Request headers (used to decode charset when event_body is bytes). + :type headers: Optional[Dict[str, str]] :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. :rtype: NumbersWebhooksEvent """ - if isinstance(event_body, str): + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): event_body = parse_json(event_body) timestamp = event_body.get("timestamp") if timestamp: diff --git a/sinch/domains/sms/webhooks/v1/sms_webhooks.py b/sinch/domains/sms/webhooks/v1/sms_webhooks.py index d5f26563..c64dde6e 100644 --- a/sinch/domains/sms/webhooks/v1/sms_webhooks.py +++ b/sinch/domains/sms/webhooks/v1/sms_webhooks.py @@ -5,6 +5,7 @@ validate_webhook_signature_with_nonce, ) from sinch.domains.authentication.webhooks.v1.webhook_utils import ( + decode_payload, parse_json, normalize_iso_timestamp, ) @@ -34,26 +35,35 @@ def __init__(self, app_secret: Optional[str] = None): self.app_secret = app_secret def validate_authentication_header( - self, headers: Dict[str, str], json_payload: str + self, + headers: Dict[str, str], + json_payload: Union[str, bytes], ) -> bool: """ Validate the authorization header for a callback request. :param headers: Incoming request's headers :type headers: Dict[str, str] - :param json_payload: Incoming request's raw body - :type json_payload: str + :param json_payload: Incoming request's raw body (str or bytes) + :type json_payload: Union[str, bytes] :returns: True if the X-Sinch-Webhook-Signature header is valid :rtype: bool """ if not self.app_secret: return False + payload_str = ( + decode_payload(json_payload, headers) + if isinstance(json_payload, bytes) + else json_payload + ) return validate_webhook_signature_with_nonce( - self.app_secret, headers, json_payload + self.app_secret, headers, payload_str ) def parse_event( - self, event_body: Union[str, Dict[str, Any]] + self, + event_body: Union[str, bytes, Dict[str, Any]], + headers: Optional[Dict[str, str]] = None, ) -> SmsCallback: """ Parse the event payload into an SMS callback object. @@ -61,13 +71,17 @@ def parse_event( Handles datetime conversion for timestamp fields and routes to the appropriate event type based on the `type` field. - :param event_body: The event payload (JSON string or dict). - :type event_body: Union[str, Dict[str, Any]] + :param event_body: The event payload (JSON string, raw bytes, or dict). + :type event_body: Union[str, bytes, Dict[str, Any]] + :param headers: Request headers (used to decode charset when event_body is bytes). + :type headers: Optional[Dict[str, str]] :returns: A parsed SMS callback object. :rtype: SmsCallback :raises ValueError: If the event type is unknown or parsing fails. """ - if isinstance(event_body, str): + if isinstance(event_body, bytes): + event_body = parse_json(decode_payload(event_body, headers)) + elif isinstance(event_body, str): event_body = parse_json(event_body) event_type = event_body.get("type") diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index db251a59..630c476c 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -142,3 +142,33 @@ def step_validate_page_count(context, count): assert context.pages_iteration == expected_pages_count, ( f'Expected {expected_pages_count} pages, got {context.pages_iteration}' ) + + +@when('I send a request to list the last messages sent to specified channel identities') +def step_list_last_messages_channel_identities(context): + pass + + +@then('the response contains "{count}" last messages sent to specified channel identities') +def step_validate_last_messages_count(context, count): + pass + + +@when('I send a request to list all the last messages sent to specified channel identities') +def step_list_all_last_messages_channel_identities(context): + pass + + +@then('the response list contains "{count}" last messages sent to specified channel identities') +def step_validate_response_list_count(context, count): + pass + + +@when('I iterate manually over the last messages sent to specified channel identities pages') +def step_iterate_last_messages_pages(context): + pass + + +@then('the result contains the data from "{count}" pages of last messages sent to specified channel identities') +def step_validate_last_messages_page_count(context, count): + pass diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py new file mode 100644 index 00000000..7c066f39 --- /dev/null +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -0,0 +1,382 @@ +import requests +from behave import given, when, then +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.models.v1.webhooks import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) +from tests.e2e.helpers import has_key_or_attr, store_webhook_response + + +APP_SECRET = "CactusKnight_SurfsWaves" + + +def process_event(context, response): + store_webhook_response(context, response) + context.event = context.conversation_webhooks.parse_event(context.raw_event) + + +def _fetch_and_process(context, path_suffix): + base_url = context.sinch.configuration.conversation_origin + url = f"{base_url}/webhooks/conversation/{path_suffix}" + response = requests.get(url) + process_event(context, response) + + +@given("the Conversation Webhooks handler is available") +def step_conversation_webhooks_available(context): + context.sinch.configuration.auth_origin = "http://localhost:3014" + context.sinch.configuration.conversation_origin = "http://localhost:3014" + context.conversation_webhooks = ConversationWebhooks(APP_SECRET) + + +# --- CAPABILITY --- +@when('I send a request to trigger a "CAPABILITY" event') +def step_trigger_capability(context): + pass + + +# TODO: Refactor to parameterized step to avoid duplication. +@then('the header of the Conversation event "CAPABILITY" contains a valid signature') +def step_signature_valid_capability(context): + pass + + +@then('the Conversation event describes a "CAPABILITY" event type') +def step_describes_capability_event_type(context): + pass + + +# --- CONTACT_CREATE --- +@when('I send a request to trigger a "CONTACT_CREATE" event') +def step_trigger_contact_create(context): + pass + + +@then('the header of the Conversation event "CONTACT_CREATE" contains a valid signature') +def step_signature_valid_contact_create(context): + pass + + +@then('the Conversation event describes a "CONTACT_CREATE" event type') +def step_describes_contact_create_event_type(context): + pass + + +# --- CONTACT_DELETE --- +@when('I send a request to trigger a "CONTACT_DELETE" event') +def step_trigger_contact_delete(context): + pass + + +@then('the header of the Conversation event "CONTACT_DELETE" contains a valid signature') +def step_signature_valid_contact_delete(context): + pass + + +@then('the Conversation event describes a "CONTACT_DELETE" event type') +def step_describes_contact_delete_event_type(context): + pass + + +# --- CONTACT_MERGE --- +@when('I send a request to trigger a "CONTACT_MERGE" event') +def step_trigger_contact_merge(context): + pass + + +@then('the header of the Conversation event "CONTACT_MERGE" contains a valid signature') +def step_signature_valid_contact_merge(context): + pass + + +@then('the Conversation event describes a "CONTACT_MERGE" event type') +def step_describes_contact_merge_event_type(context): + pass + + +# --- CONTACT_UPDATE --- +@when('I send a request to trigger a "CONTACT_UPDATE" event') +def step_trigger_contact_update(context): + pass + + +@then('the header of the Conversation event "CONTACT_UPDATE" contains a valid signature') +def step_signature_valid_contact_update(context): + pass + + +@then('the Conversation event describes a "CONTACT_UPDATE" event type') +def step_describes_contact_update_event_type(context): + pass + + +# --- CONVERSATION_DELETE --- +@when('I send a request to trigger a "CONVERSATION_DELETE" event') +def step_trigger_conversation_delete(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_DELETE" contains a valid signature') +def step_signature_valid_conversation_delete(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_DELETE" event type') +def step_describes_conversation_delete_event_type(context): + pass + + +# --- CONVERSATION_START --- +@when('I send a request to trigger a "CONVERSATION_START" event') +def step_trigger_conversation_start(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_START" contains a valid signature') +def step_signature_valid_conversation_start(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_START" event type') +def step_describes_conversation_start_event_type(context): + pass + + +# --- CONVERSATION_STOP --- +@when('I send a request to trigger a "CONVERSATION_STOP" event') +def step_trigger_conversation_stop(context): + pass + + +@then('the header of the Conversation event "CONVERSATION_STOP" contains a valid signature') +def step_signature_valid_conversation_stop(context): + pass + + +@then('the Conversation event describes a "CONVERSATION_STOP" event type') +def step_describes_conversation_stop_event_type(context): + pass + + +# --- EVENT_DELIVERY (FAILED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "FAILED" status') +def step_trigger_event_delivery_failed(context): + _fetch_and_process(context, "event-delivery-report/failed") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_event_delivery_failed(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "EVENT_DELIVERY" event type') +def step_describes_event_delivery_event_type(context): + pass + + +@then("the Conversation event describes a FAILED event delivery status and its reason") +def step_check_failed_event_delivery_reason(context): + pass + + +# --- EVENT_DELIVERY (DELIVERED) --- +@when('I send a request to trigger a "EVENT_DELIVERY" event with a "DELIVERED" status') +def step_trigger_event_delivery_delivered(context): + _fetch_and_process(context, "event-delivery-report/succeeded") + + +@then('the header of the Conversation event "EVENT_DELIVERY" with a "DELIVERED" status contains a valid signature') +def step_signature_valid_event_delivery_delivered(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event EVENT_DELIVERY with status DELIVERED" + + +# --- EVENT_INBOUND --- +@when('I send a request to trigger a "EVENT_INBOUND" event') +def step_trigger_event_inbound(context): + pass + + +@then('the header of the Conversation event "EVENT_INBOUND" contains a valid signature') +def step_signature_valid_event_inbound(context): + pass + + +@then('the Conversation event describes a "EVENT_INBOUND" event type') +def step_describes_event_inbound_event_type(context): + pass + + +# --- MESSAGE_DELIVERY (FAILED) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "FAILED" status') +def step_trigger_message_delivery_failed(context): + _fetch_and_process(context, "message-delivery-report/failed") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "FAILED" status contains a valid signature') +def step_signature_valid_message_delivery_failed(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_DELIVERY with status FAILED" + + +@then('the Conversation event describes a "MESSAGE_DELIVERY" event type') +def step_describes_message_delivery_event_type(context): + event = context.event + assert isinstance(event, MessageDeliveryReceiptEvent), ( + f"Expected MessageDeliveryReceiptEvent, got {type(event)}" + ) + assert event.message_delivery_report is not None, "message_delivery_report must be present" + + +@then("the Conversation event describes a FAILED message delivery status and its reason") +def step_check_failed_message_delivery_reason(context): + message_delivery_report = context.event.message_delivery_report + assert message_delivery_report is not None, "message_delivery_report is missing" + assert message_delivery_report.status == "FAILED", ( + f"Expected status 'FAILED', got {message_delivery_report.status!r}" + ) + assert message_delivery_report.reason is not None, "reason is missing for FAILED delivery" + assert message_delivery_report.reason.code == "RECIPIENT_NOT_REACHABLE", ( + f"Expected reason code 'RECIPIENT_NOT_REACHABLE', got {message_delivery_report.reason.code!r}" + ) + + +# --- MESSAGE_DELIVERY (QUEUED_ON_CHANNEL) --- +@when('I send a request to trigger a "MESSAGE_DELIVERY" event with a "QUEUED_ON_CHANNEL" status') +def step_trigger_message_delivery_queued(context): + _fetch_and_process(context, "message-delivery-report/succeeded") + + +@then('the header of the Conversation event "MESSAGE_DELIVERY" with a "QUEUED_ON_CHANNEL" status contains a valid signature') +def step_signature_valid_message_delivery_queued(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_DELIVERY with status QUEUED_ON_CHANNEL" + + +# --- MESSAGE_INBOUND --- +@when('I send a request to trigger a "MESSAGE_INBOUND" event') +def step_trigger_message_inbound(context): + _fetch_and_process(context, "message-inbound") + + +@then('the header of the Conversation event "MESSAGE_INBOUND" contains a valid signature') +def step_signature_valid_message_inbound(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_INBOUND" + + +@then('the Conversation event describes a "MESSAGE_INBOUND" event type') +def step_describes_message_inbound_event_type(context): + event = context.event + assert isinstance(event, MessageInboundEvent), ( + f"Expected MessageInboundEvent, got {type(event)}" + ) + assert event.message is not None, "message must be present" + + +# --- MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION --- +@when('I send a request to trigger a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event') +def step_trigger_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the header of the Conversation event "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" contains a valid signature') +def step_signature_valid_message_inbound_smart_conversation_redaction(context): + pass + + +@then('the Conversation event describes a "MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION" event type') +def step_describes_message_inbound_smart_conversation_redaction_event_type(context): + pass + + +# --- MESSAGE_SUBMIT (media) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "media" message') +def step_trigger_message_submit_media(context): + _fetch_and_process(context, "message-submit/media") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "media" message contains a valid signature') +def step_signature_valid_message_submit_media(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for media message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "media" message') +def step_check_message_submit_media(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "media_message"), ( + "Expected submitted_message.media_message to be present" + ) + + +# --- MESSAGE_SUBMIT (text) --- +@when('I send a request to trigger a "MESSAGE_SUBMIT" event for a "text" message') +def step_trigger_message_submit_text(context): + _fetch_and_process(context, "message-submit/text") + + +@then('the header of the Conversation event "MESSAGE_SUBMIT" for a "text" message contains a valid signature') +def step_signature_valid_message_submit_text(context): + assert context.conversation_webhooks.validate_authentication_header( + context.webhook_headers, context.raw_event + ), "Signature validation failed for event MESSAGE_SUBMIT for text message" + + +@then('the Conversation event describes a "MESSAGE_SUBMIT" event type for a "text" message') +def step_check_message_submit_text(context): + message_submit_event = context.event + assert isinstance(message_submit_event, MessageSubmitEvent), ( + f"Expected MessageSubmitEvent, got {type(message_submit_event)}" + ) + assert message_submit_event.message_submit_notification is not None + submitted = message_submit_event.message_submit_notification.submitted_message + assert has_key_or_attr(submitted, "text_message"), ( + "Expected submitted_message.text_message to be present" + ) + + +# --- SMART_CONVERSATIONS (media) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "media" message') +def step_trigger_smart_conversations_media(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "media" message contains a valid signature') +def step_signature_valid_smart_conversations_media(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "media" message') +def step_check_smart_conversations_media(context): + pass + + +# --- SMART_CONVERSATIONS (text) --- +@when('I send a request to trigger a "SMART_CONVERSATIONS" event for a "text" message') +def step_trigger_smart_conversations_text(context): + pass + + +@then('the header of the Conversation event "SMART_CONVERSATIONS" for a "text" message contains a valid signature') +def step_signature_valid_smart_conversations_text(context): + pass + + +@then('the Conversation event describes a "SMART_CONVERSATIONS" event type for a "text" message') +def step_check_smart_conversations_text(context): + pass diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 00000000..0f20e188 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,16 @@ +""" +Common utility helpers for E2E tests, shared across domains. +""" + + +def store_webhook_response(context, response): + context.webhook_headers = dict(response.headers) + context.raw_event = response.text + + +def has_key_or_attr(obj, key): + if obj is None: + return False + if isinstance(obj, dict): + return key in obj and obj[key] is not None + return getattr(obj, key, None) is not None diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py index a681fd96..28409ff2 100644 --- a/tests/e2e/numbers/features/steps/webhooks.steps.py +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -1,16 +1,11 @@ import json import requests from behave import given, when, then +from tests.e2e.helpers import store_webhook_response SINCH_NUMBERS_CALLBACK_SECRET = 'strongPa$$PhraseWith36CharactersMax' -def parse_event(context, response): - context.headers = response.headers - context.raw_event = response.text - return json.loads(context.raw_event) - - @given('the Numbers Webhooks handler is available') def step_webhook_handler_is_available(context): context.numbers_webhook = context.sinch.numbers.webhooks(SINCH_NUMBERS_CALLBACK_SECRET) @@ -20,14 +15,15 @@ def step_webhook_handler_is_available(context): def step_send_trigger_event(context, status, event_type): endpoint = 'succeeded' if status == 'success' else 'failed' response = requests.get(f'http://localhost:3013/webhooks/numbers/provisioning_to_voice_platform/{endpoint}') - event_json = parse_event(context, response) + store_webhook_response(context, response) + event_json = json.loads(context.raw_event) context.event = context.numbers_webhook.parse_event(event_json) @then('the header of the "{status}" for "{event_type}" event contains a valid signature') def step_check_valid_signature(context, status, event_type): assert context.numbers_webhook.validate_authentication_header( - context.headers, context.raw_event + context.webhook_headers, context.raw_event ), 'Signature validation failed' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 1ce5a626..096ae9cd 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -9,15 +9,11 @@ BatchDeliveryReport, RecipientDeliveryReport, ) +from tests.e2e.helpers import store_webhook_response SINCH_SMS_CALLBACK_SECRET = 'KayakingTheSwell' -def parse_event(context, response): - context.headers = dict(response.headers) - context.raw_event = response.text - - @given('the SMS Webhooks handler is available') def step_webhook_handler_is_available(context): context.sms_webhook = SmsWebhooks(SINCH_SMS_CALLBACK_SECRET) @@ -26,7 +22,7 @@ def step_webhook_handler_is_available(context): @when('I send a request to trigger an "incoming SMS" event') def step_send_incoming_sms_event(context): response = requests.get('http://localhost:3017/webhooks/sms/incoming-sms') - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -34,7 +30,7 @@ def step_send_incoming_sms_event(context): @then('the header of the event "{event_type}" with the status "{status}" contains a valid signature') def step_check_valid_signature(context, event_type, status=None): assert context.sms_webhook.validate_authentication_header( - context.headers, context.raw_event + context.webhook_headers, context.raw_event ), 'Signature validation failed' @@ -54,7 +50,7 @@ def step_check_incoming_sms_event(context): @when('I send a request to trigger an "SMS delivery report" event') def step_send_delivery_report_event(context): response = requests.get('http://localhost:3017/webhooks/sms/delivery-report-sms') - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -82,7 +78,7 @@ def step_send_recipient_delivery_report_event_delivered(context): response = requests.get( 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' ) - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) @@ -91,7 +87,7 @@ def step_send_recipient_delivery_report_event_aborted(context): response = requests.get( 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' ) - parse_event(context, response) + store_webhook_response(context, response) context.event = context.sms_webhook.parse_event(context.raw_event) diff --git a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py new file mode 100644 index 00000000..d062e0a0 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py @@ -0,0 +1,82 @@ +"""Unit tests for Conversation webhook event models.""" +import pytest + +from sinch.domains.conversation.models.v1.webhooks import ( + ConversationWebhookEventBase, + MessageDeliveryReceiptEvent, + MessageDeliveryReport, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def message_delivery_report_data(): + return { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + } + + +def test_message_delivery_report_expects_parsed(message_delivery_report_data): + report = MessageDeliveryReport(**message_delivery_report_data) + assert report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert report.conversation_id == "01EPYATA64TMNZ1FV02JKF12JF" + assert report.status == "QUEUED_ON_CHANNEL" + assert report.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert report.channel_identity is not None + assert report.channel_identity.channel == "WHATSAPP" + assert report.channel_identity.identity == "1234567890" + + +def test_message_delivery_receipt_event_expects_parsed(message_delivery_report_data): + payload = { + "app_id": "app1", + "project_id": "proj1", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_delivery_report": message_delivery_report_data, + } + event = MessageDeliveryReceiptEvent(**payload) + assert event.app_id == "app1" + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + + +def test_message_inbound_event_expects_parsed(): + payload = { + "app_id": "app1", + "message": { + "contact_id": "contact1", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "SMS", "identity": "+15551234567"}, + }, + } + event = MessageInboundEvent(**payload) + assert event.message is not None + assert event.message.contact_id == "contact1" + assert event.message.contact_message.text_message.text == "Hello" + + +def test_message_submit_event_expects_parsed(): + payload = { + "app_id": "app1", + "message_submit_notification": { + "contact_id": "contact1", + "channel_identity": {"channel": "MESSENGER", "identity": "123"}, + }, + } + event = MessageSubmitEvent(**payload) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "contact1" + + +def test_conversation_webhook_event_base_optional_fields(): + payload = {"app_id": "app1"} + event = ConversationWebhookEventBase(**payload) + assert event.app_id == "app1" + assert event.project_id is None + assert event.accepted_time is None + assert event.event_time is None diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py new file mode 100644 index 00000000..f5e50212 --- /dev/null +++ b/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py @@ -0,0 +1,129 @@ +"""Unit tests for Conversation API webhooks (signature validation and parse_event).""" +from datetime import datetime, timezone + +import pytest + +from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.models.v1.webhooks import ( + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, +) + + +@pytest.fixture +def webhook_secret(): + return "foo_secret1234" + + +@pytest.fixture +def conversation_webhooks(webhook_secret): + return ConversationWebhooks(webhook_secret) + + +@pytest.fixture +def sample_body(): + return ( + '{"app_id":"01EB37HMH1M6SV18BSNS3G135H","accepted_time":"2020-11-17T15:09:11.659Z",' + '"project_id":"c36f3d3d-1513-2edd-ae42-11995557ff61",' + '"message_delivery_report":{"message_id":"01EQBC1A3BEK731GY4YXEN0C2R",' + '"conversation_id":"01EPYATA64TMNZ1FV02JKF12JF","status":"QUEUED_ON_CHANNEL",' + '"contact_id":"01EXA07N79THJ20WSN6AS30TMW"}}' + ) + + +VALID_SIGNATURE_HEADERS = { + "x-sinch-webhook-signature": "Yc+3R1pIS78xLASybulhs8BsSo9BPB3Pr92QCUoczfk=", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", +} + + +def test_validate_authentication_header_valid_expects_true(conversation_webhooks, sample_body): + assert conversation_webhooks.validate_authentication_header( + VALID_SIGNATURE_HEADERS, sample_body + ) is True + + +def test_validate_authentication_header_missing_headers_expects_false(conversation_webhooks, sample_body): + assert conversation_webhooks.validate_authentication_header({}, sample_body) is False + + +def test_validate_authentication_header_invalid_signature_expects_false(conversation_webhooks, sample_body): + headers = { + "x-sinch-webhook-signature": "invalid", + "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", + "x-sinch-webhook-signature-timestamp": "1634579353", + } + assert conversation_webhooks.validate_authentication_header(headers, sample_body) is False + + +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "event_time": "2020-11-17T15:09:13.267185Z", + "message_delivery_report": { + "message_id": "01EQBC1A3BEK731GY4YXEN0C2R", + "conversation_id": "01EPYATA64TMNZ1FV02JKF12JF", + "status": "QUEUED_ON_CHANNEL", + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.message_delivery_report is not None + assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" + assert event.message_delivery_report.status == "QUEUED_ON_CHANNEL" + assert event.accepted_time == datetime(2020, 11, 17, 15, 9, 11, 659000, tzinfo=timezone.utc) + + +def test_parse_event_message_inbound_expects_message_inbound_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "contact_message": {"text_message": {"text": "Hello"}}, + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageInboundEvent) + assert event.message is not None + assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + assert event.message.contact_message is not None + assert hasattr(event.message.contact_message, "text_message") + assert event.message.contact_message.text_message.text == "Hello" + + +def test_parse_event_message_submit_expects_message_submit_event(conversation_webhooks): + payload = { + "app_id": "01EB37HMH1M6SV18BSNS3G135H", + "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", + "accepted_time": "2020-11-17T15:09:11.659Z", + "message_submit_notification": { + "contact_id": "01EXA07N79THJ20WSN6AS30TMW", + "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, + "submitted_message": {"text_message": {"text": "Hi"}}, + }, + } + event = conversation_webhooks.parse_event(payload) + assert isinstance(event, MessageSubmitEvent) + assert event.message_submit_notification is not None + assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" + + +def test_parse_event_json_string_expects_parsed(conversation_webhooks): + payload_str = '{"app_id":"app1","message_delivery_report":{"status":"DELIVERED"}}' + event = conversation_webhooks.parse_event(payload_str) + assert isinstance(event, MessageDeliveryReceiptEvent) + assert event.app_id == "app1" + assert event.message_delivery_report.status == "DELIVERED" + + +def test_parse_event_invalid_json_expects_value_error(conversation_webhooks): + with pytest.raises(ValueError, match="Failed to decode JSON"): + conversation_webhooks.parse_event("not json") From 4c02ad82dc8a8880fd9398c8ebc7b7093e53003e Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 26 Feb 2026 17:57:29 +0100 Subject: [PATCH 091/106] DEVEXP-1277: OAS Synch - Conversation (#123) --- MIGRATION_GUIDE.md | 2 +- .../conversation/messages/list/snippet.py | 1 - .../snippet.py | 36 ++++++ .../conversation/api/v1/internal/__init__.py | 6 +- .../api/v1/internal/messages_endpoints.py | 63 ++++++++-- .../conversation/api/v1/messages_apis.py | 79 +++++++++++- .../whatsapp/buttons/__init__.py | 15 +++ ...hatsapp_payment_settings_boleto_button.py} | 7 +- ...p_payment_settings_payment_link_button.py} | 7 +- .../whatsapp_payment_settings_pix_button.py} | 7 +- .../whatsapp/payment/__init__.py | 6 +- .../payment/payment_order_details_content.py | 15 +-- .../v1/messages/internal/request/__init__.py | 4 + ...st_messages_by_channel_identity_request.py | 59 +++++++++ .../internal/request/list_messages_request.py | 8 +- .../models/v1/messages/response/__init__.py | 5 + .../{types => }/send_message_response.py | 0 .../v1/messages/response/types/__init__.py | 22 +--- .../response/types/payment_settings.py | 26 ---- .../response/types/whatsapp_payment_button.py | 16 +++ .../types/conversation_direction_type.py | 2 +- .../features/steps/conversation.steps.py | 48 +++++++- ...t_messages_by_channel_identity_endpoint.py | 112 ++++++++++++++++++ .../messages/test_send_message_endpoint.py | 4 +- ...st_messages_by_channel_identity_request.py | 36 ++++++ .../response/test_send_message_response.py | 2 +- .../v1/test_conversation_messages.py | 2 +- 27 files changed, 500 insertions(+), 90 deletions(-) create mode 100644 examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/{payment/boleto.py => buttons/whatsapp_payment_settings_boleto_button.py} (61%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/{payment/payment_link.py => buttons/whatsapp_payment_settings_payment_link_button.py} (53%) rename sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/{payment/dynamic_pix.py => buttons/whatsapp_payment_settings_pix_button.py} (71%) create mode 100644 sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py rename sinch/domains/conversation/models/v1/messages/response/{types => }/send_message_response.py (100%) delete mode 100644 sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py create mode 100644 sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py create mode 100644 tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py create mode 100644 tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index b8259588..805f7990 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -102,7 +102,7 @@ sinch_client.configuration.conversation_region = "eu" | Old class | New class | |-----------|-----------| | `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` | `send()`: pass `app_id`, `message` (dict or [`SendMessageRequestBodyDict`](sinch/domains/conversation/models/v1/messages/types/send_message_request_body_dict.py)), and either `contact_id` or `recipient_identities`. Internally uses [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py), [`SendMessageRequestBody`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request_body.py). For typed payloads use `send_text_message()`, `send_card_message()`, etc. -| `sinch.domains.conversation.models.message.responses.SendConversationMessageResponse` | [`SendMessageResponse`](sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py) (`message_id`, optional `accepted_time` as `datetime`) | +| `sinch.domains.conversation.models.message.responses.SendConversationMessageResponse` | [`SendMessageResponse`](sinch/domains/conversation/models/v1/messages/response/send_message_response.py) (`message_id`, optional `accepted_time` as `datetime`) | | `sinch.domains.conversation.models.message.requests.GetConversationMessageRequest` | `get(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | | `sinch.domains.conversation.models.message.responses.GetConversationMessageResponse` | [`ConversationMessageResponse`](sinch/domains/conversation/models/v1/messages/response/types/__init__.py) (Union of app/contact message response types) | | `sinch.domains.conversation.models.message.requests.DeleteConversationMessageRequest` | `delete(message_id, messages_source=None, **kwargs)`. Internally uses [`MessageIdRequest`](sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py). | diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py index 7f81d1dc..ab68afb3 100644 --- a/examples/snippets/conversation/messages/list/snippet.py +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -23,7 +23,6 @@ messages = sinch_client.conversation.messages.list( app_id=app_id, - page_size=10 ) page_counter = 1 diff --git a/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py new file mode 100644 index 00000000..66300d6b --- /dev/null +++ b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py @@ -0,0 +1,36 @@ +""" +Sinch Python Snippet + +TODO: Update links when v2 is released. +This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +""" + +import os +from dotenv import load_dotenv +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" +) + +# Channel identities to fetch the last message +channel_identities = ["CHANNEL_IDENTITY_1", "CHANNEL_IDENTITY_2"] + +messages = sinch_client.conversation.messages.list_last_messages_by_channel_identity( + channel_identities=channel_identities, +) + +page_counter = 1 +while True: + print(f"Page {page_counter} Last messages: {messages}") + + if not messages.has_next_page: + break + + messages = messages.next_page() + page_counter += 1 diff --git a/sinch/domains/conversation/api/v1/internal/__init__.py b/sinch/domains/conversation/api/v1/internal/__init__.py index 3fbd813d..95c46c42 100644 --- a/sinch/domains/conversation/api/v1/internal/__init__.py +++ b/sinch/domains/conversation/api/v1/internal/__init__.py @@ -1,15 +1,17 @@ from sinch.domains.conversation.api.v1.internal.messages_endpoints import ( DeleteMessageEndpoint, GetMessageEndpoint, + ListLastMessagesByChannelIdentityEndpoint, ListMessagesEndpoint, - UpdateMessageMetadataEndpoint, SendMessageEndpoint, + UpdateMessageMetadataEndpoint, ) __all__ = [ "DeleteMessageEndpoint", "GetMessageEndpoint", + "ListLastMessagesByChannelIdentityEndpoint", "ListMessagesEndpoint", - "UpdateMessageMetadataEndpoint", "SendMessageEndpoint", + "UpdateMessageMetadataEndpoint", ] diff --git a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py index d371c95e..9d98efcd 100644 --- a/sinch/domains/conversation/api/v1/internal/messages_endpoints.py +++ b/sinch/domains/conversation/api/v1/internal/messages_endpoints.py @@ -3,6 +3,7 @@ from sinch.core.models.http_response import HTTPResponse from sinch.domains.conversation.models.v1.messages.internal.request import ( ListMessagesRequest, + ListLastMessagesByChannelIdentityRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, @@ -12,6 +13,8 @@ ) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, +) +from sinch.domains.conversation.models.v1.messages.response import ( SendMessageResponse, ) from sinch.domains.conversation.api.v1.internal.base import ( @@ -20,6 +23,23 @@ from sinch.domains.conversation.api.v1.exceptions import ConversationException +class ListMessagesResponseMixin: + """ + Mixin for endpoints that return ListMessagesResponse; centralizes response handling. + """ + + def handle_response(self, response: HTTPResponse) -> ListMessagesResponse: + try: + super().handle_response(response) + except ConversationException as e: + raise ConversationException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListMessagesResponse) + + class MessageEndpoint(ConversationEndpoint): """ Base class for message-related endpoints that share common query parameter handling. @@ -40,7 +60,7 @@ def build_query_params(self) -> dict: return query_params -class ListMessagesEndpoint(MessageEndpoint): +class ListMessagesEndpoint(ListMessagesResponseMixin, MessageEndpoint): ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages" HTTP_METHOD = HTTPMethods.GET.value HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value @@ -66,16 +86,32 @@ def __init__(self, project_id: str, request_data: ListMessagesRequest): self.project_id = project_id self.request_data = request_data - def handle_response(self, response: HTTPResponse) -> ListMessagesResponse: - try: - super(ListMessagesEndpoint, self).handle_response(response) - except ConversationException as e: - raise ConversationException( - message=e.args[0], - response=e.http_response, - is_from_server=e.is_from_server, - ) - return self.process_response_model(response.body, ListMessagesResponse) + +class ListLastMessagesByChannelIdentityEndpoint( + ListMessagesResponseMixin, ConversationEndpoint +): + ENDPOINT_URL = ( + "{origin}/v1/projects/{project_id}/messages:fetch-last-message" + ) + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__( + self, + project_id: str, + request_data: ListLastMessagesByChannelIdentityRequest, + ): + super(ListLastMessagesByChannelIdentityEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data_dict = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data_dict) class DeleteMessageEndpoint(MessageEndpoint): @@ -181,9 +217,10 @@ def __init__(self, project_id: str, request_data: SendMessageRequest): self.request_data = request_data def request_body(self): - path_params = self._get_path_params_from_url() request_data_dict = self.request_data.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude=path_params + mode="json", + by_alias=True, + exclude_none=True, ) return json.dumps(request_data_dict) diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 36222476..623b24dd 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -3,14 +3,17 @@ from sinch.core.pagination import Paginator, TokenBasedPaginator from sinch.domains.conversation.models.v1.messages.internal.request import ( ListMessagesRequest, + ListLastMessagesByChannelIdentityRequest, MessageIdRequest, UpdateMessageMetadataRequest, SendMessageRequest, SendMessageRequestBody, ) +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) from sinch.domains.conversation.models.v1.messages.response.types import ( ConversationMessageResponse, - SendMessageResponse, ) from sinch.domains.conversation.models.v1.messages.types import ( ConversationChannelType, @@ -61,6 +64,7 @@ ) from sinch.domains.conversation.api.v1.internal import ( DeleteMessageEndpoint, + ListLastMessagesByChannelIdentityEndpoint, GetMessageEndpoint, ListMessagesEndpoint, UpdateMessageMetadataEndpoint, @@ -214,6 +218,79 @@ def list( ), ) + def list_last_messages_by_channel_identity( + self, + channel_identities: Optional[List[str]] = None, + contact_ids: Optional[List[str]] = None, + app_id: Optional[str] = None, + messages_source: Optional[MessagesSourceType] = None, + page_size: Optional[int] = None, + page_token: Optional[str] = None, + view: Optional[ConversationMessagesViewType] = None, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + channel: Optional[ConversationChannelType] = None, + direction: Optional[ConversationDirectionType] = None, + **kwargs, + ) -> Paginator[ConversationMessageResponse]: + """ + Retrieves the last message sent to specified channel identities. + In CONVERSATION_SOURCE mode, you can query either by channel_identities or by contact_ids. + Note: Use either contact_ids OR channel_identities per request, not both. + DISPATCH_SOURCE mode does not support contact_ids. + + :param channel_identities: Optional. Filter messages by channel_identity. + :type channel_identities: Optional[List[str]] + :param contact_ids: Optional. Resource name (id) of the contact. CONVERSATION_SOURCE: list last messages by contact_id. DISPATCH_SOURCE: unsupported. + :type contact_ids: Optional[List[str]] + :param app_id: Optional. Resource name (id) of the app. + :type app_id: Optional[str] + :param messages_source: Specifies the message source for the request. + :type messages_source: Optional[MessagesSourceType] + :param page_size: Optional. Maximum number of messages to fetch. Defaults to 10, maximum is 1000. + :type page_size: Optional[int] + :param page_token: Optional. Next page token previously returned if any. + :type page_token: Optional[str] + :param view: Optional. Specifies the representation (WITH_METADATA or WITHOUT_METADATA). Default WITH_METADATA. + :type view: Optional[ConversationMessagesViewType] + :param start_time: Optional. Only fetch messages with accept_time after this date. + :type start_time: Optional[datetime] + :param end_time: Optional. Only fetch messages with accept_time before this date. + :type end_time: Optional[datetime] + :param channel: Optional. Only fetch messages from the specified channel. + :type channel: Optional[ConversationChannelType] + :param direction: Optional. Only fetch messages with the specified direction (TO_APP or TO_CONTACT). + :type direction: Optional[ConversationDirectionType] + # Code review: :param **kwargs is invalid Sphinx syntax; use kwargs or document as "Additional keyword arguments". + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: TokenBasedPaginator with ConversationMessageResponse items + :rtype: Paginator[ConversationMessageResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/conversation/. + """ + return TokenBasedPaginator( + sinch=self._sinch, + endpoint=ListLastMessagesByChannelIdentityEndpoint( + project_id=self._sinch.configuration.project_id, + request_data=ListLastMessagesByChannelIdentityRequest( + channel_identities=channel_identities, + contact_ids=contact_ids, + app_id=app_id, + messages_source=messages_source, + page_size=page_size, + page_token=page_token, + view=view, + start_time=start_time, + end_time=end_time, + channel=channel, + direction=direction, + **kwargs, + ), + ), + ) + def update( self, message_id: str, diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py new file mode 100644 index 00000000..a7c8fee5 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/__init__.py @@ -0,0 +1,15 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_boleto_button import ( + WhatsAppPaymentSettingsBoletoButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_payment_link_button import ( + WhatsAppPaymentSettingsPaymentLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_pix_button import ( + WhatsAppPaymentSettingsPixButton, +) + +__all__ = [ + "WhatsAppPaymentSettingsBoletoButton", + "WhatsAppPaymentSettingsPaymentLinkButton", + "WhatsAppPaymentSettingsPixButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py similarity index 61% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py index ef46a9a8..da9404ca 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/boleto.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_boleto_button.py @@ -1,10 +1,15 @@ +from typing import Literal from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfiguration, ) -class Boleto(BaseModelConfiguration): +class WhatsAppPaymentSettingsBoletoButton(BaseModelConfiguration): + type: Literal["boleto"] = Field( + ..., + description="The Boleto button identifier", + ) digitable_line: StrictStr = Field( ..., description="The Boleto digitable line which will be copied to the clipboard when the user taps the Boleto button.", diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py similarity index 53% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py index c621eb66..c07ea17d 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_link.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_payment_link_button.py @@ -1,10 +1,15 @@ +from typing import Literal from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfiguration, ) -class PaymentLink(BaseModelConfiguration): +class WhatsAppPaymentSettingsPaymentLinkButton(BaseModelConfiguration): + type: Literal["payment_link"] = Field( + ..., + description="The payment link button identifier", + ) uri: StrictStr = Field( ..., description="The payment link to be used by the buyer to pay." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py similarity index 71% rename from sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py rename to sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py index 8e43f16d..9775ce87 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/dynamic_pix.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/buttons/whatsapp_payment_settings_pix_button.py @@ -1,3 +1,4 @@ +from typing import Literal from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.types.pix_key_type import ( PixKeyType, @@ -7,7 +8,11 @@ ) -class DynamicPix(BaseModelConfiguration): +class WhatsAppPaymentSettingsPixButton(BaseModelConfiguration): + type: Literal["pix_dynamic_code"] = Field( + ..., + description="The dynamic Pix code button identifier", + ) code: StrictStr = Field( ..., description="The dynamic Pix code to be used by the buyer to pay." ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py index 71559f06..bb6359c2 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/__init__.py @@ -42,10 +42,10 @@ def __getattr__(name: str): __all__ = [ "OrderItem", - "PaymentOrderStatusOrder", "PaymentOrder", - "PaymentOrderDetailsContent", - "PaymentOrderStatusContent", "PaymentOrderDetailsChannelSpecificMessage", + "PaymentOrderDetailsContent", "PaymentOrderStatusChannelSpecificMessage", + "PaymentOrderStatusContent", + "PaymentOrderStatusOrder", ] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py index 67beb888..1ea4d66f 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/whatsapp/payment/payment_order_details_content.py @@ -1,13 +1,11 @@ from typing import Optional -from pydantic import Field, StrictStr, StrictInt -from sinch.domains.conversation.models.v1.messages.types.payment_order_type import ( +from pydantic import Field, StrictStr, StrictInt, conlist +from sinch.domains.conversation.models.v1.messages.types import ( PaymentOrderType, -) -from sinch.domains.conversation.models.v1.messages.types.payment_order_goods_type import ( PaymentOrderGoodsType, ) -from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( - PaymentSettings, +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_payment_button import ( + WhatsAppPaymentButton, ) from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment import ( PaymentOrder, @@ -31,4 +29,7 @@ class PaymentOrderDetailsContent(BaseModelConfiguration): description="Integer representing the total amount of the transaction.", ) order: PaymentOrder = Field(..., description="The payment order.") - payment_settings: Optional[PaymentSettings] = None + payment_buttons: Optional[conlist(WhatsAppPaymentButton)] = Field( + default=None, + description="Array of payment buttons (1 to 2 items).", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py index 43b2a215..29b96be6 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/__init__.py @@ -18,9 +18,13 @@ from sinch.domains.conversation.models.v1.messages.internal.request.send_message_request import ( SendMessageRequest, ) +from sinch.domains.conversation.models.v1.messages.internal.request.list_messages_by_channel_identity_request import ( + ListLastMessagesByChannelIdentityRequest, +) __all__ = [ "ListMessagesRequest", + "ListLastMessagesByChannelIdentityRequest", "MessageIdRequest", "UpdateMessageMetadataRequest", "Recipient", diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py new file mode 100644 index 00000000..a3d1f13a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import Optional +from pydantic import Field, StrictInt, StrictStr, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.types import ( + ConversationChannelType, + ConversationDirectionType, + ConversationMessagesViewType, + MessagesSourceType, +) + + +class ListLastMessagesByChannelIdentityRequest(BaseModelConfiguration): + channel_identities: Optional[conlist(StrictStr)] = Field( + default=None, + description="Optional. Filter messages by channel_identity.", + ) + contact_ids: Optional[conlist(StrictStr)] = Field( + default=None, + description="Optional. Resource name (id) of the contact. In CONVERSATION_SOURCE: Can list last messages by contact_id. In DISPATCH_SOURCE: The field is unsupported and cannot be set.", + ) + app_id: Optional[StrictStr] = Field( + default=None, + description="Optional. Resource name (id) of the app.", + ) + messages_source: Optional[MessagesSourceType] = Field( + default=None, + description="Specifies the message source for the request.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="Optional. Maximum number of messages to fetch. Defaults to 10 and the maximum is 1000.", + ) + page_token: Optional[StrictStr] = Field( + default=None, + description="Optional. Next page token previously returned if any.", + ) + view: Optional[ConversationMessagesViewType] = Field( + default=None, + description="Optional. Specifies the representation in which messages should be returned. Defaults to WITH_METADATA.", + ) + start_time: Optional[datetime] = Field( + default=None, + description="Optional. Only fetch messages with accept_time after this date.", + ) + end_time: Optional[datetime] = Field( + default=None, + description="Optional. Only fetch messages with accept_time before this date.", + ) + channel: Optional[ConversationChannelType] = Field( + default=None, + description="Optional. Only fetch messages from the channel.", + ) + direction: Optional[ConversationDirectionType] = Field( + default=None, + description="Optional. Only fetch messages with the specified direction. If direction is not specified, it will list both TO_APP and TO_CONTACT messages.", + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py index 7e5c8507..19ddafa2 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -33,7 +33,7 @@ class ListMessagesRequest(BaseModelConfiguration): ) start_time: Optional[datetime] = Field( default=None, - description="Filter messages with accept_time after this timestamp.", + description="Filter messages with accept_time after this timestamp. Must be before end_time if that is specified.", ) end_time: Optional[datetime] = Field( default=None, @@ -41,11 +41,11 @@ class ListMessagesRequest(BaseModelConfiguration): ) page_size: Optional[StrictInt] = Field( default=None, - description="Maximum number of messages to fetch. Defaults to 10, maximum is 1000.", + description="Maximum number of messages to fetch. Defaults to 10 and the maximum is 1000.", ) page_token: Optional[StrictStr] = Field( default=None, - description="Next page token previously returned if any.", + description="Next page token previously returned if any. When specifying this token, use the same values for the other parameters from the request that originated the token, otherwise the paged results may be inconsistent.", ) view: Optional[ConversationMessagesViewType] = Field( default=None, @@ -65,5 +65,5 @@ class ListMessagesRequest(BaseModelConfiguration): ) direction: Optional[ConversationDirectionType] = Field( default=None, - description="Only fetch messages with the specified direction. TO_APP or TO_CONTACT.", + description="Optional. Only fetch messages with the specified direction. If direction is not specified, it will list both TO_APP and TO_CONTACT messages.", ) diff --git a/sinch/domains/conversation/models/v1/messages/response/__init__.py b/sinch/domains/conversation/models/v1/messages/response/__init__.py index e69de29b..21417094 100644 --- a/sinch/domains/conversation/models/v1/messages/response/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/response/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.models.v1.messages.response.send_message_response import ( + SendMessageResponse, +) + +__all__ = ["SendMessageResponse"] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py b/sinch/domains/conversation/models/v1/messages/response/send_message_response.py similarity index 100% rename from sinch/domains/conversation/models/v1/messages/response/types/send_message_response.py rename to sinch/domains/conversation/models/v1/messages/response/send_message_response.py diff --git a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py index 767b4d0f..6fabbc12 100644 --- a/sinch/domains/conversation/models/v1/messages/response/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/response/types/__init__.py @@ -1,12 +1,6 @@ from sinch.domains.conversation.models.v1.messages.response.types.app_message import ( AppMessage, ) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.channel_specific_message_content import ( - ChannelSpecificMessageContent, -) -from sinch.domains.conversation.models.v1.messages.categories.choice.choice_option import ( - ChoiceOption, -) from sinch.domains.conversation.models.v1.messages.response.types.contact_message import ( ContactMessage, ) @@ -22,30 +16,20 @@ from sinch.domains.conversation.models.v1.messages.response.types.kakaotalk_coupon import ( KakaoTalkCoupon, ) -from sinch.domains.conversation.models.v1.messages.categories.list.list_item import ( - ListItem, -) -from sinch.domains.conversation.models.v1.messages.response.types.payment_settings import ( - PaymentSettings, -) from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_interactive_header import ( WhatsAppInteractiveHeader, ) -from sinch.domains.conversation.models.v1.messages.response.types.send_message_response import ( - SendMessageResponse, +from sinch.domains.conversation.models.v1.messages.response.types.whatsapp_payment_button import ( + WhatsAppPaymentButton, ) __all__ = [ "AppMessage", - "ChannelSpecificMessageContent", - "ChoiceOption", "ContactMessage", "ConversationMessageResponse", "KakaoTalkButton", "KakaoTalkCommerce", "KakaoTalkCoupon", - "ListItem", - "PaymentSettings", - "SendMessageResponse", + "WhatsAppPaymentButton", "WhatsAppInteractiveHeader", ] diff --git a/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py b/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py deleted file mode 100644 index 83c6991f..00000000 --- a/sinch/domains/conversation/models/v1/messages/response/types/payment_settings.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import Optional -from pydantic import Field -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.dynamic_pix import ( - DynamicPix, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.payment_link import ( - PaymentLink, -) -from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.payment.boleto import ( - Boleto, -) -from sinch.domains.conversation.models.v1.messages.internal.base import ( - BaseModelConfiguration, -) - - -class PaymentSettings(BaseModelConfiguration): - dynamic_pix: Optional[DynamicPix] = Field( - default=None, description="The dynamic Pix payment settings." - ) - payment_link: Optional[PaymentLink] = Field( - default=None, description="The payment link payment settings." - ) - boleto: Optional[Boleto] = Field( - default=None, description="The Boleto payment settings." - ) diff --git a/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py new file mode 100644 index 00000000..24d03f7f --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/response/types/whatsapp_payment_button.py @@ -0,0 +1,16 @@ +from typing import Union +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_pix_button import ( + WhatsAppPaymentSettingsPixButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_payment_link_button import ( + WhatsAppPaymentSettingsPaymentLinkButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.buttons.whatsapp_payment_settings_boleto_button import ( + WhatsAppPaymentSettingsBoletoButton, +) + +WhatsAppPaymentButton = Union[ + WhatsAppPaymentSettingsPixButton, + WhatsAppPaymentSettingsPaymentLinkButton, + WhatsAppPaymentSettingsBoletoButton, +] diff --git a/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py index 9877611c..d072fe29 100644 --- a/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py +++ b/sinch/domains/conversation/models/v1/messages/types/conversation_direction_type.py @@ -2,6 +2,6 @@ from pydantic import StrictStr ConversationDirectionType = Union[ - Literal["UNDEFINED_DIRECTION", "TO_APP", "TO_CONTACT"], + Literal["TO_APP", "TO_CONTACT"], StrictStr, ] diff --git a/tests/e2e/conversation/features/steps/conversation.steps.py b/tests/e2e/conversation/features/steps/conversation.steps.py index 630c476c..c83b95a5 100644 --- a/tests/e2e/conversation/features/steps/conversation.steps.py +++ b/tests/e2e/conversation/features/steps/conversation.steps.py @@ -146,29 +146,65 @@ def step_validate_page_count(context, count): @when('I send a request to list the last messages sent to specified channel identities') def step_list_last_messages_channel_identities(context): - pass + context.list_response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) @then('the response contains "{count}" last messages sent to specified channel identities') def step_validate_last_messages_count(context, count): - pass + expected_count = int(count) + assert len(context.list_response.content()) == expected_count, ( + f'Expected {expected_count} last messages, got {len(context.list_response.content())}' + ) @when('I send a request to list all the last messages sent to specified channel identities') def step_list_all_last_messages_channel_identities(context): - pass + """List all last messages by channel identity using iterator""" + response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) + messages_list = [] + for message in response.iterator(): + messages_list.append(message) + context.messages_list = messages_list @then('the response list contains "{count}" last messages sent to specified channel identities') def step_validate_response_list_count(context, count): - pass + expected_count = int(count) + assert len(context.messages_list) == expected_count, ( + f'Expected {expected_count} last messages, got {len(context.messages_list)}' + ) @when('I iterate manually over the last messages sent to specified channel identities pages') def step_iterate_last_messages_pages(context): - pass + context.list_response = context.messages.list_last_messages_by_channel_identity( + channel_identities=['12015555555', '12017777777', '7504610123456789'], + messages_source='CONVERSATION_SOURCE', + page_size=2, + ) + context.messages_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + while not reached_end_of_pages: + context.messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True @then('the result contains the data from "{count}" pages of last messages sent to specified channel identities') def step_validate_last_messages_page_count(context, count): - pass + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, ( + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + ) diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py new file mode 100644 index 00000000..1347834b --- /dev/null +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_list_last_messages_by_channel_identity_endpoint.py @@ -0,0 +1,112 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.conversation.api.v1.internal import ( + ListLastMessagesByChannelIdentityEndpoint, +) +from sinch.domains.conversation.models.v1.messages.internal import ( + ListMessagesResponse, +) +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListLastMessagesByChannelIdentityRequest, +) +from tests.unit.domains.conversation.v1.models.response.test_conversation_message_response_model import ( + contact_message_response_data, +) + + +@pytest.fixture +def request_data(): + return ListLastMessagesByChannelIdentityRequest( + channel_identities=["+15551234567"], + messages_source="DISPATCH_SOURCE", + page_size=2, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + + +@pytest.fixture +def mock_list_last_messages_response(contact_message_response_data): + return HTTPResponse( + status_code=200, + body={ + "messages": [contact_message_response_data], + "next_page_token": "token_next_page_abc", + }, + headers={"Content-Type": "application/json"}, + ) + + +def test_build_url_expects_correct_url(endpoint, mock_sinch_client_conversation): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_conversation) + == "https://us.conversation.api.sinch.com/v1/projects/test_project_id/messages:fetch-last-message" + ) + + +def test_request_body_expects_parsed_params(): + """Test that all body fields are serialized when set.""" + request_data = ListLastMessagesByChannelIdentityRequest( + channel_identities=["+46701234567", "+46709876543"], + contact_ids=["CONTACT123"], + app_id="APP789", + messages_source="DISPATCH_SOURCE", + page_size=20, + page_token="token_xyz", + view="WITH_METADATA", + channel="WHATSAPP", + direction="TO_APP", + ) + endpoint = ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + body = json.loads(endpoint.request_body()) + + assert body["channel_identities"] == ["+46701234567", "+46709876543"] + assert body["contact_ids"] == ["CONTACT123"] + assert body["app_id"] == "APP789" + assert body["messages_source"] == "DISPATCH_SOURCE" + assert body["page_size"] == 20 + assert body["page_token"] == "token_xyz" + assert body["view"] == "WITH_METADATA" + assert body["channel"] == "WHATSAPP" + assert body["direction"] == "TO_APP" + + +def test_handle_response_expects_list_messages_response( + endpoint, mock_list_last_messages_response +): + """Test that a successful response is parsed to ListMessagesResponse.""" + result = endpoint.handle_response(mock_list_last_messages_response) + + assert isinstance(result, ListMessagesResponse) + assert result.next_page_token == "token_next_page_abc" + assert result.messages is not None + assert len(result.messages) == 1 + assert result.messages[0].id == "CAPY123456789ABCDEFGHIJKLMNOP" + + +def test_handle_response_expects_empty_messages_list(): + """Test that response with empty messages list is handled correctly.""" + request_data = ListLastMessagesByChannelIdentityRequest(page_size=10) + endpoint = ListLastMessagesByChannelIdentityEndpoint( + "test_project_id", request_data + ) + mock_response = HTTPResponse( + status_code=200, + body={"messages": [], "next_page_token": None}, + headers={"Content-Type": "application/json"}, + ) + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListMessagesResponse) + assert result.messages == [] + assert result.next_page_token is None diff --git a/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py index 99468c2a..a1de2a95 100644 --- a/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py +++ b/tests/unit/domains/conversation/v1/endpoints/messages/test_send_message_endpoint.py @@ -11,7 +11,9 @@ Recipient, ) from sinch.domains.conversation.models.v1.messages.categories.text import TextMessage -from sinch.domains.conversation.models.v1.messages.response.types import SendMessageResponse +from sinch.domains.conversation.models.v1.messages.response import ( + SendMessageResponse, +) @pytest.fixture diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py new file mode 100644 index 00000000..81617143 --- /dev/null +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_list_last_messages_by_channel_identity_request.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from sinch.domains.conversation.models.v1.messages.internal.request import ( + ListLastMessagesByChannelIdentityRequest, +) + + +def test_list_last_messages_by_channel_identity_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + request = ListLastMessagesByChannelIdentityRequest( + channel_identities=["+46701234567", "+46709876543"], + contact_ids=["CONTACT456789ABCDEFGHIJKLMNOP"], + app_id="APP123456789ABCDEFGHIJK", + messages_source="DISPATCH_SOURCE", + page_size=50, + page_token="next_page_token_abc", + view="WITH_METADATA", + start_time=start, + end_time=end, + channel="WHATSAPP", + direction="TO_CONTACT", + ) + + assert request.channel_identities == ["+46701234567", "+46709876543"] + assert request.contact_ids == ["CONTACT456789ABCDEFGHIJKLMNOP"] + assert request.app_id == "APP123456789ABCDEFGHIJK" + assert request.messages_source == "DISPATCH_SOURCE" + assert request.page_size == 50 + assert request.page_token == "next_page_token_abc" + assert request.view == "WITH_METADATA" + assert request.start_time == start + assert request.end_time == end + assert request.channel == "WHATSAPP" + assert request.direction == "TO_CONTACT" diff --git a/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py index 2f6b542a..0ce5e801 100644 --- a/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py +++ b/tests/unit/domains/conversation/v1/models/response/test_send_message_response.py @@ -1,6 +1,6 @@ import pytest from datetime import datetime, timezone -from sinch.domains.conversation.models.v1.messages.response.types import ( +from sinch.domains.conversation.models.v1.messages.response import ( SendMessageResponse, ) diff --git a/tests/unit/domains/conversation/v1/test_conversation_messages.py b/tests/unit/domains/conversation/v1/test_conversation_messages.py index f8d7f9d2..5791e003 100644 --- a/tests/unit/domains/conversation/v1/test_conversation_messages.py +++ b/tests/unit/domains/conversation/v1/test_conversation_messages.py @@ -22,7 +22,7 @@ UpdateMessageMetadataRequest, SendMessageRequest, ) -from sinch.domains.conversation.models.v1.messages.response.types import ( +from sinch.domains.conversation.models.v1.messages.response import ( SendMessageResponse, ) From b975b3363c573cb403de51f088b04bba0eef5f54 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 27 Feb 2026 14:06:32 +0100 Subject: [PATCH 092/106] DEVEXP-1243: Remove Voice and Verification V1 (#124) --- .github/workflows/ci.yml | 3 - MIGRATION_GUIDE.md | 2 + README.md | 15 - .../clients/sinch_client_configuration.py | 28 -- sinch/core/clients/sinch_client_sync.py | 8 - sinch/core/enums.py | 1 - sinch/core/ports/http_transport.py | 18 - sinch/core/signature.py | 58 --- sinch/domains/verification/__init__.py | 345 --------------- .../verification/endpoints/__init__.py | 0 .../endpoints/get_verification_by_id.py | 35 -- .../endpoints/get_verification_by_identity.py | 36 -- .../get_verification_by_reference.py | 35 -- .../endpoints/report_verification_using_id.py | 38 -- .../report_verification_using_identity.py | 38 -- .../endpoints/start_verification.py | 75 ---- .../endpoints/verification_endpoint.py | 13 - sinch/domains/verification/enums.py | 17 - sinch/domains/verification/exceptions.py | 5 - sinch/domains/verification/models/__init__.py | 6 - sinch/domains/verification/models/requests.py | 212 --------- .../domains/verification/models/responses.py | 108 ----- sinch/domains/voice/__init__.py | 412 ------------------ sinch/domains/voice/endpoints/__init__.py | 0 .../voice/endpoints/applications/__init__.py | 0 .../endpoints/applications/assign_numbers.py | 26 -- .../applications/get_callback_urls.py | 27 -- .../endpoints/applications/get_numbers.py | 31 -- .../endpoints/applications/query_number.py | 30 -- .../endpoints/applications/unassign_number.py | 38 -- .../applications/update_callbacks.py | 38 -- .../voice/endpoints/callouts/__init__.py | 0 .../voice/endpoints/callouts/callout.py | 55 --- .../domains/voice/endpoints/calls/__init__.py | 0 .../domains/voice/endpoints/calls/get_call.py | 53 --- .../voice/endpoints/calls/manage_call.py | 32 -- .../voice/endpoints/calls/update_call.py | 30 -- .../voice/endpoints/conferences/__init__.py | 0 .../endpoints/conferences/get_conference.py | 29 -- .../conferences/kick_all_participants.py | 24 - .../endpoints/conferences/kick_participant.py | 25 -- .../conferences/manage_participant.py | 32 -- .../domains/voice/endpoints/voice_endpoint.py | 13 - sinch/domains/voice/enums.py | 82 ---- sinch/domains/voice/exceptions.py | 5 - sinch/domains/voice/models/__init__.py | 35 -- .../voice/models/applications/__init__.py | 0 .../voice/models/applications/requests.py | 34 -- .../voice/models/applications/responses.py | 40 -- .../domains/voice/models/callouts/__init__.py | 0 .../domains/voice/models/callouts/requests.py | 50 --- .../voice/models/callouts/responses.py | 7 - sinch/domains/voice/models/calls/__init__.py | 0 sinch/domains/voice/models/calls/requests.py | 25 -- sinch/domains/voice/models/calls/responses.py | 29 -- .../voice/models/conferences/__init__.py | 0 .../voice/models/conferences/requests.py | 26 -- .../voice/models/conferences/responses.py | 24 - sinch/domains/voice/models/svaml/__init__.py | 0 .../voice/models/svaml/actions/__init__.py | 13 - .../voice/models/svaml/actions/actions.py | 190 -------- .../models/svaml/instructions/__init__.py | 11 - .../models/svaml/instructions/instructions.py | 79 ---- tests/conftest.py | 43 +- .../domains/voice/test_callout_conference.py | 80 ---- tests/unit/http_transport_tests.py | 9 - tests/unit/test_client.py | 3 +- tests/unit/test_configuration.py | 4 - 68 files changed, 6 insertions(+), 2774 deletions(-) delete mode 100644 sinch/core/signature.py delete mode 100644 sinch/domains/verification/__init__.py delete mode 100644 sinch/domains/verification/endpoints/__init__.py delete mode 100644 sinch/domains/verification/endpoints/get_verification_by_id.py delete mode 100644 sinch/domains/verification/endpoints/get_verification_by_identity.py delete mode 100644 sinch/domains/verification/endpoints/get_verification_by_reference.py delete mode 100644 sinch/domains/verification/endpoints/report_verification_using_id.py delete mode 100644 sinch/domains/verification/endpoints/report_verification_using_identity.py delete mode 100644 sinch/domains/verification/endpoints/start_verification.py delete mode 100644 sinch/domains/verification/endpoints/verification_endpoint.py delete mode 100644 sinch/domains/verification/enums.py delete mode 100644 sinch/domains/verification/exceptions.py delete mode 100644 sinch/domains/verification/models/__init__.py delete mode 100644 sinch/domains/verification/models/requests.py delete mode 100644 sinch/domains/verification/models/responses.py delete mode 100644 sinch/domains/voice/__init__.py delete mode 100644 sinch/domains/voice/endpoints/__init__.py delete mode 100644 sinch/domains/voice/endpoints/applications/__init__.py delete mode 100644 sinch/domains/voice/endpoints/applications/assign_numbers.py delete mode 100644 sinch/domains/voice/endpoints/applications/get_callback_urls.py delete mode 100644 sinch/domains/voice/endpoints/applications/get_numbers.py delete mode 100644 sinch/domains/voice/endpoints/applications/query_number.py delete mode 100644 sinch/domains/voice/endpoints/applications/unassign_number.py delete mode 100644 sinch/domains/voice/endpoints/applications/update_callbacks.py delete mode 100644 sinch/domains/voice/endpoints/callouts/__init__.py delete mode 100644 sinch/domains/voice/endpoints/callouts/callout.py delete mode 100644 sinch/domains/voice/endpoints/calls/__init__.py delete mode 100644 sinch/domains/voice/endpoints/calls/get_call.py delete mode 100644 sinch/domains/voice/endpoints/calls/manage_call.py delete mode 100644 sinch/domains/voice/endpoints/calls/update_call.py delete mode 100644 sinch/domains/voice/endpoints/conferences/__init__.py delete mode 100644 sinch/domains/voice/endpoints/conferences/get_conference.py delete mode 100644 sinch/domains/voice/endpoints/conferences/kick_all_participants.py delete mode 100644 sinch/domains/voice/endpoints/conferences/kick_participant.py delete mode 100644 sinch/domains/voice/endpoints/conferences/manage_participant.py delete mode 100644 sinch/domains/voice/endpoints/voice_endpoint.py delete mode 100644 sinch/domains/voice/enums.py delete mode 100644 sinch/domains/voice/exceptions.py delete mode 100644 sinch/domains/voice/models/__init__.py delete mode 100644 sinch/domains/voice/models/applications/__init__.py delete mode 100644 sinch/domains/voice/models/applications/requests.py delete mode 100644 sinch/domains/voice/models/applications/responses.py delete mode 100644 sinch/domains/voice/models/callouts/__init__.py delete mode 100644 sinch/domains/voice/models/callouts/requests.py delete mode 100644 sinch/domains/voice/models/callouts/responses.py delete mode 100644 sinch/domains/voice/models/calls/__init__.py delete mode 100644 sinch/domains/voice/models/calls/requests.py delete mode 100644 sinch/domains/voice/models/calls/responses.py delete mode 100644 sinch/domains/voice/models/conferences/__init__.py delete mode 100644 sinch/domains/voice/models/conferences/requests.py delete mode 100644 sinch/domains/voice/models/conferences/responses.py delete mode 100644 sinch/domains/voice/models/svaml/__init__.py delete mode 100644 sinch/domains/voice/models/svaml/actions/__init__.py delete mode 100644 sinch/domains/voice/models/svaml/actions/actions.py delete mode 100644 sinch/domains/voice/models/svaml/instructions/__init__.py delete mode 100644 sinch/domains/voice/models/svaml/instructions/instructions.py delete mode 100644 tests/unit/domains/voice/test_callout_conference.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afac0688..e7b3148c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,6 @@ env: SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }} SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }} TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }} - VERIFICATION_ORIGIN: ${{ secrets.VERIFICATION_ORIGIN }} - VOICE_CALL_ID: ${{ secrets.VOICE_CALL_ID }} - VOICE_ORIGIN: ${{ secrets.VOICE_ORIGIN }} jobs: build: diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 805f7990..ccb9d2ea 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -6,6 +6,8 @@ This release removes legacy SDK support. This guide lists all removed classes and interfaces from V1 and how to migrate to their V2 equivalents. +> **Note:** Voice and Verification are not yet covered by the new V2 APIs. Support will be added in future releases. + ## Client Initialization ### Overview diff --git a/README.md b/README.md index faf73355..5133e0cc 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,6 @@ You can install this package by typing: The Sinch client provides access to the following Sinch products: - Numbers API - SMS API -- Verification API -- Voice API - Conversation API (beta release) @@ -53,19 +51,6 @@ To establish a connection with the Sinch backend, you must provide the appropria you intend to use. For security best practices, avoid hardcoding credentials. Instead, retrieve them from environment variables. -#### Verification and Voice APIs - -To initialize the client for the **Verification** and **Voice** APIs, use the following credentials: - -```python -from sinch import SinchClient - -sinch_client = SinchClient( - application_key="application_key", - application_secret="application_secret" -) -``` - #### SMS API For the SMS API in **Australia (AU)**, **Brazil (BR)**, **Canada (CA)**, **the United States (US)**, and **the European Union (EU)**, provide the following parameters: diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index da0551f3..1ca6af3a 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -20,8 +20,6 @@ def __init__( project_id: str = None, logger: Logger = None, logger_name: str = None, - application_key: str = None, - application_secret: str = None, service_plan_id: str = None, sms_api_token: str = None, sms_region: str = None, @@ -30,8 +28,6 @@ def __init__( self.key_id = key_id self.key_secret = key_secret self.project_id = project_id - self.application_key = application_key - self.application_secret = application_secret self.connection_timeout = connection_timeout self.sms_api_token = sms_api_token self.service_plan_id = service_plan_id @@ -40,11 +36,7 @@ def __init__( self._authentication_method = self._determine_authentication_method() self.auth_origin = "https://auth.sinch.com" self.numbers_origin = "https://numbers.api.sinch.com" - self.verification_origin = "https://verification.api.sinch.com" - self.voice_applications_origin = "https://callingapi.sinch.com" self.number_lookup_origin = "https://lookup.api.sinch.com" - self._voice_domain = "https://{}.api.sinch.com" - self._voice_region = None self._conversation_region = conversation_region self._conversation_domain = "https://{}.conversation.api.sinch.com" self._sms_region = sms_region @@ -60,7 +52,6 @@ def __init__( self._set_sms_origin() self._set_sms_origin_with_service_plan_id() self._set_templates_origin() - self._set_voice_origin() if logger_name: self.logger = logging.getLogger(logger_name) @@ -196,25 +187,6 @@ def _get_templates_domain(self): doc="Conversation API Templates Domain" ) - def _set_voice_origin(self): - if not self._voice_region: - self.voice_origin = self._voice_domain.format("calling") - else: - self.voice_origin = self._voice_domain.format("calling-" + self._voice_region) - - def _set_voice_region(self, region): - self._voice_region = region - self._set_voice_origin() - - def _get_voice_region(self): - return self._voice_region - - voice_region = property( - _get_voice_region, - _set_voice_region, - doc="Voice Region" - ) - def _determine_authentication_method(self): """ Determines the authentication method based on provided parameters. diff --git a/sinch/core/clients/sinch_client_sync.py b/sinch/core/clients/sinch_client_sync.py index 1b48e3ef..5ea361ef 100644 --- a/sinch/core/clients/sinch_client_sync.py +++ b/sinch/core/clients/sinch_client_sync.py @@ -6,8 +6,6 @@ from sinch.domains.numbers import VirtualNumbers from sinch.domains.conversation import Conversation from sinch.domains.sms import SMS -from sinch.domains.verification import Verification -from sinch.domains.voice import Voice from sinch.domains.number_lookup import NumberLookup @@ -24,8 +22,6 @@ def __init__( project_id: str = None, logger_name: str = None, logger: Logger = None, - application_key: str = None, - application_secret: str = None, service_plan_id: str = None, sms_api_token: str = None, sms_region: str = None, @@ -39,8 +35,6 @@ def __init__( logger=logger, transport=HTTPTransportRequests(self), token_manager=TokenManager(self), - application_key=application_key, - application_secret=application_secret, service_plan_id=service_plan_id, sms_api_token=sms_api_token, sms_region=sms_region, @@ -51,6 +45,4 @@ def __init__( self.numbers = VirtualNumbers(self) self.conversation = Conversation(self) self.sms = SMS(self) - self.verification = Verification(self) - self.voice = Voice(self) self.number_lookup = NumberLookup(self) diff --git a/sinch/core/enums.py b/sinch/core/enums.py index 0898c2cc..0ba15c1b 100644 --- a/sinch/core/enums.py +++ b/sinch/core/enums.py @@ -12,5 +12,4 @@ class HTTPMethods(Enum): class HTTPAuthentication(Enum): BASIC = "BASIC" OAUTH = "OAUTH" - SIGNED = "SIGNED" SMS_TOKEN = "SMS_TOKEN" diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 29a6acc8..f4e30dcd 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,7 +1,6 @@ from abc import ABC, abstractmethod from platform import python_version from sinch.core.endpoint import HTTPEndpoint -from sinch.core.signature import Signature from sinch.core.models.http_request import HttpRequest from sinch.core.models.http_response import HTTPResponse from sinch.core.exceptions import ValidationException, SinchException @@ -45,23 +44,6 @@ def authenticate(self, endpoint, request_data): "Authorization": f"Bearer {token}", "Content-Type": "application/json" }) - elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SIGNED.value: - if not self.sinch.configuration.application_key or not self.sinch.configuration.application_secret: - raise ValidationException( - message=( - "application key and application secret are required by this API. " - "Those credentials can be obtained from Sinch portal." - ), - is_from_server=False, - response=None - ) - signature = Signature( - self.sinch, - endpoint.HTTP_METHOD, - request_data.request_body, - endpoint.get_url_without_origin(self.sinch) - ) - request_data.headers = signature.get_http_headers_with_signature() elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value: if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id: raise ValidationException( diff --git a/sinch/core/signature.py b/sinch/core/signature.py deleted file mode 100644 index 5e456266..00000000 --- a/sinch/core/signature.py +++ /dev/null @@ -1,58 +0,0 @@ -import hashlib -import hmac -import base64 -from datetime import datetime, timezone - - -class Signature: - def __init__( - self, - sinch, - http_method, - request_data, - request_uri, - content_type=None, - signature_timestamp=None - ): - self.sinch = sinch - self.http_method = http_method - self.content_type = content_type or 'application/json; charset=UTF-8' - self.request_data = request_data - self.signature_timestamp = signature_timestamp or datetime.now(timezone.utc).isoformat() - self.request_uri = request_uri - self.authorization_signature = None - - def get_http_headers_with_signature(self): - if not self.authorization_signature: - self.calculate() - - return { - "Content-Type": self.content_type, - "Authorization": ( - f"Application {self.sinch.configuration.application_key}:{self.authorization_signature}" - ), - "x-timestamp": self.signature_timestamp - } - - def calculate(self): - b64_decoded_application_secret = base64.b64decode(self.sinch.configuration.application_secret) - if self.request_data: - encoded_verification_request = hashlib.md5(self.request_data.encode()) - encoded_verification_request = base64.b64encode(encoded_verification_request.digest()) - - else: - encoded_verification_request = ''.encode() - - request_timestamp = "x-timestamp:" + self.signature_timestamp - - string_to_sign = ( - self.http_method + '\n' - + encoded_verification_request.decode() + '\n' - + self.content_type + '\n' - + request_timestamp + '\n' - + self.request_uri - ) - - self.authorization_signature = base64.b64encode( - hmac.new(b64_decoded_application_secret, string_to_sign.encode(), hashlib.sha256).digest() - ).decode() diff --git a/sinch/domains/verification/__init__.py b/sinch/domains/verification/__init__.py deleted file mode 100644 index 1a39699d..00000000 --- a/sinch/domains/verification/__init__.py +++ /dev/null @@ -1,345 +0,0 @@ -from sinch.domains.verification.endpoints.start_verification import StartVerificationEndpoint -from sinch.domains.verification.endpoints.report_verification_using_identity import ( - ReportVerificationByIdentityEndpoint -) -from sinch.domains.verification.endpoints.report_verification_using_id import ( - ReportVerificationByIdEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_identity import ( - GetVerificationStatusByIdentityEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_reference import ( - GetVerificationStatusByReferenceEndpoint -) -from sinch.domains.verification.endpoints.get_verification_by_id import ( - GetVerificationStatusByIdEndpoint -) -from sinch.domains.verification.models.responses import ( - StartVerificationResponse, - ReportVerificationByIdentityResponse, - ReportVerificationByIdResponse, - GetVerificationStatusByIdentityResponse, - GetVerificationStatusByReferenceResponse, - GetVerificationStatusByIdResponse -) -from sinch.domains.verification.models.requests import ( - StartSMSVerificationRequest, - StartFlashCallVerificationRequest, - StartPhoneCallVerificationRequest, - StartCalloutVerificationRequest, - StartDataVerificationRequest, - ReportVerificationByIdentityRequestLegacy, - ReportVerificationByIdRequestLegacy, - ReportVerificationByIdentityAndSMSRequest, - ReportVerificationByIdentityAndFlashCallRequest, - ReportVerificationByIdentityAndPhoneCallRequest, - ReportVerificationByIdAndSMSRequest, - ReportVerificationByIdAndFlashCallRequest, - ReportVerificationByIdAndPhoneCallRequest, - GetVerificationStatusByIdentityRequest, - GetVerificationStatusByReferenceRequest, - GetVerificationStatusByIdRequest -) -from sinch.domains.verification.models import VerificationIdentity - - -class Verifications: - def __init__(self, sinch): - self._sinch = sinch - - def start_sms( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None, - expiry: str = None, - code_type: str = None, - template: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartSMSVerificationRequest( - identity=identity, - reference=reference, - custom=custom, - expiry=expiry, - code_type=code_type, - template=template - ) - ) - ) - - def start_flash_call( - self, - identity: VerificationIdentity, - reference: str = None, - dial_timeout: int = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartFlashCallVerificationRequest( - identity=identity, - reference=reference, - dial_timeout=dial_timeout, - custom=custom - ) - ) - ) - - def start_phone_call( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartPhoneCallVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def start_callout( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None, - speech_locale: str = None - ) -> StartVerificationResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - Use start_phone_call method instead. - """ - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartCalloutVerificationRequest( - identity=identity, - reference=reference, - custom=custom, - speech_locale=speech_locale - ) - ) - ) - - def start_seamless( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - Use start_data method instead. - """ - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartDataVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def start_data( - self, - identity: VerificationIdentity, - reference: str = None, - custom: str = None - ) -> StartVerificationResponse: - return self._sinch.configuration.transport.request( - StartVerificationEndpoint( - request_data=StartDataVerificationRequest( - identity=identity, - reference=reference, - custom=custom - ) - ) - ) - - def report_sms_by_id( - self, - id: str, - code: str, - cli: str = None - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndSMSRequest( - id, - code, - cli - ) - ) - ) - - def report_flash_call_by_id( - self, - id: str, - cli: str - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndFlashCallRequest( - id, - cli - ) - ) - ) - - def report_phone_call_by_id( - self, - id: str, - code: str = None - ) -> ReportVerificationByIdResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdAndPhoneCallRequest( - id, - code - ) - ) - ) - - def report_sms_by_identity( - self, - endpoint: str, - code: str, - cli: str = None - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndSMSRequest( - endpoint, - code, - cli - ) - ) - ) - - def report_flash_call_by_identity( - self, - endpoint: str, - cli: str = None - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndFlashCallRequest( - endpoint, - cli - ) - ) - ) - - def report_phone_call_by_identity( - self, - endpoint: str, - code: str - ) -> ReportVerificationByIdentityResponse: - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityAndPhoneCallRequest( - endpoint, - code - ) - ) - ) - - def report_by_id( - self, - id: str, - verification_report_request: dict - ) -> ReportVerificationByIdResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - """ - return self._sinch.configuration.transport.request( - ReportVerificationByIdEndpoint( - request_data=ReportVerificationByIdRequestLegacy( - id, - verification_report_request - ) - ) - ) - - def report_by_identity( - self, - endpoint, - verification_report_request - ) -> ReportVerificationByIdentityResponse: - """ - This method is not supported anymore. - It should be used only for backward compatibility reasons. - """ - return self._sinch.configuration.transport.request( - ReportVerificationByIdentityEndpoint( - request_data=ReportVerificationByIdentityRequestLegacy( - endpoint, - verification_report_request - ) - ) - ) - - -class VerificationStatus: - def __init__(self, sinch): - self._sinch = sinch - - def get_by_id(self, id: str) -> GetVerificationStatusByIdResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByIdEndpoint( - request_data=GetVerificationStatusByIdRequest( - id=id - ) - ) - ) - - def get_by_reference(self, reference: str) -> GetVerificationStatusByReferenceResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByReferenceEndpoint( - request_data=GetVerificationStatusByReferenceRequest( - reference=reference - ) - ) - ) - - def get_by_identity( - self, - endpoint: str, - method: str - ) -> GetVerificationStatusByIdentityResponse: - return self._sinch.configuration.transport.request( - GetVerificationStatusByIdentityEndpoint( - request_data=GetVerificationStatusByIdentityRequest( - endpoint=endpoint, - method=method - ) - ) - ) - - -class VerificationBase: - """ - Documentation for the Verification API: https://developers.sinch.com/docs/verification/ - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Verification(VerificationBase): - """ - Synchronous version of the Verification Domain - """ - __doc__ += VerificationBase.__doc__ - - def __init__(self, sinch): - super(Verification, self).__init__(sinch) - self.verifications = Verifications(self._sinch) - self.verification_status = VerificationStatus(self._sinch) diff --git a/sinch/domains/verification/endpoints/__init__.py b/sinch/domains/verification/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/verification/endpoints/get_verification_by_id.py b/sinch/domains/verification/endpoints/get_verification_by_id.py deleted file mode 100644 index 131e1e75..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_id.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByIdRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByIdResponse - - -class GetVerificationStatusByIdEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/id/{id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByIdRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - id=self.request_data.id - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByIdResponse: - super().handle_response(response) - return GetVerificationStatusByIdResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/get_verification_by_identity.py b/sinch/domains/verification/endpoints/get_verification_by_identity.py deleted file mode 100644 index 88008df5..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_identity.py +++ /dev/null @@ -1,36 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByIdentityRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByIdentityResponse - - -class GetVerificationStatusByIdentityEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/{method}/number/{endpoint}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByIdentityRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - method=self.request_data.method, - endpoint=self.request_data.endpoint - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByIdentityResponse: - super().handle_response(response) - return GetVerificationStatusByIdentityResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/get_verification_by_reference.py b/sinch/domains/verification/endpoints/get_verification_by_reference.py deleted file mode 100644 index 3a5115bb..00000000 --- a/sinch/domains/verification/endpoints/get_verification_by_reference.py +++ /dev/null @@ -1,35 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import GetVerificationStatusByReferenceRequest -from sinch.domains.verification.models.responses import GetVerificationStatusByReferenceResponse - - -class GetVerificationStatusByReferenceEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/reference/{reference}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVerificationStatusByReferenceRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - reference=self.request_data.reference - ) - - def handle_response(self, response: HTTPResponse) -> GetVerificationStatusByReferenceResponse: - super().handle_response(response) - return GetVerificationStatusByReferenceResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/report_verification_using_id.py b/sinch/domains/verification/endpoints/report_verification_using_id.py deleted file mode 100644 index 5b441673..00000000 --- a/sinch/domains/verification/endpoints/report_verification_using_id.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import ReportVerificationByIdRequest -from sinch.domains.verification.models.responses import ReportVerificationByIdResponse - - -class ReportVerificationByIdEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/id/{id}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ReportVerificationByIdRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - id=self.request_data.id - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ReportVerificationByIdResponse: - super().handle_response(response) - return ReportVerificationByIdResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/report_verification_using_identity.py b/sinch/domains/verification/endpoints/report_verification_using_identity.py deleted file mode 100644 index 56d30507..00000000 --- a/sinch/domains/verification/endpoints/report_verification_using_identity.py +++ /dev/null @@ -1,38 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.models.requests import ReportVerificationByIdentityRequest -from sinch.domains.verification.models.responses import ReportVerificationByIdentityResponse - - -class ReportVerificationByIdentityEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications/number/{endpoint}" - HTTP_METHOD = HTTPMethods.PUT.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ReportVerificationByIdentityRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - endpoint=self.request_data.endpoint - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ReportVerificationByIdentityResponse: - super().handle_response(response) - return ReportVerificationByIdentityResponse( - id=response.body.get("id"), - method=response.body.get("method"), - status=response.body.get("status"), - price=response.body.get("price"), - identity=response.body.get("identity"), - country_id=response.body.get("country_id"), - verification_timestamp=response.body.get("verification_timestamp"), - reference=response.body.get("reference"), - reason=response.body.get("reason"), - call_complete=response.body.get("call_complete") - ) diff --git a/sinch/domains/verification/endpoints/start_verification.py b/sinch/domains/verification/endpoints/start_verification.py deleted file mode 100644 index 7a10bf7a..00000000 --- a/sinch/domains/verification/endpoints/start_verification.py +++ /dev/null @@ -1,75 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.verification.endpoints.verification_endpoint import VerificationEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.verification.enums import VerificationMethod -from sinch.domains.verification.models.requests import StartVerificationRequest -from sinch.domains.verification.models.responses import ( - FlashCallResponse, - SMSResponse, - DataResponse, - StartVerificationResponse, - StartSMSVerificationResponse, - StartDataVerificationResponse, - StartPhoneCallVerificationResponse, - StartFlashCallVerificationResponse -) - - -class StartVerificationEndpoint(VerificationEndpoint): - ENDPOINT_URL = "{origin}/verification/v1/verifications" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: StartVerificationRequest): - self.request_data = request_data - - def build_url(self, sinch): - return self.ENDPOINT_URL.format( - origin=sinch.configuration.verification_origin, - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> StartVerificationResponse: - super().handle_response(response) - if self.request_data.method == VerificationMethod.SMS.value: - sms_response = response.body.get("sms") - return StartSMSVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - sms=SMSResponse( - interception_timeout=response.body["sms"].get("interceptionTimeout"), - template=response.body["sms"].get("template") - ) if sms_response else None - ) - elif self.request_data.method == VerificationMethod.FLASH_CALL.value: - flash_call_response = response.body.get("flashCall") - return StartFlashCallVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - flash_call=FlashCallResponse( - cli_filter=response.body["flashCall"].get("cliFilter"), - interception_timeout=response.body["flashCall"].get("interceptionTimeout"), - report_timeout=response.body["flashCall"].get("reportTimeout"), - deny_call_after=response.body["flashCall"].get("denyCallAfter") - ) if flash_call_response else None - ) - elif self.request_data.method == VerificationMethod.CALLOUT.value: - return StartPhoneCallVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links") - ) - elif self.request_data.method == VerificationMethod.SEAMLESS.value: - seamless_response = response.body.get("seamless") - return StartDataVerificationResponse( - id=response.body.get("id"), - method=response.body.get("method"), - _links=response.body.get("_links"), - seamless=DataResponse( - target_uri=response.body["seamless"].get("targetUri") - ) if seamless_response else None - ) diff --git a/sinch/domains/verification/endpoints/verification_endpoint.py b/sinch/domains/verification/endpoints/verification_endpoint.py deleted file mode 100644 index e7898b62..00000000 --- a/sinch/domains/verification/endpoints/verification_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.verification.exceptions import VerificationException - - -class VerificationEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise VerificationException( - message=response.body["message"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/verification/enums.py b/sinch/domains/verification/enums.py deleted file mode 100644 index 26e0212c..00000000 --- a/sinch/domains/verification/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -from enum import Enum - - -class VerificationMethod(Enum): - SMS = "sms" - FLASH_CALL = "flashCall" - CALLOUT = "callout" - SEAMLESS = "seamless" - - -class VerificationStatus(Enum): - PENDING = "PENDING" - SUCCESSFUL = "SUCCESSFUL" - FAIL = "FAIL" - DENIED = "DENIED" - ABORTED = "ABORTED" - ERROR = "ERROR" diff --git a/sinch/domains/verification/exceptions.py b/sinch/domains/verification/exceptions.py deleted file mode 100644 index 91d913a8..00000000 --- a/sinch/domains/verification/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.core.exceptions import SinchException - - -class VerificationException(SinchException): - pass diff --git a/sinch/domains/verification/models/__init__.py b/sinch/domains/verification/models/__init__.py deleted file mode 100644 index a8786da8..00000000 --- a/sinch/domains/verification/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from typing import TypedDict, Literal - - -class VerificationIdentity(TypedDict): - type: Literal["number"] - endpoint: str diff --git a/sinch/domains/verification/models/requests.py b/sinch/domains/verification/models/requests.py deleted file mode 100644 index 6539e768..00000000 --- a/sinch/domains/verification/models/requests.py +++ /dev/null @@ -1,212 +0,0 @@ -import json -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.verification.enums import VerificationMethod -from sinch.domains.verification.models import VerificationIdentity - - -class ReportPhoneCallVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "callout": {}} - - if request_data.get("code"): - payload["callout"]["code"] = request_data["code"] - - return payload - - -class ReportFlashCallVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "flashCall": {}} - - if request_data.get("cli"): - payload["flashCall"]["cli"] = request_data["cli"] - - return payload - - -class ReportSMSVerificationDataTransformationMixin: - def as_dict(self): - request_data = super().as_dict() - payload = {"method": request_data["method"], "sms": {}} - - if request_data.get("code"): - payload["sms"]["code"] = request_data["code"] - - if request_data.get("cli"): - payload["sms"]["cli"] = request_data["cli"] - - return payload - - -@dataclass -class StartVerificationRequest(SinchRequestBaseModel): - identity: VerificationIdentity - reference: str - custom: str - - -@dataclass -class StartSMSVerificationRequest(StartVerificationRequest): - expiry: str - code_type: str - template: str - method: str = VerificationMethod.SMS.value - - def as_dict(self): - payload = super().as_dict() - payload["smsOptions"] = {} - - if payload.get("code_type"): - payload["smsOptions"].update({ - "codeType": payload.pop("code_type") - }) - elif payload.get("expiry"): - payload["smsOptions"].update({ - "expiry": payload.pop("expiry") - }) - elif payload.get("template"): - payload["smsOptions"].update({ - "template": payload.pop("template") - }) - return payload - - -@dataclass -class StartFlashCallVerificationRequest(StartVerificationRequest): - dial_timeout: int - method: str = VerificationMethod.FLASH_CALL.value - - def as_dict(self): - payload = super().as_dict() - if payload.get("dial_timeout"): - payload["flashCallOptions"] = { - "dialTimeout": payload.pop("dial_timeout") - } - return payload - - -@dataclass -class StartPhoneCallVerificationRequest(StartVerificationRequest): - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class StartCalloutVerificationRequest(StartVerificationRequest): - speech_locale: str - method: str = VerificationMethod.CALLOUT.value - - def as_dict(self): - payload = super().as_dict() - if payload.get("speech_locale"): - payload["calloutOptions"] = { - "speech": { - "locale": payload.pop("speech_locale") - } - } - return payload - - -@dataclass -class StartDataVerificationRequest(StartVerificationRequest): - method: str = VerificationMethod.SEAMLESS.value - - -@dataclass -class ReportVerificationByIdentityRequest(SinchRequestBaseModel): - endpoint: str - - -@dataclass -class ReportVerificationByIdentityAndSMSRequest( - ReportSMSVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - code: str - cli: str - method: str = VerificationMethod.SMS.value - - -@dataclass -class ReportVerificationByIdentityAndFlashCallRequest( - ReportFlashCallVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - cli: str - method: str = VerificationMethod.FLASH_CALL.value - - -@dataclass -class ReportVerificationByIdentityAndPhoneCallRequest( - ReportPhoneCallVerificationDataTransformationMixin, - ReportVerificationByIdentityRequest -): - code: str - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class ReportVerificationByIdRequest(SinchRequestBaseModel): - id: str - - -@dataclass -class ReportVerificationByIdAndSMSRequest( - ReportSMSVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - code: str - cli: str - method: str = VerificationMethod.SMS.value - - -@dataclass -class ReportVerificationByIdAndFlashCallRequest( - ReportFlashCallVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - cli: str - method: str = VerificationMethod.FLASH_CALL.value - - -@dataclass -class ReportVerificationByIdAndPhoneCallRequest( - ReportPhoneCallVerificationDataTransformationMixin, - ReportVerificationByIdRequest -): - code: str - method: str = VerificationMethod.CALLOUT.value - - -@dataclass -class GetVerificationStatusByReferenceRequest(SinchRequestBaseModel): - reference: str - - -@dataclass -class GetVerificationStatusByIdentityRequest(SinchRequestBaseModel): - endpoint: str - method: str - - -@dataclass -class GetVerificationStatusByIdRequest(SinchRequestBaseModel): - id: str - - -@dataclass -class ReportVerificationByIdentityRequestLegacy(ReportVerificationByIdentityRequest): - verification_report_request: dict - - def as_json(self): - return json.dumps(self.verification_report_request) - - -@dataclass -class ReportVerificationByIdRequestLegacy(ReportVerificationByIdRequest): - verification_report_request: dict - - def as_json(self): - return json.dumps(self.verification_report_request) diff --git a/sinch/domains/verification/models/responses.py b/sinch/domains/verification/models/responses.py deleted file mode 100644 index 5a55aabc..00000000 --- a/sinch/domains/verification/models/responses.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.verification.enums import VerificationMethod, VerificationStatus - - -@dataclass -class FlashCallResponse: - cli_filter: str - interception_timeout: int - report_timeout: int - deny_call_after: int - - -@dataclass -class SMSResponse: - template: str - interception_timeout: str - - -@dataclass -class DataResponse: - target_uri: str - - -@dataclass -class StartVerificationResponse(SinchBaseModel): - id: str - method: VerificationMethod - _links: list - - -@dataclass -class StartFlashCallInitiateVerificationResponse(StartVerificationResponse): - flash_call: FlashCallResponse - - -@dataclass -class StartDataInitiateVerificationResponse(StartVerificationResponse): - seamless: DataResponse - - -@dataclass -class StartCalloutInitiateVerificationResponse(StartVerificationResponse): - pass - - -@dataclass -class StartSMSVerificationResponse(StartVerificationResponse): - sms: SMSResponse - - -@dataclass -class StartFlashCallVerificationResponse(StartVerificationResponse): - flash_call: FlashCallResponse - - -@dataclass -class StartDataVerificationResponse(StartVerificationResponse): - seamless: DataResponse - - -@dataclass -class StartPhoneCallVerificationResponse(StartVerificationResponse): - pass - - -@dataclass -class VerificationResponse(SinchBaseModel): - id: str - method: VerificationMethod - status: VerificationStatus - price: dict - identity: dict - country_id: str - verification_timestamp: str - reference: str - reason: str - call_complete: bool - - -@dataclass -class GetVerificationStatusByIdResponse(VerificationResponse): - pass - - -@dataclass -class ReportVerificationResponse(VerificationResponse): - pass - - -@dataclass -class ReportVerificationByIdentityResponse(ReportVerificationResponse): - pass - - -@dataclass -class ReportVerificationByIdResponse(ReportVerificationResponse): - pass - - -@dataclass -class GetVerificationStatusByReferenceResponse(VerificationResponse): - pass - - -@dataclass -class GetVerificationStatusByIdentityResponse(VerificationResponse): - pass diff --git a/sinch/domains/voice/__init__.py b/sinch/domains/voice/__init__.py deleted file mode 100644 index 7cc1c647..00000000 --- a/sinch/domains/voice/__init__.py +++ /dev/null @@ -1,412 +0,0 @@ -from typing import List, Literal, Union -from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint -from sinch.domains.voice.endpoints.calls.get_call import GetCallEndpoint -from sinch.domains.voice.endpoints.calls.update_call import UpdateCallEndpoint -from sinch.domains.voice.endpoints.calls.manage_call import ManageCallEndpoint - -from sinch.domains.voice.endpoints.applications.get_numbers import GetVoiceNumbersEndpoint -from sinch.domains.voice.endpoints.applications.query_number import QueryVoiceNumberEndpoint -from sinch.domains.voice.endpoints.applications.get_callback_urls import GetVoiceCallbacksEndpoint -from sinch.domains.voice.endpoints.applications.unassign_number import UnAssignVoiceNumberEndpoint -from sinch.domains.voice.endpoints.applications.assign_numbers import AssignVoiceNumbersEndpoint -from sinch.domains.voice.endpoints.applications.update_callbacks import UpdateVoiceCallbacksEndpoint - -from sinch.domains.voice.endpoints.conferences.kick_participant import KickParticipantConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.kick_all_participants import KickAllConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.manage_participant import ManageParticipantConferenceEndpoint -from sinch.domains.voice.endpoints.conferences.get_conference import GetConferenceEndpoint - -from sinch.domains.voice.enums import CalloutMethod -from sinch.domains.voice.models.callouts.responses import VoiceCalloutResponse -from sinch.domains.voice.models.callouts.requests import ( - ConferenceVoiceCalloutRequest, - TextToSpeechVoiceCalloutRequest, - CustomVoiceCalloutRequest, - Destination, - ConferenceDTMFOptions -) -from sinch.domains.voice.models.calls.requests import ( - GetVoiceCallRequest, - UpdateVoiceCallRequest, - ManageVoiceCallRequest -) -from sinch.domains.voice.models.calls.responses import ( - GetVoiceCallResponse, - UpdateVoiceCallResponse, - ManageVoiceCallResponse -) -from sinch.domains.voice.models.conferences.requests import ( - GetVoiceConferenceRequest, - KickAllVoiceConferenceRequest, - KickParticipantVoiceConferenceRequest, - ManageParticipantVoiceConferenceRequest -) -from sinch.domains.voice.models.conferences.responses import ( - GetVoiceConferenceResponse, - KickAllVoiceConferenceResponse, - ManageParticipantVoiceConferenceResponse, - KickParticipantVoiceConferenceResponse -) -from sinch.domains.voice.models.applications.requests import ( - AssignNumbersVoiceApplicationRequest, - UnassignNumbersVoiceApplicationRequest, - QueryNumberVoiceApplicationRequest, - UpdateCallbackUrlsVoiceApplicationRequest, - GetCallbackUrlsVoiceApplicationRequest -) -from sinch.domains.voice.models.applications.responses import ( - GetNumbersVoiceApplicationResponse, - AssignNumbersVoiceApplicationResponse, - UnassignNumbersVoiceApplicationResponse, - GetCallbackUrlsVoiceApplicationResponse, - QueryNumberVoiceApplicationResponse -) -from sinch.domains.voice.models.svaml.actions.actions import Action -from sinch.domains.voice.models.svaml.instructions.instructions import Instruction - - -class Callouts: - def __init__(self, sinch): - self._sinch = sinch - - def text_to_speech( - self, - destination: Destination, - cli: str = None, - dtmf: str = None, - domain: Literal["pstn", "mxp"] = None, - custom: str = None, - locale: str = None, - text: str = None, - prompts: str = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.TEXT_TO_SPEECH.value, - request_data=TextToSpeechVoiceCalloutRequest( - destination=destination, - cli=cli, - dtmf=dtmf, - domain=domain, - custom=custom, - locale=locale, - text=text, - prompts=prompts, - enableAce=enable_ace, - enableDice=enable_dice, - enablePie=enable_pie - ) - ) - ) - - def conference( - self, - destination: Destination, - conference_id: str, - cli: str = None, - conference_dtmf_options: ConferenceDTMFOptions = None, - dtmf: str = None, - conference: str = None, - max_duration: int = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None, - locale: str = None, - greeting: str = None, - moh_class: str = None, - custom: str = None, - domain: Literal["pstn", "mxp"] = None, - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.CONFERENCE.value, - request_data=ConferenceVoiceCalloutRequest( - destination=destination, - conferenceId=conference_id, - cli=cli, - conferenceDtmfOptions=conference_dtmf_options, - dtmf=dtmf, - conference=conference, - maxDuration=max_duration, - enableAce=enable_ace, - enableDice=enable_dice, - enablePie=enable_pie, - locale=locale, - greeting=greeting, - mohClass=moh_class, - custom=custom, - domain=domain - ) - ) - ) - - def custom( - self, - cli: str = None, - destination: Destination = None, - dtmf: str = None, - custom: str = None, - max_duration: int = None, - ice: str = None, - ace: str = None, - pie: str = None - ) -> VoiceCalloutResponse: - return self._sinch.configuration.transport.request( - CalloutEndpoint( - callout_method=CalloutMethod.CUSTOM.value, - request_data=CustomVoiceCalloutRequest( - cli=cli, - destination=destination, - dtmf=dtmf, - custom=custom, - maxDuration=max_duration, - ice=ice, - ace=ace, - pie=pie - ) - ) - ) - - -class Calls: - def __init__(self, sinch): - self._sinch = sinch - - def get(self, call_id) -> GetVoiceCallResponse: - return self._sinch.configuration.transport.request( - GetCallEndpoint( - request_data=GetVoiceCallRequest( - call_id=call_id - ) - ) - ) - - def update( - self, - call_id: str, - instructions: Union[list, List[Instruction]], - action: Union[dict, Action] - ) -> UpdateVoiceCallResponse: - return self._sinch.configuration.transport.request( - UpdateCallEndpoint( - request_data=UpdateVoiceCallRequest( - call_id=call_id, - instructions=instructions, - action=action - ) - ) - ) - - def manage_with_call_leg( - self, - call_id: str, - call_leg: str, - instructions: Union[list, List[Instruction]], - action: Union[dict, Action] - ) -> ManageVoiceCallResponse: - return self._sinch.configuration.transport.request( - ManageCallEndpoint( - request_data=ManageVoiceCallRequest( - call_id=call_id, - call_leg=call_leg, - instructions=instructions, - action=action - ) - ) - ) - - -class Conferences: - def __init__(self, sinch): - self._sinch = sinch - - def call( - self, - destination: Destination, - conference_id: str, - cli: str = None, - conference_dtmf_options: ConferenceDTMFOptions = None, - dtmf: str = None, - conference: str = None, - max_duration: int = None, - enable_ace: bool = None, - enable_dice: bool = None, - enable_pie: bool = None, - locale: str = None, - greeting: str = None, - moh_class: str = None, - custom: str = None, - domain: Literal["pstn", "mxp"] = None, - ) -> VoiceCalloutResponse: - return self._sinch.voice.callouts.conference( - destination=destination, - conference_id=conference_id, - cli=cli, - conference_dtmf_options=conference_dtmf_options, - dtmf=dtmf, - conference=conference, - max_duration=max_duration, - enable_ace=enable_ace, - enable_dice=enable_dice, - enable_pie=enable_pie, - locale=locale, - greeting=greeting, - moh_class=moh_class, - custom=custom, - domain=domain - ) - - def get(self, conference_id: str) -> GetVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - GetConferenceEndpoint( - request_data=GetVoiceConferenceRequest( - conference_id=conference_id - ) - ) - ) - - def kick_all(self, conference_id: str) -> KickAllVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - KickAllConferenceEndpoint( - request_data=KickAllVoiceConferenceRequest( - conference_id=conference_id - ) - ) - ) - - def kick_participant( - self, - call_id: str, - conference_id: str, - ) -> KickParticipantVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - KickParticipantConferenceEndpoint( - request_data=KickParticipantVoiceConferenceRequest( - call_id=call_id, - conference_id=conference_id - ) - ) - ) - - def manage_participant( - self, - call_id: str, - conference_id: str, - command: str, - moh: str = None - ) -> ManageParticipantVoiceConferenceResponse: - return self._sinch.configuration.transport.request( - ManageParticipantConferenceEndpoint( - request_data=ManageParticipantVoiceConferenceRequest( - call_id=call_id, - conference_id=conference_id, - command=command, - moh=moh - ) - ) - ) - - -class Applications: - def __init__(self, sinch): - self._sinch = sinch - - def get_numbers(self) -> GetNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - GetVoiceNumbersEndpoint() - ) - - def assign_numbers( - self, - numbers: List[str], - application_key: str = None, - capability: str = None - ) -> AssignNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - AssignVoiceNumbersEndpoint( - request_data=AssignNumbersVoiceApplicationRequest( - numbers=numbers, - application_key=application_key, - capability=capability - ) - ) - ) - - def unassign_number( - self, - number: str, - application_key: str = None, - capability: str = None - - ) -> UnassignNumbersVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - UnAssignVoiceNumberEndpoint( - request_data=UnassignNumbersVoiceApplicationRequest( - number=number, - application_key=application_key, - capability=capability - ) - ) - ) - - def get_callback_urls( - self, - application_key: str - ) -> GetCallbackUrlsVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - GetVoiceCallbacksEndpoint( - request_data=GetCallbackUrlsVoiceApplicationRequest( - application_key=application_key - ) - ) - ) - - def update_callback_urls( - self, - application_key: str, - primary: str = None, - fallback: str = None - ): - return self._sinch.configuration.transport.request( - UpdateVoiceCallbacksEndpoint( - request_data=UpdateCallbackUrlsVoiceApplicationRequest( - application_key=application_key, - primary=primary, - fallback=fallback - ) - ) - ) - - def query_number(self, number) -> QueryNumberVoiceApplicationResponse: - return self._sinch.configuration.transport.request( - QueryVoiceNumberEndpoint( - request_data=QueryNumberVoiceApplicationRequest( - number=number - ) - ) - ) - - -class VoiceBase: - """ - Documentation for the Voice API: https://developers.sinch.com/docs/voice/ - """ - def __init__(self, sinch): - self._sinch = sinch - - -class Voice(VoiceBase): - """ - Synchronous version of the Voice Domain - """ - __doc__ += VoiceBase.__doc__ - - def __init__(self, sinch): - super().__init__(sinch) - self.callouts = Callouts(self._sinch) - self.calls = Calls(self._sinch) - self.conferences = Conferences(self._sinch) - self.applications = Applications(self._sinch) diff --git a/sinch/domains/voice/endpoints/__init__.py b/sinch/domains/voice/endpoints/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/applications/__init__.py b/sinch/domains/voice/endpoints/applications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/applications/assign_numbers.py b/sinch/domains/voice/endpoints/applications/assign_numbers.py deleted file mode 100644 index d07bcac4..00000000 --- a/sinch/domains/voice/endpoints/applications/assign_numbers.py +++ /dev/null @@ -1,26 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import AssignNumbersVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import AssignNumbersVoiceApplicationResponse - - -class AssignVoiceNumbersEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: AssignNumbersVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def request_body(self): - return self.request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> AssignNumbersVoiceApplicationResponse: - super().handle_response(response) - return AssignNumbersVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/applications/get_callback_urls.py b/sinch/domains/voice/endpoints/applications/get_callback_urls.py deleted file mode 100644 index 65c15ea7..00000000 --- a/sinch/domains/voice/endpoints/applications/get_callback_urls.py +++ /dev/null @@ -1,27 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.responses import GetCallbackUrlsVoiceApplicationResponse -from sinch.domains.voice.models.applications.requests import GetCallbackUrlsVoiceApplicationRequest - - -class GetVoiceCallbacksEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/callbacks/applications/{application_key}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetCallbackUrlsVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - application_key=self.request_data.application_key - ) - - def handle_response(self, response: HTTPResponse) -> GetCallbackUrlsVoiceApplicationResponse: - super().handle_response(response) - return GetCallbackUrlsVoiceApplicationResponse( - primary=response.body["url"].get("primary"), - fallback=response.body["url"].get("fallback") - ) diff --git a/sinch/domains/voice/endpoints/applications/get_numbers.py b/sinch/domains/voice/endpoints/applications/get_numbers.py deleted file mode 100644 index ceed55a2..00000000 --- a/sinch/domains/voice/endpoints/applications/get_numbers.py +++ /dev/null @@ -1,31 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.domains.voice.models import ApplicationNumber -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.responses import GetNumbersVoiceApplicationResponse - - -class GetVoiceNumbersEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self): - pass - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def handle_response(self, response: HTTPResponse) -> GetNumbersVoiceApplicationResponse: - super().handle_response(response) - return GetNumbersVoiceApplicationResponse( - numbers=[ - ApplicationNumber( - number=number.get("number"), - capability=number.get("capability"), - applicationkey=number.get("applicationkey") - ) for number in response.body["numbers"] - ] - ) diff --git a/sinch/domains/voice/endpoints/applications/query_number.py b/sinch/domains/voice/endpoints/applications/query_number.py deleted file mode 100644 index f5cef057..00000000 --- a/sinch/domains/voice/endpoints/applications/query_number.py +++ /dev/null @@ -1,30 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import QueryNumberVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import QueryNumberVoiceApplicationResponse - - -class QueryVoiceNumberEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/calling/query/number/{number}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: QueryNumberVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - number=self.request_data.number - ) - - def handle_response(self, response: HTTPResponse) -> QueryNumberVoiceApplicationResponse: - super().handle_response(response) - return QueryNumberVoiceApplicationResponse( - country_id=response.body["number"]["countryId"], - number_type=response.body["number"]["numberType"], - normalized_number=response.body["number"]["normalizedNumber"], - restricted=response.body["number"]["restricted"], - rate=response.body["number"]["rate"] - ) diff --git a/sinch/domains/voice/endpoints/applications/unassign_number.py b/sinch/domains/voice/endpoints/applications/unassign_number.py deleted file mode 100644 index a2b6bfd5..00000000 --- a/sinch/domains/voice/endpoints/applications/unassign_number.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import UnassignNumbersVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import UnassignNumbersVoiceApplicationResponse - - -class UnAssignVoiceNumberEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/numbers" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UnassignNumbersVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin - ) - - def request_body(self): - request_data = {} - - if self.request_data.number: - request_data["number"] = self.request_data.number - - if self.request_data.application_key: - request_data["applicationKey"] = self.request_data.application_key - - if self.request_data.capability: - request_data["capability"] = self.request_data.capability - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> UnassignNumbersVoiceApplicationResponse: - super().handle_response(response) - return UnassignNumbersVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/applications/update_callbacks.py b/sinch/domains/voice/endpoints/applications/update_callbacks.py deleted file mode 100644 index f95630e1..00000000 --- a/sinch/domains/voice/endpoints/applications/update_callbacks.py +++ /dev/null @@ -1,38 +0,0 @@ -import json -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.applications.requests import UpdateCallbackUrlsVoiceApplicationRequest -from sinch.domains.voice.models.applications.responses import UpdateCallbackUrlsVoiceApplicationResponse - - -class UpdateVoiceCallbacksEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/v1/configuration/callbacks/applications/{application_key}" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UpdateCallbackUrlsVoiceApplicationRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_applications_origin, - application_key=self.request_data.application_key - ) - - def request_body(self): - request_data = { - "url": {} - } - - if self.request_data.primary: - request_data["url"]["primary"] = self.request_data.primary - - if self.request_data.primary: - request_data["url"]["fallback"] = self.request_data.fallback - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse) -> UpdateCallbackUrlsVoiceApplicationResponse: - super().handle_response(response) - return UpdateCallbackUrlsVoiceApplicationResponse() diff --git a/sinch/domains/voice/endpoints/callouts/__init__.py b/sinch/domains/voice/endpoints/callouts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/callouts/callout.py b/sinch/domains/voice/endpoints/callouts/callout.py deleted file mode 100644 index ef889a05..00000000 --- a/sinch/domains/voice/endpoints/callouts/callout.py +++ /dev/null @@ -1,55 +0,0 @@ -import json -from sinch.domains.voice.enums import CalloutMethod -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.callouts.responses import VoiceCalloutResponse - - -class CalloutEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/callouts" - HTTP_METHOD = HTTPMethods.POST.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data, callout_method): - self.request_data = request_data - self.callout_method = callout_method - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin - ) - - def request_body(self): - request_data = {} - if self.callout_method == CalloutMethod.TEXT_TO_SPEECH.value: - request_data["method"] = CalloutMethod.TEXT_TO_SPEECH.value - request_data[CalloutMethod.TEXT_TO_SPEECH.value] = self.request_data.as_dict() - - elif self.callout_method == CalloutMethod.CUSTOM.value: - request_data["method"] = CalloutMethod.CUSTOM.value - request_data[CalloutMethod.CUSTOM.value] = self.request_data.as_dict() - - elif self.callout_method == CalloutMethod.CONFERENCE.value: - request_data["method"] = CalloutMethod.CONFERENCE.value - if self.request_data.conferenceDtmfOptions: - dtmf_options = {} - - if self.request_data.conferenceDtmfOptions["mode"]: - dtmf_options["mode"] = self.request_data.conferenceDtmfOptions["mode"] - - if self.request_data.conferenceDtmfOptions["timeout_mills"]: - dtmf_options["timeoutMills"] = self.request_data.conferenceDtmfOptions["timeout_mills"] - - if self.request_data.conferenceDtmfOptions["max_digits"]: - dtmf_options["maxDigits"] = self.request_data.conferenceDtmfOptions["max_digits"] - - self.request_data.conferenceDtmfOptions = dtmf_options - - request_data[CalloutMethod.CONFERENCE.value] = self.request_data.as_dict() - - return json.dumps(request_data) - - def handle_response(self, response: HTTPResponse): - super().handle_response(response) - return VoiceCalloutResponse(call_id=response.body["callId"]) diff --git a/sinch/domains/voice/endpoints/calls/__init__.py b/sinch/domains/voice/endpoints/calls/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/calls/get_call.py b/sinch/domains/voice/endpoints/calls/get_call.py deleted file mode 100644 index 5e5b4504..00000000 --- a/sinch/domains/voice/endpoints/calls/get_call.py +++ /dev/null @@ -1,53 +0,0 @@ -from sinch.core.deserializers import timestamp_to_datetime_in_utc_deserializer -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import GetVoiceCallResponse -from sinch.domains.voice.models.calls.requests import GetVoiceCallRequest -from sinch.domains.voice.models import Price, Destination - - -class GetCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id - ) - - def handle_response(self, response: HTTPResponse) -> GetVoiceCallResponse: - super().handle_response(response) - call_origin = response.body.get("from") - call_destination = response.body.get("to") - return GetVoiceCallResponse( - from_=Destination( - type=call_origin["type"], - endpoint=call_origin.get["endpoint"], - ) if call_origin else None, - to=Destination( - type=call_destination.get("type"), - endpoint=call_destination.get("endpoint") - ) if call_destination else None, - domain=response.body.get("domain"), - call_id=response.body.get("callId"), - duration=response.body.get("duration"), - status=response.body.get("status"), - result=response.body.get("result"), - reason=response.body.get("reason"), - timestamp=timestamp_to_datetime_in_utc_deserializer(response.body["timestamp"]), - custom=response.body.get("custom"), - user_rate=Price( - currency_id=response.body["userRate"]["currencyId"], - amount=response.body["userRate"]["amount"] - ), - debit=Price( - currency_id=response.body["userRate"]["currencyId"], - amount=response.body["userRate"]["amount"] - ) - ) diff --git a/sinch/domains/voice/endpoints/calls/manage_call.py b/sinch/domains/voice/endpoints/calls/manage_call.py deleted file mode 100644 index b734f305..00000000 --- a/sinch/domains/voice/endpoints/calls/manage_call.py +++ /dev/null @@ -1,32 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import ManageVoiceCallResponse -from sinch.domains.voice.models.calls.requests import ManageVoiceCallRequest - - -class ManageCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}/leg/{call_leg}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ManageVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id, - call_leg=self.request_data.call_leg - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.call_leg = None - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ManageVoiceCallResponse: - super().handle_response(response) - return ManageVoiceCallResponse() diff --git a/sinch/domains/voice/endpoints/calls/update_call.py b/sinch/domains/voice/endpoints/calls/update_call.py deleted file mode 100644 index 7dd982ef..00000000 --- a/sinch/domains/voice/endpoints/calls/update_call.py +++ /dev/null @@ -1,30 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.calls.responses import UpdateVoiceCallResponse -from sinch.domains.voice.models.calls.requests import UpdateVoiceCallRequest - - -class UpdateCallEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/calls/id/{call_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: UpdateVoiceCallRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - call_id=self.request_data.call_id - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> UpdateVoiceCallResponse: - super().handle_response(response) - return UpdateVoiceCallResponse() diff --git a/sinch/domains/voice/endpoints/conferences/__init__.py b/sinch/domains/voice/endpoints/conferences/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/endpoints/conferences/get_conference.py b/sinch/domains/voice/endpoints/conferences/get_conference.py deleted file mode 100644 index 8f2ec107..00000000 --- a/sinch/domains/voice/endpoints/conferences/get_conference.py +++ /dev/null @@ -1,29 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import GetVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import GetVoiceConferenceRequest -from sinch.domains.voice.models import ConferenceParticipant - - -class GetConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}" - HTTP_METHOD = HTTPMethods.GET.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: GetVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id - ) - - def handle_response(self, response: HTTPResponse) -> GetVoiceConferenceResponse: - super().handle_response(response) - return GetVoiceConferenceResponse( - participants=[ - ConferenceParticipant(**participant) for participant in response.body["participants"] - ] - ) diff --git a/sinch/domains/voice/endpoints/conferences/kick_all_participants.py b/sinch/domains/voice/endpoints/conferences/kick_all_participants.py deleted file mode 100644 index 7f3e5dc5..00000000 --- a/sinch/domains/voice/endpoints/conferences/kick_all_participants.py +++ /dev/null @@ -1,24 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import KickAllVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import KickAllVoiceConferenceRequest - - -class KickAllConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: KickAllVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id - ) - - def handle_response(self, response: HTTPResponse) -> KickAllVoiceConferenceResponse: - super().handle_response(response) - return KickAllVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/conferences/kick_participant.py b/sinch/domains/voice/endpoints/conferences/kick_participant.py deleted file mode 100644 index 2dd0b4ee..00000000 --- a/sinch/domains/voice/endpoints/conferences/kick_participant.py +++ /dev/null @@ -1,25 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import KickParticipantVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import KickParticipantVoiceConferenceRequest - - -class KickParticipantConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}/{call_id}" - HTTP_METHOD = HTTPMethods.DELETE.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: KickParticipantVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id, - call_id=self.request_data.call_id - ) - - def handle_response(self, response: HTTPResponse) -> KickParticipantVoiceConferenceResponse: - super().handle_response(response) - return KickParticipantVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/conferences/manage_participant.py b/sinch/domains/voice/endpoints/conferences/manage_participant.py deleted file mode 100644 index 593f9e3b..00000000 --- a/sinch/domains/voice/endpoints/conferences/manage_participant.py +++ /dev/null @@ -1,32 +0,0 @@ -from copy import deepcopy -from sinch.core.models.http_response import HTTPResponse -from sinch.domains.voice.endpoints.voice_endpoint import VoiceEndpoint -from sinch.core.enums import HTTPAuthentication, HTTPMethods -from sinch.domains.voice.models.conferences.responses import ManageParticipantVoiceConferenceResponse -from sinch.domains.voice.models.conferences.requests import ManageParticipantVoiceConferenceRequest - - -class ManageParticipantConferenceEndpoint(VoiceEndpoint): - ENDPOINT_URL = "{origin}/calling/v1/conferences/id/{conference_id}/{call_id}" - HTTP_METHOD = HTTPMethods.PATCH.value - HTTP_AUTHENTICATION = HTTPAuthentication.SIGNED.value - - def __init__(self, request_data: ManageParticipantVoiceConferenceRequest): - self.request_data = request_data - - def build_url(self, sinch) -> str: - return self.ENDPOINT_URL.format( - origin=sinch.configuration.voice_origin, - conference_id=self.request_data.conference_id, - call_id=self.request_data.call_id - ) - - def request_body(self): - request_data = deepcopy(self.request_data) - request_data.conference_id = None - request_data.call_id = None - return request_data.as_json() - - def handle_response(self, response: HTTPResponse) -> ManageParticipantVoiceConferenceResponse: - super().handle_response(response) - return ManageParticipantVoiceConferenceResponse() diff --git a/sinch/domains/voice/endpoints/voice_endpoint.py b/sinch/domains/voice/endpoints/voice_endpoint.py deleted file mode 100644 index 05950720..00000000 --- a/sinch/domains/voice/endpoints/voice_endpoint.py +++ /dev/null @@ -1,13 +0,0 @@ -from sinch.core.models.http_response import HTTPResponse -from sinch.core.endpoint import HTTPEndpoint -from sinch.domains.voice.exceptions import VoiceException - - -class VoiceEndpoint(HTTPEndpoint): - def handle_response(self, response: HTTPResponse): - if response.status_code >= 400: - raise VoiceException( - message=response.body["message"], - response=response, - is_from_server=True - ) diff --git a/sinch/domains/voice/enums.py b/sinch/domains/voice/enums.py deleted file mode 100644 index 40de184d..00000000 --- a/sinch/domains/voice/enums.py +++ /dev/null @@ -1,82 +0,0 @@ -from enum import Enum - - -class CalloutMethod(Enum): - TEXT_TO_SPEECH = "ttsCallout" - CUSTOM = "customCallout" - CONFERENCE = "conferenceCallout" - - -class Region(Enum): - EUROPE = "euc1" - NORTH_AMERICA = "use1" - SOUTH_AMERICA = "sae1" - SOUTH_EAST_ASIA_1 = "apse1" - SOUTH_EAST_ASIA_2 = "apse2" - - -class ConferenceCommand(Enum): - MUTE = "mute" - UNMUTE = "unmute" - ONHOLD = "onhold" - RESUME = "resume" - - -class MusicOnHold(Enum): - RING = "ring" - MUSIC_1 = "music1" - MUSIC_2 = "music2" - MUSIC_3 = "music3" - - -class ConferenceDTMFOptionsMode(Enum): - IGNORE = "ignore" - FORWARD = "forward" - DETECT = "detect" - - -class Indications(Enum): - AUSTRIA = "at" - AUSTRALIA = "au" - BULGARIA = "bg" - BRAZIL = "br" - BELGIUM = "be" - SWITZERLAND = "ch" - CHILE = "cl" - CHINA = "cn" - CZECH_REPUBLIC = "cz" - GERMANY = "de" - DENMARK = "dk" - ESTONIA = "ee" - SPAIN = "es" - FINLAND = "fi" - FRANCE = "fr" - GREECE = "gr" - HUNGARY = "hu" - ISRAEL = "il" - INDIA = "in" - ITALY = "it" - LITHUANIA = "lt" - JAPAN = "jp" - MEXICO = "mx" - MALAYSIA = "my" - NETHERLANDS = "nl" - NORWAY = "no" - NEW_ZEALAND = "nz" - PHILIPPINES = "ph" - POLAND = "pl" - PORTUGAL = "pt" - RUSSIA = "ru" - SWEDEN = "se" - SINGAPORE = "sg" - THAILAND = "th" - UNITED_KINGDOM = "uk" - UNITED_STATES = "us" - TAIWAN = "tw" - VENEZUELA = "ve" - SOUTH_AFRICA = "za" - - -class Capability(Enum): - VOCE = "voice" - SMS = "sms" diff --git a/sinch/domains/voice/exceptions.py b/sinch/domains/voice/exceptions.py deleted file mode 100644 index 630a7802..00000000 --- a/sinch/domains/voice/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.core.exceptions import SinchException - - -class VoiceException(SinchException): - pass diff --git a/sinch/domains/voice/models/__init__.py b/sinch/domains/voice/models/__init__.py deleted file mode 100644 index dc15940d..00000000 --- a/sinch/domains/voice/models/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -from dataclasses import dataclass -from typing import TypedDict, Literal - - -@dataclass -class Price: - currency_id: str - amount: float - - -@dataclass -class ConferenceParticipant: - cli: str - id: str - duration: int - muted: bool - onhold: bool - - -@dataclass -class ApplicationNumber: - number: str - capability: str - applicationkey: str - - -class Destination(TypedDict): - type: Literal["number", "username"] - endpoint: str - - -class ConferenceDTMFOptions(TypedDict): - mode: Literal["ignore", "forward", "detect"] - max_digits: int - timeout_mills: int diff --git a/sinch/domains/voice/models/applications/__init__.py b/sinch/domains/voice/models/applications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/applications/requests.py b/sinch/domains/voice/models/applications/requests.py deleted file mode 100644 index 3d0883f6..00000000 --- a/sinch/domains/voice/models/applications/requests.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import List -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class AssignNumbersVoiceApplicationRequest(SinchRequestBaseModel): - numbers: List[str] - application_key: str - capability: str - - -@dataclass -class UnassignNumbersVoiceApplicationRequest(SinchRequestBaseModel): - number: str - application_key: str - capability: str - - -@dataclass -class QueryNumberVoiceApplicationRequest(SinchRequestBaseModel): - number: str - - -@dataclass -class UpdateCallbackUrlsVoiceApplicationRequest(SinchRequestBaseModel): - application_key: str - primary: str - fallback: str - - -@dataclass -class GetCallbackUrlsVoiceApplicationRequest(SinchRequestBaseModel): - application_key: str diff --git a/sinch/domains/voice/models/applications/responses.py b/sinch/domains/voice/models/applications/responses.py deleted file mode 100644 index a1637d88..00000000 --- a/sinch/domains/voice/models/applications/responses.py +++ /dev/null @@ -1,40 +0,0 @@ -from typing import List - -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import ApplicationNumber, Price - - -@dataclass -class GetNumbersVoiceApplicationResponse(SinchBaseModel): - numbers: List[ApplicationNumber] - - -@dataclass -class AssignNumbersVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class UnassignNumbersVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class UpdateCallbackUrlsVoiceApplicationResponse(SinchBaseModel): - pass - - -@dataclass -class GetCallbackUrlsVoiceApplicationResponse(SinchBaseModel): - primary: str - fallback: str - - -@dataclass -class QueryNumberVoiceApplicationResponse(SinchBaseModel): - country_id: str - number_type: str - normalized_number: str - restricted: bool - rate: Price diff --git a/sinch/domains/voice/models/callouts/__init__.py b/sinch/domains/voice/models/callouts/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/callouts/requests.py b/sinch/domains/voice/models/callouts/requests.py deleted file mode 100644 index 753ce2f5..00000000 --- a/sinch/domains/voice/models/callouts/requests.py +++ /dev/null @@ -1,50 +0,0 @@ -from dataclasses import dataclass -from typing import Literal -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models import Destination, ConferenceDTMFOptions - - -@dataclass -class TextToSpeechVoiceCalloutRequest(SinchRequestBaseModel): - destination: Destination - cli: str - dtmf: str - domain: Literal["pstn", "mxp"] - custom: str - locale: str - text: str - prompts: str - enableAce: bool - enableDice: bool - enablePie: bool - - -@dataclass -class ConferenceVoiceCalloutRequest(SinchRequestBaseModel): - destination: Destination - conferenceId: str - cli: str - conferenceDtmfOptions: ConferenceDTMFOptions - dtmf: str - conference: str - maxDuration: int - enableAce: bool - enableDice: bool - enablePie: bool - locale: str - greeting: str - mohClass: str - custom: str - domain: Literal["pstn", "mxp"] - - -@dataclass -class CustomVoiceCalloutRequest(SinchRequestBaseModel): - cli: str - destination: Destination - dtmf: str - custom: str - maxDuration: int - ice: str - ace: str - pie: str diff --git a/sinch/domains/voice/models/callouts/responses.py b/sinch/domains/voice/models/callouts/responses.py deleted file mode 100644 index 12691078..00000000 --- a/sinch/domains/voice/models/callouts/responses.py +++ /dev/null @@ -1,7 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class VoiceCalloutResponse(SinchBaseModel): - call_id: str diff --git a/sinch/domains/voice/models/calls/__init__.py b/sinch/domains/voice/models/calls/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/calls/requests.py b/sinch/domains/voice/models/calls/requests.py deleted file mode 100644 index a49e29f7..00000000 --- a/sinch/domains/voice/models/calls/requests.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Union, List -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models.svaml.actions.actions import Action -from sinch.domains.voice.models.svaml.instructions.instructions import Instruction - - -@dataclass -class GetVoiceCallRequest(SinchRequestBaseModel): - call_id: str - - -@dataclass -class UpdateVoiceCallRequest(SinchRequestBaseModel): - call_id: str - instructions: Union[list, List[Instruction]] - action: Action - - -@dataclass -class ManageVoiceCallRequest(SinchRequestBaseModel): - call_id: str - call_leg: str - instructions: Union[list, List[Instruction]] - action: Action diff --git a/sinch/domains/voice/models/calls/responses.py b/sinch/domains/voice/models/calls/responses.py deleted file mode 100644 index f0e87efa..00000000 --- a/sinch/domains/voice/models/calls/responses.py +++ /dev/null @@ -1,29 +0,0 @@ -from datetime import datetime -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import Price, Destination - - -@dataclass -class GetVoiceCallResponse(SinchBaseModel): - from_: Destination - to: Destination - domain: str - call_id: str - duration: int - status: str - result: str - reason: str - timestamp: datetime - custom: str - user_rate: Price - debit: Price - - -@dataclass -class UpdateVoiceCallResponse(SinchBaseModel): - pass - - -class ManageVoiceCallResponse(SinchBaseModel): - pass diff --git a/sinch/domains/voice/models/conferences/__init__.py b/sinch/domains/voice/models/conferences/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/conferences/requests.py b/sinch/domains/voice/models/conferences/requests.py deleted file mode 100644 index aca3f0b8..00000000 --- a/sinch/domains/voice/models/conferences/requests.py +++ /dev/null @@ -1,26 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class GetVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - - -@dataclass -class KickAllVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - - -@dataclass -class ManageParticipantVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - call_id: str - command: str - moh: str - - -@dataclass -class KickParticipantVoiceConferenceRequest(SinchRequestBaseModel): - conference_id: str - call_id: str diff --git a/sinch/domains/voice/models/conferences/responses.py b/sinch/domains/voice/models/conferences/responses.py deleted file mode 100644 index ad0c0423..00000000 --- a/sinch/domains/voice/models/conferences/responses.py +++ /dev/null @@ -1,24 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.voice.models import ConferenceParticipant - - -@dataclass -class GetVoiceConferenceResponse(SinchBaseModel): - participants: List[ConferenceParticipant] - - -@dataclass -class KickAllVoiceConferenceResponse(SinchBaseModel): - pass - - -@dataclass -class ManageParticipantVoiceConferenceResponse(SinchBaseModel): - pass - - -@dataclass -class KickParticipantVoiceConferenceResponse(SinchBaseModel): - pass diff --git a/sinch/domains/voice/models/svaml/__init__.py b/sinch/domains/voice/models/svaml/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/sinch/domains/voice/models/svaml/actions/__init__.py b/sinch/domains/voice/models/svaml/actions/__init__.py deleted file mode 100644 index 94f6d89c..00000000 --- a/sinch/domains/voice/models/svaml/actions/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .actions import ( - AnsweringMachineDetection, CallHeader, HangupAction, - ContinueAction, ConnectPstnAction, ConnectMxpAction, - Option, MenuOption, ConnectSipAction, ConnectConfAction, - RunMenuAction, ParkAction -) - -__all__ = [ - "AnsweringMachineDetection", "CallHeader", "HangupAction", - "ContinueAction", "ConnectPstnAction", "ConnectMxpAction", - "Option", "MenuOption", "ConnectSipAction", "ConnectConfAction", - "RunMenuAction", "ParkAction" -] diff --git a/sinch/domains/voice/models/svaml/actions/actions.py b/sinch/domains/voice/models/svaml/actions/actions.py deleted file mode 100644 index 125d6b87..00000000 --- a/sinch/domains/voice/models/svaml/actions/actions.py +++ /dev/null @@ -1,190 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, List, TypedDict -from sinch.core.models.base_model import SinchRequestBaseModel -from sinch.domains.voice.models import Destination, ConferenceDTMFOptions - - -class Action(SinchRequestBaseModel): - name: str - - -class AnsweringMachineDetection(TypedDict): - enabled: bool - - -class CallHeader(TypedDict): - key: str - value: str - - -@dataclass -class HangupAction(Action): - name: str = "hangup" - - -@dataclass -class ContinueAction(Action): - name: str = "continue" - - -@dataclass -class ConnectPstnAction(Action): - name: str = "connectPstn" - number: Optional[str] = None - locale: Optional[str] = None - max_duration: Optional[int] = None - dial_timeout: Optional[int] = None - cli: Optional[str] = None - suppress_callbacks: Optional[bool] = None - dtmf: Optional[str] = None - indications: Optional[str] = None - amd: Optional[AnsweringMachineDetection] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - if payload.get("dial_timeout"): - payload["dialTimeout"] = payload.pop("dial_timeout") - - if payload.get("suppress_callbacks"): - payload["suppressCallbacks"] = payload.pop("suppress_callbacks") - - return payload - - -@dataclass -class ConnectMxpAction(Action): - name: str = "connectMxp" - destination: Optional[Destination] = None - call_headers: Optional[List[CallHeader]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("call_headers"): - payload["callHeaders"] = payload.pop("call_headers") - - return payload - - -@dataclass -class Option(SinchRequestBaseModel): - dtmf: str - action: str - - -@dataclass -class MenuOption(SinchRequestBaseModel): - id: str - main_prompt: Optional[str] = None - repeat_prompt: Optional[str] = None - repeats: Optional[int] = None - max_digits: Optional[int] = None - timeout_mills: Optional[int] = None - max_timeout_mills: Optional[int] = None - options: Optional[List[Option]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("main_prompt"): - payload["mainPrompt"] = payload.pop("main_prompt") - - if payload.get("repeat_prompt"): - payload["repeatPrompt"] = payload.pop("repeat_prompt") - - if payload.get("max_digits"): - payload["maxDigits"] = payload.pop("max_digits") - - if payload.get("timeout_mills"): - payload["timeoutMills"] = payload.pop("timeout_mills") - - if payload.get("max_timeout_mills"): - payload["maxTimeoutMills"] = payload.pop("max_timeout_mills") - - return payload - - -@dataclass -class ConnectSipAction(Action): - destination: Optional[Destination] - name: str = "connectSip" - max_duration: Optional[int] = None - cli: Optional[str] = None - transport: Optional[str] = None - suppress_callbacks: Optional[bool] = None - call_headers: Optional[List[CallHeader]] = None - moh: Optional[str] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - if payload.get("suppress_callbacks"): - payload["suppressCallbacks"] = payload.pop("suppress_callbacks") - - if payload.get("call_headers"): - payload["callHeaders"] = payload.pop("call_headers") - - return payload - - -@dataclass -class ConnectConfAction(Action): - conference_id: str - name: str = "connectConf" - conference_dtmf_options: Optional[ConferenceDTMFOptions] = None - moh: Optional[str] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("conference_id"): - payload["conferenceId"] = payload.pop("conference_id") - - if payload.get("conference_dtmf_options"): - payload["conferenceDtmfOptions"] = payload.pop("conference_dtmf_options") - - return payload - - -@dataclass -class RunMenuAction(Action): - name: str = "runMenu" - barge: Optional[bool] = None - locale: Optional[str] = None - main_menu: Optional[str] = None - enable_voice: Optional[bool] = None - menus: Optional[List[MenuOption]] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("main_menu"): - payload["mainMenu"] = payload.pop("main_menu") - - if payload.get("enable_voice"): - payload["enableVoice"] = payload.pop("enable_voice") - - return payload - - -@dataclass -class ParkAction(Action): - name: str = "park" - locale: Optional[str] = None - intro_prompt: Optional[str] = None - hold_prompt: Optional[str] = None - max_duration: Optional[int] = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("intro_prompt"): - payload["introPrompt"] = payload.pop("intro_prompt") - - if payload.get("hold_prompt"): - payload["holdPrompt"] = payload.pop("hold_prompt") - - if payload.get("max_duration"): - payload["maxDuration"] = payload.pop("max_duration") - - return payload diff --git a/sinch/domains/voice/models/svaml/instructions/__init__.py b/sinch/domains/voice/models/svaml/instructions/__init__.py deleted file mode 100644 index b4cc2553..00000000 --- a/sinch/domains/voice/models/svaml/instructions/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -from .instructions import ( - TranscriptionOptions, RecordingOptions, PlayFileInstruction, - SayInstruction, SendDtmfInstruction, SetCookieInstruction, - AnswerInstruction, StartRecordingInstruction, StopRecordingInstruction -) - -__all__ = [ - "TranscriptionOptions", "RecordingOptions", "PlayFileInstruction", - "SayInstruction", "SendDtmfInstruction", "SetCookieInstruction", - "AnswerInstruction", "StartRecordingInstruction", "StopRecordingInstruction" -] diff --git a/sinch/domains/voice/models/svaml/instructions/instructions.py b/sinch/domains/voice/models/svaml/instructions/instructions.py deleted file mode 100644 index 8b254a24..00000000 --- a/sinch/domains/voice/models/svaml/instructions/instructions.py +++ /dev/null @@ -1,79 +0,0 @@ -from dataclasses import dataclass -from typing import Optional, List -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class Instruction(SinchRequestBaseModel): - pass - - -@dataclass -class TranscriptionOptions(SinchRequestBaseModel): - enabled: str = None - locale: str = None - - -@dataclass -class RecordingOptions(SinchRequestBaseModel): - destination_url: str = None - credentials: str = None - format: str = None - notification_events: str = None - transcription_options: TranscriptionOptions = None - - def as_dict(self): - payload = super().as_dict() - if payload.get("destination_url"): - payload["destinationUrl"] = payload.pop("destination_url") - - if payload.get("notification_events"): - payload["notificationEvents"] = payload.pop("notification_events") - - if payload.get("transcription_options"): - payload["transcriptionOptions"] = payload.pop("transcription_options") - - return payload - - -@dataclass -class PlayFileInstruction(Instruction): - ids: List[List[str]] - locale: str - name: str = "playFiles" - - -@dataclass -class SayInstruction(Instruction): - name: str = "say" - text: Optional[str] = None - locale: Optional[str] = None - - -@dataclass -class SendDtmfInstruction(Instruction): - name: str = "sendDtmf" - value: Optional[str] = None - - -@dataclass -class SetCookieInstruction(Instruction): - name: str = "setCookie" - key: Optional[str] = None - value: Optional[str] = None - - -@dataclass -class AnswerInstruction(Instruction): - name: str = "answer" - - -@dataclass -class StartRecordingInstruction(Instruction): - name: str = "startRecording" - options: Optional[RecordingOptions] = None - - -@dataclass -class StopRecordingInstruction(Instruction): - name: str = "stopRecording" diff --git a/tests/conftest.py b/tests/conftest.py index a8a8f164..3cae4d3c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,9 +45,7 @@ def configure_origin( conversation_origin, templates_origin, auth_origin, - sms_origin, - verification_origin, - voice_origin + sms_origin ): if auth_origin: sinch_client.configuration.auth_origin = auth_origin @@ -65,13 +63,6 @@ def configure_origin( sinch_client.configuration.sms_origin = sms_origin sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin - if verification_origin: - sinch_client.configuration.verification_origin = verification_origin - - if voice_origin: - sinch_client.configuration.voice_origin = voice_origin - sinch_client.configuration.voice_applications_origin = voice_origin - return sinch_client @@ -110,16 +101,6 @@ def sms_origin(): return os.getenv("SMS_ORIGIN") -@pytest.fixture -def verification_origin(): - return os.getenv("VERIFICATION_ORIGIN") - - -@pytest.fixture -def voice_origin(): - return os.getenv("VOICE_ORIGIN") - - @pytest.fixture def templates_origin(): return os.getenv("TEMPLATES_ORIGIN") @@ -130,16 +111,6 @@ def disable_ssl(): return os.getenv("DISABLE_SSL") -@pytest.fixture -def application_key(): - return os.getenv("APPLICATION_KEY") - - -@pytest.fixture -def application_secret(): - return os.getenv("APPLICATION_SECRET") - - @pytest.fixture def service_plan_id(): return os.getenv("SERVICE_PLAN_ID") @@ -208,32 +179,24 @@ def third_int_based_pagination_response(): def sinch_client_sync( key_id, key_secret, - application_key, - application_secret, numbers_origin, conversation_origin, templates_origin, auth_origin, sms_origin, - verification_origin, - voice_origin, project_id ): return configure_origin( SinchClient( key_id=key_id, key_secret=key_secret, - project_id=project_id, - application_key=application_key, - application_secret=application_secret + project_id=project_id ), numbers_origin, conversation_origin, templates_origin, auth_origin, - sms_origin, - verification_origin, - voice_origin + sms_origin ) diff --git a/tests/unit/domains/voice/test_callout_conference.py b/tests/unit/domains/voice/test_callout_conference.py deleted file mode 100644 index 83581547..00000000 --- a/tests/unit/domains/voice/test_callout_conference.py +++ /dev/null @@ -1,80 +0,0 @@ -import json -import pytest -from sinch.domains.voice.enums import CalloutMethod - -from sinch.domains.voice.endpoints.callouts.callout import CalloutEndpoint - -from sinch.domains.voice.models.callouts.requests import ConferenceVoiceCalloutRequest - - -@pytest.fixture -def request_data(): - return ConferenceVoiceCalloutRequest( - destination={ - "type": "number", - "endpoint": "+33612345678", - }, - cli="", - greeting='Welcome', - conferenceId="123456", - conferenceDtmfOptions={ - "mode": "forward", - "max_digits": 2, - "timeout_mills": 2500 - }, - dtmf="dtmf", - conference="conference", - maxDuration=10, - enableAce=True, - enableDice=True, - enablePie=True, - locale="locale", - mohClass="moh_class", - custom="custom", - domain="pstn" - ) - - -@pytest.fixture -def endpoint(request_data): - return CalloutEndpoint(request_data, CalloutMethod.CONFERENCE.value) - - -@pytest.fixture -def mock_response_body(): - expected_body = { - "method": "conferenceCallout", - "conferenceCallout": { - "destination": { - "type": "number", - "endpoint": "+33612345678" - }, - "conferenceId": "123456", - "cli": "", - "conferenceDtmfOptions": { - "mode": "forward", - "timeoutMills": 2500, - "maxDigits": 2 - }, - "dtmf": "dtmf", - "conference": "conference", - "maxDuration": 10, - "enableAce": True, - "enableDice": True, - "enablePie": True, - "locale": "locale", - "greeting": "Welcome", - "mohClass": "moh_class", - "custom": "custom", - "domain": "pstn" - } - } - return json.dumps(expected_body) - - -def test_handle_response(endpoint, mock_response_body): - """ - Check if response is handled and mapped to the appropriate fields correctly. - """ - request_body = endpoint.request_body() - assert request_body == mock_response_body diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py index 1d2ebf97..bee82710 100644 --- a/tests/unit/http_transport_tests.py +++ b/tests/unit/http_transport_tests.py @@ -37,8 +37,6 @@ def mock_sinch(): sinch.configuration.key_id = "test_key_id" sinch.configuration.key_secret = "test_key_secret" sinch.configuration.project_id = "test_project_id" - sinch.configuration.application_key = "test_app_key" - sinch.configuration.application_secret = "dGVzdF9hcHBfc2VjcmV0X2Jhc2U2NA==" sinch.configuration.sms_api_token = "test_sms_token" sinch.configuration.service_plan_id = "test_service_plan" return sinch @@ -68,7 +66,6 @@ class TestHTTPTransport: @pytest.mark.parametrize("auth_type", [ HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value, - HTTPAuthentication.SIGNED.value, HTTPAuthentication.SMS_TOKEN.value ]) def test_authenticate(self, mock_sinch, base_request, auth_type): @@ -85,11 +82,6 @@ def test_authenticate(self, mock_sinch, base_request, auth_type): assert result.headers["Authorization"] == "Bearer test_token" assert result.headers["Content-Type"] == "application/json" - elif auth_type == HTTPAuthentication.SIGNED.value: - result = transport.authenticate(endpoint, base_request) - assert "x-timestamp" in result.headers - assert "Authorization" in result.headers - elif auth_type == HTTPAuthentication.SMS_TOKEN.value: result = transport.authenticate(endpoint, base_request) assert result.headers["Authorization"] == "Bearer test_sms_token" @@ -98,7 +90,6 @@ def test_authenticate(self, mock_sinch, base_request, auth_type): @pytest.mark.parametrize("auth_type,missing_creds", [ (HTTPAuthentication.BASIC.value, {"key_id": None}), (HTTPAuthentication.OAUTH.value, {"key_secret": None}), - (HTTPAuthentication.SIGNED.value, {"application_key": None}), (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) ]) def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index de7efd87..ce63624e 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -35,8 +35,7 @@ def test_sinch_client_expects_all_attributes(): assert hasattr(sinch_client, "sms") assert hasattr(sinch_client, "conversation") assert hasattr(sinch_client, "numbers") - assert hasattr(sinch_client, "verification") - assert hasattr(sinch_client, "voice") + assert hasattr(sinch_client, "number_lookup") assert hasattr(sinch_client, "configuration") assert isinstance(sinch_client.configuration, Configuration) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py index 571f9c5f..db790b67 100644 --- a/tests/unit/test_configuration.py +++ b/tests/unit/test_configuration.py @@ -15,8 +15,6 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): project_id="CapybaraProjectX", logger=getLogger("CapyTrace"), connection_timeout=10, - application_key="AppybaraKey", - application_secret="SecretHabitatEntry", service_plan_id="CappyPremiumPlan", sms_api_token="HappyCappyToken", sms_region="us", @@ -27,8 +25,6 @@ def test_configuration_happy_capy_expects_initialization(sinch_client_sync): assert client_configuration.key_secret == "CapybaraWhisper" assert client_configuration.project_id == "CapybaraProjectX" assert isinstance(client_configuration.logger, Logger) - assert client_configuration.application_key == "AppybaraKey" - assert client_configuration.application_secret == "SecretHabitatEntry" assert client_configuration.service_plan_id == "CappyPremiumPlan" assert client_configuration.sms_api_token == "HappyCappyToken" assert client_configuration.sms_region == "us" From feb3817ddf002c32f165c3e45713eaccd248a5b3 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 4 Mar 2026 09:16:52 +0100 Subject: [PATCH 093/106] DEVEXP-1228: Conversation Getting Started (SMS) (#125) --- .../send_handle_incoming_sms/.env.example | 16 +++ .../send_handle_incoming_sms/README.md | 100 ++++++++++++++++++ .../send_handle_incoming_sms/controller.py | 36 +++++++ .../send_handle_incoming_sms/pyproject.toml | 15 +++ .../send_handle_incoming_sms/server.py | 62 +++++++++++ .../server_business_logic.py | 52 +++++++++ 6 files changed, 281 insertions(+) create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/.env.example create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/README.md create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/controller.py create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/server.py create mode 100644 examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/.env.example b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example new file mode 100644 index 00000000..9716be1d --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example @@ -0,0 +1,16 @@ +# Sinch credentials (from dashboard.sinch.com → Access Keys) +SINCH_PROJECT_ID= +SINCH_KEY_ID= +SINCH_KEY_SECRET= + +# Conversation API: existing app (already created and configured for SMS). +# SINCH_CONVERSATION_REGION is required. +# Set it to the same region as the one your app was created in (e.g. eu). +CONVERSATION_APP_ID= +SINCH_CONVERSATION_REGION= + +# Webhook secret (set when configuring the callback in Sinch dashboard) +CONVERSATION_WEBHOOKS_SECRET= + +# Server +SERVER_PORT=3001 diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md new file mode 100644 index 00000000..ee2fda88 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -0,0 +1,100 @@ +# Getting Started: Receive Mobile-originated (MO) SMS and send Mobile-terminated (MT) reply (Conversation API) + + +This directory contains a small server built with the [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) +that receives mobile-originated (MO) SMS on your Sinch number and sends a mobile-terminated (MT) SMS back +to the same phone. The reply echoes the incoming text (e.g. *"Your message said: <content of MO>"*) so you can +see that the MO was received and processed. + + + +## Requirements + +- [Python 3.9+](https://www.python.org/) +- [Flask](https://flask.palletsprojects.com/en/stable/) +- [Sinch account](https://dashboard.sinch.com/) +- An existing Conversation API app configured for SMS (with a Sinch number) +- [ngrok](https://ngrok.com/docs) (or similar) to expose your local server +- [Poetry](https://python-poetry.org/) + +## Configuration + +1. **Environment variables** + Copy [.env.example](.env.example) to `.env` in this directory, then set your credentials and app settings. + + - Sinch credentials (from the Sinch dashboard, Access Keys): + ``` + SINCH_PROJECT_ID=your_project_id + SINCH_KEY_ID=your_key_id + SINCH_KEY_SECRET=your_key_secret + ``` + + - Conversation API app (existing app, already configured for SMS). Set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`): + ``` + CONVERSATION_APP_ID=your_conversation_app_id + SINCH_CONVERSATION_REGION= + ``` + + - Webhook secret (the value you set when configuring the callback URL for this app). + See [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks): + ``` + CONVERSATION_WEBHOOKS_SECRET=your_webhook_secret + ``` + + - Server port (optional; default 3001): + ``` + SERVER_PORT=3001 + ``` + +2. **Install dependencies** + From this directory: + ```bash + poetry install + ``` + Install the Sinch SDK from the **repository root**: `pip install -e .` (recommended when developing from this repo). + Alternatively, install with pip: `flask`, `python-dotenv`, and `sinch` (e.g. from PyPI). + +## Usage + +### Running the server + +1. Navigate to this directory: + ``` + cd examples/getting-started/conversation/send_handle_incoming_sms + ``` + + +2. Start the server: + ```bash + poetry run python server.py + ``` + Or run it directly: + ```bash + python server.py + ``` + +The server listens on the port set in your `.env` file (default: 3001). + +### Exposing the server with ngrok + +To receive webhooks on your machine, expose the server with a tunnel (e.g. ngrok). + + +```bash +ngrok http 3001 +``` + +You will see output similar to: +``` +Forwarding https://abc123.ngrok-free.app -> http://localhost:3001 +``` + +Use the **HTTPS** URL when configuring the callback: +`https:///ConversationEvent` + +Configure this callback URL (and the webhook secret) in the Sinch dashboard for your Conversation API app. +The webhook secret must match `CONVERSATION_WEBHOOKS_SECRET` in your `.env`. + +### Sending an SMS to your Sinch number + +Send an SMS from your phone to the **Sinch number** linked to your Conversation API app. You should receive the echo reply on your phone. diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py new file mode 100644 index 00000000..bc1d7178 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -0,0 +1,36 @@ +from flask import request, Response +from server_business_logic import handle_conversation_event + + +class ConversationController: + def __init__(self, sinch_client, webhooks_secret, app_id): + self.sinch_client = sinch_client + self.webhooks_secret = webhooks_secret + self.app_id = app_id + self.logger = self.sinch_client.configuration.logger + + def conversation_event(self): + headers = dict(request.headers) + raw_body = getattr(request, "raw_body", None) or b"" + + webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret) + + # Set to True to enforce signature validation (recommended in production) + ensure_valid_signature = False + if ensure_valid_signature: + valid = webhooks_service.validate_authentication_header( + headers=headers, + json_payload=raw_body, + ) + if not valid: + return Response(status=401) + + event = webhooks_service.parse_event(raw_body, headers) + handle_conversation_event( + event=event, + logger=self.logger, + sinch_client=self.sinch_client, + app_id=self.app_id, + ) + + return Response(status=200) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml new file mode 100644 index 00000000..7d9661ea --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "sinch-getting-started-send-handle-incoming-sms" +version = "0.1.0" +description = "Getting Started: send and handle incoming SMS with Conversation API (DISPATCH, channel identity)" +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "^3.9" +python-dotenv = "^1.0.0" +flask = "^3.0.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server.py b/examples/getting-started/conversation/send_handle_incoming_sms/server.py new file mode 100644 index 00000000..6582205a --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server.py @@ -0,0 +1,62 @@ +import logging +from pathlib import Path + + +from flask import Flask, request +from dotenv import dotenv_values + +from sinch import SinchClient +from controller import ConversationController + +app = Flask(__name__) + + +def load_config(): + current_dir = Path(__file__).resolve().parent + env_file = current_dir / ".env" + if not env_file.exists(): + raise FileNotFoundError(f"Missing .env in {current_dir}. Copy from .env.example.") + return dict(dotenv_values(env_file)) + + +config = load_config() +port = int(config.get("SERVER_PORT") or "3001") +app_id = config.get("CONVERSATION_APP_ID") or "" +webhooks_secret = config.get("CONVERSATION_WEBHOOKS_SECRET") or "" +conversation_region = (config.get("SINCH_CONVERSATION_REGION") or "").strip() +if not conversation_region: + raise ValueError( + "SINCH_CONVERSATION_REGION is required in .env to provide all parameters needed for Conversation API requests. " + "Set it to the same region as the one your Conversation API app was created in (e.g. eu)." + ) + +sinch_client = SinchClient( + project_id=config.get("SINCH_PROJECT_ID", ""), + key_id=config.get("SINCH_KEY_ID", ""), + key_secret=config.get("SINCH_KEY_SECRET", ""), + conversation_region=conversation_region, +) +logging.basicConfig() +sinch_client.configuration.logger.setLevel(logging.INFO) + +conversation_controller = ConversationController( + sinch_client, webhooks_secret, app_id +) + + +@app.before_request +def before_request(): + request.raw_body = request.get_data() + + +app.add_url_rule( + "/ConversationEvent", + methods=["POST"], + view_func=conversation_controller.conversation_event, +) + +if __name__ == "__main__": + print("Getting Started: MO SMS → MT reply (Conversation API, DISPATCH, channel identity)") + print(f"App ID: {app_id or '(set CONVERSATION_APP_ID in .env)'}") + print(f"Listening on port {port}. Expose with: ngrok http {port}") + app.run(port=port) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py new file mode 100644 index 00000000..506f9f73 --- /dev/null +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -0,0 +1,52 @@ +""" +On inbound SMS (MO), send a reply (MT) to the same number: "Your message said: ". +Uses channel identity (SMS + phone number) only; app is in DISPATCH mode. +""" + +from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent + + +def handle_conversation_event(event, logger, sinch_client, app_id): + """Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" + if not isinstance(event, MessageInboundEvent): + return + _handle_message_inbound(event, logger, sinch_client, app_id) + + +def _get_mo_text(event: MessageInboundEvent) -> str: + """Return the inbound message text, or a short placeholder if none.""" + msg = event.message + contact_msg = msg.contact_message + if getattr(contact_msg, "text_message", None): + return contact_msg.text_message.text or "(empty)" + return "(no text content)" + + +def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client, app_id): + """Parse MO, then send MT echo to the same number via Conversation API.""" + msg = event.message + channel_identity = msg.channel_identity + if not channel_identity: + logger.warning("MESSAGE_INBOUND with no channel_identity") + return + + identity = channel_identity.identity + mo_text = _get_mo_text(event) + logger.info("MO SMS from %s: %s", identity, mo_text) + + if not app_id: + logger.warning("CONVERSATION_APP_ID not set; skipping MT reply.") + return + + reply_text = f"Your message said: {mo_text}" + response = sinch_client.conversation.messages.send_text_message( + app_id=app_id, + text=reply_text, + recipient_identities=[{"channel": "SMS", "identity": identity}], + ) + logger.info("MT reply sent to %s (channel identity): %s", identity, reply_text[:60]) + logger.debug( + "Response: message_id=%s accepted_time=%s", + response.message_id, + response.accepted_time + ) From 183aa159b994d2428e73ec19a13ca8ae4cffdca0 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 6 Mar 2026 19:55:00 +0100 Subject: [PATCH 094/106] Remove Webhooks validation from Getting started (#127) --- .../send_handle_incoming_sms/.env.example | 4 ---- .../send_handle_incoming_sms/README.md | 12 ++---------- .../send_handle_incoming_sms/controller.py | 18 ++---------------- .../send_handle_incoming_sms/server.py | 7 +------ .../server_business_logic.py | 9 +++++---- sinch/domains/conversation/conversation.py | 2 +- 6 files changed, 11 insertions(+), 41 deletions(-) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/.env.example b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example index 9716be1d..af1dc362 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/.env.example +++ b/examples/getting-started/conversation/send_handle_incoming_sms/.env.example @@ -6,11 +6,7 @@ SINCH_KEY_SECRET= # Conversation API: existing app (already created and configured for SMS). # SINCH_CONVERSATION_REGION is required. # Set it to the same region as the one your app was created in (e.g. eu). -CONVERSATION_APP_ID= SINCH_CONVERSATION_REGION= -# Webhook secret (set when configuring the callback in Sinch dashboard) -CONVERSATION_WEBHOOKS_SECRET= - # Server SERVER_PORT=3001 diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md index ee2fda88..b9a5e5e4 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/README.md +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -29,18 +29,11 @@ see that the MO was received and processed. SINCH_KEY_SECRET=your_key_secret ``` - - Conversation API app (existing app, already configured for SMS). Set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`): + - Conversation API: set `SINCH_CONVERSATION_REGION` to the same region as the one your app was created in (e.g. `eu`). ``` - CONVERSATION_APP_ID=your_conversation_app_id SINCH_CONVERSATION_REGION= ``` - - Webhook secret (the value you set when configuring the callback URL for this app). - See [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks): - ``` - CONVERSATION_WEBHOOKS_SECRET=your_webhook_secret - ``` - - Server port (optional; default 3001): ``` SERVER_PORT=3001 @@ -92,8 +85,7 @@ Forwarding https://abc123.ngrok-free.app -> http://localhost:3001 Use the **HTTPS** URL when configuring the callback: `https:///ConversationEvent` -Configure this callback URL (and the webhook secret) in the Sinch dashboard for your Conversation API app. -The webhook secret must match `CONVERSATION_WEBHOOKS_SECRET` in your `.env`. +Configure this callback URL in the Sinch dashboard for your Conversation API app. ### Sending an SMS to your Sinch number diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py index bc1d7178..e5816460 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -3,34 +3,20 @@ class ConversationController: - def __init__(self, sinch_client, webhooks_secret, app_id): + def __init__(self, sinch_client): self.sinch_client = sinch_client - self.webhooks_secret = webhooks_secret - self.app_id = app_id self.logger = self.sinch_client.configuration.logger def conversation_event(self): headers = dict(request.headers) raw_body = getattr(request, "raw_body", None) or b"" - webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret) - - # Set to True to enforce signature validation (recommended in production) - ensure_valid_signature = False - if ensure_valid_signature: - valid = webhooks_service.validate_authentication_header( - headers=headers, - json_payload=raw_body, - ) - if not valid: - return Response(status=401) - + webhooks_service = self.sinch_client.conversation.webhooks() event = webhooks_service.parse_event(raw_body, headers) handle_conversation_event( event=event, logger=self.logger, sinch_client=self.sinch_client, - app_id=self.app_id, ) return Response(status=200) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server.py b/examples/getting-started/conversation/send_handle_incoming_sms/server.py index 6582205a..f5805228 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/server.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server.py @@ -21,8 +21,6 @@ def load_config(): config = load_config() port = int(config.get("SERVER_PORT") or "3001") -app_id = config.get("CONVERSATION_APP_ID") or "" -webhooks_secret = config.get("CONVERSATION_WEBHOOKS_SECRET") or "" conversation_region = (config.get("SINCH_CONVERSATION_REGION") or "").strip() if not conversation_region: raise ValueError( @@ -39,9 +37,7 @@ def load_config(): logging.basicConfig() sinch_client.configuration.logger.setLevel(logging.INFO) -conversation_controller = ConversationController( - sinch_client, webhooks_secret, app_id -) +conversation_controller = ConversationController(sinch_client) @app.before_request @@ -57,6 +53,5 @@ def before_request(): if __name__ == "__main__": print("Getting Started: MO SMS → MT reply (Conversation API, DISPATCH, channel identity)") - print(f"App ID: {app_id or '(set CONVERSATION_APP_ID in .env)'}") print(f"Listening on port {port}. Expose with: ngrok http {port}") app.run(port=port) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py index 506f9f73..28107874 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -6,11 +6,11 @@ from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent -def handle_conversation_event(event, logger, sinch_client, app_id): +def handle_conversation_event(event, logger, sinch_client): """Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" if not isinstance(event, MessageInboundEvent): return - _handle_message_inbound(event, logger, sinch_client, app_id) + _handle_message_inbound(event, logger, sinch_client) def _get_mo_text(event: MessageInboundEvent) -> str: @@ -22,7 +22,7 @@ def _get_mo_text(event: MessageInboundEvent) -> str: return "(no text content)" -def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client, app_id): +def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client): """Parse MO, then send MT echo to the same number via Conversation API.""" msg = event.message channel_identity = msg.channel_identity @@ -34,8 +34,9 @@ def _handle_message_inbound(event: MessageInboundEvent, logger, sinch_client, ap mo_text = _get_mo_text(event) logger.info("MO SMS from %s: %s", identity, mo_text) + app_id = event.app_id if not app_id: - logger.warning("CONVERSATION_APP_ID not set; skipping MT reply.") + logger.warning("Event has no app_id; skipping MT reply.") return reply_text = f"Your message said: {mo_text}" diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py index b85cff21..f9f06685 100644 --- a/sinch/domains/conversation/conversation.py +++ b/sinch/domains/conversation/conversation.py @@ -14,7 +14,7 @@ def __init__(self, sinch): self._sinch = sinch self.messages = Messages(self._sinch) - def webhooks(self, callback_secret: str) -> ConversationWebhooks: + def webhooks(self, callback_secret: str = "") -> ConversationWebhooks: """ Create a Conversation API webhooks handler with the given webhook secret. From 6a0c40705e15999a6c96ac223f27aeade808c8e9 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Tue, 10 Mar 2026 17:34:42 +0100 Subject: [PATCH 095/106] DEVEXP-1303: OAS-Synchro - Conversation (#129) --- .../conversation/api/v1/messages_apis.py | 22 ++++++++-------- .../channel_specific_message_content.py | 4 +++ .../channelspecific/line/__init__.py | 23 ++++++++++++++++ .../channelspecific/line/buttons/__init__.py | 7 +++++ ...ne_notification_message_template_button.py | 12 +++++++++ ...line_notification_message_template_body.py | 26 +++++++++++++++++++ ...cation_message_template_emphasized_item.py | 12 +++++++++ ...line_notification_message_template_item.py | 12 +++++++++ ...e_notification_message_template_message.py | 18 +++++++++++++ ...st_messages_by_channel_identity_request.py | 4 +-- .../internal/request/list_messages_request.py | 4 +-- .../internal/request/message_id_request.py | 4 +-- .../update_message_metadata_request.py | 4 +-- .../models/v1/messages/types/__init__.py | 4 +-- .../types/channel_specific_message_type.py | 1 + .../v1/messages/types/messages_source_type.py | 2 +- 16 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py create mode 100644 sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 623b24dd..3c95b3b1 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -21,7 +21,7 @@ ConversationMessagesViewType, MessageContentType, MessageQueueType, - MessagesSourceType, + MessageSourceType, MetadataUpdateStrategyType, ProcessingStrategyType, CardMessageDict, @@ -82,7 +82,7 @@ class Messages(BaseConversation): def delete( self, message_id: str, - messages_source: Optional[MessagesSourceType] = None, + messages_source: Optional[MessageSourceType] = None, **kwargs, ) -> None: """ @@ -97,7 +97,7 @@ def delete( operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). (optional) - :type messages_source: Optional[MessagesSource] + :type messages_source: Optional[MessageSourceType] :param **kwargs: Additional parameters for the request. :type **kwargs: dict @@ -114,7 +114,7 @@ def delete( def get( self, message_id: str, - messages_source: Optional[MessagesSourceType] = None, + messages_source: Optional[MessageSourceType] = None, **kwargs, ) -> ConversationMessageResponse: """ @@ -126,7 +126,7 @@ def get( operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). (optional) - :type messages_source: Optional[MessagesSource] + :type messages_source: Optional[MessageSourceType] :param **kwargs: Additional parameters for the request. :type **kwargs: dict @@ -151,7 +151,7 @@ def list( start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, view: Optional[ConversationMessagesViewType] = None, - messages_source: Optional[MessagesSourceType] = None, + messages_source: Optional[MessageSourceType] = None, only_recipient_originated: Optional[bool] = None, channel: Optional[ConversationChannelType] = None, direction: Optional[ConversationDirectionType] = None, @@ -180,7 +180,7 @@ def list( :param view: Messages view type. WITH_METADATA or WITHOUT_METADATA. :type view: Optional[ConversationMessagesViewType] :param messages_source: Specifies the message source for the request. - :type messages_source: Optional[MessagesSourceType] + :type messages_source: Optional[MessageSourceType] :param only_recipient_originated: Only fetch recipient-originated messages. :type only_recipient_originated: Optional[bool] :param channel: Only fetch messages from the specified channel. @@ -223,7 +223,7 @@ def list_last_messages_by_channel_identity( channel_identities: Optional[List[str]] = None, contact_ids: Optional[List[str]] = None, app_id: Optional[str] = None, - messages_source: Optional[MessagesSourceType] = None, + messages_source: Optional[MessageSourceType] = None, page_size: Optional[int] = None, page_token: Optional[str] = None, view: Optional[ConversationMessagesViewType] = None, @@ -246,7 +246,7 @@ def list_last_messages_by_channel_identity( :param app_id: Optional. Resource name (id) of the app. :type app_id: Optional[str] :param messages_source: Specifies the message source for the request. - :type messages_source: Optional[MessagesSourceType] + :type messages_source: Optional[MessageSourceType] :param page_size: Optional. Maximum number of messages to fetch. Defaults to 10, maximum is 1000. :type page_size: Optional[int] :param page_token: Optional. Next page token previously returned if any. @@ -295,7 +295,7 @@ def update( self, message_id: str, metadata: str, - messages_source: Optional[MessagesSourceType] = None, + messages_source: Optional[MessageSourceType] = None, **kwargs, ) -> ConversationMessageResponse: """ @@ -309,7 +309,7 @@ def update( operations on messages in Dispatch Mode. Defaults to `CONVERSATION_SOURCE` when not specified. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/). (optional) - :type messages_source: Optional[MessagesSource] + :type messages_source: Optional[MessageSourceType] :param **kwargs: Additional parameters for the request. :type **kwargs: dict diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py index d7e5eac7..06aa2e1c 100644 --- a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/channel_specific_message_content.py @@ -6,6 +6,9 @@ from sinch.domains.conversation.models.v1.messages.categories.channelspecific.kakaotalk.commerce.kakaotalk_commerce_channel_specific_message import ( KakaoTalkCommerceChannelSpecificMessage, ) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_message import ( + LineNotificationMessageTemplateMessage, +) from sinch.domains.conversation.models.v1.messages.categories.channelspecific.whatsapp.flows.flow_channel_specific_message import ( FlowChannelSpecificMessage, ) @@ -22,4 +25,5 @@ PaymentOrderStatusChannelSpecificMessage, KakaoTalkCommerceChannelSpecificMessage, KakaoTalkCarouselCommerceChannelSpecificMessage, + LineNotificationMessageTemplateMessage, ] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py new file mode 100644 index 00000000..ad72d09b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/__init__.py @@ -0,0 +1,23 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_emphasized_item import ( + LineNotificationMessageTemplateEmphasizedItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_item import ( + LineNotificationMessageTemplateItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons import ( + LineNotificationMessageTemplateButton, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_body import ( + LineNotificationMessageTemplateBody, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_message import ( + LineNotificationMessageTemplateMessage, +) + +__all__ = [ + "LineNotificationMessageTemplateEmphasizedItem", + "LineNotificationMessageTemplateItem", + "LineNotificationMessageTemplateButton", + "LineNotificationMessageTemplateBody", + "LineNotificationMessageTemplateMessage", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py new file mode 100644 index 00000000..93ca8ffe --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/__init__.py @@ -0,0 +1,7 @@ +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons.line_notification_message_template_button import ( + LineNotificationMessageTemplateButton, +) + +__all__ = [ + "LineNotificationMessageTemplateButton", +] diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py new file mode 100644 index 00000000..7ef0fd1b --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/buttons/line_notification_message_template_button.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateButton(BaseModelConfiguration): + button_key: StrictStr = Field( + ..., + description="Button key. See LINE documentation for available keys.", + ) + url: StrictStr = Field(..., description="Button URL.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py new file mode 100644 index 00000000..d71a9bc6 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_body.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import Field, conlist +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_emphasized_item import ( + LineNotificationMessageTemplateEmphasizedItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_item import ( + LineNotificationMessageTemplateItem, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.buttons import ( + LineNotificationMessageTemplateButton, +) + + +class LineNotificationMessageTemplateBody(BaseModelConfiguration): + emphasized_item: Optional[ + LineNotificationMessageTemplateEmphasizedItem + ] = Field(default=None, description="Template emphasized item.") + items: Optional[conlist(LineNotificationMessageTemplateItem)] = Field( + default=None, description="List of template items." + ) + buttons: Optional[conlist(LineNotificationMessageTemplateButton)] = Field( + default=None, description="List of template buttons." + ) diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py new file mode 100644 index 00000000..66adc2f3 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_emphasized_item.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateEmphasizedItem(BaseModelConfiguration): + item_key: StrictStr = Field( + ..., + description="Item key. See LINE documentation for available keys.", + ) + content: StrictStr = Field(..., description="Item value.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py new file mode 100644 index 00000000..a6a10e42 --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_item.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) + + +class LineNotificationMessageTemplateItem(BaseModelConfiguration): + item_key: StrictStr = Field( + ..., + description="Item key. See LINE documentation for available keys.", + ) + content: StrictStr = Field(..., description="Item value.") diff --git a/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py new file mode 100644 index 00000000..4a02ed0a --- /dev/null +++ b/sinch/domains/conversation/models/v1/messages/categories/channelspecific/line/line_notification_message_template_message.py @@ -0,0 +1,18 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.conversation.models.v1.messages.internal.base import ( + BaseModelConfiguration, +) +from sinch.domains.conversation.models.v1.messages.categories.channelspecific.line.line_notification_message_template_body import ( + LineNotificationMessageTemplateBody, +) + + +class LineNotificationMessageTemplateMessage(BaseModelConfiguration): + template_key: StrictStr = Field( + ..., + description="Template key. See LINE documentation for available keys.", + ) + body: Optional[LineNotificationMessageTemplateBody] = Field( + default=None, description="Template body." + ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py index a3d1f13a..6ea3689c 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_by_channel_identity_request.py @@ -8,7 +8,7 @@ ConversationChannelType, ConversationDirectionType, ConversationMessagesViewType, - MessagesSourceType, + MessageSourceType, ) @@ -25,7 +25,7 @@ class ListLastMessagesByChannelIdentityRequest(BaseModelConfiguration): default=None, description="Optional. Resource name (id) of the app.", ) - messages_source: Optional[MessagesSourceType] = Field( + messages_source: Optional[MessageSourceType] = Field( default=None, description="Specifies the message source for the request.", ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py index 19ddafa2..a0c6398d 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/list_messages_request.py @@ -8,7 +8,7 @@ ConversationChannelType, ConversationDirectionType, ConversationMessagesViewType, - MessagesSourceType, + MessageSourceType, ) @@ -51,7 +51,7 @@ class ListMessagesRequest(BaseModelConfiguration): default=None, description="Messages view type. WITH_METADATA or WITHOUT_METADATA.", ) - messages_source: Optional[MessagesSourceType] = Field( + messages_source: Optional[MessageSourceType] = Field( default=None, description="Specifies the message source for the request.", ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py index 7823c8c1..2e623643 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/message_id_request.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import Field from sinch.domains.conversation.models.v1.messages.types import ( - MessagesSourceType, + MessageSourceType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfiguration, @@ -10,7 +10,7 @@ class MessageIdRequest(BaseModelConfiguration): message_id: str = Field(..., description="The unique ID of the message.") - messages_source: Optional[MessagesSourceType] = Field( + messages_source: Optional[MessageSourceType] = Field( default=None, description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode. For more information, see [Processing Modes](https://developers.sinch.com/docs/conversation/processing-modes/).", ) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py index f454ff7e..93376ef0 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/update_message_metadata_request.py @@ -1,7 +1,7 @@ from typing import Optional from pydantic import Field, StrictStr from sinch.domains.conversation.models.v1.messages.types import ( - MessagesSourceType, + MessageSourceType, ) from sinch.domains.conversation.models.v1.messages.internal.base import ( BaseModelConfiguration, @@ -13,7 +13,7 @@ class UpdateMessageMetadataRequest(BaseModelConfiguration): metadata: StrictStr = Field( ..., description="Metadata that should be associated with the message." ) - messages_source: Optional[MessagesSourceType] = Field( + messages_source: Optional[MessageSourceType] = Field( default=None, description="Specifies the message source for which the request will be processed. Used for operations on messages in Dispatch Mode.", ) diff --git a/sinch/domains/conversation/models/v1/messages/types/__init__.py b/sinch/domains/conversation/models/v1/messages/types/__init__.py index 5834cd05..facc18bf 100644 --- a/sinch/domains/conversation/models/v1/messages/types/__init__.py +++ b/sinch/domains/conversation/models/v1/messages/types/__init__.py @@ -20,7 +20,7 @@ CardHeightType, ) from sinch.domains.conversation.models.v1.messages.types.messages_source_type import ( - MessagesSourceType, + MessageSourceType, ) from sinch.domains.conversation.models.v1.messages.types.payment_order_goods_type import ( PaymentOrderGoodsType, @@ -106,7 +106,7 @@ "RecipientDict", "ChannelRecipientIdentityDict", "SendMessageRequestBodyDict", - "MessagesSourceType", + "MessageSourceType", "PaymentOrderGoodsType", "PaymentOrderStatusType", "PaymentOrderType", diff --git a/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py index 62824da8..b4c41a7c 100644 --- a/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py +++ b/sinch/domains/conversation/models/v1/messages/types/channel_specific_message_type.py @@ -9,6 +9,7 @@ "ORDER_STATUS", "COMMERCE", "CAROUSEL_COMMERCE", + "NOTIFICATION_MESSAGE_TEMPLATE", ], StrictStr, ] diff --git a/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py index 023c1cb9..5acd391c 100644 --- a/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py +++ b/sinch/domains/conversation/models/v1/messages/types/messages_source_type.py @@ -2,6 +2,6 @@ from pydantic import StrictStr -MessagesSourceType = Union[ +MessageSourceType = Union[ Literal["CONVERSATION_SOURCE", "DISPATCH_SOURCE"], StrictStr ] From 990b413896c502b035f57291ca2bc4f2ccfb3c38 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 12 Mar 2026 13:59:22 +0100 Subject: [PATCH 096/106] DEVEXP-1303: OAS-Synchro Numbers (#128) --- .github/CODEOWNERS | 2 +- .../internal/list_active_numbers_request.py | 5 +- .../list_available_numbers_request.py | 5 +- .../models/v1/internal/number_request.py | 5 +- .../v1/internal/rent_any_number_request.py | 5 +- .../models/v1/internal/rent_number_request.py | 5 +- .../update_number_configuration_request.py | 5 +- .../models/v1/response/active_number.py | 10 ++- .../models/v1/response/available_number.py | 10 ++- .../models/v1/response/available_region.py | 6 +- .../numbers/models/v1/shared/__init__.py | 4 ++ .../models/v1/shared/sms_configuration.py | 14 ++--- .../v1/shared/sms_configuration_base.py | 10 +++ sinch/domains/numbers/virtual_numbers.py | 61 +++++++------------ 14 files changed, 89 insertions(+), 58 deletions(-) create mode 100644 sinch/domains/numbers/models/v1/shared/sms_configuration_base.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 585690ea..10b70ef9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -* @matsk-sinch @Dovchik @krogers0607 @asein-sinch @JPPortier \ No newline at end of file +* @matsk-sinch @asein-sinch @JPPortier @rpredescu-sinch \ No newline at end of file diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index 3c6a5ca6..b484595a 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -12,7 +12,10 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): - region_code: StrictStr = Field(alias="regionCode") + region_code: StrictStr = Field( + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py index 4f255ca6..6a3c8082 100644 --- a/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_available_numbers_request.py @@ -11,7 +11,10 @@ class ListAvailableNumbersRequest(BaseModelConfigurationRequest): - region_code: StrictStr = Field(alias="regionCode") + region_code: StrictStr = Field( + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) number_type: NumberType = Field(alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="size") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/internal/number_request.py b/sinch/domains/numbers/models/v1/internal/number_request.py index 8204f73e..658147e8 100644 --- a/sinch/domains/numbers/models/v1/internal/number_request.py +++ b/sinch/domains/numbers/models/v1/internal/number_request.py @@ -5,4 +5,7 @@ class NumberRequest(BaseModelConfigurationRequest): - phone_number: StrictStr = Field(alias="phoneNumber") + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index 4cf721b9..d0a43a6f 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -11,7 +11,10 @@ class RentAnyNumberRequest(BaseModelConfigurationRequest): - region_code: StrictStr = Field(alias="regionCode") + region_code: StrictStr = Field( + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) number_type: NumberType = Field(alias="type") number_pattern: Optional[Dict[str, Any]] = Field( default=None, alias="numberPattern" diff --git a/sinch/domains/numbers/models/v1/internal/rent_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_number_request.py index 24e75b3d..14b16b1e 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_number_request.py @@ -9,7 +9,10 @@ class RentNumberRequest(BaseModelConfigurationRequest): - phone_number: StrictStr = Field(alias="phoneNumber") + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) # Accepts only dictionary input, not Pydantic models sms_configuration: Optional[Dict] = Field( default=None, alias="smsConfiguration" diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py index 456a39f7..07ebef0e 100644 --- a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -9,7 +9,10 @@ class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest): - phone_number: StrictStr = Field(alias="phoneNumber") + phone_number: StrictStr = Field( + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) display_name: Optional[StrictStr] = Field( default=None, alias="displayName" ) diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index ce27b44f..c7072c45 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -14,13 +14,19 @@ class ActiveNumber(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field( - default=None, alias="phoneNumber" + default=None, + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", ) project_id: Optional[StrictStr] = Field(default=None, alias="projectId") display_name: Optional[StrictStr] = Field( default=None, alias="displayName" ) - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) type: Optional[NumberType] = Field(default=None) capabilities: Optional[conlist(CapabilityType)] = Field(default=None) money: Optional[Money] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/response/available_number.py b/sinch/domains/numbers/models/v1/response/available_number.py index 0aa128ea..e5383c21 100644 --- a/sinch/domains/numbers/models/v1/response/available_number.py +++ b/sinch/domains/numbers/models/v1/response/available_number.py @@ -9,9 +9,15 @@ class AvailableNumber(BaseModelConfigurationResponse): phone_number: Optional[StrictStr] = Field( - default=None, alias="phoneNumber" + default=None, + alias="phoneNumber", + description="Phone number in E.164 format with leading '+'. Example: '+12025550134'.", + ) + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", ) - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") type: Optional[NumberType] = Field(default=None) capability: Optional[conlist(CapabilityType)] = Field(default=None) setup_price: Optional[Money] = Field(default=None, alias="setupPrice") diff --git a/sinch/domains/numbers/models/v1/response/available_region.py b/sinch/domains/numbers/models/v1/response/available_region.py index 0e418a78..58aa42b1 100644 --- a/sinch/domains/numbers/models/v1/response/available_region.py +++ b/sinch/domains/numbers/models/v1/response/available_region.py @@ -7,6 +7,10 @@ class AvailableRegion(BaseModelConfigurationResponse): - region_code: Optional[StrictStr] = Field(default=None, alias="regionCode") + region_code: Optional[StrictStr] = Field( + default=None, + alias="regionCode", + description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", + ) region_name: Optional[StrictStr] = Field(default=None, alias="regionName") types: Optional[conlist(NumberType)] = Field(default=None) diff --git a/sinch/domains/numbers/models/v1/shared/__init__.py b/sinch/domains/numbers/models/v1/shared/__init__.py index fcd463aa..63332c08 100644 --- a/sinch/domains/numbers/models/v1/shared/__init__.py +++ b/sinch/domains/numbers/models/v1/shared/__init__.py @@ -21,6 +21,9 @@ from sinch.domains.numbers.models.v1.shared.sms_configuration import ( SmsConfiguration, ) +from sinch.domains.numbers.models.v1.shared.sms_configuration_base import ( + SmsConfigurationBase, +) from sinch.domains.numbers.models.v1.shared.voice_configuration_est import ( VoiceConfigurationEST, ) @@ -41,6 +44,7 @@ "ScheduledVoiceProvisioningFAX", "ScheduledVoiceProvisioningRTC", "SmsConfiguration", + "SmsConfigurationBase", "VoiceConfigurationEST", "VoiceConfigurationRTC", "VoiceConfigurationFAX", diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration.py b/sinch/domains/numbers/models/v1/shared/sms_configuration.py index d197b304..d91d01c1 100644 --- a/sinch/domains/numbers/models/v1/shared/sms_configuration.py +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration.py @@ -1,14 +1,14 @@ from typing import Optional -from pydantic import StrictStr, Field -from sinch.domains.numbers.models.v1.internal.base import ( - BaseModelConfigurationResponse, +from pydantic import Field +from sinch.domains.numbers.models.v1.shared.scheduled_sms_provisioning import ( + ScheduledSmsProvisioning, +) +from sinch.domains.numbers.models.v1.shared.sms_configuration_base import ( + SmsConfigurationBase, ) -from sinch.domains.numbers.models.v1.shared import ScheduledSmsProvisioning -class SmsConfiguration(BaseModelConfigurationResponse): - service_plan_id: StrictStr = Field(alias="servicePlanId") - campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") +class SmsConfiguration(SmsConfigurationBase): scheduled_provisioning: Optional[ScheduledSmsProvisioning] = Field( default=None, alias="scheduledProvisioning" ) diff --git a/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py b/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py new file mode 100644 index 00000000..33ec021c --- /dev/null +++ b/sinch/domains/numbers/models/v1/shared/sms_configuration_base.py @@ -0,0 +1,10 @@ +from typing import Optional +from pydantic import StrictStr, Field +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class SmsConfigurationBase(BaseModelConfigurationResponse): + service_plan_id: StrictStr = Field(alias="servicePlanId") + campaign_id: Optional[StrictStr] = Field(default=None, alias="campaignId") diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 8bf39526..fd03cd6c 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -69,7 +69,7 @@ def list( """ Search for all active virtual numbers associated with a certain project. - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. :type region_code: str :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). @@ -159,23 +159,19 @@ def update( Make updates to the configuration of your virtual number. Update the display name, change the currency type, or reconfigure for either SMS and/or Voice. - :param phone_number: The phone number in E.164 format with leading +. + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. :type phone_number: str :param display_name: The display name for the virtual number. :type display_name: Optional[str] - :param sms_configuration: A dictionary defining the SMS configuration. Including fields such as:: - - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). :type sms_configuration: Optional[SmsConfigurationDict] :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - - ``VoiceConfigurationRTCDict``: type 'RTC' with an ``app_id`` field. - - ``VoiceConfigurationESTDict``: type 'EST' with a ``trunk_id`` field. - - ``VoiceConfigurationFAXDict``: type 'FAX' with a ``service_id`` field. + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL for the virtual number. @@ -197,9 +193,9 @@ def update( def get(self, phone_number: str, **kwargs) -> ActiveNumber: """ - List of configuration settings for your virtual number. + Get the configuration settings for your virtual number. - :param phone_number: The phone number in E.164 format with leading +. + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. :type phone_number: str :param kwargs: Additional parameters for the request. @@ -216,7 +212,7 @@ def release(self, phone_number: str, **kwargs) -> ActiveNumber: """ Release virtual numbers you no longer need from your project. - :param phone_number: The phone number in E.164 format with leading +. + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. :type phone_number: str :param kwargs: Additional parameters for the request. @@ -235,7 +231,7 @@ def check_availability( """ Enter a specific phone number to check availability. - :param phone_number: The phone number in E.164 format with leading ``+``. + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. :type phone_number: str :param kwargs: Additional parameters for the request. @@ -291,16 +287,11 @@ def rent( """ Rent a virtual number to use with SMS, Voice, or both products. - :param phone_number: The phone number in E.164 format with leading ``+``. + :param phone_number: Phone number in E.164 format with leading '+'. Example: '+12025550134'. :type phone_number: str - :param sms_configuration: A dictionary defining the SMS configuration. - Include the following fields:: - - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). :type sms_configuration: Optional[SmsConfigurationDict] :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. @@ -377,43 +368,35 @@ def rent_any( Search for and activate an available Sinch virtual number all in one API call. Currently, the ``rent_any`` operation works only for US 10DLC numbers. - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. :type region_code: str :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). Defaults to ``"MOBILE"``. :type number_type: NumberType - :param number_pattern: A dictionary defining the specific sequence of digits to search for. - Include fields such as:: - - ``pattern`` (str): The specific sequence of digits. - - ``search_pattern`` (str): - The pattern to apply (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). + :param number_pattern: Optional dict with ``pattern`` (str) and ``search_pattern`` (e.g., ``"START"``, ``"CONTAINS"``, ``"END"``). :type number_pattern: Optional[NumberPatternDict] :param capabilities: Capabilities required for the number (e.g., ``["SMS", "VOICE"]``). - :type capabilities: Optional[CapabilityType] - - :param sms_configuration: A dictionary defining the SMS configuration. Includes fields such as:: + :type capabilities: Optional[List[CapabilityType]] - - ``service_plan_id`` (str): The service plan ID. - - ``campaign_id`` (Optional[str]): The campaign ID. + :param sms_configuration: A dictionary defining the SMS configuration, including fields such as: ``service_plan_id`` (str), ``campaign_id`` (optional, required for US 10DLC). :type sms_configuration: Optional[SmsConfigurationDict] :param voice_configuration: A dictionary defining the Voice configuration. Supported types include:: - - - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. - - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. - - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. + - ``VoiceConfigurationRTCDict``: type ``'RTC'`` with an ``app_id`` field. + - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. + - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] :param callback_url: The callback URL to receive notifications. - :type callback_url: str + :type callback_url: Optional[str] :param kwargs: Additional parameters for the request. :type kwargs: dict :returns: A response object with the activated number and its details. - :rtype: RentAnyNumberRequest + :rtype: ActiveNumber For detailed documentation, visit: https://developers.sinch.com """ @@ -441,7 +424,7 @@ def search_for_available_numbers( """ Search for available virtual numbers for you to rent using a variety of parameters to filter results. - :param region_code: ISO 3166-1 alpha-2 country code of the phone number. + :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. :type region_code: str :param number_type: Type of number (e.g., ``"MOBILE"``, ``"LOCAL"``, ``"TOLL_FREE"``). From 9fb22d6618ed00ef6066a64e1d16a08fd8cbffe7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Mon, 16 Mar 2026 13:47:36 +0100 Subject: [PATCH 097/106] chore/merge main into v2.0 (#130) --- .github/workflows/ci.yml | 2 +- README.md | 4 +++- pyproject.toml | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7b3148c..8e75080a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index 5133e0cc..683d16eb 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ [![Python 3.10](https://img.shields.io/badge/python-3.10-blue.svg)](https://www.python.org/downloads/release/python-3100/) [![Python 3.11](https://img.shields.io/badge/python-3.11-blue.svg)](https://www.python.org/downloads/release/python-3110/) [![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](https://www.python.org/downloads/release/python-3120/) +[![Python 3.13](https://img.shields.io/badge/python-3.13-blue.svg)](https://www.python.org/downloads/release/python-3130/) +[![Python 3.14](https://img.shields.io/badge/python-3.14-blue.svg)](https://www.python.org/downloads/release/python-3140/) @@ -24,7 +26,7 @@ For more information on the Sinch APIs on which this SDK is based, refer to the ## Prerequisites -- Python in one of the supported versions - 3.9, 3.10, 3.11, 3.12 +- Python in one of the supported versions - 3.9, 3.10, 3.11, 3.12, 3.13, 3.14 - pip - Sinch account diff --git a/pyproject.toml b/pyproject.toml index 88e7c51f..4e4533f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Communications :: Telephony", From c81282190166a6428aa7e7d7beb6ed00a25cadf4 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 18 Mar 2026 20:43:11 +0100 Subject: [PATCH 098/106] DEVEXP-1310: Redesign webhooks (#131) --- MIGRATION_GUIDE.md | 93 +++++++++++++++++++ .../{webhooks => sinch_events}/.env.example | 0 examples/{webhooks => sinch_events}/README.md | 10 +- .../conversation_api/__init__.py | 0 .../conversation_api/controller.py | 0 .../conversation_api/server_business_logic.py | 0 .../numbers_api/__init__.py | 0 .../numbers_api/controller.py | 8 +- .../numbers_api/server_business_logic.py | 6 +- .../{webhooks => sinch_events}/pyproject.toml | 0 examples/{webhooks => sinch_events}/server.py | 0 .../sinch_client_helper.py | 0 .../sms_api/__init__.py | 0 .../sms_api/controller.py | 0 .../sms_api/server_business_logic.py | 0 .../get/snippet.py | 4 +- .../update/snippet.py | 4 +- sinch/domains/numbers/api/v1/__init__.py | 6 +- .../numbers/api/v1/active_numbers_apis.py | 4 +- .../numbers/api/v1/available_numbers_apis.py | 8 +- .../api/v1/callback_configuration_apis.py | 55 ----------- .../numbers/api/v1/event_destinations_apis.py | 53 +++++++++++ .../numbers/api/v1/internal/__init__.py | 10 +- ...nts.py => event_destinations_endpoints.py} | 34 ++++--- .../numbers/models/v1/internal/__init__.py | 6 +- .../v1/internal/rent_any_number_request.py | 2 +- .../models/v1/internal/rent_number_request.py | 2 +- ...py => update_event_destination_request.py} | 2 +- .../update_number_configuration_request.py | 2 +- .../numbers/models/v1/response/__init__.py | 6 +- .../models/v1/response/active_number.py | 2 +- ...ponse.py => event_destination_response.py} | 2 +- .../{webhooks => sinch_events}/__init__.py | 0 .../numbers/sinch_events/v1/__init__.py | 5 + .../sinch_events/v1/events/__init__.py | 5 + .../v1/events/number_sinch_event.py} | 4 +- .../sinch_events/v1/internal/__init__.py | 5 + .../v1/internal/sinch_event.py} | 4 +- .../v1/sinch_events.py} | 12 +-- sinch/domains/numbers/virtual_numbers.py | 58 ++++++------ sinch/domains/numbers/webhooks/v1/__init__.py | 3 - .../numbers/webhooks/v1/events/__init__.py | 5 - .../numbers/webhooks/v1/internal/__init__.py | 5 - .../steps/callback-configuration.steps.py | 4 +- .../numbers/features/steps/numbers.steps.py | 6 +- .../numbers/features/steps/webhooks.steps.py | 2 +- .../test_get_active_numbers_endpoint.py | 2 +- .../test_list_active_numbers_endpoint.py | 2 +- .../test_release_active_numbers_endpoint.py | 2 +- .../test_update_active_numbers_endpoint.py | 2 +- .../test_rent_any_number_endpoint.py | 4 +- .../test_get_event_destination_endpoint.py} | 10 +- ...test_update_event_destination_endpoint.py} | 12 +-- .../test_rent_any_number_request_model.py | 4 +- ...est_update_active_numbers_request_model.py | 4 +- ...update_event_destination_request_model.py} | 10 +- .../response/test_active_number_model.py | 2 +- ... test_event_destination_response_model.py} | 4 +- ...callback.py => test_event_destinations.py} | 26 +++--- .../test_numbers_webhooks_event_model.py | 8 +- .../v1/webhooks/test_numbers_webhooks.py | 8 +- 61 files changed, 315 insertions(+), 222 deletions(-) rename examples/{webhooks => sinch_events}/.env.example (100%) rename examples/{webhooks => sinch_events}/README.md (87%) rename examples/{webhooks => sinch_events}/conversation_api/__init__.py (100%) rename examples/{webhooks => sinch_events}/conversation_api/controller.py (100%) rename examples/{webhooks => sinch_events}/conversation_api/server_business_logic.py (100%) rename examples/{webhooks => sinch_events}/numbers_api/__init__.py (100%) rename examples/{webhooks => sinch_events}/numbers_api/controller.py (76%) rename examples/{webhooks => sinch_events}/numbers_api/server_business_logic.py (50%) rename examples/{webhooks => sinch_events}/pyproject.toml (100%) rename examples/{webhooks => sinch_events}/server.py (100%) rename examples/{webhooks => sinch_events}/sinch_client_helper.py (100%) rename examples/{webhooks => sinch_events}/sms_api/__init__.py (100%) rename examples/{webhooks => sinch_events}/sms_api/controller.py (100%) rename examples/{webhooks => sinch_events}/sms_api/server_business_logic.py (100%) rename examples/snippets/numbers/{callback_configuration => event_destinations}/get/snippet.py (81%) rename examples/snippets/numbers/{callback_configuration => event_destinations}/update/snippet.py (81%) delete mode 100644 sinch/domains/numbers/api/v1/callback_configuration_apis.py create mode 100644 sinch/domains/numbers/api/v1/event_destinations_apis.py rename sinch/domains/numbers/api/v1/internal/{callback_configuration_endpoints.py => event_destinations_endpoints.py} (71%) rename sinch/domains/numbers/models/v1/internal/{update_callback_configuration_request.py => update_event_destination_request.py} (76%) rename sinch/domains/numbers/models/v1/response/{callback_configuration_response.py => event_destination_response.py} (82%) rename sinch/domains/numbers/{webhooks => sinch_events}/__init__.py (100%) create mode 100644 sinch/domains/numbers/sinch_events/v1/__init__.py create mode 100644 sinch/domains/numbers/sinch_events/v1/events/__init__.py rename sinch/domains/numbers/{webhooks/v1/events/numbers_webhooks_event.py => sinch_events/v1/events/number_sinch_event.py} (94%) create mode 100644 sinch/domains/numbers/sinch_events/v1/internal/__init__.py rename sinch/domains/numbers/{webhooks/v1/internal/webhook_event.py => sinch_events/v1/internal/sinch_event.py} (62%) rename sinch/domains/numbers/{webhooks/v1/numbers_webhooks.py => sinch_events/v1/sinch_events.py} (89%) delete mode 100644 sinch/domains/numbers/webhooks/v1/__init__.py delete mode 100644 sinch/domains/numbers/webhooks/v1/events/__init__.py delete mode 100644 sinch/domains/numbers/webhooks/v1/internal/__init__.py rename tests/unit/domains/numbers/v1/endpoints/{callbacks/test_get_numbers_callback_endpoint.py => event_destination/test_get_event_destination_endpoint.py} (85%) rename tests/unit/domains/numbers/v1/endpoints/{callbacks/test_update_numbers_callback_endpoint.py => event_destination/test_update_event_destination_endpoint.py} (77%) rename tests/unit/domains/numbers/v1/models/internal/{test_update_callback_config_request_model.py => test_update_event_destination_request_model.py} (76%) rename tests/unit/domains/numbers/v1/models/response/{test_numbers_callback_model.py => test_event_destination_response_model.py} (81%) rename tests/unit/domains/numbers/v1/{test_numbers_callback.py => test_event_destinations.py} (62%) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index ccb9d2ea..1733a2f4 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -190,3 +190,96 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | + +
+ +### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) + +##### Replacement APIs / attributes + +| Old | New | +|-----|-----| +| `sinch_client.numbers.callbacks` (attribute) | `sinch_client.numbers.event_destinations` (attribute) | +| `numbers.callbacks.get_configuration()` (method) | `numbers.event_destinations.get()` (method) | +| `numbers.callbacks.update_configuration(hmac_secret)` (method) | `numbers.event_destinations.update(hmac_secret=hmac_secret)` (method) | + +##### Replacement models + +| Old class | New class | +|-----------|-----------| +| `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` | +| `GetNumbersCallbackConfigurationResponse` | `EventDestinationResponse` | +| `UpdateNumbersCallbackConfigurationResponse` | `EventDestinationResponse` | + +**Example:** + +```python +# Old +config = sinch_client.numbers.callbacks.get_configuration() +sinch_client.numbers.callbacks.update_configuration("your_hmac_secret") + +# New +config = sinch_client.numbers.event_destinations.get() +sinch_client.numbers.event_destinations.update(hmac_secret="your_hmac_secret") +``` + +##### Available and Active: method locations + +| Old method | New method | +|------------|------------| +| `numbers.available.rent_any(...)`, `numbers.available.activate(...)`, `numbers.available.check_availability(...)`, `numbers.available.list(...)` | `numbers.rent_any(...)`, `numbers.rent(...)`, `numbers.check_availability(...)`, `numbers.search_for_available_numbers(...)` | +| `numbers.active.list(...)`, `numbers.active.get(...)`, `numbers.active.update(...)`, `numbers.active.release(...)` | `numbers.list(...)`, `numbers.get(...)`, `numbers.update(...)`, `numbers.release(...)` | + +#### Sinch Events (Event Destinations payload models and package path) + +| Old | New | +|-----|-----| +| — _(N/A)_ | `sinch.domains.numbers.sinch_events` (package path) | +| — | `NumberSinchEvent` (class, payload model) | + +To obtain a Numbers Sinch Events handler: `sinch_client.numbers.sinch_events(callback_secret)` returns a `SinchEvents` instance; `handler.parse_event(request_body)` returns a `NumberSinchEvent`. + + +```python +# New +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent +handler = sinch_client.numbers.sinch_events("your_callback_secret") +event = handler.parse_event(request_body) # event is a NumberSinchEvent +``` + +#### Request and response fields: callback URL → event destination target + +| | Old | New | +|---|-----|-----| +| **Methods that accept the parameter** | Only `numbers.available.rent_any(..., callback_url=...)` | `numbers.rent(...)`, `numbers.rent_any(...)`, and `numbers.update(...)` accept `event_destination_target` | +| **Parameter name** | `callback_url` | `event_destination_target` | + + +##### Replacement request/response attributes + +| Old | New | +|-----|-----| +| `RentAnyNumberRequest.callback_url` | `RentNumberRequest.event_destination_target`, `RentAnyNumberRequest.event_destination_target`, `UpdateNumberConfigurationRequest.event_destination_target` | +| `ActiveNumber` has no callback field | `ActiveNumber.event_destination_target` (response) | + +**Example:** + +```python +# Old +sinch_client.numbers.available.rent_any( + region_code="US", + type_="LOCAL", + sms_configuration={...}, + voice_configuration={...}, + callback_url="https://example.com/events", +) + +# New +sinch_client.numbers.rent_any( + region_code="US", + number_type="LOCAL", + sms_configuration={...}, + voice_configuration={...}, + event_destination_target="https://example.com/events", +) +``` diff --git a/examples/webhooks/.env.example b/examples/sinch_events/.env.example similarity index 100% rename from examples/webhooks/.env.example rename to examples/sinch_events/.env.example diff --git a/examples/webhooks/README.md b/examples/sinch_events/README.md similarity index 87% rename from examples/webhooks/README.md rename to examples/sinch_events/README.md index c2f88a45..20693812 100644 --- a/examples/webhooks/README.md +++ b/examples/sinch_events/README.md @@ -1,4 +1,4 @@ -# Webhook Handlers for Sinch Python SDK +# Sinch Events Handlers for Sinch Python SDK This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) to process incoming webhooks from Sinch services. @@ -21,7 +21,7 @@ This directory contains both the webhook handlers and the server application (`s ## Configuration 1. **Environment Variables**: - Rename [.env.example](.env.example) to `.env` in this directory (`examples/webhooks/`), then add your credentials from the Sinch dashboard under the Access Keys section. + Rename [.env.example](.env.example) to `.env` in this directory (`examples/sinch_events/`), then add your credentials from the Sinch dashboard under the Access Keys section. - Server Port: Define the port your server will listen to on (default: 3001): @@ -30,7 +30,7 @@ This directory contains both the webhook handlers and the server application (`s ``` - Controller Settings - - Numbers controller: Set the `numbers` webhook secret. You can retrieve it using the `/callback_configuration` endpoint (see SDK implementation: [callback_configuration_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/callback_configuration_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): + - Numbers controller: Set the `numbers` Sinch Event secret. You can retrieve it using the `/event_destination` endpoint (see SDK implementation: [event_destinations_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/event_destinations_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): ``` NUMBERS_WEBHOOKS_SECRET=Your Sinch Numbers Webhook Secret ``` @@ -49,9 +49,9 @@ This directory contains both the webhook handlers and the server application (`s ### Running the server application -1. Navigate to the webhooks' directory: +1. Navigate to the examples events directory: ``` - cd examples/webhooks + cd examples/sinch_events ``` 2. Install the project dependencies: diff --git a/examples/webhooks/conversation_api/__init__.py b/examples/sinch_events/conversation_api/__init__.py similarity index 100% rename from examples/webhooks/conversation_api/__init__.py rename to examples/sinch_events/conversation_api/__init__.py diff --git a/examples/webhooks/conversation_api/controller.py b/examples/sinch_events/conversation_api/controller.py similarity index 100% rename from examples/webhooks/conversation_api/controller.py rename to examples/sinch_events/conversation_api/controller.py diff --git a/examples/webhooks/conversation_api/server_business_logic.py b/examples/sinch_events/conversation_api/server_business_logic.py similarity index 100% rename from examples/webhooks/conversation_api/server_business_logic.py rename to examples/sinch_events/conversation_api/server_business_logic.py diff --git a/examples/webhooks/numbers_api/__init__.py b/examples/sinch_events/numbers_api/__init__.py similarity index 100% rename from examples/webhooks/numbers_api/__init__.py rename to examples/sinch_events/numbers_api/__init__.py diff --git a/examples/webhooks/numbers_api/controller.py b/examples/sinch_events/numbers_api/controller.py similarity index 76% rename from examples/webhooks/numbers_api/controller.py rename to examples/sinch_events/numbers_api/controller.py index 2380eda4..00eaac47 100644 --- a/examples/webhooks/numbers_api/controller.py +++ b/examples/sinch_events/numbers_api/controller.py @@ -12,11 +12,13 @@ def numbers_event(self): headers = dict(request.headers) raw_body = request.raw_body if request.raw_body else b"" - webhooks_service = self.sinch_client.numbers.webhooks(self.webhooks_secret) + sinch_events_service = self.sinch_client.numbers.sinch_events( + self.webhooks_secret + ) ensure_valid_authentication = False if ensure_valid_authentication: - valid_auth = webhooks_service.validate_authentication_header( + valid_auth = sinch_events_service.validate_authentication_header( headers=headers, json_payload=raw_body, ) @@ -24,7 +26,7 @@ def numbers_event(self): if not valid_auth: return Response(status=401) - event = webhooks_service.parse_event(raw_body, headers) + event = sinch_events_service.parse_event(raw_body, headers) handle_numbers_event(numbers_event=event, logger=self.logger) diff --git a/examples/webhooks/numbers_api/server_business_logic.py b/examples/sinch_events/numbers_api/server_business_logic.py similarity index 50% rename from examples/webhooks/numbers_api/server_business_logic.py rename to examples/sinch_events/numbers_api/server_business_logic.py index 80082812..305772ce 100644 --- a/examples/webhooks/numbers_api/server_business_logic.py +++ b/examples/sinch_events/numbers_api/server_business_logic.py @@ -1,11 +1,11 @@ -from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import NumbersWebhooksEvent +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent -def handle_numbers_event(numbers_event: NumbersWebhooksEvent, logger): +def handle_numbers_event(numbers_event: NumberSinchEvent, logger): """ This method handles a Numbers event. Args: - numbers_event (NumbersWebhooksEvent): The Numbers event data. + numbers_event (NumberSinchEvent): The Numbers event data. logger (logging.Logger, optional): Logger instance for logging. Defaults to None. """ logger.info(f'Handling Numbers event:\n{numbers_event.model_dump_json(indent=2)}') diff --git a/examples/webhooks/pyproject.toml b/examples/sinch_events/pyproject.toml similarity index 100% rename from examples/webhooks/pyproject.toml rename to examples/sinch_events/pyproject.toml diff --git a/examples/webhooks/server.py b/examples/sinch_events/server.py similarity index 100% rename from examples/webhooks/server.py rename to examples/sinch_events/server.py diff --git a/examples/webhooks/sinch_client_helper.py b/examples/sinch_events/sinch_client_helper.py similarity index 100% rename from examples/webhooks/sinch_client_helper.py rename to examples/sinch_events/sinch_client_helper.py diff --git a/examples/webhooks/sms_api/__init__.py b/examples/sinch_events/sms_api/__init__.py similarity index 100% rename from examples/webhooks/sms_api/__init__.py rename to examples/sinch_events/sms_api/__init__.py diff --git a/examples/webhooks/sms_api/controller.py b/examples/sinch_events/sms_api/controller.py similarity index 100% rename from examples/webhooks/sms_api/controller.py rename to examples/sinch_events/sms_api/controller.py diff --git a/examples/webhooks/sms_api/server_business_logic.py b/examples/sinch_events/sms_api/server_business_logic.py similarity index 100% rename from examples/webhooks/sms_api/server_business_logic.py rename to examples/sinch_events/sms_api/server_business_logic.py diff --git a/examples/snippets/numbers/callback_configuration/get/snippet.py b/examples/snippets/numbers/event_destinations/get/snippet.py similarity index 81% rename from examples/snippets/numbers/callback_configuration/get/snippet.py rename to examples/snippets/numbers/event_destinations/get/snippet.py index e1593f73..413fd0d0 100644 --- a/examples/snippets/numbers/callback_configuration/get/snippet.py +++ b/examples/snippets/numbers/event_destinations/get/snippet.py @@ -17,6 +17,6 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -response = sinch_client.numbers.callback_configuration.get() +response = sinch_client.numbers.event_destinations.get() -print("Callback Configuration:\n", response) +print("Event Destination Configuration:\n", response) diff --git a/examples/snippets/numbers/callback_configuration/update/snippet.py b/examples/snippets/numbers/event_destinations/update/snippet.py similarity index 81% rename from examples/snippets/numbers/callback_configuration/update/snippet.py rename to examples/snippets/numbers/event_destinations/update/snippet.py index cfe2ef9b..51722f76 100644 --- a/examples/snippets/numbers/callback_configuration/update/snippet.py +++ b/examples/snippets/numbers/event_destinations/update/snippet.py @@ -18,8 +18,8 @@ ) hmac_secret = "NEW_HMAC_SECRET" -response = sinch_client.numbers.callback_configuration.update( +response = sinch_client.numbers.event_destinations.update( hmac_secret=hmac_secret ) -print("Updated callback configuration:\n", response) +print("Updated event destination configuration:\n", response) diff --git a/sinch/domains/numbers/api/v1/__init__.py b/sinch/domains/numbers/api/v1/__init__.py index 15b4bde9..79f11071 100644 --- a/sinch/domains/numbers/api/v1/__init__.py +++ b/sinch/domains/numbers/api/v1/__init__.py @@ -5,8 +5,8 @@ from sinch.domains.numbers.api.v1.available_regions_apis import ( AvailableRegions, ) -from sinch.domains.numbers.api.v1.callback_configuration_apis import ( - CallbackConfiguration, +from sinch.domains.numbers.api.v1.event_destinations_apis import ( + EventDestinations, ) @@ -14,5 +14,5 @@ "ActiveNumbers", "AvailableNumbers", "AvailableRegions", - "CallbackConfiguration", + "EventDestinations", ] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 9cf6dbd3..2f2746f9 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -61,7 +61,7 @@ def update( display_name: Optional[str] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: request_data = UpdateNumberConfigurationRequest( @@ -69,7 +69,7 @@ def update( display_name=display_name, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) return self._request(UpdateNumberConfigurationEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/available_numbers_apis.py b/sinch/domains/numbers/api/v1/available_numbers_apis.py index cf415f61..a5538a3f 100644 --- a/sinch/domains/numbers/api/v1/available_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/available_numbers_apis.py @@ -66,14 +66,14 @@ def rent( phone_number: str, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: request_data = RentNumberRequest( phone_number=phone_number, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) return self._request(RentNumberEndpoint, request_data) @@ -86,7 +86,7 @@ def rent_any( capabilities: Optional[List[CapabilityType]] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: request_data = RentAnyNumberRequest( @@ -96,7 +96,7 @@ def rent_any( capabilities=capabilities, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) return self._request(RentAnyNumberEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/callback_configuration_apis.py b/sinch/domains/numbers/api/v1/callback_configuration_apis.py deleted file mode 100644 index f4d38c67..00000000 --- a/sinch/domains/numbers/api/v1/callback_configuration_apis.py +++ /dev/null @@ -1,55 +0,0 @@ -from sinch.domains.numbers.api.v1.base import BaseNumbers -from sinch.domains.numbers.api.v1.internal import ( - GetCallbackConfigurationEndpoint, - UpdateCallbackConfigurationEndpoint, -) -from sinch.domains.numbers.models.v1.internal import ( - UpdateCallbackConfigurationRequest, -) -from sinch.domains.numbers.models.v1.internal.base import ( - BaseModelConfigurationRequest, -) -from sinch.domains.numbers.models.v1.response import ( - CallbackConfigurationResponse, -) - - -class CallbackConfiguration(BaseNumbers): - def get(self, **kwargs) -> CallbackConfigurationResponse: - """ - Returns the callback configuration for the specified project - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: The callback configuration for the project. - :rtype: NumbersCallbackConfigResponse - - For detailed documentation, visit: https://developers.sinch.com - """ - request_data = None - if kwargs: - request_data = BaseModelConfigurationRequest(**kwargs) - return self._request(GetCallbackConfigurationEndpoint, request_data) - - def update( - self, hmac_secret: str, **kwargs - ) -> CallbackConfigurationResponse: - """ - Updates the callback configuration for the specified project - - :param hmac_secret: The HMAC secret used to sign the callback requests. - :type hmac_secret: str - - :param kwargs: Additional parameters for the request. - :type kwargs: dict - - :returns: The updated callback configuration for the project. - :rtype: NumbersCallbackConfigResponse - - For detailed documentation, visit https://developers.sinch.com - """ - request_data = UpdateCallbackConfigurationRequest( - hmac_secret=hmac_secret, **kwargs - ) - return self._request(UpdateCallbackConfigurationEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/event_destinations_apis.py b/sinch/domains/numbers/api/v1/event_destinations_apis.py new file mode 100644 index 00000000..e84a8056 --- /dev/null +++ b/sinch/domains/numbers/api/v1/event_destinations_apis.py @@ -0,0 +1,53 @@ +from sinch.domains.numbers.api.v1.base import BaseNumbers +from sinch.domains.numbers.api.v1.internal import ( + GetEventDestinationEndpoint, + UpdateEventDestinationEndpoint, +) +from sinch.domains.numbers.models.v1.internal import ( + UpdateEventDestinationRequest, +) +from sinch.domains.numbers.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.numbers.models.v1.response import ( + EventDestinationResponse, +) + + +class EventDestinations(BaseNumbers): + def get(self, **kwargs) -> EventDestinationResponse: + """ + Returns the event destination configuration for the specified project + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The event destination configuration for the project. + :rtype: EventDestinationResponse + + For detailed documentation, visit: https://developers.sinch.com + """ + request_data = None + if kwargs: + request_data = BaseModelConfigurationRequest(**kwargs) + return self._request(GetEventDestinationEndpoint, request_data) + + def update(self, hmac_secret: str, **kwargs) -> EventDestinationResponse: + """ + Updates the event destination configuration for the specified project + + :param hmac_secret: The HMAC secret used to sign the event destination requests. + :type hmac_secret: str + + :param kwargs: Additional parameters for the request. + :type kwargs: dict + + :returns: The updated event destination configuration for the project. + :rtype: EventDestinationResponse + + For detailed documentation, visit https://developers.sinch.com + """ + request_data = UpdateEventDestinationRequest( + hmac_secret=hmac_secret, **kwargs + ) + return self._request(UpdateEventDestinationEndpoint, request_data) diff --git a/sinch/domains/numbers/api/v1/internal/__init__.py b/sinch/domains/numbers/api/v1/internal/__init__.py index 083ffb0a..3e42b294 100644 --- a/sinch/domains/numbers/api/v1/internal/__init__.py +++ b/sinch/domains/numbers/api/v1/internal/__init__.py @@ -13,14 +13,14 @@ from sinch.domains.numbers.api.v1.internal.available_regions_endpoints import ( ListAvailableRegionsEndpoint, ) -from sinch.domains.numbers.api.v1.internal.callback_configuration_endpoints import ( - GetCallbackConfigurationEndpoint, - UpdateCallbackConfigurationEndpoint, +from sinch.domains.numbers.api.v1.internal.event_destinations_endpoints import ( + GetEventDestinationEndpoint, + UpdateEventDestinationEndpoint, ) __all__ = [ "AvailableNumbersEndpoint", - "GetCallbackConfigurationEndpoint", + "GetEventDestinationEndpoint", "GetNumberConfigurationEndpoint", "ListActiveNumbersEndpoint", "ListAvailableRegionsEndpoint", @@ -28,6 +28,6 @@ "RentNumberEndpoint", "RentAnyNumberEndpoint", "SearchForNumberEndpoint", - "UpdateCallbackConfigurationEndpoint", + "UpdateEventDestinationEndpoint", "UpdateNumberConfigurationEndpoint", ] diff --git a/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py b/sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py similarity index 71% rename from sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py rename to sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py index e50dac4f..f2fe06bd 100644 --- a/sinch/domains/numbers/api/v1/internal/callback_configuration_endpoints.py +++ b/sinch/domains/numbers/api/v1/internal/event_destinations_endpoints.py @@ -7,16 +7,16 @@ ) from sinch.domains.numbers.api.v1.internal.base import NumbersEndpoint from sinch.domains.numbers.models.v1.internal import ( - UpdateCallbackConfigurationRequest, + UpdateEventDestinationRequest, ) from sinch.domains.numbers.models.v1.response import ( - CallbackConfigurationResponse, + EventDestinationResponse, ) -class GetCallbackConfigurationEndpoint(NumbersEndpoint): +class GetEventDestinationEndpoint(NumbersEndpoint): """ - Endpoint to get the callbacks configuration for a project. + Endpoint to get the event destination configuration for a project. """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" @@ -24,7 +24,7 @@ class GetCallbackConfigurationEndpoint(NumbersEndpoint): HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__(self, project_id: str, request_data=None): - super(GetCallbackConfigurationEndpoint, self).__init__( + super(GetEventDestinationEndpoint, self).__init__( project_id, request_data ) self.project_id = project_id @@ -32,7 +32,7 @@ def __init__(self, project_id: str, request_data=None): def build_url(self, sinch) -> str: if self.request_data: - super(GetCallbackConfigurationEndpoint, self).build_url(sinch) + super(GetEventDestinationEndpoint, self).build_url(sinch) return self.ENDPOINT_URL.format( origin=sinch.configuration.numbers_origin, project_id=self.project_id, @@ -47,11 +47,9 @@ def build_query_params(self) -> dict: def handle_response( self, response: HTTPResponse - ) -> CallbackConfigurationResponse: + ) -> EventDestinationResponse: try: - super(GetCallbackConfigurationEndpoint, self).handle_response( - response - ) + super(GetEventDestinationEndpoint, self).handle_response(response) except NumbersException as e: raise NumberNotFoundException( message=e.args[0], @@ -59,13 +57,13 @@ def handle_response( is_from_server=e.is_from_server, ) return self.process_response_model( - response.body, CallbackConfigurationResponse + response.body, EventDestinationResponse ) -class UpdateCallbackConfigurationEndpoint(NumbersEndpoint): +class UpdateEventDestinationEndpoint(NumbersEndpoint): """ - Endpoint to update the callbacks configuration for a project. + Endpoint to update the event destination configuration for a project. """ ENDPOINT_URL = "{origin}/v1/projects/{project_id}/callbackConfiguration" @@ -73,9 +71,9 @@ class UpdateCallbackConfigurationEndpoint(NumbersEndpoint): HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value def __init__( - self, project_id: str, request_data: UpdateCallbackConfigurationRequest + self, project_id: str, request_data: UpdateEventDestinationRequest ): - super(UpdateCallbackConfigurationEndpoint, self).__init__( + super(UpdateEventDestinationEndpoint, self).__init__( project_id, request_data ) self.project_id = project_id @@ -89,9 +87,9 @@ def request_body(self): def handle_response( self, response: HTTPResponse - ) -> CallbackConfigurationResponse: + ) -> EventDestinationResponse: try: - super(UpdateCallbackConfigurationEndpoint, self).handle_response( + super(UpdateEventDestinationEndpoint, self).handle_response( response ) except NumbersException as e: @@ -101,5 +99,5 @@ def handle_response( is_from_server=e.is_from_server, ) return self.process_response_model( - response.body, CallbackConfigurationResponse + response.body, EventDestinationResponse ) diff --git a/sinch/domains/numbers/models/v1/internal/__init__.py b/sinch/domains/numbers/models/v1/internal/__init__.py index a73547d5..c69e43e6 100644 --- a/sinch/domains/numbers/models/v1/internal/__init__.py +++ b/sinch/domains/numbers/models/v1/internal/__init__.py @@ -28,8 +28,8 @@ from sinch.domains.numbers.models.v1.internal.sms_configuration_request import ( SmsConfigurationRequest, ) -from sinch.domains.numbers.models.v1.internal.update_callback_configuration_request import ( - UpdateCallbackConfigurationRequest, +from sinch.domains.numbers.models.v1.internal.update_event_destination_request import ( + UpdateEventDestinationRequest, ) from sinch.domains.numbers.models.v1.internal.update_number_configuration_request import ( UpdateNumberConfigurationRequest, @@ -52,7 +52,7 @@ "RentAnyNumberRequest", "RentNumberRequest", "SmsConfigurationRequest", - "UpdateCallbackConfigurationRequest", + "UpdateEventDestinationRequest", "UpdateNumberConfigurationRequest", "VoiceConfigurationCustom", "VoiceConfigurationEST", diff --git a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py index d0a43a6f..85b88f73 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_any_number_request.py @@ -26,7 +26,7 @@ class RentAnyNumberRequest(BaseModelConfigurationRequest): voice_configuration: Optional[Dict[str, Any]] = Field( default=None, alias="voiceConfiguration" ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, alias="callbackUrl" ) diff --git a/sinch/domains/numbers/models/v1/internal/rent_number_request.py b/sinch/domains/numbers/models/v1/internal/rent_number_request.py index 14b16b1e..9c2980b6 100644 --- a/sinch/domains/numbers/models/v1/internal/rent_number_request.py +++ b/sinch/domains/numbers/models/v1/internal/rent_number_request.py @@ -20,7 +20,7 @@ class RentNumberRequest(BaseModelConfigurationRequest): voice_configuration: Optional[Dict] = Field( default=None, alias="voiceConfiguration" ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, alias="callbackUrl" ) diff --git a/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py similarity index 76% rename from sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py rename to sinch/domains/numbers/models/v1/internal/update_event_destination_request.py index 33b52ae7..6f2e5abc 100644 --- a/sinch/domains/numbers/models/v1/internal/update_callback_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py @@ -5,5 +5,5 @@ ) -class UpdateCallbackConfigurationRequest(BaseModelConfigurationRequest): +class UpdateEventDestinationRequest(BaseModelConfigurationRequest): hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py index 07ebef0e..5586ecdf 100644 --- a/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py +++ b/sinch/domains/numbers/models/v1/internal/update_number_configuration_request.py @@ -22,7 +22,7 @@ class UpdateNumberConfigurationRequest(BaseModelConfigurationRequest): voice_configuration: Optional[Dict] = Field( default=None, alias="voiceConfiguration" ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, alias="callbackUrl" ) diff --git a/sinch/domains/numbers/models/v1/response/__init__.py b/sinch/domains/numbers/models/v1/response/__init__.py index 5852d55f..d96ad096 100644 --- a/sinch/domains/numbers/models/v1/response/__init__.py +++ b/sinch/domains/numbers/models/v1/response/__init__.py @@ -5,13 +5,13 @@ from sinch.domains.numbers.models.v1.response.available_region import ( AvailableRegion, ) -from sinch.domains.numbers.models.v1.response.callback_configuration_response import ( - CallbackConfigurationResponse, +from sinch.domains.numbers.models.v1.response.event_destination_response import ( + EventDestinationResponse, ) __all__ = [ "ActiveNumber", "AvailableNumber", "AvailableRegion", - "CallbackConfigurationResponse", + "EventDestinationResponse", ] diff --git a/sinch/domains/numbers/models/v1/response/active_number.py b/sinch/domains/numbers/models/v1/response/active_number.py index c7072c45..0306abab 100644 --- a/sinch/domains/numbers/models/v1/response/active_number.py +++ b/sinch/domains/numbers/models/v1/response/active_number.py @@ -43,6 +43,6 @@ class ActiveNumber(BaseModelConfigurationResponse): voice_configuration: Optional[VoiceConfiguration] = Field( default=None, alias="voiceConfiguration" ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, alias="callbackUrl" ) diff --git a/sinch/domains/numbers/models/v1/response/callback_configuration_response.py b/sinch/domains/numbers/models/v1/response/event_destination_response.py similarity index 82% rename from sinch/domains/numbers/models/v1/response/callback_configuration_response.py rename to sinch/domains/numbers/models/v1/response/event_destination_response.py index b1a26be6..b94d915a 100644 --- a/sinch/domains/numbers/models/v1/response/callback_configuration_response.py +++ b/sinch/domains/numbers/models/v1/response/event_destination_response.py @@ -5,6 +5,6 @@ ) -class CallbackConfigurationResponse(BaseModelConfigurationResponse): +class EventDestinationResponse(BaseModelConfigurationResponse): project_id: Optional[StrictStr] = Field(default=None, alias="projectId") hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret") diff --git a/sinch/domains/numbers/webhooks/__init__.py b/sinch/domains/numbers/sinch_events/__init__.py similarity index 100% rename from sinch/domains/numbers/webhooks/__init__.py rename to sinch/domains/numbers/sinch_events/__init__.py diff --git a/sinch/domains/numbers/sinch_events/v1/__init__.py b/sinch/domains/numbers/sinch_events/v1/__init__.py new file mode 100644 index 00000000..50027ca3 --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.sinch_events import ( + SinchEvents, +) + +__all__ = ["SinchEvents"] diff --git a/sinch/domains/numbers/sinch_events/v1/events/__init__.py b/sinch/domains/numbers/sinch_events/v1/events/__init__.py new file mode 100644 index 00000000..94728f9c --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/events/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.events.number_sinch_event import ( + NumberSinchEvent, +) + +__all__ = ["NumberSinchEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py similarity index 94% rename from sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py rename to sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py index 2070b4f3..43633e2c 100644 --- a/sinch/domains/numbers/webhooks/v1/events/numbers_webhooks_event.py +++ b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py @@ -1,10 +1,10 @@ from datetime import datetime from typing import Optional, Union, Literal from pydantic import Field, StrictStr -from sinch.domains.numbers.webhooks.v1.internal import WebhookEvent +from sinch.domains.numbers.sinch_events.v1.internal import SinchEvent -class NumbersWebhooksEvent(WebhookEvent): +class NumberSinchEvent(SinchEvent): event_id: Optional[StrictStr] = Field(default=None, alias="eventId") timestamp: Optional[datetime] = Field(default=None) project_id: Optional[StrictStr] = Field(default=None, alias="projectId") diff --git a/sinch/domains/numbers/sinch_events/v1/internal/__init__.py b/sinch/domains/numbers/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..6af7aa73 --- /dev/null +++ b/sinch/domains/numbers/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.numbers.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py b/sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py similarity index 62% rename from sinch/domains/numbers/webhooks/v1/internal/webhook_event.py rename to sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py index 6c9cf47f..5ec2f5ce 100644 --- a/sinch/domains/numbers/webhooks/v1/internal/webhook_event.py +++ b/sinch/domains/numbers/sinch_events/v1/internal/sinch_event.py @@ -3,7 +3,7 @@ ) -# Alias for NumbersWebhooksEvent used for request modeling. +# Base for NumberSinchEvent used for request modeling. # Not to be confused with a response as in BaseModelConfigurationResponse. -class WebhookEvent(BaseModelConfigurationResponse): +class SinchEvent(BaseModelConfigurationResponse): pass diff --git a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py b/sinch/domains/numbers/sinch_events/v1/sinch_events.py similarity index 89% rename from sinch/domains/numbers/webhooks/v1/numbers_webhooks.py rename to sinch/domains/numbers/sinch_events/v1/sinch_events.py index b804fb74..d591acbe 100644 --- a/sinch/domains/numbers/webhooks/v1/numbers_webhooks.py +++ b/sinch/domains/numbers/sinch_events/v1/sinch_events.py @@ -7,10 +7,10 @@ parse_json, normalize_iso_timestamp, ) -from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent -class NumbersWebhooks: +class SinchEvents: def __init__(self, callback_secret: str): self.callback_secret = callback_secret @@ -42,9 +42,9 @@ def parse_event( self, event_body: Union[str, bytes, Dict[str, Any]], headers: Optional[Dict[str, str]] = None, - ) -> NumbersWebhooksEvent: + ) -> NumberSinchEvent: """ - Parses the event payload into a NumbersWebhooksEvent object. + Parses the event payload into a NumberSinchEvent object. Handles a known issue where the server omits timezone information from the ``timestamp`` field. If the timezone is missing, the method assumes @@ -55,7 +55,7 @@ def parse_event( :param headers: Request headers (used to decode charset when event_body is bytes). :type headers: Optional[Dict[str, str]] :returns: A parsed Pydantic object with a timezone-aware ``timestamp``. - :rtype: NumbersWebhooksEvent + :rtype: NumberSinchEvent """ if isinstance(event_body, bytes): event_body = parse_json(decode_payload(event_body, headers)) @@ -65,6 +65,6 @@ def parse_event( if timestamp: event_body["timestamp"] = normalize_iso_timestamp(timestamp) try: - return NumbersWebhooksEvent(**event_body) + return NumberSinchEvent(**event_body) except Exception as e: raise ValueError(f"Failed to parse event body: {e}") diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index fd03cd6c..7827db8b 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -3,7 +3,7 @@ ActiveNumbers, AvailableNumbers, AvailableRegions, - CallbackConfiguration, + EventDestinations, ) from sinch.core.pagination import Paginator from sinch.domains.numbers.models.v1.response import ( @@ -22,7 +22,7 @@ VoiceConfigurationESTDict, NumberPatternDict, ) -from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks +from sinch.domains.numbers.sinch_events.v1 import SinchEvents class VirtualNumbers: @@ -36,21 +36,21 @@ class VirtualNumbers: def __init__(self, sinch): self._sinch = sinch self.regions = AvailableRegions(self._sinch) - self.callback_configuration = CallbackConfiguration(self._sinch) + self.event_destinations = EventDestinations(self._sinch) self._active = ActiveNumbers(self._sinch) self._available = AvailableNumbers(self._sinch) - def webhooks(self, callback_secret: str) -> NumbersWebhooks: + def sinch_events(self, callback_secret: str) -> SinchEvents: """ - Create a Numbers webhooks handler with the specified callback secret. + Create a Numbers Sinch Events handler with the specified callback secret. :param callback_secret: Secret used for webhook validation. :type callback_secret: str - :returns: A configured webhooks handler - :rtype: NumbersWebhooks + :returns: A configured Sinch Events handler + :rtype: SinchEvents """ - return NumbersWebhooks(callback_secret) + return SinchEvents(callback_secret) # ====== High-Level Convenience Methods ====== @@ -120,7 +120,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -131,7 +131,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -142,7 +142,7 @@ def update( sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, display_name: Optional[str] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -152,7 +152,7 @@ def update( display_name: Optional[str] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: """ @@ -174,8 +174,8 @@ def update( - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] - :param callback_url: The callback URL for the virtual number. - :type callback_url: Optional[str] + :param event_destination_target: The event destination URL for the virtual number. + :type event_destination_target: Optional[str] :param kwargs: Additional parameters for the request. :type kwargs: dict @@ -187,7 +187,7 @@ def update( display_name=display_name, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) @@ -252,7 +252,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationESTDict, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -262,7 +262,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationFAXDict, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -272,7 +272,7 @@ def rent( phone_number: str, sms_configuration: SmsConfigurationDict, voice_configuration: VoiceConfigurationRTCDict, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -281,7 +281,7 @@ def rent( phone_number: str, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: """ @@ -296,8 +296,8 @@ def rent( - ``VoiceConfigurationESTDict``: type ``'EST'`` with a ``trunk_id`` field. - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] - :param callback_url: The callback URL to be called. - :type callback_url: Optional[str] + :param event_destination_target: The event destination URL to be called. + :type event_destination_target: Optional[str] :param kwargs: Additional parameters for the request. :type kwargs: dict @@ -310,7 +310,7 @@ def rent( phone_number=phone_number, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) @@ -323,7 +323,7 @@ def rent_any( voice_configuration: VoiceConfigurationRTCDict, number_pattern: NumberPatternDict, capabilities: Optional[CapabilityType] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -336,7 +336,7 @@ def rent_any( voice_configuration: VoiceConfigurationFAXDict, number_pattern: NumberPatternDict, capabilities: Optional[List[CapabilityType]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -349,7 +349,7 @@ def rent_any( voice_configuration: VoiceConfigurationESTDict, number_pattern: NumberPatternDict, capabilities: Optional[List[CapabilityType]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, ) -> ActiveNumber: pass @@ -361,7 +361,7 @@ def rent_any( capabilities: Optional[List[CapabilityType]] = None, sms_configuration: Optional[SmsConfigurationDict] = None, voice_configuration: Optional[VoiceConfigurationDict] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, **kwargs, ) -> ActiveNumber: """ @@ -389,8 +389,8 @@ def rent_any( - ``VoiceConfigurationFAXDict``: type ``'FAX'`` with a ``service_id`` field. :type voice_configuration: Optional[VoiceConfigurationDict] - :param callback_url: The callback URL to receive notifications. - :type callback_url: Optional[str] + :param event_destination_target: The event destination URL to receive notifications. + :type event_destination_target: Optional[str] :param kwargs: Additional parameters for the request. :type kwargs: dict @@ -407,7 +407,7 @@ def rent_any( capabilities=capabilities, sms_configuration=sms_configuration, voice_configuration=voice_configuration, - callback_url=callback_url, + event_destination_target=event_destination_target, **kwargs, ) diff --git a/sinch/domains/numbers/webhooks/v1/__init__.py b/sinch/domains/numbers/webhooks/v1/__init__.py deleted file mode 100644 index c6b1d66f..00000000 --- a/sinch/domains/numbers/webhooks/v1/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from sinch.domains.numbers.webhooks.v1.numbers_webhooks import NumbersWebhooks - -__all__ = ["NumbersWebhooks"] diff --git a/sinch/domains/numbers/webhooks/v1/events/__init__.py b/sinch/domains/numbers/webhooks/v1/events/__init__.py deleted file mode 100644 index b5fb44cc..00000000 --- a/sinch/domains/numbers/webhooks/v1/events/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.numbers.webhooks.v1.events.numbers_webhooks_event import ( - NumbersWebhooksEvent, -) - -__all__ = ["NumbersWebhooksEvent"] diff --git a/sinch/domains/numbers/webhooks/v1/internal/__init__.py b/sinch/domains/numbers/webhooks/v1/internal/__init__.py deleted file mode 100644 index 892d0749..00000000 --- a/sinch/domains/numbers/webhooks/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.numbers.webhooks.v1.internal.webhook_event import ( - WebhookEvent, -) - -__all__ = ["WebhookEvent"] diff --git a/tests/e2e/numbers/features/steps/callback-configuration.steps.py b/tests/e2e/numbers/features/steps/callback-configuration.steps.py index 522c4634..b7e7624f 100644 --- a/tests/e2e/numbers/features/steps/callback-configuration.steps.py +++ b/tests/e2e/numbers/features/steps/callback-configuration.steps.py @@ -15,7 +15,7 @@ def step_callback_config_service_is_available(context): @when('I send a request to retrieve the callback configuration') def step_retrieve_callback_configuration(context): - context.response = context.numbers.callback_configuration.get() + context.response = context.numbers.event_destinations.get() @then('the response contains the project\'s callback configuration') @@ -27,7 +27,7 @@ def step_check_callback_configuration(context): @when('I send a request to update the callback configuration with the secret "{hmac_secret}"') def step_update_callback_configuration(context, hmac_secret): try: - context.response = context.numbers.callback_configuration.update(hmac_secret=hmac_secret) + context.response = context.numbers.event_destinations.update(hmac_secret=hmac_secret) context.error = None except NumberNotFoundException as e: context.error = e diff --git a/tests/e2e/numbers/features/steps/numbers.steps.py b/tests/e2e/numbers/features/steps/numbers.steps.py index 94bec075..25d7b192 100644 --- a/tests/e2e/numbers/features/steps/numbers.steps.py +++ b/tests/e2e/numbers/features/steps/numbers.steps.py @@ -103,7 +103,7 @@ def step_validate_rented_number(context): '2024-06-06T14:42:42.022227+00:00' ).astimezone(tz=timezone.utc) assert data.expire_at is None - assert data.callback_url == '' + assert data.event_destination_target == '' assert data.sms_configuration.service_plan_id == '' assert data.sms_configuration.campaign_id == '' assert data.sms_configuration.scheduled_provisioning.service_plan_id == 'SpaceMonkeySquadron' @@ -218,7 +218,7 @@ def step_when_update_phone_number(context, phone_number): 'type': 'FAX', 'service_id': '01W4FFL35P4NC4K35FAXSERVICE' }, - callback_url='https://my-callback-server.com/numbers' + event_destination_target='https://my-callback-server.com/numbers' ) @@ -247,7 +247,7 @@ def step_then_response_contains_updated_number(context): assert data.voice_configuration.scheduled_voice_provisioning.last_updated_time == datetime.fromisoformat( '2024-06-06T20:02:20.437509+00:00' ).astimezone(tz=timezone.utc) - assert data.callback_url == 'https://my-callback-server.com/numbers' + assert data.event_destination_target == 'https://my-callback-server.com/numbers' @when('I send a request to retrieve the phone number "{phone_number}"') diff --git a/tests/e2e/numbers/features/steps/webhooks.steps.py b/tests/e2e/numbers/features/steps/webhooks.steps.py index 28409ff2..cb93cbbb 100644 --- a/tests/e2e/numbers/features/steps/webhooks.steps.py +++ b/tests/e2e/numbers/features/steps/webhooks.steps.py @@ -8,7 +8,7 @@ @given('the Numbers Webhooks handler is available') def step_webhook_handler_is_available(context): - context.numbers_webhook = context.sinch.numbers.webhooks(SINCH_NUMBERS_CALLBACK_SECRET) + context.numbers_webhook = context.sinch.numbers.sinch_events(SINCH_NUMBERS_CALLBACK_SECRET) @when('I send a request to trigger the "{status}" for "{event_type}" event') diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py index 3f5dc4d9..ee3444a4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_get_active_numbers_endpoint.py @@ -69,4 +69,4 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): expected_expire_at = ( datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) assert parsed_response.expire_at == expected_expire_at - assert parsed_response.callback_url == "https://yourcallback/numbers" + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index e674d5c6..d291aeab 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -102,6 +102,6 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): 2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc ) assert number.expire_at == expected_expire_at - assert number.callback_url == "https://yourcallback/numbers" + assert number.event_destination_target == "https://yourcallback/numbers" assert parsed_response.next_page_token == "CgtwaG9uoLnNDQzajQSDCsxMzE1OTA0MzM1OQ==" assert parsed_response.total_size == 10 diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py index 1588b3ba..f09c88a3 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_release_active_numbers_endpoint.py @@ -69,4 +69,4 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): expected_expire_at = ( datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) assert parsed_response.expire_at == expected_expire_at - assert parsed_response.callback_url == "https://yourcallback/numbers" + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py index 2e4fbbfa..34af95e3 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_update_active_numbers_endpoint.py @@ -104,4 +104,4 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): expected_expire_at = ( datetime(2025, 2, 28, 14, 4, 26, 190127, tzinfo=timezone.utc)) assert parsed_response.expire_at == expected_expire_at - assert parsed_response.callback_url == "https://yourcallback/numbers" + assert parsed_response.event_destination_target == "https://yourcallback/numbers" diff --git a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py index db09d00a..ee521ea0 100644 --- a/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/available/test_rent_any_number_endpoint.py @@ -19,7 +19,7 @@ def valid_request_data(): capabilities=["SMS"], sms_configuration={"servicePlanId": "string", "campaignId": "string"}, voice_configuration={"appId": "string"}, - callback_url="https://www.your-callback-server.com/callback", + event_destination_target="https://www.your-callback-server.com/callback", ) @@ -140,4 +140,4 @@ def test_handle_response_expects_valid_mapping(valid_response_data): assert voice_config.scheduled_voice_provisioning.status == "PROVISIONING_STATUS_UNSPECIFIED" assert voice_config.scheduled_voice_provisioning.trunk_id == "string" assert voice_config.app_id == "string" - assert response.callback_url == "https://www.your-callback-server.com/callback" + assert response.event_destination_target == "https://www.your-callback-server.com/callback" diff --git a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py similarity index 85% rename from tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py index 8555146f..4f8af3b0 100644 --- a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_get_numbers_callback_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_get_event_destination_endpoint.py @@ -1,8 +1,8 @@ import pytest from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal import GetCallbackConfigurationEndpoint +from sinch.domains.numbers.api.v1.internal import GetEventDestinationEndpoint from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.response import EventDestinationResponse @pytest.fixture @@ -19,7 +19,7 @@ def mock_response(): @pytest.fixture def endpoint_empty_request_data(): - return GetCallbackConfigurationEndpoint("test_project_id", request_data=None) + return GetEventDestinationEndpoint("test_project_id", request_data=None) @pytest.fixture @@ -29,7 +29,7 @@ def endpoint_extra_request_data(): "extra_field": "extra value" } request_model = BaseModelConfigurationRequest(**data) - return GetCallbackConfigurationEndpoint("test_project_id", request_data=request_model) + return GetEventDestinationEndpoint("test_project_id", request_data=request_model) endpoint_fixtures = pytest.mark.parametrize("endpoint_fixture", [ @@ -73,6 +73,6 @@ def test_handle_response_expects_correct_mapping(endpoint_fixture, mock_response """ endpoint = request.getfixturevalue(endpoint_fixture) parsed_response = endpoint.handle_response(mock_response) - assert isinstance(parsed_response, CallbackConfigurationResponse) + assert isinstance(parsed_response, EventDestinationResponse) assert parsed_response.project_id == "j55aa9aa-b888-777c-dd6d-ee55e1010101010" assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py similarity index 77% rename from tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py rename to tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py index fe20ed24..0b1871f4 100644 --- a/tests/unit/domains/numbers/v1/endpoints/callbacks/test_update_numbers_callback_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/event_destination/test_update_event_destination_endpoint.py @@ -1,14 +1,14 @@ import json import pytest from sinch.core.models.http_response import HTTPResponse -from sinch.domains.numbers.api.v1.internal import UpdateCallbackConfigurationEndpoint -from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse +from sinch.domains.numbers.api.v1.internal import UpdateEventDestinationEndpoint +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest +from sinch.domains.numbers.models.v1.response import EventDestinationResponse @pytest.fixture def mock_request_data(): - return UpdateCallbackConfigurationRequest( + return UpdateEventDestinationRequest( hmac_secret="your_hmac_secret" ) @@ -35,7 +35,7 @@ def mock_response_body(): @pytest.fixture def endpoint(mock_request_data): - return UpdateCallbackConfigurationEndpoint("test_project_id", mock_request_data) + return UpdateEventDestinationEndpoint("test_project_id", mock_request_data) def test_build_url(endpoint, mock_sinch_client_numbers): @@ -59,6 +59,6 @@ def test_handle_response_expects_correct_mapping(endpoint, mock_response): Check if response is handled and mapped to the appropriate fields correctly. """ parsed_response = endpoint.handle_response(mock_response) - assert isinstance(parsed_response, CallbackConfigurationResponse) + assert isinstance(parsed_response, EventDestinationResponse) assert parsed_response.project_id == "a99aa9aa-b888-777c-dd6d-ee55e5555555" assert parsed_response.hmac_secret == "your_hmac_secret" diff --git a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py index 22f1f66f..e615c2fd 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_rent_any_number_request_model.py @@ -41,7 +41,7 @@ def test_rent_any_number_request_expects_valid_data(): "type": "RTC", "appId": "string" } - assert request.callback_url == "https://www.your-callback-server.com/callback" + assert request.event_destination_target == "https://www.your-callback-server.com/callback" def test_rent_any_number_request_expects_missing_optional_fields(): @@ -62,4 +62,4 @@ def test_rent_any_number_request_expects_missing_optional_fields(): assert request.capabilities is None assert request.sms_configuration is None assert request.voice_configuration is None - assert request.callback_url is None + assert request.event_destination_target is None diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py index 12bf9ca8..4b7c0db3 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_active_numbers_request_model.py @@ -31,7 +31,7 @@ def test_update_number_configuration_request_valid_expects_parsed_response(): "type": "RTC", "appId": "YOUR_Voice_appId" } - assert request.callback_url == "https://www.your-callback-server.com/callback" + assert request.event_destination_target == "https://www.your-callback-server.com/callback" def test_update_number_configuration_request_missing_phone_number_expects_error(): @@ -71,4 +71,4 @@ def test_update_number_configuration_request_optional_fields(): assert request.display_name is None assert request.sms_configuration is None assert request.voice_configuration is None - assert request.callback_url is None + assert request.event_destination_target is None diff --git a/tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py similarity index 76% rename from tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py rename to tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py index 1344c31a..4d1b2820 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_update_callback_config_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_update_event_destination_request_model.py @@ -1,6 +1,6 @@ import pytest from pydantic import ValidationError -from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest def test_update_numbers_callback_config_request_expects_parsed_input(): @@ -10,7 +10,7 @@ def test_update_numbers_callback_config_request_expects_parsed_input(): data = { "hmacSecret": "test-secret-key" } - request = UpdateCallbackConfigurationRequest(**data) + request = UpdateEventDestinationRequest(**data) assert request.hmac_secret == "test-secret-key" @@ -21,7 +21,7 @@ def test_update_numbers_callback_request_expects_validation_for_extra_type(): data = { "extra": "Extra Value" } - request = UpdateCallbackConfigurationRequest(**data) + request = UpdateEventDestinationRequest(**data) assert request.extra == "Extra Value" @@ -30,7 +30,7 @@ def test_update_numbers_callback_config_request_expects_optional_field_handled() Test that hmac_secret is optional and can be None. """ data = {} - request = UpdateCallbackConfigurationRequest(**data) + request = UpdateEventDestinationRequest(**data) assert request.hmac_secret is None @@ -42,4 +42,4 @@ def test_update_numbers_callback_config_request_expects_validation_error(): "hmacSecret": 12345 } with pytest.raises(ValidationError): - UpdateCallbackConfigurationRequest(**data) + UpdateEventDestinationRequest(**data) diff --git a/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py index e52066bb..1429fb44 100644 --- a/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_active_number_model.py @@ -98,7 +98,7 @@ def test_active_number_response_expects_all_fields_mapped_correctly(test_data): expected_expire_at = ( datetime(2025, 2, 4, 13, 15, 31, 95000, tzinfo=timezone.utc)) assert response.expire_at == expected_expire_at - assert response.callback_url == "https://www.your-callback-server.com/callback" + assert response.event_destination_target == "https://www.your-callback-server.com/callback" assert_sms_configuration(response.sms_configuration) assert_voice_configuration(response.voice_configuration) diff --git a/tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py b/tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py similarity index 81% rename from tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py rename to tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py index 1caf4baa..f568e549 100644 --- a/tests/unit/domains/numbers/v1/models/response/test_numbers_callback_model.py +++ b/tests/unit/domains/numbers/v1/models/response/test_event_destination_response_model.py @@ -1,5 +1,5 @@ import pytest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.response import EventDestinationResponse @pytest.fixture @@ -17,7 +17,7 @@ def test_numbers_callback_config_response_all_fields(test_data): Expects all fields to map correctly from camelCase input and handle extra fields appropriately """ - response = CallbackConfigurationResponse(**test_data) + response = EventDestinationResponse(**test_data) assert response.project_id == "project-test-id" assert response.hmac_secret == "secret-key-456" diff --git a/tests/unit/domains/numbers/v1/test_numbers_callback.py b/tests/unit/domains/numbers/v1/test_event_destinations.py similarity index 62% rename from tests/unit/domains/numbers/v1/test_numbers_callback.py rename to tests/unit/domains/numbers/v1/test_event_destinations.py index cf818696..a9d55ff9 100644 --- a/tests/unit/domains/numbers/v1/test_numbers_callback.py +++ b/tests/unit/domains/numbers/v1/test_event_destinations.py @@ -1,11 +1,11 @@ import pytest -from sinch.domains.numbers.api.v1 import CallbackConfiguration +from sinch.domains.numbers.api.v1 import EventDestinations from sinch.domains.numbers.api.v1.internal import ( - GetCallbackConfigurationEndpoint, UpdateCallbackConfigurationEndpoint + GetEventDestinationEndpoint, UpdateEventDestinationEndpoint ) -from sinch.domains.numbers.models.v1.internal import UpdateCallbackConfigurationRequest +from sinch.domains.numbers.models.v1.internal import UpdateEventDestinationRequest from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest -from sinch.domains.numbers.models.v1.response import CallbackConfigurationResponse +from sinch.domains.numbers.models.v1.response import EventDestinationResponse @pytest.mark.parametrize( @@ -27,12 +27,12 @@ def test_get_numbers_callback_config_expects_valid_request( Test that the get() method sends the correct request and handles the response properly with or without extra parameters. """ - mock_response = CallbackConfigurationResponse(project_id="test_project_id", hmac_secret="test_secret") + mock_response = EventDestinationResponse(project_id="test_project_id", hmac_secret="test_secret") mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response - spy_endpoint = mocker.spy(GetCallbackConfigurationEndpoint, "__init__") + spy_endpoint = mocker.spy(GetEventDestinationEndpoint, "__init__") - callback_configuration = CallbackConfiguration(mock_sinch_client_numbers) - response = callback_configuration.get(**config_kwargs) + event_destination = EventDestinations(mock_sinch_client_numbers) + response = event_destination.get(**config_kwargs) spy_endpoint.assert_called_once() _, kwargs = spy_endpoint.call_args @@ -50,19 +50,19 @@ def test_update_numbers_callback_config_expects_valid_request(mock_sinch_client_ Test that the update() method sends the correct request and handles the response properly. """ - mock_response = CallbackConfigurationResponse(project_id="test_project_id", hmac_secret="new_secret") + mock_response = EventDestinationResponse(project_id="test_project_id", hmac_secret="new_secret") mock_sinch_client_numbers.configuration.transport.request.return_value = mock_response - spy_endpoint = mocker.spy(UpdateCallbackConfigurationEndpoint, "__init__") + spy_endpoint = mocker.spy(UpdateEventDestinationEndpoint, "__init__") - callback_configuration = CallbackConfiguration(mock_sinch_client_numbers) - response = callback_configuration.update(hmac_secret="new_secret") + event_destination = EventDestinations(mock_sinch_client_numbers) + response = event_destination.update(hmac_secret="new_secret") spy_endpoint.assert_called_once() _, kwargs = spy_endpoint.call_args assert kwargs["project_id"] == "test_project_id" - assert kwargs["request_data"] == UpdateCallbackConfigurationRequest(hmac_secret="new_secret") + assert kwargs["request_data"] == UpdateEventDestinationRequest(hmac_secret="new_secret") assert response == mock_response mock_sinch_client_numbers.configuration.transport.request.assert_called_once() diff --git a/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py b/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py index 8ec761ef..44419896 100644 --- a/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py +++ b/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py @@ -1,7 +1,7 @@ import pytest from datetime import datetime, timezone from pydantic import ValidationError -from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent @pytest.fixture @@ -35,7 +35,7 @@ def test_numbers_webhooks_response_expects_parsed_data(valid_data): Expects all fields to map correctly from camelCase input and handle valid data appropriately. """ - response = NumbersWebhooksEvent(**valid_data) + response = NumberSinchEvent(**valid_data) assert response.event_id == "event-123" assert response.timestamp == datetime( @@ -59,7 +59,7 @@ def test_numbers_webhooks_response_missing_optional_fields_expects_parsed_data() "eventId": "event-123", "projectId": "project-456" } - response = NumbersWebhooksEvent(**data) + response = NumberSinchEvent(**data) assert response.event_id == "event-123" assert response.project_id == "project-456" @@ -76,4 +76,4 @@ def test_numbers_webhooks_response_invalid_data_expects_validation_error(invalid Expects the model to raise a validation error for invalid data. """ with pytest.raises(ValidationError): - NumbersWebhooksEvent(**invalid_data) + NumberSinchEvent(**invalid_data) diff --git a/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py b/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py index 171a22fc..b157cdb1 100644 --- a/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py +++ b/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone import pytest -from sinch.domains.numbers.webhooks.v1 import NumbersWebhooks -from sinch.domains.numbers.webhooks.v1.events import NumbersWebhooksEvent +from sinch.domains.numbers.sinch_events.v1 import SinchEvents +from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent @pytest.fixture @@ -16,7 +16,7 @@ def string_to_sign(): @pytest.fixture def numbers_webhooks(): - return NumbersWebhooks('my-callback-secret') + return SinchEvents('my-callback-secret') @pytest.fixture @@ -67,7 +67,7 @@ def test_parse_event_expects_timestamp_as_utc(numbers_webhooks, test_name, times def test_parse_event_expects_parsed_response(numbers_webhooks, base_payload_parse_event): response = numbers_webhooks.parse_event(base_payload_parse_event) - assert isinstance(response, NumbersWebhooksEvent) + assert isinstance(response, NumberSinchEvent) assert response.event_id == "01jr7stexp0znky34pj07dwp41" assert response.project_id == "project-id" assert response.resource_id == "+1234567890" From b1531522d5dea7a6d0730ca6a1498a3851b07eff Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Fri, 20 Mar 2026 18:03:40 +0100 Subject: [PATCH 099/106] DEVEXP-1310: Redesign Webhooks - SMS (#132) --- examples/sinch_events/.env.example | 10 +- examples/sinch_events/README.md | 32 +++---- .../conversation_api/controller.py | 10 +- .../sinch_events/numbers_api/controller.py | 8 +- examples/sinch_events/server.py | 24 ++--- examples/sinch_events/sinch_client_helper.py | 4 +- examples/sinch_events/sms_api/controller.py | 12 +-- .../sms_api/server_business_logic.py | 8 +- sinch/domains/sms/api/v1/batches_apis.py | 96 +++++++++---------- .../v1/internal/update_binary_request.py | 3 +- .../v1/internal/update_media_request.py | 3 +- .../models/v1/internal/update_text_request.py | 3 +- .../sms/models/v1/shared/binary_request.py | 3 +- .../sms/models/v1/shared/binary_response.py | 6 +- .../sms/models/v1/shared/media_request.py | 3 +- .../sms/models/v1/shared/media_response.py | 3 +- .../sms/models/v1/shared/text_request.py | 3 +- .../sms/models/v1/shared/text_response.py | 3 +- sinch/domains/sms/sinch_events/v1/__init__.py | 5 + .../sms/sinch_events/v1/events/__init__.py | 17 ++++ .../v1/events/sms_sinch_event.py} | 22 ++--- .../sms/sinch_events/v1/internal/__init__.py | 5 + .../v1/internal/sinch_event.py} | 2 +- .../v1/sms_sinch_event.py} | 28 +++--- sinch/domains/sms/sms.py | 16 ++-- .../sms/webhooks/v1/events/__init__.py | 17 ---- .../sms/webhooks/v1/internal/__init__.py | 5 - .../e2e/sms/features/steps/webhooks.steps.py | 22 ++--- .../batches/test_send_batches_endpoint.py | 15 +++ .../internal/test_dry_run_request_model.py | 12 +-- .../test_replace_binary_request_model.py | 4 +- .../test_replace_media_request_model.py | 4 +- ...date_binary_request_with_batch_id_model.py | 4 +- ...pdate_media_request_with_batch_id_model.py | 4 +- ...update_text_request_with_batch_id_model.py | 4 +- tests/unit/domains/sms/v1/test_batches.py | 6 +- 36 files changed, 231 insertions(+), 195 deletions(-) create mode 100644 sinch/domains/sms/sinch_events/v1/__init__.py create mode 100644 sinch/domains/sms/sinch_events/v1/events/__init__.py rename sinch/domains/sms/{webhooks/v1/events/sms_webhooks_event.py => sinch_events/v1/events/sms_sinch_event.py} (84%) create mode 100644 sinch/domains/sms/sinch_events/v1/internal/__init__.py rename sinch/domains/sms/{webhooks/v1/internal/webhook_event.py => sinch_events/v1/internal/sinch_event.py} (66%) rename sinch/domains/sms/{webhooks/v1/sms_webhooks.py => sinch_events/v1/sms_sinch_event.py} (89%) delete mode 100644 sinch/domains/sms/webhooks/v1/events/__init__.py delete mode 100644 sinch/domains/sms/webhooks/v1/internal/__init__.py diff --git a/examples/sinch_events/.env.example b/examples/sinch_events/.env.example index 13561254..02133356 100644 --- a/examples/sinch_events/.env.example +++ b/examples/sinch_events/.env.example @@ -1,11 +1,11 @@ # Server Configuration SERVER_PORT = -# Webhook Configuration -# The secret value used for webhook calls validation +# Sinch Event Configuration +# The secret value used for Sinch Event callback validation # See https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/ -NUMBERS_WEBHOOKS_SECRET = NUMBERS_WEBHOOKS_SECRET +NUMBERS_SINCH_EVENT_SECRET = NUMBERS_SINCH_EVENT_SECRET # See https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks -SMS_WEBHOOKS_SECRET = SMS_WEBHOOKS_SECRET +SMS_SINCH_EVENT_SECRET = SMS_SINCH_EVENT_SECRET # See https://developers.sinch.com/docs/conversation/callbacks -CONVERSATION_WEBHOOKS_SECRET = CONVERSATION_WEBHOOKS_SECRET \ No newline at end of file +CONVERSATION_SINCH_EVENT_SECRET = CONVERSATION_SINCH_EVENT_SECRET \ No newline at end of file diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index 20693812..826fe987 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -3,12 +3,12 @@ This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) to process incoming webhooks from Sinch services. -The webhook handlers are organized by service: -- **SMS**: Handlers for SMS webhook events (`sms_api/`) -- **Numbers**: Handlers for Numbers API webhook events (`numbers_api/`) -- **Conversation**: Handlers for Conversation API webhook events (`conversation_api/`) +The Sinch Events Handlers are organized by service: +- **SMS**: Handlers for SMS events (`sms_api/`) +- **Numbers**: Handlers for Numbers API events (`numbers_api/`) +- **Conversation**: Handlers for Conversation API events (`conversation_api/`) -This directory contains both the webhook handlers and the server application (`server.py`) that uses them. +This directory contains both the Event handlers and the server application (`server.py`) that uses them. ## Requirements @@ -32,17 +32,17 @@ This directory contains both the webhook handlers and the server application (`s - Controller Settings - Numbers controller: Set the `numbers` Sinch Event secret. You can retrieve it using the `/event_destination` endpoint (see SDK implementation: [event_destinations_apis.py](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/event_destinations_apis.py); for additional details, refer to the [Numbers API callbacks documentation](https://developers.sinch.com/docs/numbers/api-reference/numbers/tag/Numbers-Callbacks/)): ``` - NUMBERS_WEBHOOKS_SECRET=Your Sinch Numbers Webhook Secret + NUMBERS_SINCH_EVENT_SECRET=Your Sinch Numbers Sinch Event Secret ``` - - SMS controller: To configure the `sms` webhooks secret, contact your account manager to enable authentication for SMS callbacks. For more details, refer to + - SMS controller: To configure the `sms` Sinch Event secret, contact your account manager to enable authentication for SMS callbacks. For more details, refer to [SMS API](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks), ``` - SMS_WEBHOOKS_SECRET=Your Sinch SMS Webhook Secret + SMS_SINCH_EVENT_SECRET=Your Sinch SMS Sinch Event Secret ``` - Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): ``` - CONVERSATION_WEBHOOKS_SECRET=Your Conversation Webhook Secret + CONVERSATION_SINCH_EVENT_SECRET=Your Conversation Sinch Event Secret ``` ## Usage @@ -96,18 +96,18 @@ ngrok ... Forwarding https://adbd-79-148-170-158.ngrok-free.app -> http://localhost:3001 ``` -Use the `https` forwarding URL in your callback configuration. For example: +Use the `https` forwarding URL in your event destination configuration. For example: - Numbers: https://adbd-79-148-170-158.ngrok-free.app/NumbersEvent - SMS: https://adbd-79-148-170-158.ngrok-free.app/SmsEvent - Conversation: https://adbd-79-148-170-158.ngrok-free.app/ConversationEvent -Use this value to configure the callback URLs: -- **Numbers**: Set the `callback_url` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)). -- **SMS**: Set the `callback_url` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L146), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. +Use this value to configure the Sinch Events URLs: +- **Numbers**: Set the `event_destination_target` parameter when renting or updating a number via the SDK (e.g., `available_numbers_apis` rent/update flow: [rent](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L69), [update](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/available_numbers_apis.py#L89)); you can also update active numbers via `active_numbers_apis` ([example](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/numbers/api/v1/active_numbers_apis.py#L64)). +- **SMS**: Set the `event_destination_target` parameter when configuring your SMS service plan via the SDK (see `batches_apis` examples: [send/dry-run callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L146), [update/replace callbacks](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/sms/api/v1/batches_apis.py#L491)); you can also set it directly via the SMS API. - **Conversation**: Set the `callback_url` parameter when sending a message via the SDK (see `messages_apis` example: [send_text_message](https://github.com/sinch/sinch-sdk-python/blob/v2.0/sinch/domains/conversation/api/v1/messages_apis.py#L420)). -You can also set these callback URLs in the Sinch dashboard; the API parameters above override the default values configured there. +You can also set these Sinch Events URLs in the Sinch dashboard; the API parameters above override the default values configured there. -> **Note**: If you have set a webhook secret (e.g., `SMS_WEBHOOKS_SECRET`), the webhook URL must be configured in the Sinch dashboard -> and cannot be overridden via API parameters. The webhook secret is used to validate incoming webhook requests, +> **Note**: If you have set a Sinch Event secret (e.g., `SMS_SINCH_EVENT_SECRET`), the Sinch Event URL must be configured in the Sinch dashboard +> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming webhook requests, > and the URL associated with it must be set in the dashboard. diff --git a/examples/sinch_events/conversation_api/controller.py b/examples/sinch_events/conversation_api/controller.py index 74e47888..b71a287b 100644 --- a/examples/sinch_events/conversation_api/controller.py +++ b/examples/sinch_events/conversation_api/controller.py @@ -1,18 +1,20 @@ from flask import request, Response -from webhooks.conversation_api.server_business_logic import handle_conversation_event +from sinch_events.conversation_api.server_business_logic import handle_conversation_event class ConversationController: - def __init__(self, sinch_client, webhooks_secret): + def __init__(self, sinch_client, sinch_event_secret): self.sinch_client = sinch_client - self.webhooks_secret = webhooks_secret + self.sinch_event_secret = sinch_event_secret self.logger = self.sinch_client.configuration.logger def conversation_event(self): headers = dict(request.headers) raw_body = request.raw_body if request.raw_body else b"" - webhooks_service = self.sinch_client.conversation.webhooks(self.webhooks_secret) + webhooks_service = self.sinch_client.conversation.webhooks( + self.sinch_event_secret + ) # Set to True to enforce signature validation (recommended in production) ensure_valid_signature = False diff --git a/examples/sinch_events/numbers_api/controller.py b/examples/sinch_events/numbers_api/controller.py index 00eaac47..cdc43e22 100644 --- a/examples/sinch_events/numbers_api/controller.py +++ b/examples/sinch_events/numbers_api/controller.py @@ -1,11 +1,11 @@ from flask import request, Response -from webhooks.numbers_api.server_business_logic import handle_numbers_event +from sinch_events.numbers_api.server_business_logic import handle_numbers_event class NumbersController: - def __init__(self, sinch_client, webhooks_secret): + def __init__(self, sinch_client, sinch_event_secret): self.sinch_client = sinch_client - self.webhooks_secret = webhooks_secret + self.sinch_event_secret = sinch_event_secret self.logger = self.sinch_client.configuration.logger def numbers_event(self): @@ -13,7 +13,7 @@ def numbers_event(self): raw_body = request.raw_body if request.raw_body else b"" sinch_events_service = self.sinch_client.numbers.sinch_events( - self.webhooks_secret + self.sinch_event_secret ) ensure_valid_authentication = False diff --git a/examples/sinch_events/server.py b/examples/sinch_events/server.py index 98caa89a..368cec68 100644 --- a/examples/sinch_events/server.py +++ b/examples/sinch_events/server.py @@ -2,33 +2,35 @@ import sys from pathlib import Path -# Add examples directory to Python path to allow importing webhooks +# Add examples directory to Python path to allow importing sinch_events examples_dir = Path(__file__).resolve().parent.parent if str(examples_dir) not in sys.path: sys.path.insert(0, str(examples_dir)) from flask import Flask, request -from webhooks.numbers_api.controller import NumbersController -from webhooks.sms_api.controller import SmsController -from webhooks.conversation_api.controller import ConversationController -from webhooks.sinch_client_helper import get_sinch_client, load_config +from sinch_events.numbers_api.controller import NumbersController +from sinch_events.sms_api.controller import SmsController +from sinch_events.conversation_api.controller import ConversationController +from sinch_events.sinch_client_helper import get_sinch_client, load_config app = Flask(__name__) config = load_config() port = int(config.get('SERVER_PORT') or 3001) -numbers_webhooks_secret = config.get('NUMBERS_WEBHOOKS_SECRET') -sms_webhooks_secret = config.get('SMS_WEBHOOKS_SECRET') -conversation_webhooks_secret = config.get('CONVERSATION_WEBHOOKS_SECRET') +numbers_sinch_event_secret = config.get('NUMBERS_SINCH_EVENT_SECRET') +sms_sinch_event_secret = config.get('SMS_SINCH_EVENT_SECRET') +conversation_sinch_event_secret = config.get('CONVERSATION_SINCH_EVENT_SECRET') sinch_client = get_sinch_client(config) # Set up logging at the INFO level logging.basicConfig() sinch_client.configuration.logger.setLevel(logging.INFO) -numbers_controller = NumbersController(sinch_client, numbers_webhooks_secret) -sms_controller = SmsController(sinch_client, sms_webhooks_secret) -conversation_controller = ConversationController(sinch_client, conversation_webhooks_secret or '') +numbers_controller = NumbersController(sinch_client, numbers_sinch_event_secret) +sms_controller = SmsController(sinch_client, sms_sinch_event_secret) +conversation_controller = ConversationController( + sinch_client, conversation_sinch_event_secret or '' +) # Middleware to capture raw body diff --git a/examples/sinch_events/sinch_client_helper.py b/examples/sinch_events/sinch_client_helper.py index 109fce1c..fdc662ab 100644 --- a/examples/sinch_events/sinch_client_helper.py +++ b/examples/sinch_events/sinch_client_helper.py @@ -5,7 +5,7 @@ def load_config() -> dict[str, str]: """ - Load configuration from the .env file in the webhooks directory. + Load configuration from the .env file in the sinch_events directory. Returns: dict[str, str]: Dictionary containing configuration values @@ -15,7 +15,7 @@ def load_config() -> dict[str, str]: env_file = current_dir / '.env' if not env_file.exists(): - raise FileNotFoundError(f"Could not find .env file in webhooks directory: {env_file}") + raise FileNotFoundError(f"Could not find .env file in sinch_events directory: {env_file}") config_dict = dotenv_values(env_file) diff --git a/examples/sinch_events/sms_api/controller.py b/examples/sinch_events/sms_api/controller.py index b5bd50f5..2ebfda6c 100644 --- a/examples/sinch_events/sms_api/controller.py +++ b/examples/sinch_events/sms_api/controller.py @@ -1,27 +1,27 @@ from flask import request, Response -from webhooks.sms_api.server_business_logic import ( +from sinch_events.sms_api.server_business_logic import ( handle_sms_event, ) class SmsController: - def __init__(self, sinch_client, webhooks_secret): + def __init__(self, sinch_client, sinch_event_secret): self.sinch_client = sinch_client - self.webhooks_secret = webhooks_secret + self.sinch_event_secret = sinch_event_secret self.logger = self.sinch_client.configuration.logger def sms_event(self): headers = dict(request.headers) raw_body = request.raw_body if request.raw_body else b"" - webhooks_service = self.sinch_client.sms.webhooks(self.webhooks_secret) + sinch_events_service = self.sinch_client.sms.sinch_events(self.sinch_event_secret) # Signature headers may be absent unless your account manager enables them # (see README: Configuration -> Controller Settings -> SMS controller); # leave auth disabled here unless SMS callbacks are configured. ensure_valid_authentication = False if ensure_valid_authentication: - valid_auth = webhooks_service.validate_authentication_header( + valid_auth = sinch_events_service.validate_authentication_header( headers=headers, json_payload=raw_body, ) @@ -29,7 +29,7 @@ def sms_event(self): if not valid_auth: return Response(status=401) - event = webhooks_service.parse_event(raw_body, headers) + event = sinch_events_service.parse_event(raw_body, headers) handle_sms_event(sms_event=event, logger=self.logger) diff --git a/examples/sinch_events/sms_api/server_business_logic.py b/examples/sinch_events/sms_api/server_business_logic.py index aa47e470..7061394d 100644 --- a/examples/sinch_events/sms_api/server_business_logic.py +++ b/examples/sinch_events/sms_api/server_business_logic.py @@ -1,11 +1,13 @@ -from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import IncomingSMSWebhookEvent +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, +) -def handle_sms_event(sms_event: IncomingSMSWebhookEvent, logger): +def handle_sms_event(sms_event: IncomingSMSSinchEvent, logger): """ This method handles an SMS event. Args: - sms_event (SmsWebhooksEvent): The SMS event data. + sms_event (IncomingSMSSinchEvent): The SMS event data. logger (logging.Logger, optional): Logger instance for logging. Defaults to None. """ logger.info(f'Handling SMS event:\n{sms_event.model_dump_json(indent=2)}') diff --git a/sinch/domains/sms/api/v1/batches_apis.py b/sinch/domains/sms/api/v1/batches_apis.py index 843ad482..e4a34576 100644 --- a/sinch/domains/sms/api/v1/batches_apis.py +++ b/sinch/domains/sms/api/v1/batches_apis.py @@ -143,7 +143,7 @@ def dry_run_sms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, flash_message: Optional[bool] = None, @@ -175,8 +175,8 @@ def dry_run_sms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -209,7 +209,7 @@ def dry_run_sms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, flash_message=flash_message, @@ -232,7 +232,7 @@ def dry_run_binary( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, from_ton: Optional[int] = None, @@ -261,8 +261,8 @@ def dry_run_binary( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -289,7 +289,7 @@ def dry_run_binary( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, from_ton=from_ton, @@ -309,7 +309,7 @@ def dry_run_mms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, strict_validation: Optional[bool] = None, @@ -337,8 +337,8 @@ def dry_run_mms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -363,7 +363,7 @@ def dry_run_mms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, strict_validation=strict_validation, @@ -487,7 +487,7 @@ def replace_sms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, flash_message: Optional[bool] = None, @@ -516,8 +516,8 @@ def replace_sms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -550,7 +550,7 @@ def replace_sms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, flash_message=flash_message, @@ -573,7 +573,7 @@ def replace_binary( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, from_ton: Optional[int] = None, @@ -600,8 +600,8 @@ def replace_binary( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -627,7 +627,7 @@ def replace_binary( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, from_ton=from_ton, @@ -645,7 +645,7 @@ def replace_mms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, strict_validation: Optional[bool] = None, @@ -670,8 +670,8 @@ def replace_mms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -696,7 +696,7 @@ def replace_mms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, strict_validation=strict_validation, @@ -751,7 +751,7 @@ def send_sms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, flash_message: Optional[bool] = None, @@ -784,8 +784,8 @@ def send_sms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -817,7 +817,7 @@ def send_sms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, flash_message=flash_message, @@ -839,7 +839,7 @@ def send_binary( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, from_ton: Optional[int] = None, @@ -870,8 +870,8 @@ def send_binary( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -896,7 +896,7 @@ def send_binary( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, from_ton=from_ton, @@ -913,7 +913,7 @@ def send_mms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, strict_validation: Optional[bool] = None, @@ -942,8 +942,8 @@ def send_mms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -967,7 +967,7 @@ def send_mms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, strict_validation=strict_validation, @@ -1057,7 +1057,7 @@ def update_sms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, parameters: Optional[Dict[str, Dict[str, str]]] = None, @@ -1087,8 +1087,8 @@ def update_sms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -1122,7 +1122,7 @@ def update_sms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, parameters=parameters, @@ -1146,7 +1146,7 @@ def update_binary( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, from_ton: Optional[int] = None, @@ -1174,8 +1174,8 @@ def update_binary( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -1202,7 +1202,7 @@ def update_binary( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, from_ton=from_ton, @@ -1221,7 +1221,7 @@ def update_mms( delivery_report: Optional[DeliveryReportType] = None, send_at: Optional[datetime] = None, expire_at: Optional[datetime] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, client_reference: Optional[str] = None, feedback_enabled: Optional[bool] = None, parameters: Optional[Dict[str, Dict[str, str]]] = None, @@ -1247,8 +1247,8 @@ def update_mms( :type send_at: Optional[datetime] :param expire_at: The time to expire the message at. (optional) :type expire_at: Optional[datetime] - :param callback_url: The callback URL to receive the delivery report. (optional) - :type callback_url: Optional[str] + :param event_destination_target: The callback URL to receive the delivery report. (optional) + :type event_destination_target: Optional[str] :param client_reference: The client reference to identify the message. (optional) :type client_reference: Optional[str] :param feedback_enabled: Whether to enable feedback. (optional) @@ -1274,7 +1274,7 @@ def update_mms( delivery_report=delivery_report, send_at=send_at, expire_at=expire_at, - callback_url=callback_url, + event_destination_target=event_destination_target, client_reference=client_reference, feedback_enabled=feedback_enabled, parameters=parameters, diff --git a/sinch/domains/sms/models/v1/internal/update_binary_request.py b/sinch/domains/sms/models/v1/internal/update_binary_request.py index a73f090f..f23c5182 100644 --- a/sinch/domains/sms/models/v1/internal/update_binary_request.py +++ b/sinch/domains/sms/models/v1/internal/update_binary_request.py @@ -34,8 +34,9 @@ class UpdateBinaryRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/internal/update_media_request.py b/sinch/domains/sms/models/v1/internal/update_media_request.py index 1aa5f6dc..ed347d3d 100644 --- a/sinch/domains/sms/models/v1/internal/update_media_request.py +++ b/sinch/domains/sms/models/v1/internal/update_media_request.py @@ -32,8 +32,9 @@ class UpdateMediaRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/internal/update_text_request.py b/sinch/domains/sms/models/v1/internal/update_text_request.py index 81e4e2ea..d43ee83f 100644 --- a/sinch/domains/sms/models/v1/internal/update_text_request.py +++ b/sinch/domains/sms/models/v1/internal/update_text_request.py @@ -39,8 +39,9 @@ class UpdateTextRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Constraints: Must be after `send_at` Default: 3 days after `send_at` ", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Constraints: Must be valid URL. ", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/shared/binary_request.py b/sinch/domains/sms/models/v1/shared/binary_request.py index 027d98e9..35fc4146 100644 --- a/sinch/domains/sms/models/v1/shared/binary_request.py +++ b/sinch/domains/sms/models/v1/shared/binary_request.py @@ -40,8 +40,9 @@ class BinaryRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/shared/binary_response.py b/sinch/domains/sms/models/v1/shared/binary_response.py index 171b2fd2..96309913 100644 --- a/sinch/domains/sms/models/v1/shared/binary_response.py +++ b/sinch/domains/sms/models/v1/shared/binary_response.py @@ -61,8 +61,10 @@ class BinaryResponse(BaseModelConfigurationResponse): default=None, description="If set, the date and time the message will expire. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601). For example: `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[StrictStr] = Field( - default=None, description="The callback URL provided in the request." + event_destination_target: Optional[StrictStr] = Field( + default=None, + alias="callback_url", + description="The callback URL provided in the request.", ) client_reference: Optional[StrictStr] = Field( default=None, diff --git a/sinch/domains/sms/models/v1/shared/media_request.py b/sinch/domains/sms/models/v1/shared/media_request.py index 54ef9203..57d5311a 100644 --- a/sinch/domains/sms/models/v1/shared/media_request.py +++ b/sinch/domains/sms/models/v1/shared/media_request.py @@ -35,8 +35,9 @@ class MediaRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. ", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Must be valid URL.", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/shared/media_response.py b/sinch/domains/sms/models/v1/shared/media_response.py index 7da3a82f..5971208a 100644 --- a/sinch/domains/sms/models/v1/shared/media_response.py +++ b/sinch/domains/sms/models/v1/shared/media_response.py @@ -50,8 +50,9 @@ class MediaResponse(BaseModelConfigurationResponse): default=None, description="If set the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after send_at. YYYY-MM-DDThh:mm:ss.SSSZ format", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Must be valid URL.", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/shared/text_request.py b/sinch/domains/sms/models/v1/shared/text_request.py index 2416633b..e5ecce0f 100644 --- a/sinch/domains/sms/models/v1/shared/text_request.py +++ b/sinch/domains/sms/models/v1/shared/text_request.py @@ -42,8 +42,9 @@ class TextRequest(BaseModelConfigurationRequest): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the *default* callback URL for this batch. Must be a valid URL. Learn how to set a default callback URL [here](https://community.sinch.com/t5/SMS/How-do-I-assign-a-callback-URL-to-an-SMS-service-plan/ta-p/8414).", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/models/v1/shared/text_response.py b/sinch/domains/sms/models/v1/shared/text_response.py index 650906e5..f5fc5975 100644 --- a/sinch/domains/sms/models/v1/shared/text_response.py +++ b/sinch/domains/sms/models/v1/shared/text_response.py @@ -51,8 +51,9 @@ class TextResponse(BaseModelConfigurationResponse): default=None, description="If set, the system will stop trying to deliver the message at this point. Must be after `send_at`. Default and max is 3 days after `send_at`. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`.", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, + alias="callback_url", description="Override the default callback URL for this batch. Must be valid URL.", ) client_reference: Optional[StrictStr] = Field( diff --git a/sinch/domains/sms/sinch_events/v1/__init__.py b/sinch/domains/sms/sinch_events/v1/__init__.py new file mode 100644 index 00000000..522d374f --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import ( + SmsSinchEvent, +) + +__all__ = ["SmsSinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/events/__init__.py b/sinch/domains/sms/sinch_events/v1/events/__init__.py new file mode 100644 index 00000000..00aba842 --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/events/__init__.py @@ -0,0 +1,17 @@ +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, + MediaBody, + MediaItem, +) + +__all__ = [ + "IncomingSMSSinchEvent", + "MOTextSinchEvent", + "MOBinarySinchEvent", + "MOMediaSinchEvent", + "MediaBody", + "MediaItem", +] diff --git a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py similarity index 84% rename from sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py rename to sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py index f8abe3cb..fc87e608 100644 --- a/sinch/domains/sms/webhooks/v1/events/sms_webhooks_event.py +++ b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py @@ -1,10 +1,10 @@ from datetime import datetime from typing import Optional, Union, Literal, Annotated from pydantic import Field, StrictStr, StrictInt, conlist -from sinch.domains.sms.webhooks.v1.internal import WebhookEvent +from sinch.domains.sms.sinch_events.v1.internal import SinchEvent -class MediaItem(WebhookEvent): +class MediaItem(SinchEvent): url: StrictStr = Field(..., description="URL to the media file") content_type: StrictStr = Field( ..., description="Content type of the media file" @@ -15,7 +15,7 @@ class MediaItem(WebhookEvent): code: StrictInt = Field(..., description="Status code") -class MediaBody(WebhookEvent): +class MediaBody(SinchEvent): subject: Optional[StrictStr] = Field( default=None, description="The subject text" ) @@ -25,7 +25,7 @@ class MediaBody(WebhookEvent): media: conlist(MediaItem) = Field(..., description="Array of media items") -class BaseIncomingSMSWebhookEvent(WebhookEvent): +class BaseIncomingSMSSinchEvent(SinchEvent): from_: StrictStr = Field( ..., alias="from", @@ -54,7 +54,7 @@ class BaseIncomingSMSWebhookEvent(WebhookEvent): ) -class MOTextWebhookEvent(BaseIncomingSMSWebhookEvent): +class MOTextSinchEvent(BaseIncomingSMSSinchEvent): body: StrictStr = Field( ..., description="The incoming message body. Maximum 2000 characters.", @@ -64,7 +64,7 @@ class MOTextWebhookEvent(BaseIncomingSMSWebhookEvent): ) -class MOBinaryWebhookEvent(BaseIncomingSMSWebhookEvent): +class MOBinarySinchEvent(BaseIncomingSMSSinchEvent): body: StrictStr = Field( ..., description="The incoming message body (Base64 encoded)." ) @@ -76,7 +76,7 @@ class MOBinaryWebhookEvent(BaseIncomingSMSWebhookEvent): ) -class MOMediaWebhookEvent(BaseIncomingSMSWebhookEvent): +class MOMediaSinchEvent(BaseIncomingSMSSinchEvent): body: MediaBody = Field( ..., description="The media message body containing subject, message, and media items.", @@ -87,11 +87,11 @@ class MOMediaWebhookEvent(BaseIncomingSMSWebhookEvent): # Union type for isinstance checks -_IncomingSMSWebhookEventUnion = Union[ - MOTextWebhookEvent, MOBinaryWebhookEvent, MOMediaWebhookEvent +_IncomingSMSSinchEventUnion = Union[ + MOTextSinchEvent, MOBinarySinchEvent, MOMediaSinchEvent ] # Discriminated union for validation -IncomingSMSWebhookEvent = Annotated[ - _IncomingSMSWebhookEventUnion, Field(discriminator="type") +IncomingSMSSinchEvent = Annotated[ + _IncomingSMSSinchEventUnion, Field(discriminator="type") ] diff --git a/sinch/domains/sms/sinch_events/v1/internal/__init__.py b/sinch/domains/sms/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..43b3a8dd --- /dev/null +++ b/sinch/domains/sms/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.sms.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/sms/webhooks/v1/internal/webhook_event.py b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py similarity index 66% rename from sinch/domains/sms/webhooks/v1/internal/webhook_event.py rename to sinch/domains/sms/sinch_events/v1/internal/sinch_event.py index 0d2857ed..184012f9 100644 --- a/sinch/domains/sms/webhooks/v1/internal/webhook_event.py +++ b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py @@ -3,5 +3,5 @@ ) -class WebhookEvent(BaseModelConfigurationResponse): +class SinchEvent(BaseModelConfigurationResponse): pass diff --git a/sinch/domains/sms/webhooks/v1/sms_webhooks.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py similarity index 89% rename from sinch/domains/sms/webhooks/v1/sms_webhooks.py rename to sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index c64dde6e..8f8daee9 100644 --- a/sinch/domains/sms/webhooks/v1/sms_webhooks.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -9,11 +9,11 @@ parse_json, normalize_iso_timestamp, ) -from sinch.domains.sms.webhooks.v1.events import ( - IncomingSMSWebhookEvent, - MOTextWebhookEvent, - MOBinaryWebhookEvent, - MOMediaWebhookEvent, +from sinch.domains.sms.sinch_events.v1.events import ( + IncomingSMSSinchEvent, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, ) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, @@ -21,16 +21,16 @@ ) -SmsCallback = Union[ +SmsSinchEventPayload = Union[ BatchDeliveryReport, RecipientDeliveryReport, - MOTextWebhookEvent, - MOBinaryWebhookEvent, - MOMediaWebhookEvent, + MOTextSinchEvent, + MOBinarySinchEvent, + MOMediaSinchEvent, ] -class SmsWebhooks: +class SmsSinchEvent: def __init__(self, app_secret: Optional[str] = None): self.app_secret = app_secret @@ -64,7 +64,7 @@ def parse_event( self, event_body: Union[str, bytes, Dict[str, Any]], headers: Optional[Dict[str, str]] = None, - ) -> SmsCallback: + ) -> SmsSinchEventPayload: """ Parse the event payload into an SMS callback object. @@ -75,8 +75,8 @@ def parse_event( :type event_body: Union[str, bytes, Dict[str, Any]] :param headers: Request headers (used to decode charset when event_body is bytes). :type headers: Optional[Dict[str, str]] - :returns: A parsed SMS callback object. - :rtype: SmsCallback + :returns: A parsed SMS Sinch Event payload object. + :rtype: SmsSinchEventPayload :raises ValueError: If the event type is unknown or parsing fails. """ if isinstance(event_body, bytes): @@ -114,7 +114,7 @@ def parse_event( event_body["sent_at"] ) - adapter = TypeAdapter(IncomingSMSWebhookEvent) + adapter = TypeAdapter(IncomingSMSSinchEvent) return adapter.validate_python(event_body) raise ValueError(f"Unknown SMS event type: {event_type}") diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index 3c5c3ff3..c312c2de 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,7 +2,7 @@ Batches, DeliveryReports, ) -from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent class SMS: @@ -17,13 +17,13 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) - def webhooks(self, callback_secret: str) -> SmsWebhooks: + def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: """ - Create an SMS webhooks handler with the specified callback secret. + Create an SMS Sinch Events handler with the specified Sinch Event secret. - :param callback_secret: Secret used for webhook validation. - :type callback_secret: str - :returns: A configured webhooks handler - :rtype: SmsWebhooks + :param sinch_event_secret: Secret used for Sinch Event validation. + :type sinch_event_secret: str + :returns: A configured Sinch Events handler + :rtype: SmsSinchEvent """ - return SmsWebhooks(callback_secret) + return SmsSinchEvent(sinch_event_secret) diff --git a/sinch/domains/sms/webhooks/v1/events/__init__.py b/sinch/domains/sms/webhooks/v1/events/__init__.py deleted file mode 100644 index bb5a10da..00000000 --- a/sinch/domains/sms/webhooks/v1/events/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -from sinch.domains.sms.webhooks.v1.events.sms_webhooks_event import ( - IncomingSMSWebhookEvent, - MOTextWebhookEvent, - MOBinaryWebhookEvent, - MOMediaWebhookEvent, - MediaBody, - MediaItem, -) - -__all__ = [ - "IncomingSMSWebhookEvent", - "MOTextWebhookEvent", - "MOBinaryWebhookEvent", - "MOMediaWebhookEvent", - "MediaBody", - "MediaItem", -] diff --git a/sinch/domains/sms/webhooks/v1/internal/__init__.py b/sinch/domains/sms/webhooks/v1/internal/__init__.py deleted file mode 100644 index 329bdf65..00000000 --- a/sinch/domains/sms/webhooks/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.sms.webhooks.v1.internal.webhook_event import ( - WebhookEvent, -) - -__all__ = ["WebhookEvent"] diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 096ae9cd..99907fc4 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -1,9 +1,9 @@ import requests from datetime import datetime, timezone from behave import given, when, then -from sinch.domains.sms.webhooks.v1.sms_webhooks import SmsWebhooks -from sinch.domains.sms.webhooks.v1.events import ( - MOTextWebhookEvent, +from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent +from sinch.domains.sms.sinch_events.v1.events import ( + MOTextSinchEvent, ) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, @@ -11,32 +11,32 @@ ) from tests.e2e.helpers import store_webhook_response -SINCH_SMS_CALLBACK_SECRET = 'KayakingTheSwell' +SINCH_SMS_SINCH_EVENT_SECRET = 'KayakingTheSwell' @given('the SMS Webhooks handler is available') def step_webhook_handler_is_available(context): - context.sms_webhook = SmsWebhooks(SINCH_SMS_CALLBACK_SECRET) + context.sms_sinch_event = SmsSinchEvent(SINCH_SMS_SINCH_EVENT_SECRET) @when('I send a request to trigger an "incoming SMS" event') def step_send_incoming_sms_event(context): response = requests.get('http://localhost:3017/webhooks/sms/incoming-sms') store_webhook_response(context, response) - context.event = context.sms_webhook.parse_event(context.raw_event) + context.event = context.sms_sinch_event.parse_event(context.raw_event) @then('the header of the event "{event_type}" contains a valid signature') @then('the header of the event "{event_type}" with the status "{status}" contains a valid signature') def step_check_valid_signature(context, event_type, status=None): - assert context.sms_webhook.validate_authentication_header( + assert context.sms_sinch_event.validate_authentication_header( context.webhook_headers, context.raw_event ), 'Signature validation failed' @then('the SMS event describes an "incoming SMS" event') def step_check_incoming_sms_event(context): - incoming_sms_event: MOTextWebhookEvent = context.event + incoming_sms_event: MOTextSinchEvent = context.event assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' assert incoming_sms_event.from_ == '12015555555' assert incoming_sms_event.to == '12017777777' @@ -51,7 +51,7 @@ def step_check_incoming_sms_event(context): def step_send_delivery_report_event(context): response = requests.get('http://localhost:3017/webhooks/sms/delivery-report-sms') store_webhook_response(context, response) - context.event = context.sms_webhook.parse_event(context.raw_event) + context.event = context.sms_sinch_event.parse_event(context.raw_event) @then('the SMS event describes an "SMS delivery report" event') @@ -79,7 +79,7 @@ def step_send_recipient_delivery_report_event_delivered(context): 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-delivered' ) store_webhook_response(context, response) - context.event = context.sms_webhook.parse_event(context.raw_event) + context.event = context.sms_sinch_event.parse_event(context.raw_event) @when('I send a request to trigger an "SMS recipient delivery report" event with the status "Aborted"') @@ -88,7 +88,7 @@ def step_send_recipient_delivery_report_event_aborted(context): 'http://localhost:3017/webhooks/sms/recipient-delivery-report-sms-aborted' ) store_webhook_response(context, response) - context.event = context.sms_webhook.parse_event(context.raw_event) + context.event = context.sms_sinch_event.parse_event(context.raw_event) @then('the SMS event describes an SMS recipient delivery report event with the status "Delivered"') diff --git a/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py index f20303b1..41aee1f7 100644 --- a/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py +++ b/tests/unit/domains/sms/v1/endpoints/batches/test_send_batches_endpoint.py @@ -92,6 +92,21 @@ def test_request_body_expects_text_request_data(text_request_data): assert body["body"] == "Your verification code is 123456" +def test_request_body_uses_callback_url_alias_for_event_destination_target(): + """Ensure event_destination_target is serialized as backend callback_url.""" + request = TextRequest( + to=["+46701234567"], + from_="+46701111111", + body="Hello", + event_destination_target="https://example.com/callback", + ) + endpoint = SendSMSEndpoint("test_project_id", request) + body = json.loads(endpoint.request_body()) + + assert body["callback_url"] == "https://example.com/callback" + assert "event_destination_target" not in body + + def test_request_body_expects_binary_request_data(binary_request_data): """Test that binary request body contains correct fields.""" endpoint = SendSMSEndpoint("test_project_id", binary_request_data) diff --git a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py index c308af5a..a3560f01 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_dry_run_request_model.py @@ -119,7 +119,7 @@ def test_dry_run_text_request_expects_valid_inputs_and_all_fields( delivery_report="summary", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/callback", + event_destination_target="https://capybara.com/callback", client_reference="test-ref", feedback_enabled=True, flash_message=False, @@ -134,7 +134,7 @@ def test_dry_run_text_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "summary" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/callback" + assert request.event_destination_target == "https://capybara.com/callback" assert request.client_reference == "test-ref" assert request.feedback_enabled is True assert request.flash_message is False @@ -191,7 +191,7 @@ def test_dry_run_binary_request_expects_valid_inputs_and_all_fields( delivery_report="full", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/callback", + event_destination_target="https://capybara.com/callback", client_reference="binary-ref", feedback_enabled=False, from_ton=0, @@ -203,7 +203,7 @@ def test_dry_run_binary_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "full" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/callback" + assert request.event_destination_target == "https://capybara.com/callback" assert request.client_reference == "binary-ref" assert request.feedback_enabled is False assert request.from_ton == 0 @@ -256,7 +256,7 @@ def test_dry_run_media_request_expects_valid_inputs_and_all_fields( delivery_report="summary", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/callback", + event_destination_target="https://capybara.com/callback", client_reference="media-ref", feedback_enabled=True, ) @@ -266,7 +266,7 @@ def test_dry_run_media_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "summary" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/callback" + assert request.event_destination_target == "https://capybara.com/callback" assert request.client_reference == "media-ref" assert request.feedback_enabled is True diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py index 7e632deb..10eded9c 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_binary_request_model.py @@ -39,7 +39,7 @@ def test_replace_binary_request_expects_valid_inputs_and_all_fields( delivery_report="summary", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/callback", + event_destination_target="https://capybara.com/callback", client_reference="test-ref", feedback_enabled=True, from_ton=1, @@ -49,7 +49,7 @@ def test_replace_binary_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "summary" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/callback" + assert request.event_destination_target == "https://capybara.com/callback" assert request.client_reference == "test-ref" assert request.feedback_enabled is True assert request.from_ton == 1 diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py index 49b3b9a5..6b8b30e7 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_media_request_model.py @@ -49,7 +49,7 @@ def test_replace_media_request_expects_valid_inputs_and_all_fields( delivery_report="full", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/webhook", + event_destination_target="https://capybara.com/webhook", client_reference="capybara-media-batch-123", feedback_enabled=True, strict_validation=True, @@ -62,7 +62,7 @@ def test_replace_media_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "full" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/webhook" + assert request.event_destination_target == "https://capybara.com/webhook" assert request.client_reference == "capybara-media-batch-123" assert request.feedback_enabled is True assert request.strict_validation is True diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py index 5587ee7b..90d95c38 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_update_binary_request_with_batch_id_model.py @@ -37,7 +37,7 @@ def test_update_binary_request_expects_valid_inputs_and_all_fields( delivery_report="full", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/binary-callback", + event_destination_target="https://capybara.com/binary-callback", client_reference="binary-update-456", feedback_enabled=True, from_ton=3, @@ -52,7 +52,7 @@ def test_update_binary_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "full" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/binary-callback" + assert request.event_destination_target == "https://capybara.com/binary-callback" assert request.client_reference == "binary-update-456" assert request.feedback_enabled is True assert request.from_ton == 3 diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py index fe6b3700..06dda0de 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_update_media_request_with_batch_id_model.py @@ -46,7 +46,7 @@ def test_update_media_request_expects_valid_inputs_and_all_fields( delivery_report="none", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/media-callback", + event_destination_target="https://capybara.com/media-callback", client_reference="media-update-789", feedback_enabled=True, strict_validation=True, @@ -62,7 +62,7 @@ def test_update_media_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "none" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/media-callback" + assert request.event_destination_target == "https://capybara.com/media-callback" assert request.client_reference == "media-update-789" assert request.feedback_enabled is True assert request.strict_validation is True diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py index 8737571c..23df2edb 100644 --- a/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py +++ b/tests/unit/domains/sms/v1/models/internal/test_update_text_request_with_batch_id_model.py @@ -35,7 +35,7 @@ def test_update_text_request_expects_valid_inputs_and_all_fields( delivery_report="summary", send_at=send_at, expire_at=expire_at, - callback_url="https://capybara.com/webhook", + event_destination_target="https://capybara.com/webhook", client_reference="update-ref-123", feedback_enabled=True, flash_message=True, @@ -54,7 +54,7 @@ def test_update_text_request_expects_valid_inputs_and_all_fields( assert request.delivery_report == "summary" assert request.send_at == send_at assert request.expire_at == expire_at - assert request.callback_url == "https://capybara.com/webhook" + assert request.event_destination_target == "https://capybara.com/webhook" assert request.client_reference == "update-ref-123" assert request.feedback_enabled is True assert request.flash_message is True diff --git a/tests/unit/domains/sms/v1/test_batches.py b/tests/unit/domains/sms/v1/test_batches.py index 23064769..a95c3d64 100644 --- a/tests/unit/domains/sms/v1/test_batches.py +++ b/tests/unit/domains/sms/v1/test_batches.py @@ -193,7 +193,7 @@ def test_batches_send_sms_expects_correct_request( delivery_report="full", send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), - callback_url="https://example.com/callback", + event_destination_target="https://example.com/callback", client_reference="test-ref", feedback_enabled=True, flash_message=False, @@ -262,7 +262,7 @@ def test_batches_send_binary_expects_correct_request( delivery_report="summary", send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), - callback_url="https://example.com/callback", + event_destination_target="https://example.com/callback", client_reference="test-ref", feedback_enabled=True, from_ton=1, @@ -330,7 +330,7 @@ def test_batches_send_mms_expects_correct_request( delivery_report="full", send_at=datetime(2024, 6, 6, 9, 25, 0, tzinfo=timezone.utc), expire_at=datetime(2024, 6, 10, 9, 25, 0, tzinfo=timezone.utc), - callback_url="https://example.com/callback", + event_destination_target="https://example.com/callback", client_reference="test-ref", feedback_enabled=True, strict_validation=True, From a2d286f8d305035e02202fb2ac85ba7822148011 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 10:43:01 +0100 Subject: [PATCH 100/106] DEVEXP-1310: Redesign Webhooks - Conversation (#133) --- .gitignore | 5 +- MIGRATION_GUIDE.md | 32 +++++++ .../send_handle_incoming_sms/README.md | 2 +- .../send_handle_incoming_sms/controller.py | 4 +- .../server_business_logic.py | 4 +- examples/sinch_events/README.md | 8 +- .../conversation_api/controller.py | 6 +- .../conversation_api/server_business_logic.py | 10 +-- examples/sinch_events/pyproject.toml | 2 +- .../{webhooks => sinch_events}/__init__.py | 0 .../{webhooks => sinch_events}/v1/__init__.py | 0 .../v1/authentication_validation.py | 28 +++---- .../v1/sinch_event_utils.py} | 0 .../conversation/api/v1/messages_apis.py | 84 +++++++++---------- sinch/domains/conversation/conversation.py | 16 ++-- .../internal/request/send_message_request.py | 5 +- .../models/v1/sinch_events/__init__.py | 39 +++++++++ .../events/conversation_sinch_event_base.py} | 6 +- .../conversation_sinch_event_payload.py | 22 +++++ .../events/delivery_status_type.py | 0 .../events/inbound_message.py | 4 +- .../events/message_delivery_receipt_event.py | 17 ++++ .../events/message_delivery_report.py | 6 +- .../events/message_inbound_event.py | 16 ++++ .../events/message_submit_event.py | 16 ++++ .../events/message_submit_notification.py | 4 +- .../models/v1/webhooks/__init__.py | 39 --------- .../events/conversation_webhook_event.py | 22 ----- .../events/message_delivery_receipt_event.py | 17 ---- .../webhooks/events/message_inbound_event.py | 16 ---- .../webhooks/events/message_submit_event.py | 16 ---- .../conversation/sinch_events/v1/__init__.py | 5 ++ .../v1/conversation_sinch_event.py} | 55 ++++++------ .../sinch_events/v1/internal/__init__.py | 5 ++ .../v1/internal/sinch_event.py} | 4 +- .../conversation/webhooks/v1/__init__.py | 5 -- .../webhooks/v1/internal/__init__.py | 5 -- .../numbers/sinch_events/v1/sinch_events.py | 4 +- .../v1/response/recipient_delivery_report.py | 2 +- .../sms/sinch_events/v1/sms_sinch_event.py | 8 +- .../features/steps/webhooks-events.steps.py | 22 ++--- .../test_authentication_validation.py | 4 +- ...ook_utils.py => test_sinch_event_utils.py} | 2 +- .../request/test_send_message_request.py | 15 ++++ .../test_conversation_sinch_event_model.py} | 10 +-- .../test_conversation_sinch_event.py} | 44 +++++----- .../v1/utils/test_message_helpers.py | 4 +- 47 files changed, 344 insertions(+), 296 deletions(-) rename sinch/domains/authentication/{webhooks => sinch_events}/__init__.py (100%) rename sinch/domains/authentication/{webhooks => sinch_events}/v1/__init__.py (100%) rename sinch/domains/authentication/{webhooks => sinch_events}/v1/authentication_validation.py (83%) rename sinch/domains/authentication/{webhooks/v1/webhook_utils.py => sinch_events/v1/sinch_event_utils.py} (100%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/__init__.py rename sinch/domains/conversation/models/v1/{webhooks/events/conversation_webhook_event_base.py => sinch_events/events/conversation_sinch_event_base.py} (83%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/delivery_status_type.py (100%) rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/inbound_message.py (79%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/message_delivery_report.py (87%) create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py create mode 100644 sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py rename sinch/domains/conversation/models/v1/{webhooks => sinch_events}/events/message_submit_notification.py (92%) delete mode 100644 sinch/domains/conversation/models/v1/webhooks/__init__.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py delete mode 100644 sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py create mode 100644 sinch/domains/conversation/sinch_events/v1/__init__.py rename sinch/domains/conversation/{webhooks/v1/conversation_webhooks.py => sinch_events/v1/conversation_sinch_event.py} (67%) create mode 100644 sinch/domains/conversation/sinch_events/v1/internal/__init__.py rename sinch/domains/conversation/{webhooks/v1/internal/webhook_event.py => sinch_events/v1/internal/sinch_event.py} (52%) delete mode 100644 sinch/domains/conversation/webhooks/v1/__init__.py delete mode 100644 sinch/domains/conversation/webhooks/v1/internal/__init__.py rename tests/unit/domains/authentication/{test_webhook_utils.py => test_sinch_event_utils.py} (97%) rename tests/unit/domains/conversation/v1/models/{webhooks/events/test_conversation_webhooks_event_model.py => sinch_events/events/test_conversation_sinch_event_model.py} (91%) rename tests/unit/domains/conversation/v1/{webhooks/test_conversation_webhooks.py => sinch_events/test_conversation_sinch_event.py} (77%) diff --git a/.gitignore b/.gitignore index 3de3edbd..f17ea8a2 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,7 @@ poetry.lock # .DS_Store files .DS_Store -qodana.yaml \ No newline at end of file +qodana.yaml + +# AI stuff +.claude \ No newline at end of file diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 1733a2f4..b2d5dc74 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -126,6 +126,38 @@ The Conversation domain API access remains `sinch_client.conversation`; message | `list()` with `ListConversationMessagesRequest` | In Progress | | — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source`| +##### Replacement APIs / attributes + +| Old | New | +|-----|-----| +| `sinch_client.conversation.webhook` (REST: create, list, get, update, delete webhooks; models under `sinch.domains.conversation.models.webhook`, e.g. `CreateConversationWebhookRequest`, `SinchListWebhooksResponse`) | **Not available in V2.** The Conversation client only exposes `messages` and `sinch_events`; More features are planned for future releases. To validate and parse inbound Sinch Events payloads, use `sinch_client.conversation.sinch_events(callback_secret)`—see **Sinch Events** below. | + +#### Sinch Events (Event Destinations payload models and package path) + +| Old | New | +|-----|-----| +| — _(N/A)_ | `sinch.domains.conversation.models.v1.sinch_events` (package path for inbound payload models) | +| — | [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) (handler: signature validation and `parse_event`) | +| — | `ConversationSinchEventPayload`, `ConversationSinchEventBase`, and concrete event types (e.g. `MessageInboundEvent`, `MessageDeliveryReceiptEvent`, `MessageSubmitEvent`) | + +To obtain a Conversation Sinch Events handler: `sinch_client.conversation.sinch_events(callback_secret)` returns a [`ConversationSinchEvent`](sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py) instance; `handler.parse_event(request_body)` returns a `ConversationSinchEventPayload`. + +```python +# New +handler = sinch_client.conversation.sinch_events("your_callback_secret") +event = handler.parse_event(request_body) +``` + +#### Request and response fields: callback URL → event destination target + +| | Old | New | +|---|-----|-----| +| **Messages (`send`)** | `sinch.domains.conversation.models.message.requests.SendConversationMessageRequest` field `callback_url` | [`SendMessageRequest`](sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py) field `event_destination_target` | +| **Messages (methods)** | `ConversationMessage.send(..., callback_url=...)` | `sinch_client.conversation.messages.send()`, `send_text_message()`, and other `send_*_message()` methods with `event_destination_target=...` | +| **Send event** | `sinch.domains.conversation.models.event.requests.SendConversationEventRequest` field `callback_url` | `event_destination_target` on the V2 send-event request model when that API is exposed | + +The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2, use the Python parameter / model field `event_destination_target`; it is serialized as `callback_url` on the wire (same pattern as other domains, e.g. SMS). +
### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/README.md b/examples/getting-started/conversation/send_handle_incoming_sms/README.md index b9a5e5e4..4e091b73 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/README.md +++ b/examples/getting-started/conversation/send_handle_incoming_sms/README.md @@ -70,7 +70,7 @@ The server listens on the port set in your `.env` file (default: 3001). ### Exposing the server with ngrok -To receive webhooks on your machine, expose the server with a tunnel (e.g. ngrok). +To receive Conversation API Sinch Events on your machine, expose the server with a tunnel (e.g. ngrok). ```bash diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py index e5816460..0e187eee 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/controller.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/controller.py @@ -11,8 +11,8 @@ def conversation_event(self): headers = dict(request.headers) raw_body = getattr(request, "raw_body", None) or b"" - webhooks_service = self.sinch_client.conversation.webhooks() - event = webhooks_service.parse_event(raw_body, headers) + sinch_events_service = self.sinch_client.conversation.sinch_events() + event = sinch_events_service.parse_event(raw_body, headers) handle_conversation_event( event=event, logger=self.logger, diff --git a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py index 28107874..6ed9986a 100644 --- a/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py +++ b/examples/getting-started/conversation/send_handle_incoming_sms/server_business_logic.py @@ -3,11 +3,11 @@ Uses channel identity (SMS + phone number) only; app is in DISPATCH mode. """ -from sinch.domains.conversation.models.v1.webhooks import MessageInboundEvent +from sinch.domains.conversation.models.v1.sinch_events import MessageInboundEvent def handle_conversation_event(event, logger, sinch_client): - """Webhook entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" + """Sinch Event entry: handle only MESSAGE_INBOUND; delegate to inbound handler.""" if not isinstance(event, MessageInboundEvent): return _handle_message_inbound(event, logger, sinch_client) diff --git a/examples/sinch_events/README.md b/examples/sinch_events/README.md index 826fe987..1523eb86 100644 --- a/examples/sinch_events/README.md +++ b/examples/sinch_events/README.md @@ -1,7 +1,7 @@ # Sinch Events Handlers for Sinch Python SDK This directory contains a server application built with [Sinch Python SDK](https://github.com/sinch/sinch-sdk-python) -to process incoming webhooks from Sinch services. +to process incoming events from Sinch services. The Sinch Events Handlers are organized by service: - **SMS**: Handlers for SMS events (`sms_api/`) @@ -40,7 +40,7 @@ This directory contains both the Event handlers and the server application (`ser ``` SMS_SINCH_EVENT_SECRET=Your Sinch SMS Sinch Event Secret ``` - - Conversation controller: Set the webhook secret you configured when creating the webhook (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): + - Conversation controller: Set the Sinch Event secret you configured for your Conversation app event destination (see [Conversation API callbacks](https://developers.sinch.com/docs/conversation/callbacks)): ``` CONVERSATION_SINCH_EVENT_SECRET=Your Conversation Sinch Event Secret ``` @@ -82,7 +82,7 @@ The server exposes the following endpoints: ## Using ngrok to expose your local server -To test your webhook locally, you can tunnel requests to your local server using ngrok. +To test your "Sinch Events" processing locally, you can tunnel requests to your local server using ngrok. *Note: The default port is `3001`, but this can be changed (see [Server port](#Configuration))* @@ -109,5 +109,5 @@ Use this value to configure the Sinch Events URLs: You can also set these Sinch Events URLs in the Sinch dashboard; the API parameters above override the default values configured there. > **Note**: If you have set a Sinch Event secret (e.g., `SMS_SINCH_EVENT_SECRET`), the Sinch Event URL must be configured in the Sinch dashboard -> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming webhook requests, +> and cannot be overridden via API parameters. The Sinch Event secret is used to validate incoming Sinch Events requests, > and the URL associated with it must be set in the dashboard. diff --git a/examples/sinch_events/conversation_api/controller.py b/examples/sinch_events/conversation_api/controller.py index b71a287b..5ac8c2c7 100644 --- a/examples/sinch_events/conversation_api/controller.py +++ b/examples/sinch_events/conversation_api/controller.py @@ -12,21 +12,21 @@ def conversation_event(self): headers = dict(request.headers) raw_body = request.raw_body if request.raw_body else b"" - webhooks_service = self.sinch_client.conversation.webhooks( + sinch_events_service = self.sinch_client.conversation.sinch_events( self.sinch_event_secret ) # Set to True to enforce signature validation (recommended in production) ensure_valid_signature = False if ensure_valid_signature: - valid = webhooks_service.validate_authentication_header( + valid = sinch_events_service.validate_authentication_header( headers=headers, json_payload=raw_body, ) if not valid: return Response(status=401) - event = webhooks_service.parse_event(raw_body, headers) + event = sinch_events_service.parse_event(raw_body, headers) handle_conversation_event(event=event, logger=self.logger) return Response(status=200) diff --git a/examples/sinch_events/conversation_api/server_business_logic.py b/examples/sinch_events/conversation_api/server_business_logic.py index 03ef74e9..57b7ede4 100644 --- a/examples/sinch_events/conversation_api/server_business_logic.py +++ b/examples/sinch_events/conversation_api/server_business_logic.py @@ -1,16 +1,16 @@ -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, ) -def handle_conversation_event(event: ConversationWebhookEventBase, logger): +def handle_conversation_event(event: ConversationSinchEventBase, logger): """ - Dispatch a Conversation webhook event to the appropriate handler by trigger type. + Dispatch a Conversation Sinch Event to the appropriate handler by trigger type. - :param event: Parsed webhook event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). + :param event: Parsed Sinch Event (MessageDeliveryReceiptEvent, MessageInboundEvent, etc.). :param logger: Logger instance for output. """ if isinstance(event, MessageInboundEvent): diff --git a/examples/sinch_events/pyproject.toml b/examples/sinch_events/pyproject.toml index 76a53090..206757a7 100644 --- a/examples/sinch_events/pyproject.toml +++ b/examples/sinch_events/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "sinch-sdk-python-quickstart-server" version = "0.1.0" -description = "Sinch SDK Python Quickstart Webhooks Server" +description = "Sinch SDK Python Quickstart Sinch Events Server" readme = "README.md" package-mode = false diff --git a/sinch/domains/authentication/webhooks/__init__.py b/sinch/domains/authentication/sinch_events/__init__.py similarity index 100% rename from sinch/domains/authentication/webhooks/__init__.py rename to sinch/domains/authentication/sinch_events/__init__.py diff --git a/sinch/domains/authentication/webhooks/v1/__init__.py b/sinch/domains/authentication/sinch_events/v1/__init__.py similarity index 100% rename from sinch/domains/authentication/webhooks/v1/__init__.py rename to sinch/domains/authentication/sinch_events/v1/__init__.py diff --git a/sinch/domains/authentication/webhooks/v1/authentication_validation.py b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py similarity index 83% rename from sinch/domains/authentication/webhooks/v1/authentication_validation.py rename to sinch/domains/authentication/sinch_events/v1/authentication_validation.py index 304bde67..20a16ca4 100644 --- a/sinch/domains/authentication/webhooks/v1/authentication_validation.py +++ b/sinch/domains/authentication/sinch_events/v1/authentication_validation.py @@ -33,7 +33,7 @@ def validate_signature_header( return False expected_signature = compute_hmac_signature(body, callback_secret) - return signature == expected_signature + return hmac.compare_digest(signature, expected_signature) def normalize_headers(headers: Dict[str, str]) -> Dict[str, str]: @@ -65,26 +65,26 @@ def get_header(header_value: Optional[Union[str, List[str]]]) -> Optional[str]: return header_value -def validate_webhook_signature_with_nonce( +def validate_sinch_event_signature_with_nonce( callback_secret: str, headers: Dict[str, str], body: str ) -> bool: """ - Validate signature headers for webhook callbacks that use nonce and timestamp. + Validate signature headers for Sinch Event callbacks that use nonce and timestamp. - :param callback_secret: Secret associated with the webhook. + :param callback_secret: Secret associated with the Sinch Event destination. :type callback_secret: str :param headers: Incoming request's headers. :type headers: Dict[str, str] :param body: Incoming request's body. :type body: str - :returns: True if the X-Sinch-Webhook-Signature header is valid. + :returns: True if the ``X-Sinch-Webhook-Signature`` header and related nonce/timestamp headers are valid. :rtype: bool """ if callback_secret is None: return False - + normalized_headers = normalize_headers(headers) signature = get_header(normalized_headers.get('x-sinch-webhook-signature')) if signature is None: @@ -92,7 +92,7 @@ def validate_webhook_signature_with_nonce( nonce = get_header(normalized_headers.get('x-sinch-webhook-signature-nonce')) timestamp = get_header(normalized_headers.get('x-sinch-webhook-signature-timestamp')) - + if nonce is None or timestamp is None: return False @@ -101,24 +101,24 @@ def validate_webhook_signature_with_nonce( body_as_string = json.dumps(body) signed_data = compute_signed_data(body_as_string, nonce, timestamp) - - expected_signature = calculate_webhook_signature(signed_data, callback_secret) + + expected_signature = calculate_sinch_event_signature(signed_data, callback_secret) return hmac.compare_digest(signature, expected_signature) def compute_signed_data(body: str, nonce: str, timestamp: str) -> str: """ - Compute signed data for webhook signature validation. - + Compute signed data for Sinch Event signature validation. + Format: body.nonce.timestamp (with dots as separators) """ return f'{body}.{nonce}.{timestamp}' -def calculate_webhook_signature(signed_data: str, secret: str) -> str: +def calculate_sinch_event_signature(signed_data: str, secret: str) -> str: """ - Calculate webhook signature using HMAC-SHA256 with Base64 encoding. - + Calculate Sinch Event signature using HMAC-SHA256 with Base64 encoding. + :param signed_data: The data to sign (body.nonce.timestamp) :type signed_data: str :param secret: The secret key for HMAC diff --git a/sinch/domains/authentication/webhooks/v1/webhook_utils.py b/sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py similarity index 100% rename from sinch/domains/authentication/webhooks/v1/webhook_utils.py rename to sinch/domains/authentication/sinch_events/v1/sinch_event_utils.py diff --git a/sinch/domains/conversation/api/v1/messages_apis.py b/sinch/domains/conversation/api/v1/messages_apis.py index 3c95b3b1..c4833899 100644 --- a/sinch/domains/conversation/api/v1/messages_apis.py +++ b/sinch/domains/conversation/api/v1/messages_apis.py @@ -335,7 +335,7 @@ def _send_message_variant( message: object, message_cls: type, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -372,7 +372,7 @@ def _send_message_variant( recipient=recipient_model, message=send_message_request_body, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -395,7 +395,7 @@ def send( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -425,8 +425,8 @@ def send( :type recipient_identities: Optional[List[ChannelRecipientIdentityDict]] :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -471,7 +471,7 @@ def send( recipient=recipient, message=message, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -494,7 +494,7 @@ def send_text_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -524,8 +524,8 @@ def send_text_message( :type text: str :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -560,7 +560,7 @@ def send_text_message( message=TextMessage(text=text), message_cls=TextMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -582,7 +582,7 @@ def send_card_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -612,8 +612,8 @@ def send_card_message( :type card_message: CardMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -648,7 +648,7 @@ def send_card_message( message=card_message, message_cls=CardMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -670,7 +670,7 @@ def send_carousel_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -700,8 +700,8 @@ def send_carousel_message( :type carousel_message: CarouselMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -736,7 +736,7 @@ def send_carousel_message( message=carousel_message, message_cls=CarouselMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -758,7 +758,7 @@ def send_choice_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -788,8 +788,8 @@ def send_choice_message( :type choice_message: ChoiceMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -824,7 +824,7 @@ def send_choice_message( message=choice_message, message_cls=ChoiceMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -846,7 +846,7 @@ def send_contact_info_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -876,8 +876,8 @@ def send_contact_info_message( :type contact_info_message: ContactInfoMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -912,7 +912,7 @@ def send_contact_info_message( message=contact_info_message, message_cls=ContactInfoMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -934,7 +934,7 @@ def send_list_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -964,8 +964,8 @@ def send_list_message( :type list_message: ListMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1000,7 +1000,7 @@ def send_list_message( message=list_message, message_cls=ListMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1022,7 +1022,7 @@ def send_location_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1052,8 +1052,8 @@ def send_location_message( :type location_message: LocationMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1088,7 +1088,7 @@ def send_location_message( message=location_message, message_cls=LocationMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1110,7 +1110,7 @@ def send_media_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1140,8 +1140,8 @@ def send_media_message( :type media_message: MediaPropertiesDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1176,7 +1176,7 @@ def send_media_message( message=media_message, message_cls=MediaProperties, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, @@ -1198,7 +1198,7 @@ def send_template_message( List[ChannelRecipientIdentityDict] ] = None, ttl: Optional[Union[str, int]] = None, - callback_url: Optional[str] = None, + event_destination_target: Optional[str] = None, channel_priority_order: Optional[List[ConversationChannelType]] = None, channel_properties: Optional[Dict[str, str]] = None, message_metadata: Optional[str] = None, @@ -1228,8 +1228,8 @@ def send_template_message( :type template_message: TemplateMessageDict :param ttl: The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'. :type ttl: Optional[Union[str, int]] - :param callback_url: Overwrites the default callback url for delivery receipts for this message. - :type callback_url: Optional[str] + :param event_destination_target: Overwrites the default event destination target for delivery receipts for this message. + :type event_destination_target: Optional[str] :param channel_priority_order: Explicitly define the channels and order in which they are tried when sending the message. :type channel_priority_order: Optional[List[ConversationChannelType]] :param channel_properties: Channel-specific properties. The key in the map must point to a valid channel property key. @@ -1264,7 +1264,7 @@ def send_template_message( message=template_message, message_cls=TemplateMessage, ttl=ttl, - callback_url=callback_url, + event_destination_target=event_destination_target, channel_priority_order=channel_priority_order, channel_properties=channel_properties, message_metadata=message_metadata, diff --git a/sinch/domains/conversation/conversation.py b/sinch/domains/conversation/conversation.py index f9f06685..f2eaa2b3 100644 --- a/sinch/domains/conversation/conversation.py +++ b/sinch/domains/conversation/conversation.py @@ -1,7 +1,7 @@ from sinch.domains.conversation.api.v1 import ( Messages, ) -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent class Conversation: @@ -14,13 +14,15 @@ def __init__(self, sinch): self._sinch = sinch self.messages = Messages(self._sinch) - def webhooks(self, callback_secret: str = "") -> ConversationWebhooks: + def sinch_events( + self, callback_secret: str = "" + ) -> ConversationSinchEvent: """ - Create a Conversation API webhooks handler with the given webhook secret. + Create a Conversation API Sinch Events handler with the given callback secret. - :param callback_secret: Secret used for webhook signature validation. + :param callback_secret: Secret used for Sinch Event signature validation. :type callback_secret: str - :returns: A configured webhooks handler. - :rtype: ConversationWebhooks + :returns: A configured Sinch Events handler. + :rtype: ConversationSinchEvent """ - return ConversationWebhooks(callback_secret) + return ConversationSinchEvent(callback_secret) diff --git a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py index accf6f61..96eafc99 100644 --- a/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py +++ b/sinch/domains/conversation/models/v1/messages/internal/request/send_message_request.py @@ -44,9 +44,10 @@ class SendMessageRequest(BaseModelConfiguration): default=None, description="The timeout allotted for sending the message. Can be seconds (int) or a string like '10s'.", ) - callback_url: Optional[StrictStr] = Field( + event_destination_target: Optional[StrictStr] = Field( default=None, - description="Overwrites the default callback url for delivery receipts for this message.", + alias="callback_url", + description="Overwrites the default event destination target for delivery receipts for this message.", ) channel_priority_order: Optional[List[ConversationChannelType]] = Field( default=None, diff --git a/sinch/domains/conversation/models/v1/sinch_events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/__init__.py new file mode 100644 index 00000000..3e83f5b9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/__init__.py @@ -0,0 +1,39 @@ +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_payload import ( + ConversationSinchEventPayload, +) +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.delivery_status_type import ( + DeliveryStatusType, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + +__all__ = [ + "ConversationSinchEventPayload", + "ConversationSinchEventBase", + "InboundMessage", + "MessageDeliveryReceiptEvent", + "MessageDeliveryReport", + "DeliveryStatusType", + "MessageInboundEvent", + "MessageSubmitEvent", + "MessageSubmitNotification", +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py similarity index 83% rename from sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py rename to sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py index 26cf3eea..114da8b5 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event_base.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_base.py @@ -3,11 +3,11 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent -class ConversationWebhookEventBase(WebhookEvent): - """Base fields present on every Conversation API webhook payload.""" +class ConversationSinchEventBase(SinchEvent): + """Base fields present on every Conversation API Sinch Event payload.""" app_id: Optional[StrictStr] = Field( default=None, diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py new file mode 100644 index 00000000..71e9bce0 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/conversation_sinch_event_payload.py @@ -0,0 +1,22 @@ +from typing import Union + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_receipt_event import ( + MessageDeliveryReceiptEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_inbound_event import ( + MessageInboundEvent, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_event import ( + MessageSubmitEvent, +) + + +ConversationSinchEventPayload = Union[ + MessageDeliveryReceiptEvent, + MessageInboundEvent, + MessageSubmitEvent, + ConversationSinchEventBase, +] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py b/sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py similarity index 100% rename from sinch/domains/conversation/models/v1/webhooks/events/delivery_status_type.py rename to sinch/domains/conversation/models/v1/sinch_events/events/delivery_status_type.py diff --git a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py similarity index 79% rename from sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py rename to sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py index 2b4d45ec..422e2cc9 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/inbound_message.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/inbound_message.py @@ -2,7 +2,7 @@ from pydantic import Field -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared.message_common_props import ( MessageCommonProps, ) @@ -11,7 +11,7 @@ ) -class InboundMessage(MessageCommonProps, WebhookEvent): +class InboundMessage(MessageCommonProps, SinchEvent): """Inbound message container (contact message + channel/contact info).""" contact_message: Optional[ContactMessage] = Field( diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py new file mode 100644 index 00000000..6c344458 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_receipt_event.py @@ -0,0 +1,17 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_delivery_report import ( + MessageDeliveryReport, +) + + +class MessageDeliveryReceiptEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_DELIVERY (delivery receipt for app messages).""" + + message_delivery_report: MessageDeliveryReport = Field( + default=None, + description="The delivery report payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py similarity index 87% rename from sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py rename to sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py index 222e3bba..43f1ae2c 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_report.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_delivery_report.py @@ -2,7 +2,7 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared import ( ChannelIdentity, Reason, @@ -11,12 +11,12 @@ ProcessingModeType, ) -from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( +from sinch.domains.conversation.models.v1.sinch_events.events.delivery_status_type import ( DeliveryStatusType, ) -class MessageDeliveryReport(WebhookEvent): +class MessageDeliveryReport(SinchEvent): """Delivery report for an app message (MESSAGE_DELIVERY trigger).""" message_id: Optional[StrictStr] = Field( diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py new file mode 100644 index 00000000..540ddd49 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_inbound_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.inbound_message import ( + InboundMessage, +) + + +class MessageInboundEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_INBOUND (inbound message from user).""" + + message: InboundMessage = Field( + description="The inbound message payload.", + ) diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py new file mode 100644 index 00000000..fe7026c9 --- /dev/null +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_event.py @@ -0,0 +1,16 @@ +from pydantic import Field + +from sinch.domains.conversation.models.v1.sinch_events.events.conversation_sinch_event_base import ( + ConversationSinchEventBase, +) +from sinch.domains.conversation.models.v1.sinch_events.events.message_submit_notification import ( + MessageSubmitNotification, +) + + +class MessageSubmitEvent(ConversationSinchEventBase): + """Sinch Event for MESSAGE_SUBMIT (message submission notification).""" + + message_submit_notification: MessageSubmitNotification = Field( + description="The message submit notification payload.", + ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py similarity index 92% rename from sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py rename to sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py index 69ac499b..ba09c56b 100644 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_notification.py +++ b/sinch/domains/conversation/models/v1/sinch_events/events/message_submit_notification.py @@ -2,7 +2,7 @@ from pydantic import Field, StrictStr -from sinch.domains.conversation.webhooks.v1.internal import WebhookEvent +from sinch.domains.conversation.sinch_events.v1.internal import SinchEvent from sinch.domains.conversation.models.v1.messages.shared import ( ChannelIdentity, ) @@ -14,7 +14,7 @@ ) -class MessageSubmitNotification(WebhookEvent): +class MessageSubmitNotification(SinchEvent): """Notification that an app message was submitted (MESSAGE_SUBMIT trigger).""" message_id: Optional[StrictStr] = Field( diff --git a/sinch/domains/conversation/models/v1/webhooks/__init__.py b/sinch/domains/conversation/models/v1/webhooks/__init__.py deleted file mode 100644 index bf06e529..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event import ( - ConversationWebhookEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.delivery_status_type import ( - DeliveryStatusType, -) -from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( - InboundMessage, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( - MessageDeliveryReceiptEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( - MessageDeliveryReport, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( - MessageInboundEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( - MessageSubmitEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( - MessageSubmitNotification, -) - -__all__ = [ - "ConversationWebhookEvent", - "ConversationWebhookEventBase", - "InboundMessage", - "MessageDeliveryReceiptEvent", - "MessageDeliveryReport", - "DeliveryStatusType", - "MessageInboundEvent", - "MessageSubmitEvent", - "MessageSubmitNotification", -] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py b/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py deleted file mode 100644 index 8a7d07dd..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/conversation_webhook_event.py +++ /dev/null @@ -1,22 +0,0 @@ -from typing import Union - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_receipt_event import ( - MessageDeliveryReceiptEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_inbound_event import ( - MessageInboundEvent, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_event import ( - MessageSubmitEvent, -) - - -ConversationWebhookEvent = Union[ - MessageDeliveryReceiptEvent, - MessageInboundEvent, - MessageSubmitEvent, - ConversationWebhookEventBase, -] diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py deleted file mode 100644 index 79ef1a9b..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_delivery_receipt_event.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_delivery_report import ( - MessageDeliveryReport, -) - - -class MessageDeliveryReceiptEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_DELIVERY (delivery receipt for app messages).""" - - message_delivery_report: MessageDeliveryReport = Field( - default=None, - description="The delivery report payload.", - ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py deleted file mode 100644 index 89732cb7..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_inbound_event.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.inbound_message import ( - InboundMessage, -) - - -class MessageInboundEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_INBOUND (inbound message from user).""" - - message: InboundMessage = Field( - description="The inbound message payload.", - ) diff --git a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py b/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py deleted file mode 100644 index 6e539c9b..00000000 --- a/sinch/domains/conversation/models/v1/webhooks/events/message_submit_event.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import Field - -from sinch.domains.conversation.models.v1.webhooks.events.conversation_webhook_event_base import ( - ConversationWebhookEventBase, -) -from sinch.domains.conversation.models.v1.webhooks.events.message_submit_notification import ( - MessageSubmitNotification, -) - - -class MessageSubmitEvent(ConversationWebhookEventBase): - """Webhook event for MESSAGE_SUBMIT (message submission notification).""" - - message_submit_notification: MessageSubmitNotification = Field( - description="The message submit notification payload.", - ) diff --git a/sinch/domains/conversation/sinch_events/v1/__init__.py b/sinch/domains/conversation/sinch_events/v1/__init__.py new file mode 100644 index 00000000..93471398 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.conversation_sinch_event import ( + ConversationSinchEvent, +) + +__all__ = ["ConversationSinchEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py similarity index 67% rename from sinch/domains/conversation/webhooks/v1/conversation_webhooks.py rename to sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py index 2acadc98..45acda9b 100644 --- a/sinch/domains/conversation/webhooks/v1/conversation_webhooks.py +++ b/sinch/domains/conversation/sinch_events/v1/conversation_sinch_event.py @@ -1,15 +1,16 @@ import logging from typing import Any, Dict, Union, Optional -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( - validate_webhook_signature_with_nonce, +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, ) -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, + ConversationSinchEventPayload, MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -19,33 +20,25 @@ logger = logging.getLogger(__name__) -ConversationWebhookCallback = Union[ - MessageDeliveryReceiptEvent, - MessageInboundEvent, - MessageSubmitEvent, - ConversationWebhookEventBase, -] - - -class ConversationWebhooks: +class ConversationSinchEvent: """ - Handler for Conversation API webhooks: validate signature and parse events. + Handler for Conversation API Sinch Events: validate signature and parse events. """ - def __init__(self, webhook_secret: Optional[str] = None): + def __init__(self, callback_secret: Optional[str] = None): """ - :param webhook_secret: Secret configured for the webhook (used for HMAC validation). + :param callback_secret: Secret configured for the event destination (used for HMAC validation). """ - self.webhook_secret = webhook_secret + self.callback_secret = callback_secret def _validate_signature( self, payload: Union[str, bytes], headers: Dict[str, str], - webhook_secret: Optional[str] = None, + callback_secret: Optional[str] = None, ) -> bool: """ - Validate the webhook signature using the request body and headers. + Validate the Sinch Event signature using the request body and headers. Uses x-sinch-webhook-signature, x-sinch-webhook-signature-nonce, and x-sinch-webhook-signature-timestamp. Returns True only if the signature @@ -53,18 +46,18 @@ def _validate_signature( :param payload: Raw request body (string or bytes). :param headers: Incoming request headers (key case is normalized to lower). - :param webhook_secret: Secret for this webhook; defaults to the secret passed to __init__. + :param callback_secret: Secret for this request; defaults to the secret passed to __init__. :returns: True if the signature is valid, False otherwise. """ secret = ( - webhook_secret - if webhook_secret is not None - else self.webhook_secret + callback_secret + if callback_secret is not None + else self.callback_secret ) if not secret: return False payload_str = decode_payload(payload, headers) - return validate_webhook_signature_with_nonce( + return validate_sinch_event_signature_with_nonce( secret, headers, payload_str ) @@ -74,7 +67,7 @@ def validate_authentication_header( json_payload: Union[str, bytes], ) -> bool: """ - Validate the webhook signature (convenience wrapper around internal validation). + Validate the Sinch Event signature (convenience wrapper around internal validation). :param headers: Incoming request's headers. :param json_payload: Incoming request's raw body (str or bytes). @@ -86,15 +79,15 @@ def parse_event( self, event_body: Union[str, bytes, Dict[str, Any]], headers: Optional[Dict[str, str]] = None, - ) -> ConversationWebhookCallback: + ) -> ConversationSinchEventPayload: """ - Parse the webhook payload into a typed event. + Parse the Sinch Event payload into a typed event. Parses by key: message_delivery_report → MessageDeliveryReceiptEvent, message → MessageInboundEvent, message_submit_notification → MessageSubmitEvent. Normalizes accepted_time and event_time. Injects trigger on the returned event. - :param event_body: JSON string, raw bytes, or dict of the webhook body. + :param event_body: JSON string, raw bytes, or dict of the event body. :param headers: Request headers (used to decode charset when event_body is bytes). :returns: Parsed event model. :raises ValueError: If JSON parsing fails or the payload is invalid. @@ -119,6 +112,6 @@ def parse_event( return MessageSubmitEvent(**event_body) logger.warning( - "Conversation webhook: unknown event type; returning base event." + "Conversation Sinch Event: unknown event type; returning base event." ) - return ConversationWebhookEventBase(**event_body) + return ConversationSinchEventBase(**event_body) diff --git a/sinch/domains/conversation/sinch_events/v1/internal/__init__.py b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py new file mode 100644 index 00000000..cd400379 --- /dev/null +++ b/sinch/domains/conversation/sinch_events/v1/internal/__init__.py @@ -0,0 +1,5 @@ +from sinch.domains.conversation.sinch_events.v1.internal.sinch_event import ( + SinchEvent, +) + +__all__ = ["SinchEvent"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py similarity index 52% rename from sinch/domains/conversation/webhooks/v1/internal/webhook_event.py rename to sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py index 3c2e597e..22eefc3d 100644 --- a/sinch/domains/conversation/webhooks/v1/internal/webhook_event.py +++ b/sinch/domains/conversation/sinch_events/v1/internal/sinch_event.py @@ -3,7 +3,7 @@ ) -class WebhookEvent(BaseModelConfiguration): - """Base model for Conversation API webhook events.""" +class SinchEvent(BaseModelConfiguration): + """Base model for Conversation API Sinch Event payloads.""" pass diff --git a/sinch/domains/conversation/webhooks/v1/__init__.py b/sinch/domains/conversation/webhooks/v1/__init__.py deleted file mode 100644 index 37b2f39f..00000000 --- a/sinch/domains/conversation/webhooks/v1/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.conversation.webhooks.v1.conversation_webhooks import ( - ConversationWebhooks, -) - -__all__ = ["ConversationWebhooks"] diff --git a/sinch/domains/conversation/webhooks/v1/internal/__init__.py b/sinch/domains/conversation/webhooks/v1/internal/__init__.py deleted file mode 100644 index 37ec3ff5..00000000 --- a/sinch/domains/conversation/webhooks/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.conversation.webhooks.v1.internal.webhook_event import ( - WebhookEvent, -) - -__all__ = ["WebhookEvent"] diff --git a/sinch/domains/numbers/sinch_events/v1/sinch_events.py b/sinch/domains/numbers/sinch_events/v1/sinch_events.py index d591acbe..a93708a5 100644 --- a/sinch/domains/numbers/sinch_events/v1/sinch_events.py +++ b/sinch/domains/numbers/sinch_events/v1/sinch_events.py @@ -1,8 +1,8 @@ from typing import Any, Dict, Optional, Union -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( validate_signature_header, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, diff --git a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py index 91d4aa59..ddc605ce 100644 --- a/sinch/domains/sms/models/v1/response/recipient_delivery_report.py +++ b/sinch/domains/sms/models/v1/response/recipient_delivery_report.py @@ -16,7 +16,7 @@ from sinch.domains.sms.models.v1.internal.base import ( BaseModelConfigurationResponse, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( normalize_iso_timestamp, ) diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index 8f8daee9..03f52892 100644 --- a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -1,10 +1,10 @@ import json from typing import Any, Dict, Union, Optional from pydantic import TypeAdapter -from sinch.domains.authentication.webhooks.v1.authentication_validation import ( - validate_webhook_signature_with_nonce, +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_sinch_event_signature_with_nonce, ) -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, parse_json, normalize_iso_timestamp, @@ -56,7 +56,7 @@ def validate_authentication_header( if isinstance(json_payload, bytes) else json_payload ) - return validate_webhook_signature_with_nonce( + return validate_sinch_event_signature_with_nonce( self.app_secret, headers, payload_str ) diff --git a/tests/e2e/conversation/features/steps/webhooks-events.steps.py b/tests/e2e/conversation/features/steps/webhooks-events.steps.py index 7c066f39..b4f95085 100644 --- a/tests/e2e/conversation/features/steps/webhooks-events.steps.py +++ b/tests/e2e/conversation/features/steps/webhooks-events.steps.py @@ -1,7 +1,7 @@ import requests from behave import given, when, then -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.models.v1.webhooks import ( +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -14,7 +14,7 @@ def process_event(context, response): store_webhook_response(context, response) - context.event = context.conversation_webhooks.parse_event(context.raw_event) + context.event = context.conversation_sinch_events.parse_event(context.raw_event) def _fetch_and_process(context, path_suffix): @@ -28,7 +28,7 @@ def _fetch_and_process(context, path_suffix): def step_conversation_webhooks_available(context): context.sinch.configuration.auth_origin = "http://localhost:3014" context.sinch.configuration.conversation_origin = "http://localhost:3014" - context.conversation_webhooks = ConversationWebhooks(APP_SECRET) + context.conversation_sinch_events = ConversationSinchEvent(APP_SECRET) # --- CAPABILITY --- @@ -168,7 +168,7 @@ def step_trigger_event_delivery_failed(context): @then('the header of the Conversation event "EVENT_DELIVERY" with a "FAILED" status contains a valid signature') def step_signature_valid_event_delivery_failed(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event EVENT_DELIVERY with status FAILED" @@ -191,7 +191,7 @@ def step_trigger_event_delivery_delivered(context): @then('the header of the Conversation event "EVENT_DELIVERY" with a "DELIVERED" status contains a valid signature') def step_signature_valid_event_delivery_delivered(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event EVENT_DELIVERY with status DELIVERED" @@ -220,7 +220,7 @@ def step_trigger_message_delivery_failed(context): @then('the header of the Conversation event "MESSAGE_DELIVERY" with a "FAILED" status contains a valid signature') def step_signature_valid_message_delivery_failed(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_DELIVERY with status FAILED" @@ -255,7 +255,7 @@ def step_trigger_message_delivery_queued(context): @then('the header of the Conversation event "MESSAGE_DELIVERY" with a "QUEUED_ON_CHANNEL" status contains a valid signature') def step_signature_valid_message_delivery_queued(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_DELIVERY with status QUEUED_ON_CHANNEL" @@ -268,7 +268,7 @@ def step_trigger_message_inbound(context): @then('the header of the Conversation event "MESSAGE_INBOUND" contains a valid signature') def step_signature_valid_message_inbound(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_INBOUND" @@ -306,7 +306,7 @@ def step_trigger_message_submit_media(context): @then('the header of the Conversation event "MESSAGE_SUBMIT" for a "media" message contains a valid signature') def step_signature_valid_message_submit_media(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_SUBMIT for media message" @@ -332,7 +332,7 @@ def step_trigger_message_submit_text(context): @then('the header of the Conversation event "MESSAGE_SUBMIT" for a "text" message contains a valid signature') def step_signature_valid_message_submit_text(context): - assert context.conversation_webhooks.validate_authentication_header( + assert context.conversation_sinch_events.validate_authentication_header( context.webhook_headers, context.raw_event ), "Signature validation failed for event MESSAGE_SUBMIT for text message" diff --git a/tests/unit/domains/authentication/test_authentication_validation.py b/tests/unit/domains/authentication/test_authentication_validation.py index b8f11c86..79cfddc5 100644 --- a/tests/unit/domains/authentication/test_authentication_validation.py +++ b/tests/unit/domains/authentication/test_authentication_validation.py @@ -1,5 +1,7 @@ import pytest -from sinch.domains.authentication.webhooks.v1.authentication_validation import validate_signature_header +from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( + validate_signature_header, +) @pytest.fixture diff --git a/tests/unit/domains/authentication/test_webhook_utils.py b/tests/unit/domains/authentication/test_sinch_event_utils.py similarity index 97% rename from tests/unit/domains/authentication/test_webhook_utils.py rename to tests/unit/domains/authentication/test_sinch_event_utils.py index 059b4416..555b6567 100644 --- a/tests/unit/domains/authentication/test_webhook_utils.py +++ b/tests/unit/domains/authentication/test_sinch_event_utils.py @@ -1,6 +1,6 @@ import pytest from datetime import datetime, timezone -from sinch.domains.authentication.webhooks.v1.webhook_utils import ( +from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( parse_json, normalize_iso_timestamp, ) diff --git a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py index e60dea81..9d8dd8f8 100644 --- a/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py +++ b/tests/unit/domains/conversation/v1/models/internal/request/test_send_message_request.py @@ -39,6 +39,21 @@ def test_send_message_request_expects_accepts_processing_strategy(processing_str assert request.processing_strategy == processing_strategy +def test_send_message_request_serializes_event_destination_target_as_callback_url_for_api(): + """ + User-facing name is event_destination_target; JSON body uses callback_url. + """ + request = SendMessageRequest( + app_id="my-app-id", + recipient=Recipient(contact_id="my-contact-id"), + message=SendMessageRequestBody(text_message=TextMessage(text="Hello")), + event_destination_target="https://example.com/callback", + ) + payload = request.model_dump(mode="json", exclude_none=True, by_alias=True) + assert payload["callback_url"] == "https://example.com/callback" + assert "event_destination_target" not in payload + + @pytest.mark.parametrize("ttl_input,expected_serialized", [(10, "10s"), ("10s", "10s"), ("10", "10s"), (None, None)]) def test_send_message_request_expects_ttl_serialized_to_backend(ttl_input, expected_serialized): """ diff --git a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py similarity index 91% rename from tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py rename to tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py index d062e0a0..fe5e5c13 100644 --- a/tests/unit/domains/conversation/v1/models/webhooks/events/test_conversation_webhooks_event_model.py +++ b/tests/unit/domains/conversation/v1/models/sinch_events/events/test_conversation_sinch_event_model.py @@ -1,8 +1,8 @@ -"""Unit tests for Conversation webhook event models.""" +"""Unit tests for Conversation Sinch Event models.""" import pytest -from sinch.domains.conversation.models.v1.webhooks import ( - ConversationWebhookEventBase, +from sinch.domains.conversation.models.v1.sinch_events import ( + ConversationSinchEventBase, MessageDeliveryReceiptEvent, MessageDeliveryReport, MessageInboundEvent, @@ -73,9 +73,9 @@ def test_message_submit_event_expects_parsed(): assert event.message_submit_notification.contact_id == "contact1" -def test_conversation_webhook_event_base_optional_fields(): +def test_conversation_sinch_event_base_optional_fields(): payload = {"app_id": "app1"} - event = ConversationWebhookEventBase(**payload) + event = ConversationSinchEventBase(**payload) assert event.app_id == "app1" assert event.project_id is None assert event.accepted_time is None diff --git a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py similarity index 77% rename from tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py rename to tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py index f5e50212..16d416ca 100644 --- a/tests/unit/domains/conversation/v1/webhooks/test_conversation_webhooks.py +++ b/tests/unit/domains/conversation/v1/sinch_events/test_conversation_sinch_event.py @@ -1,10 +1,10 @@ -"""Unit tests for Conversation API webhooks (signature validation and parse_event).""" +"""Unit tests for Conversation API Sinch Events (signature validation and parse_event).""" from datetime import datetime, timezone import pytest -from sinch.domains.conversation.webhooks.v1 import ConversationWebhooks -from sinch.domains.conversation.models.v1.webhooks import ( +from sinch.domains.conversation.sinch_events.v1 import ConversationSinchEvent +from sinch.domains.conversation.models.v1.sinch_events import ( MessageDeliveryReceiptEvent, MessageInboundEvent, MessageSubmitEvent, @@ -12,13 +12,13 @@ @pytest.fixture -def webhook_secret(): +def callback_secret(): return "foo_secret1234" @pytest.fixture -def conversation_webhooks(webhook_secret): - return ConversationWebhooks(webhook_secret) +def conversation_sinch_event(callback_secret): + return ConversationSinchEvent(callback_secret) @pytest.fixture @@ -39,26 +39,26 @@ def sample_body(): } -def test_validate_authentication_header_valid_expects_true(conversation_webhooks, sample_body): - assert conversation_webhooks.validate_authentication_header( +def test_validate_authentication_header_valid_expects_true(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header( VALID_SIGNATURE_HEADERS, sample_body ) is True -def test_validate_authentication_header_missing_headers_expects_false(conversation_webhooks, sample_body): - assert conversation_webhooks.validate_authentication_header({}, sample_body) is False +def test_validate_authentication_header_missing_headers_expects_false(conversation_sinch_event, sample_body): + assert conversation_sinch_event.validate_authentication_header({}, sample_body) is False -def test_validate_authentication_header_invalid_signature_expects_false(conversation_webhooks, sample_body): +def test_validate_authentication_header_invalid_signature_expects_false(conversation_sinch_event, sample_body): headers = { "x-sinch-webhook-signature": "invalid", "x-sinch-webhook-signature-nonce": "01FJA8B4A7BM43YGWSG9GBV067", "x-sinch-webhook-signature-timestamp": "1634579353", } - assert conversation_webhooks.validate_authentication_header(headers, sample_body) is False + assert conversation_sinch_event.validate_authentication_header(headers, sample_body) is False -def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_webhooks): +def test_parse_event_message_delivery_expects_message_delivery_receipt_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -71,7 +71,7 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con "contact_id": "01EXA07N79THJ20WSN6AS30TMW", }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageDeliveryReceiptEvent) assert event.message_delivery_report is not None assert event.message_delivery_report.message_id == "01EQBC1A3BEK731GY4YXEN0C2R" @@ -79,7 +79,7 @@ def test_parse_event_message_delivery_expects_message_delivery_receipt_event(con assert event.accepted_time == datetime(2020, 11, 17, 15, 9, 11, 659000, tzinfo=timezone.utc) -def test_parse_event_message_inbound_expects_message_inbound_event(conversation_webhooks): +def test_parse_event_message_inbound_expects_message_inbound_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -90,7 +90,7 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ "channel_identity": {"channel": "WHATSAPP", "identity": "1234567890"}, }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageInboundEvent) assert event.message is not None assert event.message.contact_id == "01EXA07N79THJ20WSN6AS30TMW" @@ -99,7 +99,7 @@ def test_parse_event_message_inbound_expects_message_inbound_event(conversation_ assert event.message.contact_message.text_message.text == "Hello" -def test_parse_event_message_submit_expects_message_submit_event(conversation_webhooks): +def test_parse_event_message_submit_expects_message_submit_event(conversation_sinch_event): payload = { "app_id": "01EB37HMH1M6SV18BSNS3G135H", "project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61", @@ -110,20 +110,20 @@ def test_parse_event_message_submit_expects_message_submit_event(conversation_we "submitted_message": {"text_message": {"text": "Hi"}}, }, } - event = conversation_webhooks.parse_event(payload) + event = conversation_sinch_event.parse_event(payload) assert isinstance(event, MessageSubmitEvent) assert event.message_submit_notification is not None assert event.message_submit_notification.contact_id == "01EXA07N79THJ20WSN6AS30TMW" -def test_parse_event_json_string_expects_parsed(conversation_webhooks): +def test_parse_event_json_string_expects_parsed(conversation_sinch_event): payload_str = '{"app_id":"app1","message_delivery_report":{"status":"DELIVERED"}}' - event = conversation_webhooks.parse_event(payload_str) + event = conversation_sinch_event.parse_event(payload_str) assert isinstance(event, MessageDeliveryReceiptEvent) assert event.app_id == "app1" assert event.message_delivery_report.status == "DELIVERED" -def test_parse_event_invalid_json_expects_value_error(conversation_webhooks): +def test_parse_event_invalid_json_expects_value_error(conversation_sinch_event): with pytest.raises(ValueError, match="Failed to decode JSON"): - conversation_webhooks.parse_event("not json") + conversation_sinch_event.parse_event("not json") diff --git a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py index ad6875f6..1674b68e 100644 --- a/tests/unit/domains/conversation/v1/utils/test_message_helpers.py +++ b/tests/unit/domains/conversation/v1/utils/test_message_helpers.py @@ -120,9 +120,9 @@ class TestSplitSendKwargs: {}, ), ( - {"ttl": 10, "callback_url": "https://example.com/callback"}, + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, {}, - {"ttl": 10, "callback_url": "https://example.com/callback"}, + {"ttl": 10, "event_destination_target": "https://example.com/callback"}, ), ( { From 528ffb0a15e3317a6e630624d212c92c3adf1fb4 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 14:05:16 +0100 Subject: [PATCH 101/106] DEVEXP-1306: OAS Synchro Numbers (#134) --- sinch/__init__.py | 2 +- .../domains/numbers/api/v1/active_numbers_apis.py | 4 ++-- .../v1/internal/list_active_numbers_request.py | 5 +++-- sinch/domains/numbers/virtual_numbers.py | 12 ++++++------ .../active/test_list_active_numbers_endpoint.py | 15 +++++++++++++++ .../test_list_active_numbers_request_model.py | 10 ---------- .../test_list_available_numbers_request_model.py | 3 +-- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/sinch/__init__.py b/sinch/__init__.py index b643433f..2d00736e 100644 --- a/sinch/__init__.py +++ b/sinch/__init__.py @@ -3,4 +3,4 @@ from sinch.core.clients.sinch_client_sync import SinchClient -__all__ = SinchClient +__all__ = ["SinchClient"] diff --git a/sinch/domains/numbers/api/v1/active_numbers_apis.py b/sinch/domains/numbers/api/v1/active_numbers_apis.py index 2f2746f9..ef10ed1c 100644 --- a/sinch/domains/numbers/api/v1/active_numbers_apis.py +++ b/sinch/domains/numbers/api/v1/active_numbers_apis.py @@ -27,8 +27,8 @@ class ActiveNumbers(BaseNumbers): def list( self, - region_code: str, - number_type: NumberType, + region_code: Optional[str] = None, + number_type: Optional[NumberType] = None, number_pattern: Optional[str] = None, number_search_pattern: Optional[NumberSearchPatternType] = None, capabilities: Optional[List[CapabilityType]] = None, diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py index b484595a..f832beb5 100644 --- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py +++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py @@ -12,11 +12,12 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest): - region_code: StrictStr = Field( + region_code: Optional[StrictStr] = Field( + default=None, alias="regionCode", description="ISO 3166-1 alpha-2 country code. Example: US, GB or SE.", ) - number_type: NumberType = Field(alias="type") + number_type: Optional[NumberType] = Field(default=None, alias="type") page_size: Optional[StrictInt] = Field(default=None, alias="pageSize") capabilities: Optional[conlist(CapabilityType)] = Field(default=None) number_search_pattern: Optional[NumberSearchPatternType] = Field( diff --git a/sinch/domains/numbers/virtual_numbers.py b/sinch/domains/numbers/virtual_numbers.py index 7827db8b..a76e88ab 100644 --- a/sinch/domains/numbers/virtual_numbers.py +++ b/sinch/domains/numbers/virtual_numbers.py @@ -56,8 +56,8 @@ def sinch_events(self, callback_secret: str) -> SinchEvents: def list( self, - region_code: str, - number_type: NumberType, + region_code: Optional[str] = None, + number_type: Optional[NumberType] = None, number_pattern: Optional[str] = None, number_search_pattern: Optional[NumberSearchPatternType] = None, capabilities: Optional[List[CapabilityType]] = None, @@ -69,11 +69,11 @@ def list( """ Search for all active virtual numbers associated with a certain project. - :param region_code: ISO 3166-1 alpha-2 country code. Example: US, GB or SE. - :type region_code: str + :param region_code: Optional. ISO 3166-1 alpha-2 country code. Example: US, GB or SE. + :type region_code: Optional[str] - :param number_type: Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). - :type number_type: NumberType + :param number_type: Optional. Type of number (e.g., "MOBILE", "LOCAL", "TOLL_FREE"). + :type number_type: Optional[NumberType] :param number_pattern: Specific sequence of digits to search for. :type number_pattern: Optional[str] diff --git a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py index d291aeab..aa66041a 100644 --- a/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py +++ b/tests/unit/domains/numbers/v1/endpoints/active/test_list_active_numbers_endpoint.py @@ -74,6 +74,21 @@ def test_build_query_params_expects_correct_mapping(endpoint): assert endpoint.build_query_params() == expected_params +def test_build_query_params_omits_none_region_and_type(): + """ + Optional query params must not be sent when unset. + """ + request_data = ListActiveNumbersRequest( + page_size=10, + capabilities=["SMS"], + ) + endpoint = ListActiveNumbersEndpoint("test_project_id", request_data) + assert endpoint.build_query_params() == { + "pageSize": 10, + "capabilities": ["SMS"], + } + + def test_handle_response_expects_correct_mapping(endpoint, mock_response): """ Check if response is handled and mapped to the appropriate fields correctly. diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py index 28e891e6..cedae857 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_active_numbers_request_model.py @@ -1,5 +1,4 @@ import pytest -from pydantic import ValidationError from sinch.domains.numbers.models.v1.internal import ListActiveNumbersRequest @@ -70,12 +69,3 @@ def test_list_available_numbers_request_expects_camel_case_input(): request = ListActiveNumbersRequest(**data) assert request.region_code == "US" assert request.number_type == "MOBILE" - - -def test_list_active_numbers_request_expects_validation_error_for_missing_field(): - """ - Test that missing required fields raise a ValidationError. - """ - data = {} - with pytest.raises(ValidationError): - ListActiveNumbersRequest(**data) diff --git a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py index 31ebe255..da3d02f9 100644 --- a/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py +++ b/tests/unit/domains/numbers/v1/models/internal/test_list_available_numbers_request_model.py @@ -85,13 +85,12 @@ def test_list_available_numbers_request_expects_validation_error_for_missing_req data = { "number_type": "MOBILE", "size": 10, - "capabilities": ["SMS", "VOICE"] + "capabilities": ["SMS", "VOICE"], } with pytest.raises(ValidationError) as exc_info: ListAvailableNumbersRequest(**data) - # Assert the error mentions the missing region_code field assert "region_code" in str(exc_info.value) or "regionCode" in str(exc_info.value) From 7a68c33e7c8e798b0c5f6b7ae63d430d3a93d423 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 14:59:10 +0100 Subject: [PATCH 102/106] chore: update webhooks tests (#136) --- .../events/test_number_sinch_event_model.py} | 6 +++--- .../test_number_sinch_event.py} | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) rename tests/unit/domains/numbers/v1/{webhooks/events/test_numbers_webhooks_event_model.py => sinch_events/events/test_number_sinch_event_model.py} (90%) rename tests/unit/domains/numbers/v1/{webhooks/test_numbers_webhooks.py => sinch_events/test_number_sinch_event.py} (82%) diff --git a/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py similarity index 90% rename from tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py rename to tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py index 44419896..775ac95b 100644 --- a/tests/unit/domains/numbers/v1/webhooks/events/test_numbers_webhooks_event_model.py +++ b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py @@ -30,7 +30,7 @@ def invalid_data(): } -def test_numbers_webhooks_response_expects_parsed_data(valid_data): +def test_number_sinch_event_response_expects_parsed_data(valid_data): """ Expects all fields to map correctly from camelCase input and handle valid data appropriately. @@ -51,7 +51,7 @@ def test_numbers_webhooks_response_expects_parsed_data(valid_data): assert response.extra_field == "extra_value" -def test_numbers_webhooks_response_missing_optional_fields_expects_parsed_data(): +def test_number_sinch_event_response_missing_optional_fields_expects_parsed_data(): """ Expects the model to handle missing optional fields. """ @@ -71,7 +71,7 @@ def test_numbers_webhooks_response_missing_optional_fields_expects_parsed_data() assert response.failure_code is None -def test_numbers_webhooks_response_invalid_data_expects_validation_error(invalid_data): +def test_number_sinch_event_response_invalid_data_expects_validation_error(invalid_data): """ Expects the model to raise a validation error for invalid data. """ diff --git a/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py b/tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py similarity index 82% rename from tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py rename to tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py index b157cdb1..af02fae2 100644 --- a/tests/unit/domains/numbers/v1/webhooks/test_numbers_webhooks.py +++ b/tests/unit/domains/numbers/v1/sinch_events/test_number_sinch_event.py @@ -15,7 +15,7 @@ def string_to_sign(): @pytest.fixture -def numbers_webhooks(): +def sinch_events(): return SinchEvents('my-callback-secret') @@ -34,11 +34,11 @@ def base_payload_parse_event(): } -def test_valid_signature_header_expects_successful_validation(numbers_webhooks, string_to_sign): +def test_valid_signature_header_expects_successful_validation(sinch_events, string_to_sign): headers = { "X-Sinch-Signature": "8e58baa351ffa5e0d7eaef3c739d0d7aa6093da3" } - response = numbers_webhooks.validate_authentication_header(headers, string_to_sign) + response = sinch_events.validate_authentication_header(headers, string_to_sign) assert response is True @@ -56,17 +56,17 @@ def test_valid_signature_header_expects_successful_validation(numbers_webhooks, ) ] ) -def test_parse_event_expects_timestamp_as_utc(numbers_webhooks, test_name, timestamp_str): +def test_parse_event_expects_timestamp_as_utc(sinch_events, test_name, timestamp_str): payload = {"timestamp": timestamp_str} - parsed = numbers_webhooks.parse_event(payload) + parsed = sinch_events.parse_event(payload) expected = datetime( 2025, 4, 6, 8, 45, 27, 565347, tzinfo=timezone.utc ) assert parsed.timestamp == expected -def test_parse_event_expects_parsed_response(numbers_webhooks, base_payload_parse_event): - response = numbers_webhooks.parse_event(base_payload_parse_event) +def test_parse_event_expects_parsed_response(sinch_events, base_payload_parse_event): + response = sinch_events.parse_event(base_payload_parse_event) assert isinstance(response, NumberSinchEvent) assert response.event_id == "01jr7stexp0znky34pj07dwp41" assert response.project_id == "project-id" From f56daa5cd991c8253dde492eeef6097f6cf3fab6 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 16:29:00 +0100 Subject: [PATCH 103/106] chore: update CI --- .github/workflows/ci.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e75080a..0534969d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,6 @@ name: Test Python SDK on: [ push ] env: - APPLICATION_KEY: ${{ secrets.APPLICATION_KEY }} - APPLICATION_SECRET: ${{ secrets.APPLICATION_SECRET }} AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }} CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }} DISABLE_SSL: ${{ secrets.DISABLE_SSL }} @@ -24,9 +22,9 @@ jobs: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -68,22 +66,17 @@ jobs: python -m coverage report --skip-empty - name: Checkout sinch-sdk-mockserver repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: repository: sinch/sinch-sdk-mockserver token: ${{ secrets.PAT_CI }} fetch-depth: 0 path: sinch-sdk-mockserver - - name: Install Docker Compose - run: | - sudo apt-get update - sudo apt-get install -y docker-compose - - name: Start mock servers with Docker Compose run: | cd sinch-sdk-mockserver - docker-compose up -d + docker compose up -d - name: Copy feature files run: | @@ -110,5 +103,3 @@ jobs: python -m behave tests/e2e/sms/features python -m behave tests/e2e/conversation/features python -m behave tests/e2e/number-lookup/features - - \ No newline at end of file From 1d1a7814b77d202201f0e1f97f399342fac1ea04 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 16:31:03 +0100 Subject: [PATCH 104/106] update documentation --- MIGRATION_GUIDE.md | 21 +++++++++++++-------- README.md | 38 +++++++++++++++++--------------------- 2 files changed, 30 insertions(+), 29 deletions(-) diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index b2d5dc74..19148549 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -8,6 +8,8 @@ This guide lists all removed classes and interfaces from V1 and how to migrate t > **Note:** Voice and Verification are not yet covered by the new V2 APIs. Support will be added in future releases. +--- + ## Client Initialization ### Overview @@ -62,6 +64,8 @@ token_client = SinchClient( sinch_client.configuration.sms_region = "eu" ``` +--- + ### Conversation Region **In V1:** @@ -95,6 +99,8 @@ sinch_client = SinchClient( sinch_client.configuration.conversation_region = "eu" ``` +--- + ### [`Conversation`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/conversation) #### Replacement models @@ -123,8 +129,8 @@ The Conversation domain API access remains `sinch_client.conversation`; message | `send()` with `SendConversationMessageRequest` | Use convenience methods: `send_text_message()`, `send_card_message()`, `send_carousel_message()`, `send_choice_message()`, `send_contact_info_message()`, `send_list_message()`, `send_location_message()`, `send_media_message()`, `send_template_message()`
Or `send()` with `app_id`, `message` (dict or `SendMessageRequestBodyDict`), and either `contact_id` or `recipient_identities` | | `get()` with `GetConversationMessageRequest` | `get()` with `message_id: str` parameter | | `delete()` with `DeleteConversationMessageRequest` | `delete()` with `message_id: str` parameter | -| `list()` with `ListConversationMessagesRequest` | In Progress | -| — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source`| +| `list()` with `ListConversationMessagesRequest` | `list()` with the same fields as keyword arguments (see models table above). V2 adds optional `channel_identity`, `start_time`, `end_time`, `channel`, `direction`. Returns **`Paginator[ConversationMessageResponse]`**: use `.content()` for messages on the current page, `.next_page()` to load the next page, or `.iterator()` to walk every message across all pages. | +| — | **New in V2:** `update()` with `message_id`, `metadata`, and optional `messages_source` | ##### Replacement APIs / attributes @@ -158,7 +164,7 @@ event = handler.parse_event(request_body) The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2, use the Python parameter / model field `event_destination_target`; it is serialized as `callback_url` on the wire (same pattern as other domains, e.g. SMS). -
+--- ### [`SMS`](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/sms) @@ -200,6 +206,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 #### Replacement APIs The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. + Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. ##### Batches API @@ -213,17 +220,17 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `update()` with `UpdateBatchRequest` | Use convenience methods: `update_sms()`, `update_binary()`, `update_mms()`
Or `update()` with `UpdateBatchMessageRequest` (Union of `UpdateTextRequestWithBatchId`, `UpdateBinaryRequestWithBatchId`, `UpdateMediaRequestWithBatchId`) | | `replace()` with `ReplaceBatchRequest` | Use convenience methods: `replace_sms()`, `replace_binary()`, `replace_mms()`
Or `replace()` with `ReplaceBatchRequest` (Union of `ReplaceTextRequest`, `ReplaceBinaryRequest`, `ReplaceMediaRequest`) | -
+--- ##### Delivery Reports API | Old method | New method in `sms.delivery_reports` | |------------|-------------------------------------| -| `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | +| `list()` with `ListSMSDeliveryReportsRequest` | `list()` the parameters `start_date` and `end_date` now accepts both `str` and `datetime` | | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | -
+--- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) @@ -271,7 +278,6 @@ sinch_client.numbers.event_destinations.update(hmac_secret="your_hmac_secret") To obtain a Numbers Sinch Events handler: `sinch_client.numbers.sinch_events(callback_secret)` returns a `SinchEvents` instance; `handler.parse_event(request_body)` returns a `NumberSinchEvent`. - ```python # New from sinch.domains.numbers.sinch_events.v1.events import NumberSinchEvent @@ -286,7 +292,6 @@ event = handler.parse_event(request_body) # event is a NumberSinchEvent | **Methods that accept the parameter** | Only `numbers.available.rent_any(..., callback_url=...)` | `numbers.rent(...)`, `numbers.rent_any(...)`, and `numbers.update(...)` accept `event_destination_target` | | **Parameter name** | `callback_url` | `event_destination_target` | - ##### Replacement request/response attributes | Old | New | diff --git a/README.md b/README.md index 683d16eb..7d72bfc8 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ For more information on the Sinch APIs on which this SDK is based, refer to the - [Prerequisites](#prerequisites) - [Installation](#installation) - [Getting started](#getting-started) -- [Logging]() +- [Logging](#logging) ## Prerequisites @@ -79,6 +79,10 @@ sinch_client = SinchClient( ) ``` +### SMS and Conversation regions (V2) + +You must set `sms_region` before using the SMS API and `conversation_region` before using the Conversation API—either in the `SinchClient(...)` constructor or on `sinch_client.configuration` before the first call to that product. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for examples. + ## Logging Logging configuration for this SDK utilizes following hierarchy: @@ -86,34 +90,26 @@ Logging configuration for this SDK utilizes following hierarchy: 2. If `logger_name` configurable was provided, SDK will use logger related to that name. For example: `myapp.sinch` will inherit configuration from the `myapp` logger. 3. If `logger` (logger instance) configurable was provided, SDK will use that particular logger for all its logging operations. -If all logging returned by this SDK needs to be disabled, usage of `NullHanlder` provided by the standard `logging` module is advised. +If all logging returned by this SDK needs to be disabled, usage of `NullHandler` provided by the standard `logging` module is advised. ## Sample apps -Usage example of the `numbers` domain: +Usage example of the Numbers API via [`VirtualNumbers`](sinch/domains/numbers/virtual_numbers.py) on the client (`sinch_client.numbers`)—`list()` returns your project’s active virtual numbers: ```python -available_numbers = sinch_client.numbers.available.list( +paginator = sinch_client.numbers.list( region_code="US", - number_type="LOCAL" + number_type="LOCAL", ) +for active_number in paginator.iterator(): + print(active_number) ``` -Returned values are represented as Python `dataclasses`: -```python -ListAvailableNumbersResponse( - available_numbers=[ - Number( - phone_number='+17862045855', - region_code='US', - type='LOCAL', - capability=['SMS', 'VOICE'], - setup_price={'currency_code': 'EUR', 'amount': '0.80'}, - monthly_price={'currency_code': 'EUR', 'amount': '0.80'} - ... -``` +Returned values are [Pydantic](https://docs.pydantic.dev/) model instances (for example [`ActiveNumber`](sinch/domains/numbers/models/v1/response/active_number.py)), including fields such as `phone_number`, `region_code`, `type`, and `capabilities`. + +More examples live under [examples/snippets](examples/snippets) on the `main` branch. ### Handling exceptions @@ -125,9 +121,9 @@ Example for Numbers API: from sinch.domains.numbers.api.v1.exceptions import NumbersException try: - nums = sinch_client.numbers.available.list( + paginator = sinch_client.numbers.list( region_code="US", - number_type="LOCAL" + number_type="LOCAL", ) except NumbersException as err: pass @@ -163,4 +159,4 @@ The transport must be a synchronous implementation. ## License -This project is licensed under the Apache License. See the [LICENSE](license.md) file for the license text. +This project is licensed under the Apache License. See the [LICENSE](LICENSE) file for the license text. From 2982fc118b17faa8550e052f6f52c0d4a14eedeb Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 16:53:23 +0100 Subject: [PATCH 105/106] update examples --- examples/sinch_events/pyproject.toml | 3 +-- examples/snippets/README.md | 2 +- .../conversation/messages/delete/snippet.py | 3 +-- .../conversation/messages/get/snippet.py | 3 +-- .../conversation/messages/list/snippet.py | 3 +-- .../snippet.py | 5 ++--- .../conversation/messages/send/snippet.py | 18 ++++++++++-------- .../messages/send_card_message/snippet.py | 3 +-- .../messages/send_carousel_message/snippet.py | 3 +-- .../messages/send_choice_message/snippet.py | 3 +-- .../send_contact_info_message/snippet.py | 3 +-- .../messages/send_list_message/snippet.py | 3 +-- .../messages/send_location_message/snippet.py | 3 +-- .../messages/send_media_message/snippet.py | 3 +-- .../messages/send_template_message/snippet.py | 3 +-- .../messages/send_text_message/snippet.py | 3 +-- .../conversation/messages/update/snippet.py | 7 ++++--- .../snippets/number_lookup/lookup/snippet.py | 7 +++---- .../numbers/active_numbers/get/snippet.py | 7 ++++--- .../numbers/active_numbers/list/snippet.py | 3 +-- .../active_numbers/list_auto/snippet.py | 3 +-- .../numbers/active_numbers/release/snippet.py | 7 ++++--- .../numbers/active_numbers/update/snippet.py | 13 +++++++------ .../check_availability/snippet.py | 5 +++-- .../numbers/available_numbers/rent/snippet.py | 13 +++++++------ .../available_numbers/rent_any/snippet.py | 13 ++++++++----- .../search_for_available_numbers/snippet.py | 3 +-- .../numbers/available_regions/list/snippet.py | 3 +-- .../numbers/event_destinations/get/snippet.py | 3 +-- .../event_destinations/update/snippet.py | 7 ++++--- .../snippets/sms/batches/cancel/snippet.py | 4 ++-- .../sms/batches/dry_run_binary/snippet.py | 3 +-- .../sms/batches/dry_run_mms/snippet.py | 3 +-- .../sms/batches/dry_run_sms/snippet.py | 3 +-- examples/snippets/sms/batches/get/snippet.py | 4 ++-- examples/snippets/sms/batches/list/snippet.py | 3 +-- .../sms/batches/replace_binary/snippet.py | 3 +-- .../sms/batches/replace_mms/snippet.py | 3 +-- .../sms/batches/replace_sms/snippet.py | 3 +-- .../sms/batches/send_binary/snippet.py | 3 +-- .../batches/send_delivery_feedback/snippet.py | 3 +-- .../snippets/sms/batches/send_mms/snippet.py | 3 +-- .../snippets/sms/batches/send_sms/snippet.py | 3 +-- .../sms/batches/update_binary/snippet.py | 3 +-- .../snippets/sms/batches/update_mms/snippet.py | 3 +-- .../snippets/sms/batches/update_sms/snippet.py | 3 +-- .../sms/delivery_reports/get/snippet.py | 4 ++-- .../delivery_reports/get_for_number/snippet.py | 3 +-- .../sms/delivery_reports/list/snippet.py | 3 +-- 49 files changed, 97 insertions(+), 121 deletions(-) diff --git a/examples/sinch_events/pyproject.toml b/examples/sinch_events/pyproject.toml index 206757a7..4fb38639 100644 --- a/examples/sinch_events/pyproject.toml +++ b/examples/sinch_events/pyproject.toml @@ -9,8 +9,7 @@ package-mode = false python = "^3.9" python-dotenv = "^1.0.0" flask = "^3.0.0" -# TODO: Uncomment once v2.0 is released -# sinch = "^2.0.0" +sinch = "^2.0.0" [build-system] requires = ["poetry-core"] diff --git a/examples/snippets/README.md b/examples/snippets/README.md index 35bb6550..b5dd5e2f 100644 --- a/examples/snippets/README.md +++ b/examples/snippets/README.md @@ -57,5 +57,5 @@ All available code snippets are located in subdirectories, structured by feature To execute a specific snippet, navigate to the appropriate subdirectory and run: ```shell -python run python snippet.py +python snippet.py ``` \ No newline at end of file diff --git a/examples/snippets/conversation/messages/delete/snippet.py b/examples/snippets/conversation/messages/delete/snippet.py index c520eb7c..b67d1a9f 100644 --- a/examples/snippets/conversation/messages/delete/snippet.py +++ b/examples/snippets/conversation/messages/delete/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/get/snippet.py b/examples/snippets/conversation/messages/get/snippet.py index 4c3d343c..7e9ff953 100644 --- a/examples/snippets/conversation/messages/get/snippet.py +++ b/examples/snippets/conversation/messages/get/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/list/snippet.py b/examples/snippets/conversation/messages/list/snippet.py index ab68afb3..a10dd293 100644 --- a/examples/snippets/conversation/messages/list/snippet.py +++ b/examples/snippets/conversation/messages/list/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py index 66300d6b..7cde3ee9 100644 --- a/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py +++ b/examples/snippets/conversation/messages/list_last_messages_by_channel_identity/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -18,7 +17,7 @@ conversation_region=os.environ.get("SINCH_CONVERSATION_REGION") or "MY_CONVERSATION_REGION" ) -# Channel identities to fetch the last message +# The channel identities to fetch the last message for channel_identities = ["CHANNEL_IDENTITY_1", "CHANNEL_IDENTITY_2"] messages = sinch_client.conversation.messages.list_last_messages_by_channel_identity( diff --git a/examples/snippets/conversation/messages/send/snippet.py b/examples/snippets/conversation/messages/send/snippet.py index 970c9c99..1798eb99 100644 --- a/examples/snippets/conversation/messages/send/snippet.py +++ b/examples/snippets/conversation/messages/send/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -28,14 +27,17 @@ } ] +# The conversation message payload to send +message = { + "text_message": { + "text": "[Python SDK: Conversation Message] Sample text message", + }, +} + response = sinch_client.conversation.messages.send( app_id=app_id, - message={ - "text_message": { - "text": "[Python SDK: Conversation Message] Sample text message" - } - }, - recipient_identities=recipient_identities + message=message, + recipient_identities=recipient_identities, ) print(f"Successfully sent message.\n{response}") diff --git a/examples/snippets/conversation/messages/send_card_message/snippet.py b/examples/snippets/conversation/messages/send_card_message/snippet.py index 5300eaf2..03a6496c 100644 --- a/examples/snippets/conversation/messages/send_card_message/snippet.py +++ b/examples/snippets/conversation/messages/send_card_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_carousel_message/snippet.py b/examples/snippets/conversation/messages/send_carousel_message/snippet.py index bb8ecb4f..92f5f7ec 100644 --- a/examples/snippets/conversation/messages/send_carousel_message/snippet.py +++ b/examples/snippets/conversation/messages/send_carousel_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_choice_message/snippet.py b/examples/snippets/conversation/messages/send_choice_message/snippet.py index 315e13d4..5db53bfe 100644 --- a/examples/snippets/conversation/messages/send_choice_message/snippet.py +++ b/examples/snippets/conversation/messages/send_choice_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_contact_info_message/snippet.py b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py index 70f84c57..4f3cffa4 100644 --- a/examples/snippets/conversation/messages/send_contact_info_message/snippet.py +++ b/examples/snippets/conversation/messages/send_contact_info_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_list_message/snippet.py b/examples/snippets/conversation/messages/send_list_message/snippet.py index 3b7010ac..8b807aa1 100644 --- a/examples/snippets/conversation/messages/send_list_message/snippet.py +++ b/examples/snippets/conversation/messages/send_list_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_location_message/snippet.py b/examples/snippets/conversation/messages/send_location_message/snippet.py index 451d0d83..0b8b3426 100644 --- a/examples/snippets/conversation/messages/send_location_message/snippet.py +++ b/examples/snippets/conversation/messages/send_location_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_media_message/snippet.py b/examples/snippets/conversation/messages/send_media_message/snippet.py index df7aa970..ca977797 100644 --- a/examples/snippets/conversation/messages/send_media_message/snippet.py +++ b/examples/snippets/conversation/messages/send_media_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_template_message/snippet.py b/examples/snippets/conversation/messages/send_template_message/snippet.py index cb604a74..c6b19843 100644 --- a/examples/snippets/conversation/messages/send_template_message/snippet.py +++ b/examples/snippets/conversation/messages/send_template_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/send_text_message/snippet.py b/examples/snippets/conversation/messages/send_text_message/snippet.py index 6a5bebbe..fa6431c5 100644 --- a/examples/snippets/conversation/messages/send_text_message/snippet.py +++ b/examples/snippets/conversation/messages/send_text_message/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/conversation/messages/update/snippet.py b/examples/snippets/conversation/messages/update/snippet.py index 72d31d32..b6f89a45 100644 --- a/examples/snippets/conversation/messages/update/snippet.py +++ b/examples/snippets/conversation/messages/update/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -20,10 +19,12 @@ # The ID of the message to update message_id = "MESSAGE_ID" +# The metadata string to set on the message +metadata = "MESSAGE_METADATA" response = sinch_client.conversation.messages.update( message_id=message_id, - metadata="metadata value set from Python SDK snippet" + metadata=metadata, ) print(f"Updated message:\n{response}") diff --git a/examples/snippets/number_lookup/lookup/snippet.py b/examples/snippets/number_lookup/lookup/snippet.py index 35b63e3b..98eec221 100644 --- a/examples/snippets/number_lookup/lookup/snippet.py +++ b/examples/snippets/number_lookup/lookup/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,8 +16,8 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", ) -# The phone number to lookup in E.164 format (e.g., +1234567890) -phone_number = "PHONE_NUMBER_TO_LOOKUP" +# The phone number to look up in E.164 format (e.g. +1234567890) +phone_number = "PHONE_NUMBER" response = sinch_client.number_lookup.lookup(number=phone_number) diff --git a/examples/snippets/numbers/active_numbers/get/snippet.py b/examples/snippets/numbers/active_numbers/get/snippet.py index 37516175..5787fd90 100644 --- a/examples/snippets/numbers/active_numbers/get/snippet.py +++ b/examples/snippets/numbers/active_numbers/get/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,7 +16,9 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" +# The active phone number to retrieve details for in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" + response = sinch_client.numbers.get(phone_number=phone_number) print(f"Rented number details:\n{response}") diff --git a/examples/snippets/numbers/active_numbers/list/snippet.py b/examples/snippets/numbers/active_numbers/list/snippet.py index 6ad2d486..65cfc4f7 100644 --- a/examples/snippets/numbers/active_numbers/list/snippet.py +++ b/examples/snippets/numbers/active_numbers/list/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/numbers/active_numbers/list_auto/snippet.py b/examples/snippets/numbers/active_numbers/list_auto/snippet.py index c35b1294..ff180cd6 100644 --- a/examples/snippets/numbers/active_numbers/list_auto/snippet.py +++ b/examples/snippets/numbers/active_numbers/list_auto/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/numbers/active_numbers/release/snippet.py b/examples/snippets/numbers/active_numbers/release/snippet.py index c0c56489..93accd26 100644 --- a/examples/snippets/numbers/active_numbers/release/snippet.py +++ b/examples/snippets/numbers/active_numbers/release/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,7 +16,9 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" +# The phone number to release in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" + released_number = sinch_client.numbers.release( phone_number=phone_number ) diff --git a/examples/snippets/numbers/active_numbers/update/snippet.py b/examples/snippets/numbers/active_numbers/update/snippet.py index 253fea5c..4d9902ad 100644 --- a/examples/snippets/numbers/active_numbers/update/snippet.py +++ b/examples/snippets/numbers/active_numbers/update/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,12 +16,14 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -phone_number_to_update = os.environ.get("SINCH_PHONE_NUMBER") or "MY_SINCH_PHONE_NUMBER" -updated_display_name = "Updated DISPLAY_NAME" +# The phone number to update in E.164 format +phone_number = os.environ.get("SINCH_PHONE_NUMBER") or "MY_PHONE_NUMBER" +# The display name to set for the number +display_name = "DISPLAY_NAME" response = sinch_client.numbers.update( - phone_number=phone_number_to_update, - display_name=updated_display_name + phone_number=phone_number, + display_name=display_name, ) print("Updated Number:\n", response) diff --git a/examples/snippets/numbers/available_numbers/check_availability/snippet.py b/examples/snippets/numbers/available_numbers/check_availability/snippet.py index 6ad8ab89..3cce5738 100644 --- a/examples/snippets/numbers/available_numbers/check_availability/snippet.py +++ b/examples/snippets/numbers/available_numbers/check_availability/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,7 +16,9 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) +# The phone number to check in E.164 format phone_number = "PHONE_NUMBER" + response = sinch_client.numbers.check_availability( phone_number=phone_number ) diff --git a/examples/snippets/numbers/available_numbers/rent/snippet.py b/examples/snippets/numbers/available_numbers/rent/snippet.py index 82f0f59f..f369d9fe 100644 --- a/examples/snippets/numbers/available_numbers/rent/snippet.py +++ b/examples/snippets/numbers/available_numbers/rent/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -18,14 +17,16 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -phone_number_to_be_rented = "AVAILABLE_PHONE_NUMBER_TO_BE_RENTED" -service_plan_id_to_associate_with_the_number = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +# The available phone number to rent in E.164 format +phone_number = "PHONE_NUMBER" +# The service plan ID to associate with the phone number +service_plan_id = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" sms_configuration: SmsConfigurationDict = { - "service_plan_id": service_plan_id_to_associate_with_the_number + "service_plan_id": service_plan_id, } rented_number = sinch_client.numbers.rent( - phone_number=phone_number_to_be_rented, + phone_number=phone_number, sms_configuration=sms_configuration ) print("Rented Number:\n", rented_number) diff --git a/examples/snippets/numbers/available_numbers/rent_any/snippet.py b/examples/snippets/numbers/available_numbers/rent_any/snippet.py index a7ce7a60..cc55923a 100644 --- a/examples/snippets/numbers/available_numbers/rent_any/snippet.py +++ b/examples/snippets/numbers/available_numbers/rent_any/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -18,16 +17,20 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -service_plan_id_to_associate_with_the_number = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" +# The service plan ID to associate with the phone number +service_plan_id = os.environ.get("SINCH_SERVICE_PLAN_ID") or "MY_SERVICE_PLAN_ID" sms_configuration: SmsConfigurationDict = { - "service_plan_id": service_plan_id_to_associate_with_the_number + "service_plan_id": service_plan_id, } +# The URL to receive the notifications about provisioning events +event_destination_target = "CALLBACK_URL" response = sinch_client.numbers.rent_any( region_code="US", number_type="LOCAL", capabilities=["SMS", "VOICE"], - sms_configuration=sms_configuration + sms_configuration=sms_configuration, + event_destination_target=event_destination_target ) print("Rented Number:\n", response) diff --git a/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py b/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py index ac1a8301..8d174729 100644 --- a/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py +++ b/examples/snippets/numbers/available_numbers/search_for_available_numbers/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/numbers/available_regions/list/snippet.py b/examples/snippets/numbers/available_regions/list/snippet.py index 1012de9e..59ff6459 100644 --- a/examples/snippets/numbers/available_regions/list/snippet.py +++ b/examples/snippets/numbers/available_regions/list/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/numbers/event_destinations/get/snippet.py b/examples/snippets/numbers/event_destinations/get/snippet.py index 413fd0d0..3f33031e 100644 --- a/examples/snippets/numbers/event_destinations/get/snippet.py +++ b/examples/snippets/numbers/event_destinations/get/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/numbers/event_destinations/update/snippet.py b/examples/snippets/numbers/event_destinations/update/snippet.py index 51722f76..9e6dc8b0 100644 --- a/examples/snippets/numbers/event_destinations/update/snippet.py +++ b/examples/snippets/numbers/event_destinations/update/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -17,7 +16,9 @@ key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET" ) -hmac_secret = "NEW_HMAC_SECRET" +# The HMAC secret for signing webhook requests to your event destination +hmac_secret = "HMAC_SECRET" + response = sinch_client.numbers.event_destinations.update( hmac_secret=hmac_secret ) diff --git a/examples/snippets/sms/batches/cancel/snippet.py b/examples/snippets/sms/batches/cancel/snippet.py index 55bb5738..ebc64110 100644 --- a/examples/snippets/sms/batches/cancel/snippet.py +++ b/examples/snippets/sms/batches/cancel/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -20,6 +19,7 @@ # The ID of the batch to cancel batch_id = "BATCH_ID" + response = sinch_client.sms.batches.cancel(batch_id=batch_id) print(f"Cancelled batch:\n{response}") diff --git a/examples/snippets/sms/batches/dry_run_binary/snippet.py b/examples/snippets/sms/batches/dry_run_binary/snippet.py index 03b6eb9c..689cb02e 100644 --- a/examples/snippets/sms/batches/dry_run_binary/snippet.py +++ b/examples/snippets/sms/batches/dry_run_binary/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/dry_run_mms/snippet.py b/examples/snippets/sms/batches/dry_run_mms/snippet.py index 11f54c31..8df83795 100644 --- a/examples/snippets/sms/batches/dry_run_mms/snippet.py +++ b/examples/snippets/sms/batches/dry_run_mms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/dry_run_sms/snippet.py b/examples/snippets/sms/batches/dry_run_sms/snippet.py index ae0a7d09..f9eb0ded 100644 --- a/examples/snippets/sms/batches/dry_run_sms/snippet.py +++ b/examples/snippets/sms/batches/dry_run_sms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/get/snippet.py b/examples/snippets/sms/batches/get/snippet.py index 3c30df32..369851b8 100644 --- a/examples/snippets/sms/batches/get/snippet.py +++ b/examples/snippets/sms/batches/get/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -20,6 +19,7 @@ # The ID of the batch to retrieve batch_id = "BATCH_ID" + response = sinch_client.sms.batches.get(batch_id=batch_id) print(f"Batch details:\n{response}") diff --git a/examples/snippets/sms/batches/list/snippet.py b/examples/snippets/sms/batches/list/snippet.py index c6364025..15473da4 100644 --- a/examples/snippets/sms/batches/list/snippet.py +++ b/examples/snippets/sms/batches/list/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/replace_binary/snippet.py b/examples/snippets/sms/batches/replace_binary/snippet.py index 305e4f59..17f13be2 100644 --- a/examples/snippets/sms/batches/replace_binary/snippet.py +++ b/examples/snippets/sms/batches/replace_binary/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/replace_mms/snippet.py b/examples/snippets/sms/batches/replace_mms/snippet.py index fed4d71e..69bcb708 100644 --- a/examples/snippets/sms/batches/replace_mms/snippet.py +++ b/examples/snippets/sms/batches/replace_mms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/replace_sms/snippet.py b/examples/snippets/sms/batches/replace_sms/snippet.py index 88066324..f2e8f373 100644 --- a/examples/snippets/sms/batches/replace_sms/snippet.py +++ b/examples/snippets/sms/batches/replace_sms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/send_binary/snippet.py b/examples/snippets/sms/batches/send_binary/snippet.py index ad2366a1..f93dbb10 100644 --- a/examples/snippets/sms/batches/send_binary/snippet.py +++ b/examples/snippets/sms/batches/send_binary/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/send_delivery_feedback/snippet.py b/examples/snippets/sms/batches/send_delivery_feedback/snippet.py index f19f83ad..5da87fc0 100644 --- a/examples/snippets/sms/batches/send_delivery_feedback/snippet.py +++ b/examples/snippets/sms/batches/send_delivery_feedback/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/send_mms/snippet.py b/examples/snippets/sms/batches/send_mms/snippet.py index fff15360..6627b712 100644 --- a/examples/snippets/sms/batches/send_mms/snippet.py +++ b/examples/snippets/sms/batches/send_mms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/send_sms/snippet.py b/examples/snippets/sms/batches/send_sms/snippet.py index 64cedd2d..1073cc31 100644 --- a/examples/snippets/sms/batches/send_sms/snippet.py +++ b/examples/snippets/sms/batches/send_sms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/update_binary/snippet.py b/examples/snippets/sms/batches/update_binary/snippet.py index ac67b610..7b1360d0 100644 --- a/examples/snippets/sms/batches/update_binary/snippet.py +++ b/examples/snippets/sms/batches/update_binary/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/update_mms/snippet.py b/examples/snippets/sms/batches/update_mms/snippet.py index b8a7ba1a..7c37dbb6 100644 --- a/examples/snippets/sms/batches/update_mms/snippet.py +++ b/examples/snippets/sms/batches/update_mms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/batches/update_sms/snippet.py b/examples/snippets/sms/batches/update_sms/snippet.py index d65aa994..1e6fd4e6 100644 --- a/examples/snippets/sms/batches/update_sms/snippet.py +++ b/examples/snippets/sms/batches/update_sms/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/delivery_reports/get/snippet.py b/examples/snippets/sms/delivery_reports/get/snippet.py index bcf068db..d1e715e1 100644 --- a/examples/snippets/sms/delivery_reports/get/snippet.py +++ b/examples/snippets/sms/delivery_reports/get/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os @@ -20,6 +19,7 @@ # The ID of the batch to get delivery report for batch_id = "BATCH_ID" + response = sinch_client.sms.delivery_reports.get(batch_id=batch_id) print(f"Delivery report for batch:\n{response}") diff --git a/examples/snippets/sms/delivery_reports/get_for_number/snippet.py b/examples/snippets/sms/delivery_reports/get_for_number/snippet.py index 8e304697..859a8768 100644 --- a/examples/snippets/sms/delivery_reports/get_for_number/snippet.py +++ b/examples/snippets/sms/delivery_reports/get_for_number/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os diff --git a/examples/snippets/sms/delivery_reports/list/snippet.py b/examples/snippets/sms/delivery_reports/list/snippet.py index 56f4af20..d92f88ef 100644 --- a/examples/snippets/sms/delivery_reports/list/snippet.py +++ b/examples/snippets/sms/delivery_reports/list/snippet.py @@ -1,8 +1,7 @@ """ Sinch Python Snippet -TODO: Update links when v2 is released. -This snippet is available at https://github.com/sinch/sinch-sdk-python/blob/v2.0/docs/snippets/ +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets """ import os From cca4428444d4ab5512640b58930292a322956ee5 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Wed, 25 Mar 2026 18:53:12 +0100 Subject: [PATCH 106/106] update error messages --- sinch/core/clients/sinch_client_configuration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sinch/core/clients/sinch_client_configuration.py b/sinch/core/clients/sinch_client_configuration.py index 1ca6af3a..0bba7730 100644 --- a/sinch/core/clients/sinch_client_configuration.py +++ b/sinch/core/clients/sinch_client_configuration.py @@ -256,7 +256,7 @@ def get_sms_origin_for_auth(self): "SMS region is required. " "Provide sms_region when initializing SinchClient " "Example: SinchClient(project_id='...', key_id='...', key_secret='...', sms_region='eu')" - "or set it via sinch_client.configuration.sms_region. " + " or set it via sinch_client.configuration.sms_region. " ) return origin @@ -273,7 +273,7 @@ def get_conversation_origin(self): "Conversation region is required. " "Provide conversation_region when initializing SinchClient " "Example: SinchClient(project_id='...', key_id='...', key_secret='...', conversation_region='eu')" - "or set it via sinch_client.configuration.conversation_region. " + " or set it via sinch_client.configuration.conversation_region. " ) return self.conversation_origin