Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions sinch/core/adapters/requests_http_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ def __init__(self, sinch):
super().__init__(sinch)
self.http_session = requests.Session()

def request(self, endpoint: HTTPEndpoint) -> HTTPResponse:
def send(self, endpoint: HTTPEndpoint) -> HTTPResponse:
request_data: HttpRequest = self.prepare_request(endpoint)
request_data: HttpRequest = self.authenticate(endpoint, request_data)

Expand All @@ -34,11 +34,8 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse:
f"and body: {response_body} from URL: {request_data.url}"
)

return self.handle_response(
endpoint=endpoint,
http_response=HTTPResponse(
status_code=response.status_code,
body=response_body,
headers=response.headers
)
return HTTPResponse(
status_code=response.status_code,
body=response_body,
headers=response.headers
)
33 changes: 0 additions & 33 deletions sinch/core/clients/sinch_client_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

from sinch.core.ports.http_transport import HTTPTransport
from sinch.core.token_manager import TokenManager
from sinch.core.enums import HTTPAuthentication


class Configuration:
Expand Down Expand Up @@ -43,15 +42,12 @@ def __init__(
self._sms_region_with_service_plan_id = sms_region
self._sms_domain = "https://zt.{}.sms.api.sinch.com"
self._sms_domain_with_service_plan_id = "https://{}.sms.api.sinch.com"
self._templates_region = "eu"
self._templates_domain = ".template.api.sinch.com"
self.token_manager = token_manager
self.transport: HTTPTransport = transport

self._set_conversation_origin()
self._set_sms_origin()
self._set_sms_origin_with_service_plan_id()
self._set_templates_origin()

if logger_name:
self.logger = logging.getLogger(logger_name)
Expand Down Expand Up @@ -158,35 +154,6 @@ def _get_conversation_domain(self):
doc="ConversationAPI Domain"
)

def _set_templates_origin(self):
self.templates_origin = self._templates_region + self._templates_domain

def _set_templates_region(self, region):
self._templates_region = region
self._set_templates_origin()

def _get_templates_region(self):
return self._templates_region

templates_region = property(
_get_templates_region,
_set_templates_region,
doc="Conversation API Templates Region"
)

def _set_templates_domain(self, domain):
self._templates_domain = domain
self._set_templates_origin()

def _get_templates_domain(self):
return self._templates_domain

templates_domain = property(
_get_templates_domain,
_set_templates_domain,
doc="Conversation API Templates Domain"
)

def _determine_authentication_method(self):
"""
Determines the authentication method based on provided parameters.
Expand Down
13 changes: 10 additions & 3 deletions sinch/core/models/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,29 @@ def serialize_datetime_in_dict(value: Optional[Dict[str, Any]]) -> Optional[Dict
return serialized


def model_dump_for_query_params(model, exclude_none=True, by_alias=True):
def model_dump_for_query_params(
model, exclude_none=True, by_alias=True, exclude=None
):
"""
Serializes a Pydantic model for use as query parameters.
Converts list values to comma-separated strings for APIs that expect this format.
Filters out empty values (empty strings and empty lists).

:param model: A Pydantic BaseModel instance
:type model: BaseModel
:param exclude_none: Whether to exclude None values (default: True)
:type exclude_none: bool
:param by_alias: Whether to use field aliases (default: True)
:type by_alias: bool
:param exclude: Field names to omit (e.g. URL path params), passed to model_dump
:type exclude: set[str] | None
:returns: Serialized model data with lists converted to comma-separated strings
:rtype: dict
"""
data = model.model_dump(exclude_none=exclude_none, by_alias=by_alias)
dump_kwargs = {"exclude_none": exclude_none, "by_alias": by_alias}
if exclude is not None:
dump_kwargs["exclude"] = exclude
data = model.model_dump(**dump_kwargs)
filtered_data = {}
for key, value in data.items():
# Filter out empty strings
Expand Down
69 changes: 57 additions & 12 deletions sinch/core/ports/http_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,59 @@


class HTTPTransport(ABC):
"""Base class for HTTP transports.

Subclasses implement ``send`` to perform the raw HTTP call.
The public ``request`` method adds cross-cutting concerns on top:
authentication, logging hooks, and automatic token refresh on 401.
"""

def __init__(self, sinch):
self.sinch = sinch

# ------------------------------------------------------------------
# Subclass contract
# ------------------------------------------------------------------

@abstractmethod
def send(self, endpoint: HTTPEndpoint) -> HTTPResponse:
"""Execute a single HTTP round-trip and return the response.

Implementations must prepare the request, authenticate, perform the
HTTP call, deserialize the response, and return an ``HTTPResponse``.
They should **not** handle token refresh — that is done by
``request``.
"""

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def request(self, endpoint: HTTPEndpoint) -> HTTPResponse:
pass
"""Send a request with automatic OAuth token refresh on 401.

If the server responds with 401 *and* the token is detected as
expired, the token is invalidated and **one** retry is attempted
with a fresh token. A second consecutive 401 is handed straight
to the endpoint's error handler — no further retries.
"""
http_response = self.send(endpoint)

if self._should_refresh_token(endpoint, http_response):
self.sinch.configuration.token_manager.handle_invalid_token(
http_response
)
if (
self.sinch.configuration.token_manager.token_state
== TokenState.EXPIRED
):
http_response = self.send(endpoint)

return endpoint.handle_response(http_response)

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def authenticate(self, endpoint, request_data):
if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value):
Expand Down Expand Up @@ -83,10 +130,7 @@ def deserialize_json_response(response):
response_body = response.json()
except ValueError as err:
raise SinchException(
message=(
"Error while parsing json response.",
err.msg
),
message=f"Error while parsing json response. {err}",
is_from_server=True,
response=response
)
Expand All @@ -95,10 +139,11 @@ def deserialize_json_response(response):

return response_body

def handle_response(self, endpoint: HTTPEndpoint, http_response: HTTPResponse):
if http_response.status_code == 401 and endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value:
self.sinch.configuration.token_manager.handle_invalid_token(http_response)
if self.sinch.configuration.token_manager.token_state == TokenState.EXPIRED:
return self.request(endpoint=endpoint)

return endpoint.handle_response(http_response)
@staticmethod
def _should_refresh_token(endpoint, http_response):
"""Return True when a 401 response should trigger a token refresh."""
return (
http_response.status_code == 401
and endpoint.HTTP_AUTHENTICATION
== HTTPAuthentication.OAUTH.value
)
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,15 @@ def process_response_model(

def handle_response(self, response: HTTPResponse):
if response.status_code >= 400:
error = (response.body or {}).get("error", {})
message = error.get("message", "")
status = error.get("status", "")
error_message = (
f"{message} {status}".strip()
or f"Error {response.status_code}"
)
raise ConversationException(
message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}",
message=error_message,
response=response,
is_from_server=True,
)
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,9 @@ def __init__(
self.request_data = request_data

def request_body(self):
path_params = self._get_path_params_from_url()
request_data = self.request_data.model_dump(
by_alias=True, exclude_none=True
by_alias=True, exclude_none=True, exclude=path_params
)
return json.dumps(request_data)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ def __init__(self, project_id: str, request_data: RentNumberRequest):

def request_body(self) -> str:
# Convert the request data to a dictionary and remove None values
path_params = self._get_path_params_from_url()
request_data = self.request_data.model_dump(
by_alias=True, exclude_none=True
by_alias=True, exclude_none=True, exclude=path_params
)
return json.dumps(request_data)

Expand Down
33 changes: 31 additions & 2 deletions sinch/domains/numbers/api/v1/internal/base/numbers_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from abc import ABC
from typing import Type
from sinch.core.models.http_response import HTTPResponse
Expand All @@ -23,6 +24,23 @@ def build_url(self, sinch) -> str:
**vars(self.request_data),
)

def _get_path_params_from_url(self) -> set:
"""
Extracts path parameters from ENDPOINT_URL template.

Returns:
set: Set of path parameter names that should be excluded
from request body and query params.
"""
if not self.ENDPOINT_URL:
return set()

path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL))
path_params.discard("origin")
path_params.discard("project_id")

return path_params

def build_query_params(self) -> dict:
"""
Constructs the query parameters for the endpoint.
Expand Down Expand Up @@ -60,15 +78,26 @@ def process_response_model(
raise ValueError(f"Invalid response structure: {e}") from e

def handle_response(self, response: HTTPResponse):
error_data = (response.body or {}).get("error", {})

if response.status_code == 404:
error = NotFoundError(**response.body["error"])
try:
error = NotFoundError(**error_data)
except TypeError:
error = f"Not found: {error_data}"
raise NumbersException(
message=error, response=response, is_from_server=True
)

if response.status_code >= 400:
message = error_data.get("message", "")
status = error_data.get("status", "")
error_message = (
f"{message} {status}".strip()
or f"Error {response.status_code}"
)
raise NumbersException(
message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}",
message=error_message,
response=response,
is_from_server=True,
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ def __init__(
self.request_data = request_data

def build_query_params(self) -> dict:
return model_dump_for_query_params(self.request_data)
path_params = self._get_path_params_from_url()
return model_dump_for_query_params(
self.request_data, exclude=path_params
)

def handle_response(self, response: HTTPResponse) -> BatchDeliveryReport:
try:
Expand Down
11 changes: 0 additions & 11 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ def configure_origin(
sinch_client,
numbers_origin,
conversation_origin,
templates_origin,
auth_origin,
sms_origin
):
Expand All @@ -56,9 +55,6 @@ def configure_origin(
if conversation_origin:
sinch_client.configuration.conversation_origin = conversation_origin

if templates_origin:
sinch_client.configuration.templates_origin = templates_origin

if sms_origin:
sinch_client.configuration.sms_origin = sms_origin
sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin
Expand Down Expand Up @@ -101,11 +97,6 @@ def sms_origin():
return os.getenv("SMS_ORIGIN")


@pytest.fixture
def templates_origin():
return os.getenv("TEMPLATES_ORIGIN")


@pytest.fixture
def disable_ssl():
return os.getenv("DISABLE_SSL")
Expand Down Expand Up @@ -181,7 +172,6 @@ def sinch_client_sync(
key_secret,
numbers_origin,
conversation_origin,
templates_origin,
auth_origin,
sms_origin,
project_id
Expand All @@ -194,7 +184,6 @@ def sinch_client_sync(
),
numbers_origin,
conversation_origin,
templates_origin,
auth_origin,
sms_origin
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@ def mock_response():
@pytest.fixture
def mock_response_body():
expected_body = {
"phoneNumber": "+1234567890",
"displayName": "Display Name",
"smsConfiguration": {
"servicePlanId": "Service Plan Id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ def mock_response():
@pytest.fixture
def mock_response_body():
expected_body = {
"phoneNumber": "+1234567890",
"smsConfiguration": {
"servicePlanId": "YOUR_SMS_servicePlanId"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ def test_build_url(endpoint, mock_sinch_client_sms):
def test_build_query_params(endpoint):
query_params = endpoint.build_query_params()
expected_params = {
"batch_id": "01FC66621XXXXX119Z8PMV1QPQ",
"type": "summary",
"status": "DELIVERED",
"code": "400",
Expand All @@ -79,7 +78,6 @@ def test_build_query_params_with_multiple_status_and_code():
endpoint = GetBatchDeliveryReportEndpoint("test_project_id", request_data)
query_params = endpoint.build_query_params()
expected_params = {
"batch_id": "01W4FFL35P4NC4K35SMSBATCH1",
"status": "DELIVERED,FAILED,QUEUED",
"code": "400,401,402",
}
Expand Down
Loading
Loading