From 5e561a8cebac9a985b94bb557ced263aa5d494e7 Mon Sep 17 00:00:00 2001 From: Jessica Matsuoka Date: Thu, 30 Jan 2025 16:12:31 +0100 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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"