Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ The following example replaces the default `requests` backend with `httpx` and r
import httpx
from sinch import SinchClient
from sinch.core.ports.http_transport import HTTPTransport
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.models.http_request import HttpRequest
from sinch.core.models.http_response import HTTPResponse


Expand All @@ -204,9 +204,7 @@ class MyHTTPImplementation(HTTPTransport):
proxy=f"http://{proxy_user}:{proxy_password}@{proxy_url}"
)

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

body = request_data.request_body
response = self.http_client.request(
Expand Down
25 changes: 16 additions & 9 deletions sinch/core/adapters/requests_http_transport.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import requests
from sinch.core.ports.http_transport import HTTPTransport, HttpRequest
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.models.http_response import HTTPResponse


class HTTPTransportRequests(HTTPTransport):
"""
Sync HTTP transport using the requests library.
"""

def __init__(self, sinch):
super().__init__(sinch)
self.http_session = requests.Session()

def send(self, endpoint: HTTPEndpoint) -> HTTPResponse:
request_data: HttpRequest = self.prepare_request(endpoint)
request_data: HttpRequest = self.authenticate(endpoint, request_data)
def send(self, request_data: HttpRequest) -> HTTPResponse:
"""
Performs the HTTP call with requests and maps the result to an HTTPResponse.

:param request_data: The prepared request to send.
:type request_data: HttpRequest
:returns: The HTTP response.
:rtype: HTTPResponse
"""
self.sinch.configuration.logger.debug(
f"Sync HTTP {request_data.http_method} call with headers:"
f" {request_data.headers}, body: {request_data.request_body} and query_params: {request_data.query_params} to URL: {request_data.url}"
f"Sync HTTP request {request_data.http_method} call with headers:"
f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}"
)
response = self.http_session.request(
method=request_data.http_method,
Expand All @@ -30,8 +37,8 @@ def send(self, endpoint: HTTPEndpoint) -> HTTPResponse:
response_body = self.deserialize_json_response(response)

self.sinch.configuration.logger.debug(
f"Sync HTTP {response.status_code} response with headers: {response.headers}"
f"and body: {response_body} from URL: {request_data.url}"
f"Sync HTTP response {response.status_code} with headers: {response.headers}"
f" and body: {response_body} from URL: {request_data.url}"
Comment thread
JPPortier marked this conversation as resolved.
Outdated
)

return HTTPResponse(
Expand Down
157 changes: 105 additions & 52 deletions sinch/core/ports/http_transport.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,74 @@
from abc import ABC, abstractmethod
from platform import python_version
from typing import Optional

from requests import Response
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.models.http_request import HttpRequest
from sinch.core.models.http_response import HTTPResponse
from sinch.core.exceptions import ValidationException, SinchException
from sinch.core.enums import HTTPAuthentication
from sinch.core.token_manager import TokenState
from sinch import __version__ as sdk_version


class HTTPTransport(ABC):
"""Base class for HTTP transports.
"""
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.
Subclasses implement :meth:`send` to perform the raw HTTP call. The public
:meth:`request` method adds cross-cutting concerns on top: request
preparation, authentication, 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``.
def send(self, request_data: HttpRequest) -> HTTPResponse:
Comment thread
JPPortier marked this conversation as resolved.
Outdated
"""
Performs a single HTTP round-trip for an already-prepared, authenticated request.

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
:param request_data: The prepared request to send.
:type request_data: HttpRequest
:returns: The HTTP response.
:rtype: HTTPResponse
"""

def request(self, endpoint: HTTPEndpoint) -> HTTPResponse:
"""Send a request with automatic OAuth token refresh on 401.
"""
Sends a request, renewing the token and retrying once on an expired-token 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.
:param endpoint: The endpoint to call.
:type endpoint: HTTPEndpoint
:returns: The handled HTTP response.
:rtype: HTTPResponse
"""
http_response = self.send(endpoint)
request_data = self.prepare_request(endpoint)
request_data = self.authenticate(endpoint, request_data)
http_response = self.send(request_data)

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)
used_token = self._get_bearer_token_from_request(request_data)
new_token = self.sinch.configuration.token_manager.refresh_auth_token(used_token)
self._set_bearer_token(request_data, new_token.access_token)
http_response = self.send(request_data)

return endpoint.handle_response(http_response)

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

def authenticate(self, endpoint, request_data):
def authenticate(self, endpoint: HTTPEndpoint, request_data: HttpRequest) -> HttpRequest:
"""
Stamps the credentials required by the endpoint's auth scheme onto the request.

:param endpoint: The endpoint being called, whose HTTP_AUTHENTICATION selects the scheme.
:type endpoint: HTTPEndpoint
:param request_data: The request to authenticate, mutated in place.
:type request_data: HttpRequest
:returns: The same request, with auth applied.
:rtype: HttpRequest
:raises ValidationException: If the credentials required by the scheme are missing.
"""
if endpoint.HTTP_AUTHENTICATION in (HTTPAuthentication.BASIC.value, HTTPAuthentication.OAUTH.value):
if (
not self.sinch.configuration.key_id
Expand All @@ -87,10 +91,7 @@ def authenticate(self, endpoint, request_data):

if endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.OAUTH.value:
token = self.sinch.authentication.get_auth_token().access_token
request_data.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
})
self._set_bearer_token(request_data, token)
elif endpoint.HTTP_AUTHENTICATION == HTTPAuthentication.SMS_TOKEN.value:
if not self.sinch.configuration.sms_api_token or not self.sinch.configuration.service_plan_id:
raise ValidationException(
Expand All @@ -101,14 +102,19 @@ def authenticate(self, endpoint, request_data):
is_from_server=False,
response=None
)
request_data.headers.update({
"Authorization": f"Bearer {self.sinch.configuration.sms_api_token}",
"Content-Type": "application/json"
})
self._set_bearer_token(request_data, self.sinch.configuration.sms_api_token)

return request_data

def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest:
"""
Builds the HttpRequest for an endpoint.

:param endpoint: The endpoint to build the request for.
:type endpoint: HTTPEndpoint
:returns: The prepared request.
:rtype: HttpRequest
"""
url_query_params = endpoint.build_query_params()

return HttpRequest(
Expand All @@ -124,7 +130,16 @@ def prepare_request(self, endpoint: HTTPEndpoint) -> HttpRequest:
)

@staticmethod
def deserialize_json_response(response):
def deserialize_json_response(response: Response) -> dict:
"""
Parses the JSON body of a response.

:param response: The raw HTTP response.
:type response: Response
:returns: The parsed body.
:rtype: dict
:raises SinchException: If the body is present but not valid JSON.
"""
if response.content:
try:
response_body = response.json()
Expand All @@ -140,10 +155,48 @@ def deserialize_json_response(response):
return response_body

@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
)
def _should_refresh_token(endpoint: HTTPEndpoint, http_response: HTTPResponse) -> bool:
"""
Returns True for an OAuth endpoint that got a 401 with an expired-token header.

:param endpoint: The endpoint that was called.
:type endpoint: HTTPEndpoint
:param http_response: The response received.
:type http_response: HTTPResponse
:returns: Whether the token should be refreshed and the request retried.
:rtype: bool
"""
if endpoint.HTTP_AUTHENTICATION != HTTPAuthentication.OAUTH.value:
return False
if http_response.status_code != 401:
return False
www_authenticate = http_response.headers.get("www-authenticate") or ""
return "expired" in www_authenticate

@staticmethod
def _get_bearer_token_from_request(request_data: HttpRequest) -> Optional[str]:
"""
Extracts the bearer token from the request's Authorization header.

:param request_data: The request.
:type request_data: HttpRequest
:returns: The bearer token, or None if absent or not a bearer.
:rtype: Optional[str]
"""
auth = request_data.headers.get("Authorization", "")
return auth.removeprefix("Bearer ") if auth.startswith("Bearer ") else None

@staticmethod
def _set_bearer_token(request_data: HttpRequest, token: str) -> None:
"""
Stamps the bearer token onto the request's Authorization header.

:param request_data: The request.
:type request_data: HttpRequest
:param token: The bearer token.
:type token: str
"""
request_data.headers.update({
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
})
Loading
Loading