Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
ruff format sinch/domains/sms --check --diff
ruff check sinch/domains/number_lookup --statistics
ruff format sinch/domains/number_lookup --check --diff
ruff check sinch/domains/conversation --statistics
ruff format sinch/domains/conversation --check --diff

- name: Test with Pytest
run: |
Expand Down Expand Up @@ -84,6 +86,7 @@ jobs:
cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/
cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/
cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/

- name: Wait for mock server
run: .github/scripts/wait-for-mockserver.sh
Expand Down
994 changes: 2 additions & 992 deletions sinch/domains/conversation/__init__.py

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions sinch/domains/conversation/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sinch.domains.conversation.api.v1.messages_apis import Messages

__all__ = [
"Messages",
]
5 changes: 5 additions & 0 deletions sinch/domains/conversation/api/v1/base/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sinch.domains.conversation.api.v1.base.base_conversation import (
BaseConversation,
)

__all__ = ["BaseConversation"]
23 changes: 23 additions & 0 deletions sinch/domains/conversation/api/v1/base/base_conversation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
class BaseConversation:
"""Base class for handling Sinch Conversation operations."""

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

def _request(self, endpoint_class, request_data):
"""
A helper method to make requests to endpoints.

Args:
endpoint_class: The endpoint class to call.
request_data: The request data to pass to the endpoint.

Returns:
The response from the Sinch transport request.
"""
return self._sinch.configuration.transport.request(
endpoint_class(
project_id=self._sinch.configuration.project_id,
request_data=request_data,
)
)
5 changes: 5 additions & 0 deletions sinch/domains/conversation/api/v1/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sinch.core.exceptions import SinchException


class ConversationException(SinchException):
pass
11 changes: 11 additions & 0 deletions sinch/domains/conversation/api/v1/internal/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from sinch.domains.conversation.api.v1.internal.messages_endpoints import (
DeleteMessageEndpoint,
GetMessageEndpoint,
UpdateMessageMetadataEndpoint,
)

__all__ = [
"DeleteMessageEndpoint",
"GetMessageEndpoint",
"UpdateMessageMetadataEndpoint",
]
5 changes: 5 additions & 0 deletions sinch/domains/conversation/api/v1/internal/base/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from sinch.domains.conversation.api.v1.internal.base.conversation_endpoint import (
ConversationEndpoint,
)

__all__ = ["ConversationEndpoint"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import re
from abc import ABC
from typing import Type, Union, get_origin, get_args
from sinch.core.models.http_response import HTTPResponse
from sinch.core.endpoint import HTTPEndpoint
from sinch.core.types import BM
from sinch.domains.conversation.api.v1.exceptions import ConversationException


class ConversationEndpoint(HTTPEndpoint, ABC):
def __init__(self, project_id: str, request_data: BM):
super().__init__(project_id, request_data)

def build_url(self, sinch) -> str:
if not self.ENDPOINT_URL:
raise NotImplementedError(
f"ENDPOINT_URL must be defined in the Conversation endpoint subclass "
f"'{self.__class__.__name__}'."
)

# TODO: Add support and validation for conversation_region in SinchClient initialization;

return self.ENDPOINT_URL.format(
origin=sinch.configuration.conversation_origin,
project_id=self.project_id,
**vars(self.request_data),
)

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

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

# Extract all placeholders from the URL template (e.g., {message_id}, {project_id})
path_params = set(re.findall(r"\{(\w+)\}", self.ENDPOINT_URL))

# Exclude 'origin' and 'project_id' as they are always path params but not from request_data
path_params.discard("origin")
path_params.discard("project_id")

return path_params

def build_query_params(self) -> dict:
"""
Constructs the query parameters for the endpoint.

Returns:
dict: The query parameters to be sent with the API request.
"""
return {}

def request_body(self) -> str:
"""
Returns the request body as a JSON string.

Returns:
str: The request body as a JSON string.
"""
return ""

def process_response_model(
self, response_body: dict, response_model: Type[BM]
) -> BM:
"""
Processes the response body and maps it to a response model.

Args:
response_body (dict): The raw response body.
response_model (type): The Pydantic model class or Union type to map the response.

Returns:
Parsed response object.
"""
try:
origin = get_origin(response_model)
# Check if response_model is a Union type
if origin is Union:
# For Union types, try to validate against each type in the Union sequentially
# This handles cases where TypeAdapter might not be fully defined
union_args = get_args(response_model)
last_error = None

# Try each type in the Union until one succeeds
for union_type in union_args:
try:
return union_type.model_validate(response_body)
except Exception as e:
last_error = e
continue

# If all Union types failed, raise an error with the last error details
if last_error is not None:
raise ValueError(
f"Invalid response structure: None of the Union types matched. "
f"Last error: {last_error}"
) from last_error

# Use standard model_validate for regular Pydantic models
return response_model.model_validate(response_body)
except Exception as e:
raise ValueError(f"Invalid response structure: {e}") from e

def handle_response(self, response: HTTPResponse):
if response.status_code >= 400:
raise ConversationException(
message=f"{response.body['error'].get('message')} {response.body['error'].get('status')}",
response=response,
is_from_server=True,
)
126 changes: 126 additions & 0 deletions sinch/domains/conversation/api/v1/internal/messages_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import json
from sinch.core.enums import HTTPAuthentication, HTTPMethods
from sinch.core.models.http_response import HTTPResponse
from sinch.domains.conversation.models.v1.messages.internal.request import (
MessageIdRequest,
UpdateMessageMetadataRequest,
)
from sinch.domains.conversation.models.v1.messages.response.types import (
ConversationMessageResponse,
)
from sinch.domains.conversation.api.v1.internal.base import (
ConversationEndpoint,
)
from sinch.domains.conversation.api.v1.exceptions import ConversationException


class MessageEndpoint(ConversationEndpoint):
"""
Base class for message-related endpoints that share common query parameter handling.
"""

QUERY_PARAM_FIELDS = {"messages_source"}
BODY_PARAM_FIELDS = set()

def build_query_params(self) -> dict:
path_params = self._get_path_params_from_url()
exclude_set = path_params.union(self.BODY_PARAM_FIELDS)
query_params = self.request_data.model_dump(
include=self.QUERY_PARAM_FIELDS,
exclude_none=True,
by_alias=True,
exclude=exclude_set,
)
return query_params


class DeleteMessageEndpoint(MessageEndpoint):
ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}"
HTTP_METHOD = HTTPMethods.DELETE.value
HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value

def __init__(self, project_id: str, request_data: MessageIdRequest):
super(DeleteMessageEndpoint, self).__init__(project_id, request_data)
self.project_id = project_id
self.request_data = request_data

def handle_response(self, response: HTTPResponse):
try:
super(DeleteMessageEndpoint, self).handle_response(response)
except ConversationException as e:
raise ConversationException(
message=e.args[0],
response=e.http_response,
is_from_server=e.is_from_server,
)


class GetMessageEndpoint(MessageEndpoint):
ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}"
HTTP_METHOD = HTTPMethods.GET.value
HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value

def __init__(self, project_id: str, request_data: MessageIdRequest):
super(GetMessageEndpoint, self).__init__(project_id, request_data)
self.project_id = project_id
self.request_data = request_data

def handle_response(
self, response: HTTPResponse
) -> ConversationMessageResponse:
try:
super(GetMessageEndpoint, self).handle_response(response)
except ConversationException as e:
raise ConversationException(
message=e.args[0],
response=e.http_response,
is_from_server=e.is_from_server,
)
return self.process_response_model(
response.body, ConversationMessageResponse
)


class UpdateMessageMetadataEndpoint(MessageEndpoint):
ENDPOINT_URL = "{origin}/v1/projects/{project_id}/messages/{message_id}"
HTTP_METHOD = HTTPMethods.PATCH.value
HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value

BODY_PARAM_FIELDS = {"metadata"}

def __init__(
self, project_id: str, request_data: UpdateMessageMetadataRequest
):
super(UpdateMessageMetadataEndpoint, self).__init__(
project_id, request_data
)
self.project_id = project_id
self.request_data = request_data

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

def handle_response(
self, response: HTTPResponse
) -> ConversationMessageResponse:
try:
super(UpdateMessageMetadataEndpoint, self).handle_response(
response
)
except ConversationException as e:
raise ConversationException(
message=e.args[0],
response=e.http_response,
is_from_server=e.is_from_server,
)
return self.process_response_model(
response.body, ConversationMessageResponse
)
Loading