From 0995a1e7d357468acd0ef881c93be82805944c33 Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Fri, 22 May 2026 15:14:21 +0200 Subject: [PATCH 01/12] chore: update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7d72bfc8..744b11e7 100644 --- a/README.md +++ b/README.md @@ -136,14 +136,14 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su By default, the HTTP implementation uses the `requests` library. -To use a custom HTTP client, inject your own transport during initialization: +To use a custom HTTP client, assign your transport to the client's configuration after initialization: ```python sinch_client = SinchClient( key_id="key_id", key_secret="key_secret", project_id="some_project", - transport=MyHTTPImplementation ) +sinch_client.configuration.transport = MyHTTPImplementation(sinch_client) ``` Custom client has to obey types and methods described by `HTTPTransport` abstract base class: From 1e797a386298de7ad1d59df38929dc8a1aecb744 Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Mon, 25 May 2026 12:07:50 +0200 Subject: [PATCH 02/12] chore: update README --- README.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 744b11e7..21d2131e 100644 --- a/README.md +++ b/README.md @@ -48,40 +48,46 @@ The Sinch client provides access to the following Sinch products: ### Client initialization - To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API you intend to use. For security best practices, avoid hardcoding credentials. Instead, retrieve them from environment variables. -#### SMS API -For the SMS API in **Australia (AU)**, **Brazil (BR)**, **Canada (CA)**, **the United States (US)**, -and **the European Union (EU)**, provide the following parameters: +#### Project auth + +The standard authentication method for all Sinch APIs. +If you plan to use more than one API, or if you need access to the Conversation API, use this method. + +When using the SMS API, also pass `sms_region`. When using the Conversation API, also pass `conversation_region`. +Both are required for their respective APIs and have no default value. ```python from sinch import SinchClient sinch_client = SinchClient( - service_plan_id="service_plan_id", - sms_api_token="api_token" + project_id="project_id", + key_id="key_id", + key_secret="key_secret", + sms_region="us", # required if using the SMS API + conversation_region="eu", # required if using the Conversation API ) ``` -#### All Other Sinch APIs -For all other Sinch APIs, including SMS in US and EU regions, use the following parameters: +#### SMS token auth + +An alternative authentication method exclusive to the SMS API. ```python from sinch import SinchClient sinch_client = SinchClient( - project_id="project_id", - key_id="key_id", - key_secret="key_secret" + service_plan_id="service_plan_id", + sms_api_token="api_token", + sms_region="us", ) ``` -### SMS and Conversation regions (V2) - -You must set `sms_region` before using the SMS API and `conversation_region` before using the Conversation API—either in the `SinchClient(...)` constructor or on `sinch_client.configuration` before the first call to that product. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for examples. +> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before +> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. ## Logging From 2a3ad230fa5d9790c832236fbbd37c266d48b54b Mon Sep 17 00:00:00 2001 From: marcos-sinch Date: Mon, 25 May 2026 17:18:49 +0200 Subject: [PATCH 03/12] chore: update README --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 21d2131e..9d3143c0 100644 --- a/README.md +++ b/README.md @@ -48,17 +48,21 @@ The Sinch client provides access to the following Sinch products: ### Client initialization -To establish a connection with the Sinch backend, you must provide the appropriate credentials based on the API -you intend to use. For security best practices, avoid hardcoding credentials. -Instead, retrieve them from environment variables. +To establish a connection with the Sinch backend, you must provide credentials based on the API you intend to use. +For security best practices, avoid hardcoding credentials — retrieve them from environment variables instead. + +> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before +> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. -#### Project auth -The standard authentication method for all Sinch APIs. -If you plan to use more than one API, or if you need access to the Conversation API, use this method. +#### SMS API -When using the SMS API, also pass `sms_region`. When using the Conversation API, also pass `conversation_region`. -Both are required for their respective APIs and have no default value. +The SMS API supports two authentication methods. `sms_region` is required for both and has no default. + +**Project auth (OAuth2)** + +The SDK automatically exchanges your key ID and key secret for a short-lived OAuth2 token and refreshes it automatically on expiry. +Supported regions: `us`, `eu`, `br`. ```python from sinch import SinchClient @@ -67,14 +71,14 @@ sinch_client = SinchClient( project_id="project_id", key_id="key_id", key_secret="key_secret", - sms_region="us", # required if using the SMS API - conversation_region="eu", # required if using the Conversation API + sms_region="us" ) ``` -#### SMS token auth +**Service Plan ID auth (legacy)** -An alternative authentication method exclusive to the SMS API. +Uses a static bearer token that never expires. +Support all regions: `us`, `eu`, `br`, `ca`, `au`. ```python from sinch import SinchClient @@ -82,12 +86,43 @@ from sinch import SinchClient sinch_client = SinchClient( service_plan_id="service_plan_id", sms_api_token="api_token", - sms_region="us", + sms_region="us" ) ``` -> **Note:** `sms_region` and `conversation_region` no longer have defaults and **must** be set before -> calling those APIs—omitting them will cause a runtime error. See [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) for details. +#### Conversation API - Project auth (OAuth2) + +`conversation_region` is required and has no default. +Supported regions: `us`, `eu`, `br`. + +> **Why region matters:** The Conversation API stores and routes data within the selected region for regulatory compliance. Choose the region that matches your data residency requirements. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="project_id", + key_id="key_id", + key_secret="key_secret", + conversation_region="eu" +) +``` + +> **SMS integration note:** If you also use the SMS API, `sms_region` and `conversation_region` **must match**. Mismatched regions will cause delivery failures. + +#### Other APIs - Project auth (OAuth2) + +These APIs are not regionalized and use project-based auth. + +```python +from sinch import SinchClient + +sinch_client = SinchClient( + project_id="project_id", + key_id="key_id", + key_secret="key_secret", +) +``` ## Logging @@ -142,22 +177,62 @@ For handling all possible exceptions thrown by this SDK use `SinchException` (su By default, the HTTP implementation uses the `requests` library. -To use a custom HTTP client, assign your transport to the client's configuration after initialization: +To use a custom HTTP client, assign your transport to the client's configuration after initialization. + +Custom transports must extend `HTTPTransport` and implement the `send` method. The base class provides `prepare_request` and `authenticate` helpers, and handles OAuth token refresh automatically. + +The following example replaces the default `requests` backend with `httpx` and routes traffic through an authenticated proxy: + ```python +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_response import HTTPResponse + + +class MyHTTPImplementation(HTTPTransport): + def __init__(self, sinch, proxy_url, proxy_user, proxy_password): + super().__init__(sinch) + self.http_client = httpx.Client( + 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) + + body = request_data.request_body + response = self.http_client.request( + method=request_data.http_method, + url=request_data.url, + json=body if isinstance(body, dict) else None, + content=body if not isinstance(body, dict) else None, + auth=request_data.auth, + headers=request_data.headers, + params=request_data.query_params, + timeout=self.sinch.configuration.connection_timeout, + ) + response_body = self.deserialize_json_response(response) + + return HTTPResponse( + status_code=response.status_code, + body=response_body, + headers=dict(response.headers), + ) + + sinch_client = SinchClient( key_id="key_id", key_secret="key_secret", project_id="some_project", ) -sinch_client.configuration.transport = MyHTTPImplementation(sinch_client) -``` - -Custom client has to obey types and methods described by `HTTPTransport` abstract base class: -```python -class HTTPTransport(ABC): - @abstractmethod - def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: - pass +sinch_client.configuration.transport = MyHTTPImplementation( + sinch_client, + proxy_url="proxy.example.com:8080", + proxy_user="proxy_user", + proxy_password="proxy_password", +) ``` Note: Asynchronous HTTP clients are not supported. From bd986221bf6743bba5212b7c57bc6ce09b15d2fb Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 27 May 2026 12:46:27 +0200 Subject: [PATCH 04/12] chore: update README --- .gitignore | 3 +++ README.md | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index e5edf123..c44c3ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -138,6 +138,9 @@ cython_debug/ # Poetry poetry.lock +# Pyenv +.python-version + # .DS_Store files .DS_Store diff --git a/README.md b/README.md index 9d3143c0..d8e54ef9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ The SMS API supports two authentication methods. `sms_region` is required for bo The SDK automatically exchanges your key ID and key secret for a short-lived OAuth2 token and refreshes it automatically on expiry. Supported regions: `us`, `eu`, `br`. +In your [Account dashboard](https://dashboard.sinch.com/settings/access-keys), you will find your `projectId` and access keys composed of pairs of `keyId` / `keySecret`. + ```python from sinch import SinchClient @@ -80,6 +82,8 @@ sinch_client = SinchClient( Uses a static bearer token that never expires. Support all regions: `us`, `eu`, `br`, `ca`, `au`. +In your [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services), you will find your `servicePlanId` and `apiToken` (bearer token). + ```python from sinch import SinchClient From 387f949078b37afb2177e81a36ba9fbe5e73a367 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 11:05:42 +0200 Subject: [PATCH 05/12] dependency(core): update requests minimal version required (#152) * dependency(core): update requests minimal version required * Update CHANGELOG for version 2.1.0 --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- requirements-dev.txt | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea401a01..f32b9b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,14 @@ All notable changes to the **Sinch Python SDK** are documented in this file. --- +## v2.1.0 – + +### SDK + +- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` (#152). + +--- + ## v2.0.1 – 2026-06-02 ### SMS diff --git a/pyproject.toml b/pyproject.toml index be877f57..b82833df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ keywords = ["sinch", "sdk"] [tool.poetry.dependencies] python = ">=3.9" -requests = "*" +requests = ">=2.0.0" pydantic = ">=2.0.0" [build-system] diff --git a/requirements-dev.txt b/requirements-dev.txt index 384715db..095b2f00 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,7 +8,7 @@ behave ruff # HTTP Libraries -requests +requests>=2.0.0 # Data Validation pydantic >= 2.0.0 \ No newline at end of file From e03f1e7f7df888270d7202345533bc1095664841 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 14:09:48 +0200 Subject: [PATCH 06/12] Feature/devexp 1412 dependabot vulnerability (#153) * Clarify minimum version for 'requests' dependency --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32b9b9e..5f5c1787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK -- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` (#152). +- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). --- From dc9a3fa773a6a0b7556dc8b518b1d32b0d290e38 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Fri, 5 Jun 2026 14:57:57 +0200 Subject: [PATCH 07/12] Feature/devexp 1324 sms api groups (#148) * refactor(sms): remove deprecated groups and inbound models * chore(update version): update version to 2.1.0 * feat(sms): add list and create groups * feat(sms): add delete, list_members, update and replace groups * feat(sms): fix ruff format * test(sms): add groups unit tests * test(sms): add groups e2e tests * feat(sms): fix group list snippet * feat(sms): address PR review comments * feat(sms): update CHANGELOG.md and MIGRATION_GUIDE.md --- .github/workflows/ci.yml | 2 + .gitignore | 1 + CHANGELOG.md | 7 +- MIGRATION_GUIDE.md | 39 ++- .../snippets/sms/groups/create/snippet.py | 26 ++ .../snippets/sms/groups/delete/snippet.py | 27 ++ examples/snippets/sms/groups/get/snippet.py | 29 ++ examples/snippets/sms/groups/list/snippet.py | 26 ++ .../sms/groups/list_members/snippet.py | 30 ++ .../snippets/sms/groups/replace/snippet.py | 30 ++ .../snippets/sms/groups/update/snippet.py | 32 ++ .../core/adapters/requests_http_transport.py | 2 +- sinch/domains/sms/api/v1/__init__.py | 2 + sinch/domains/sms/api/v1/groups_apis.py | 274 ++++++++++++++++++ .../sms/api/v1/internal/groups_endpoints.py | 218 ++++++++++++++ sinch/domains/sms/models/groups/__init__.py | 13 - sinch/domains/sms/models/groups/requests.py | 49 ---- sinch/domains/sms/models/groups/responses.py | 42 --- sinch/domains/sms/models/inbounds/__init__.py | 15 - sinch/domains/sms/models/inbounds/requests.py | 18 -- .../domains/sms/models/inbounds/responses.py | 17 -- .../sms/models/v1/internal/__init__.py | 25 ++ .../get_batch_delivery_report_request.py | 8 +- .../models/v1/internal/group_id_request.py | 12 + .../sms/models/v1/internal/group_request.py | 27 ++ .../models/v1/internal/list_groups_request.py | 17 ++ .../v1/internal/replace_group_request.py | 8 + .../v1/internal/update_group_request.py | 41 +++ .../sms/models/v1/response/__init__.py | 10 + .../sms/models/v1/response/group_response.py | 40 +++ .../response/list_group_members_response.py | 15 + .../v1/response/list_groups_response.py | 31 ++ .../domains/sms/models/v1/shared/__init__.py | 8 + .../sms/models/v1/shared/auto_update.py | 45 +++ .../sms/models/v1/shared/group_id_mixin.py | 10 + sinch/domains/sms/models/v1/types/__init__.py | 2 + .../sms/models/v1/types/auto_update_dict.py | 18 ++ sinch/domains/sms/sms.py | 2 + tests/e2e/sms/features/steps/batches.steps.py | 38 +-- tests/e2e/sms/features/steps/common.steps.py | 24 ++ .../features/steps/delivery_reports.steps.py | 28 +- tests/e2e/sms/features/steps/groups.steps.py | 173 +++++++++++ .../groups/test_create_group_endpoint.py | 124 ++++++++ .../groups/test_delete_group_endpoint.py | 61 ++++ .../groups/test_get_group_endpoint.py | 97 +++++++ .../test_list_group_members_endpoint.py | 66 +++++ .../groups/test_list_groups_endpoint.py | 114 ++++++++ .../groups/test_replace_group_endpoint.py | 126 ++++++++ .../groups/test_update_group_endpoint.py | 124 ++++++++ .../internal/test_group_id_request_model.py | 31 ++ .../internal/test_group_request_model.py | 58 ++++ .../test_list_groups_request_model.py | 29 ++ .../test_replace_group_request_model.py | 50 ++++ .../test_update_group_request_model.py | 56 ++++ .../response/test_group_response_model.py | 81 ++++++ .../test_list_group_members_response_model.py | 41 +++ .../test_list_groups_response_model.py | 71 +++++ tests/unit/domains/sms/v1/test_groups.py | 215 ++++++++++++++ 58 files changed, 2599 insertions(+), 226 deletions(-) create mode 100644 examples/snippets/sms/groups/create/snippet.py create mode 100644 examples/snippets/sms/groups/delete/snippet.py create mode 100644 examples/snippets/sms/groups/get/snippet.py create mode 100644 examples/snippets/sms/groups/list/snippet.py create mode 100644 examples/snippets/sms/groups/list_members/snippet.py create mode 100644 examples/snippets/sms/groups/replace/snippet.py create mode 100644 examples/snippets/sms/groups/update/snippet.py create mode 100644 sinch/domains/sms/api/v1/groups_apis.py create mode 100644 sinch/domains/sms/api/v1/internal/groups_endpoints.py delete mode 100644 sinch/domains/sms/models/groups/__init__.py delete mode 100644 sinch/domains/sms/models/groups/requests.py delete mode 100644 sinch/domains/sms/models/groups/responses.py delete mode 100644 sinch/domains/sms/models/inbounds/__init__.py delete mode 100644 sinch/domains/sms/models/inbounds/requests.py delete mode 100644 sinch/domains/sms/models/inbounds/responses.py create mode 100644 sinch/domains/sms/models/v1/internal/group_id_request.py create mode 100644 sinch/domains/sms/models/v1/internal/group_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_groups_request.py create mode 100644 sinch/domains/sms/models/v1/internal/replace_group_request.py create mode 100644 sinch/domains/sms/models/v1/internal/update_group_request.py create mode 100644 sinch/domains/sms/models/v1/response/group_response.py create mode 100644 sinch/domains/sms/models/v1/response/list_group_members_response.py create mode 100644 sinch/domains/sms/models/v1/response/list_groups_response.py create mode 100644 sinch/domains/sms/models/v1/shared/auto_update.py create mode 100644 sinch/domains/sms/models/v1/shared/group_id_mixin.py create mode 100644 sinch/domains/sms/models/v1/types/auto_update_dict.py create mode 100644 tests/e2e/sms/features/steps/common.steps.py create mode 100644 tests/e2e/sms/features/steps/groups.steps.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_group_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py create mode 100644 tests/unit/domains/sms/v1/test_groups.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0534969d..f97feb3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,8 @@ jobs: cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/groups.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/groups_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ diff --git a/.gitignore b/.gitignore index 3246a258..fc884a92 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +lcov.info # E2E features *.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5c1787..4e0d748a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,17 @@ All notable changes to the **Sinch Python SDK** are documented in this file. --- -## v2.1.0 – +## v2.1.0 – 2026-06-05 ### SDK - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). + +### SMS + +- **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)). + --- ## v2.0.1 – 2026-06-02 diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index 19148549..b14c6535 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -1,6 +1,6 @@ # Sinch Python SDK Migration Guide -## 2.0.0 +## 2.1.0 This release removes legacy SDK support. @@ -207,7 +207,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. -Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions. +Note that `sinch.sms.inbounds` is not supported yet and will be available in a future minor version. `sinch.sms.groups` is now available — see [Groups API](#groups-api) below. ##### Batches API @@ -230,6 +230,41 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and | `get_for_batch()` with `GetSMSDeliveryReportForBatchRequest` | `get()` with `batch_id: str` and optional parameters: `report_type`, `status`, `code`, `client_reference` | | `get_for_number()` with `GetSMSDeliveryReportForNumberRequest` | `get_for_number()` with `batch_id: str` and `recipient: str` parameters | +##### Groups API + +> **Added in v2.1.0.** `sinch.sms.groups` is now fully supported. + +###### Replacement models + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.groups.requests.CreateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupRequest`](sinch/domains/sms/models/v1/internal/group_request.py) | +| `sinch.domains.sms.models.groups.requests.ListSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.ListGroupsRequest`](sinch/domains/sms/models/v1/internal/list_groups_request.py) | +| `sinch.domains.sms.models.groups.requests.GetSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.DeleteSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.GetSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.GroupIdRequest`](sinch/domains/sms/models/v1/internal/group_id_request.py) | +| `sinch.domains.sms.models.groups.requests.UpdateSMSGroupRequest` | [`sinch.domains.sms.models.v1.internal.UpdateGroupRequest`](sinch/domains/sms/models/v1/internal/update_group_request.py) | +| `sinch.domains.sms.models.groups.requests.ReplaceSMSGroupPhoneNumbersRequest` | [`sinch.domains.sms.models.v1.internal.ReplaceGroupRequest`](sinch/domains/sms/models/v1/internal/replace_group_request.py) | +| `sinch.domains.sms.models.groups.responses.CreateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.GetSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.UpdateSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.ReplaceSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.GroupResponse`](sinch/domains/sms/models/v1/response/group_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchListSMSGroupResponse` | [`sinch.domains.sms.models.v1.response.ListGroupsResponse`](sinch/domains/sms/models/v1/response/list_groups_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchGetSMSGroupPhoneNumbersResponse` | [`sinch.domains.sms.models.v1.response.ListGroupMembersResponse`](sinch/domains/sms/models/v1/response/list_group_members_response.py) | +| `sinch.domains.sms.models.groups.responses.SinchDeleteSMSGroupResponse` | `None` (method returns `None`) | + +###### Replacement APIs + +| Old method | New method in `sms.groups` | +|------------|---------------------------| +| `create()` with `CreateSMSGroupRequest` | `create()` with individual parameters: `name`, `members`, `child_groups`, `auto_update` | +| `list()` with `ListSMSGroupRequest` | `list()` with individual parameters: `page`, `page_size`. Returns **`Paginator[GroupResponse]`** | +| `get()` with `GetSMSGroupRequest` | `get()` with `group_id: str` parameter | +| `update()` with `UpdateSMSGroupRequest` | `update()` with `group_id: str` and optional parameters: `add`, `remove`, `name`, `add_from_group`, `remove_from_group`, `auto_update` | +| `replace()` with `ReplaceSMSGroupPhoneNumbersRequest` | `replace()` with `group_id: str` and optional parameters: `name`, `members`, `child_groups`, `auto_update` | +| `delete()` with `DeleteSMSGroupRequest` | `delete()` with `group_id: str` parameter | +| `get_phone_numbers()` / phone number listing | `list_members()` with `group_id: str`. Returns **`Paginator[str]`** | + --- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) diff --git a/examples/snippets/sms/groups/create/snippet.py b/examples/snippets/sms/groups/create/snippet.py new file mode 100644 index 00000000..fd60244c --- /dev/null +++ b/examples/snippets/sms/groups/create/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +response = sinch_client.sms.groups.create( + name="Sinch Python SDK group", members=["+1234567890", "+1987654321"] +) + +print(f"Group created:\n{response}") diff --git a/examples/snippets/sms/groups/delete/snippet.py b/examples/snippets/sms/groups/delete/snippet.py new file mode 100644 index 00000000..156010f3 --- /dev/null +++ b/examples/snippets/sms/groups/delete/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to delete +group_id = "GROUP_ID" + +sinch_client.sms.groups.delete(group_id=group_id) + +print(f"Group {group_id} deleted") diff --git a/examples/snippets/sms/groups/get/snippet.py b/examples/snippets/sms/groups/get/snippet.py new file mode 100644 index 00000000..2c30fba8 --- /dev/null +++ b/examples/snippets/sms/groups/get/snippet.py @@ -0,0 +1,29 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to retrieve +GROUP_ID = "GROUP_ID" + +response = sinch_client.sms.groups.get( + group_id=GROUP_ID +) + +print(f"Group details:\n{response}") \ No newline at end of file diff --git a/examples/snippets/sms/groups/list/snippet.py b/examples/snippets/sms/groups/list/snippet.py new file mode 100644 index 00000000..a3b9cd75 --- /dev/null +++ b/examples/snippets/sms/groups/list/snippet.py @@ -0,0 +1,26 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +groups = sinch_client.sms.groups.list() + +print("List of groups:\n") +for group in groups.iterator(): + print(group) diff --git a/examples/snippets/sms/groups/list_members/snippet.py b/examples/snippets/sms/groups/list_members/snippet.py new file mode 100644 index 00000000..2b4e5171 --- /dev/null +++ b/examples/snippets/sms/groups/list_members/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os +from typing import List + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to list members for +group_id = "GROUP_ID" + +members = sinch_client.sms.groups.list_members(group_id=group_id) + +print("List of members:\n") +for member in members.iterator(): + print(member) diff --git a/examples/snippets/sms/groups/replace/snippet.py b/examples/snippets/sms/groups/replace/snippet.py new file mode 100644 index 00000000..0a0a5339 --- /dev/null +++ b/examples/snippets/sms/groups/replace/snippet.py @@ -0,0 +1,30 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to replace +group_id = "GROUP_ID" + +response = sinch_client.sms.groups.replace( + group_id=group_id, + members=["+1234567890", "+1987654321"], +) + +print(f"Group replaced:\n{response}") diff --git a/examples/snippets/sms/groups/update/snippet.py b/examples/snippets/sms/groups/update/snippet.py new file mode 100644 index 00000000..4edab517 --- /dev/null +++ b/examples/snippets/sms/groups/update/snippet.py @@ -0,0 +1,32 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the group to update +group_id = "GROUP_ID" + +response = sinch_client.sms.groups.update( + group_id=group_id, + add=["+1234567890"], + remove=["+1987654321"], + name="Renamed Group", +) + +print(f"Group updated:\n{response}") diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 62c0a3cb..6fc62a77 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -15,7 +15,7 @@ def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: self.sinch.configuration.logger.debug( f"Sync HTTP {request_data.http_method} call with headers:" - f" {request_data.headers} and body: {request_data.request_body} to URL: {request_data.url}" + f" {request_data.headers}, body: {request_data.request_body} and query_params: {request_data.query_params} to URL: {request_data.url}" ) response = self.http_session.request( method=request_data.http_method, diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index db903927..b322cc84 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,7 +1,9 @@ from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +from sinch.domains.sms.api.v1.groups_apis import Groups __all__ = [ "Batches", "DeliveryReports", + "Groups", ] diff --git a/sinch/domains/sms/api/v1/groups_apis.py b/sinch/domains/sms/api/v1/groups_apis.py new file mode 100644 index 00000000..69532f47 --- /dev/null +++ b/sinch/domains/sms/api/v1/groups_apis.py @@ -0,0 +1,274 @@ +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base.base_sms import BaseSms +from sinch.domains.sms.api.v1.internal.groups_endpoints import ( + CreateGroupEndpoint, + DeleteGroupEndpoint, + GetGroupEndpoint, + ListGroupMembersEndpoint, + ListGroupsEndpoint, + ReplaceGroupEndpoint, + UpdateGroupEndpoint, +) +from sinch.domains.sms.models.v1.internal.group_id_request import ( + GroupIdRequest, +) +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) +from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, +) +from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.types.auto_update_dict import AutoUpdateDict + + +class Groups(BaseSms): + def create( + self, + name: Optional[str] = None, + members: Optional[List[str]] = None, + child_groups: Optional[List[str]] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + This endpoint allows you to create a group of recipients. A new group must be created with a group + name. This is represented by the `name` field which can be up to 20 characters. In addition, there + are a number of optional fields: + + - `members` field enables groups to be created with an initial list of contacts. + - `auto_update` allows customers to auto subscribe to a new group. This contains three fields. The + `to` field contains the group creator's number. (This number **must be provisioned by contacting + your account manager**.) The `add` and `remove` fields are objects containing the keywords that + customers need to text to join or leave a group. + + :param name: Name of the group. Max 20 characters. (optional) + :type name: Optional[str] + :param members: Initial list of phone numbers in E.164 format (MSISDNs) for the group. (optional) + :type members: Optional[List[str]] + :param child_groups: MSISDNs of child groups to include in this group. If present, this group will + be auto-populated. Elements must be valid group IDs. (optional) + :type child_groups: Optional[List[str]] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupRequest( + name=name, + members=members, + child_groups=child_groups, + auto_update=auto_update, + **kwargs, + ) + return self._request(CreateGroupEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + **kwargs, + ) -> Paginator[GroupResponse]: + """ + With the list operation you can list all groups that you have created. + This operation supports pagination. + + Groups are returned in reverse chronological order. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page. (optional) + :type page_size: Optional[int] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[GroupResponse] + :rtype: Paginator[GroupResponse] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListGroupsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListGroupsRequest( + page=page, + page_size=page_size, + **kwargs, + ), + ) + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) + + def get(self, group_id: str, **kwargs) -> GroupResponse: + """ + This operation retrieves a specific group with the provided group ID. + + :param group_id: ID of a group that you are interested in getting. + :type group_id: str + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id, **kwargs) + return self._request(GetGroupEndpoint, request_data) + + def replace( + self, + group_id: str, + name: Optional[str] = None, + members: Optional[List[str]] = None, + child_groups: Optional[List[str]] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + The replace operation will replace all parameters, including members, of + an existing group with new values. + + Replacing a group targeted by a batch message scheduled in the future is + allowed and changes will be reflected when the batch is sent. + + :param group_id: ID of the group to replace. + :type group_id: str + :param name: Name of the group. Max 20 characters. (optional) + :type name: Optional[str] + :param members: Initial list of phone numbers in E.164 format (MSISDNs) for the group. (optional) + :type members: Optional[List[str]] + :param child_groups: MSISDNs of child groups to include in this group. If present, this group will + be auto-populated. Elements must be valid group IDs. (optional) + :type child_groups: Optional[List[str]] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = ReplaceGroupRequest( + group_id=group_id, + name=name, + members=members, + child_groups=child_groups, + auto_update=auto_update, + **kwargs, + ) + return self._request(ReplaceGroupEndpoint, request_data) + + def update( + self, + group_id: str, + add: Optional[List[str]] = None, + remove: Optional[List[str]] = None, + name: Optional[str] = None, + add_from_group: Optional[str] = None, + remove_from_group: Optional[str] = None, + auto_update: Optional[AutoUpdateDict] = None, + **kwargs, + ) -> GroupResponse: + """ + With the update group operation, you can add and remove members in an + existing group as well as rename the group. + + This method encompasses a few ways to update a group: + + 1. By using `add` and `remove` arrays containing phone numbers, you control the group + movements. Any list of valid numbers in E.164 format can be added. + 2. By using the `auto_update` object, your customer can add or remove themselves from groups. + 3. You can also add or remove other groups into this group with `add_from_group` and + `remove_from_group`. + + Other group update info: + + - The request will not be rejected for duplicate adds or unknown removes. + - The additions will be done before the deletions. If a phone number is on both lists, + it will not be part of the resulting group. + - Updating a group targeted by a batch message scheduled in the future is allowed. + Changes will be reflected when the batch is sent. + + :param group_id: ID of the group to update. + :type group_id: str + :param add: List of phone numbers (MSISDNs) in E.164 format to add to the group. (optional) + :type add: Optional[List[str]] + :param remove: List of phone numbers (MSISDNs) in E.164 format to remove from the group. (optional) + :type remove: Optional[List[str]] + :param name: Name of the group. Omit to leave the name unchanged; set explicitly to null to + remove the existing name. (optional) + :type name: Optional[str] + :param add_from_group: Copy the members from another group into this group. Must be a valid + group ID. (optional) + :type add_from_group: Optional[str] + :param remove_from_group: Remove the members in a specified group from this group. Must be a + valid group ID. (optional) + :type remove_from_group: Optional[str] + :param auto_update: The auto-update settings for the group. (optional) + :type auto_update: Optional[AutoUpdateDict] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: GroupResponse + :rtype: GroupResponse + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = UpdateGroupRequest( + group_id=group_id, + add=add, + remove=remove, + name=name, + add_from_group=add_from_group, + remove_from_group=remove_from_group, + auto_update=auto_update, + **kwargs, + ) + return self._request(UpdateGroupEndpoint, request_data) + + def delete(self, group_id: str, **kwargs) -> None: + """ + This operation deletes the group with the provided group ID. + + :param group_id: ID of the group to delete. + :type group_id: str + + :returns: None + :rtype: None + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id, **kwargs) + return self._request(DeleteGroupEndpoint, request_data) + + def list_members(self, group_id: str, **kwargs) -> Paginator[str]: + """ + This operation retrieves the members of the group with the provided group ID. + + :param group_id: ID of the group whose members are being retrieved. + :type group_id: str + + :returns: Paginator[str] + :rtype: Paginator[str] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = GroupIdRequest(group_id=group_id, **kwargs) + endpoint = ListGroupMembersEndpoint( + project_id=self._get_path_identifier(), + request_data=request_data, + ) + endpoint.set_authentication_method(self._sinch) + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/groups_endpoints.py b/sinch/domains/sms/api/v1/internal/groups_endpoints.py new file mode 100644 index 00000000..fa06ff12 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/groups_endpoints.py @@ -0,0 +1,218 @@ +import json + +from pydantic import StrictStr, TypeAdapter, conlist + +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import ( + GroupIdRequest, +) +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) +from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, +) +from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_group_members_response import ( + ListGroupMembersResponse, +) +from sinch.domains.sms.models.v1.response.list_groups_response import ( + ListGroupsResponse, +) + + +class CreateGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupRequest): + super(CreateGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + request_data = self.request_data.model_dump( + mode="json", by_alias=True, exclude_none=True + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(CreateGroupEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, GroupResponse) + + +class ListGroupsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListGroupsRequest): + super(ListGroupsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response(self, response: HTTPResponse) -> ListGroupsResponse: + try: + super(ListGroupsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListGroupsResponse) + + +class GetGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(GetGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(GetGroupEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, GroupResponse) + + +class ReplaceGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.PUT.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ReplaceGroupRequest): + super(ReplaceGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + exclude=path_params, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(ReplaceGroupEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, GroupResponse) + + +class UpdateGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.POST.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: UpdateGroupRequest): + super(UpdateGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def request_body(self): + path_params = self._get_path_params_from_url() + request_data = self.request_data.model_dump( + mode="json", + by_alias=True, + exclude=path_params, + ) + return json.dumps(request_data) + + def handle_response(self, response: HTTPResponse) -> GroupResponse: + try: + super(UpdateGroupEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, GroupResponse) + + +class DeleteGroupEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}" + HTTP_METHOD = HTTPMethods.DELETE.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(DeleteGroupEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> None: + try: + super(DeleteGroupEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return None + + +class ListGroupMembersEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/groups/{group_id}/members" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: GroupIdRequest): + super(ListGroupMembersEndpoint, self).__init__( + project_id, request_data + ) + self.project_id = project_id + self.request_data = request_data + + def handle_response( + self, response: HTTPResponse + ) -> ListGroupMembersResponse: + try: + super(ListGroupMembersEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + members = TypeAdapter(conlist(StrictStr)).validate_python( + response.body + ) + return ListGroupMembersResponse(members=members) diff --git a/sinch/domains/sms/models/groups/__init__.py b/sinch/domains/sms/models/groups/__init__.py deleted file mode 100644 index d5d32a6e..00000000 --- a/sinch/domains/sms/models/groups/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class SMSGroup(SinchBaseModel): - id: str - size: int - created_at: str - modified_at: str - name: str - child_groups: list - auto_update: dict diff --git a/sinch/domains/sms/models/groups/requests.py b/sinch/domains/sms/models/groups/requests.py deleted file mode 100644 index b2b37fa8..00000000 --- a/sinch/domains/sms/models/groups/requests.py +++ /dev/null @@ -1,49 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class CreateSMSGroupRequest(SinchRequestBaseModel): - name: str - members: list - child_groups: list - auto_update: dict - - -@dataclass -class ListSMSGroupRequest(SinchRequestBaseModel): - page_size: int - page: int - - -@dataclass -class DeleteSMSGroupRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class GetSMSGroupRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class GetSMSGroupPhoneNumbersRequest(SinchRequestBaseModel): - group_id: str - - -@dataclass -class UpdateSMSGroupRequest(SinchRequestBaseModel): - group_id: str - name: str - add: list - remove: list - auto_update: dict - add_from_group: str - remove_from_group: str - - -@dataclass -class ReplaceSMSGroupPhoneNumbersRequest(SinchRequestBaseModel): - group_id: str - members: list - name: str diff --git a/sinch/domains/sms/models/groups/responses.py b/sinch/domains/sms/models/groups/responses.py deleted file mode 100644 index 7ebe70af..00000000 --- a/sinch/domains/sms/models/groups/responses.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.groups import SMSGroup - - -@dataclass -class CreateSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class GetSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class SinchListSMSGroupResponse(SinchBaseModel): - page: int - page_size: int - count: int - groups: List[SMSGroup] - - -@dataclass -class SinchDeleteSMSGroupResponse(SinchBaseModel): - pass - - -@dataclass -class SinchGetSMSGroupPhoneNumbersResponse(SinchBaseModel): - phone_numbers: list - - -@dataclass -class UpdateSMSGroupResponse(SMSGroup): - pass - - -@dataclass -class ReplaceSMSGroupResponse(SMSGroup): - pass diff --git a/sinch/domains/sms/models/inbounds/__init__.py b/sinch/domains/sms/models/inbounds/__init__.py deleted file mode 100644 index 92807b53..00000000 --- a/sinch/domains/sms/models/inbounds/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchBaseModel - - -@dataclass -class InboundMessage(SinchBaseModel): - type: str - id: str - from_: str - to: str - body: str - operator_id: str - send_at: str - received_at: str - client_reference: str diff --git a/sinch/domains/sms/models/inbounds/requests.py b/sinch/domains/sms/models/inbounds/requests.py deleted file mode 100644 index b945285a..00000000 --- a/sinch/domains/sms/models/inbounds/requests.py +++ /dev/null @@ -1,18 +0,0 @@ -from dataclasses import dataclass -from sinch.core.models.base_model import SinchRequestBaseModel - - -@dataclass -class ListSMSInboundMessageRequest(SinchRequestBaseModel): - start_date: str - to: str - end_date: str - page_size: int - page_size: int - client_reference: str - page: int = 0 - - -@dataclass -class GetSMSInboundMessageRequest(SinchRequestBaseModel): - inbound_id: str diff --git a/sinch/domains/sms/models/inbounds/responses.py b/sinch/domains/sms/models/inbounds/responses.py deleted file mode 100644 index 1062ad14..00000000 --- a/sinch/domains/sms/models/inbounds/responses.py +++ /dev/null @@ -1,17 +0,0 @@ -from dataclasses import dataclass -from typing import List -from sinch.core.models.base_model import SinchBaseModel -from sinch.domains.sms.models.inbounds import InboundMessage - - -@dataclass -class SinchListInboundMessagesResponse(SinchBaseModel): - page: str - page_size: str - count: str - inbounds: List[InboundMessage] - - -@dataclass -class GetInboundMessagesResponse(InboundMessage): - pass diff --git a/sinch/domains/sms/models/v1/internal/__init__.py b/sinch/domains/sms/models/v1/internal/__init__.py index 24ca21db..d026436e 100644 --- a/sinch/domains/sms/models/v1/internal/__init__.py +++ b/sinch/domains/sms/models/v1/internal/__init__.py @@ -19,19 +19,26 @@ from sinch.domains.sms.models.v1.internal.list_delivery_reports_request import ( ListDeliveryReportsRequest, ) +from sinch.domains.sms.models.v1.internal.list_groups_request import ( + ListGroupsRequest, +) __all__ = [ "BatchIdRequest", "DeliveryFeedbackRequest", + "GroupRequest", "ListBatchesRequest", "ListDeliveryReportsResponse", "GetRecipientDeliveryReportRequest", "ListDeliveryReportsRequest", + "ListGroupsRequest", "GetBatchDeliveryReportRequest", "DryRunRequest", "ReplaceBatchRequest", + "ReplaceGroupRequest", "SendSMSRequest", "UpdateBatchMessageRequest", + "UpdateGroupRequest", ] @@ -49,6 +56,12 @@ def __getattr__(name: str): ) return ReplaceBatchRequest + if name == "ReplaceGroupRequest": + from sinch.domains.sms.models.v1.internal.replace_group_request import ( + ReplaceGroupRequest, + ) + + return ReplaceGroupRequest if name == "SendSMSRequest": from sinch.domains.sms.models.v1.internal.send_sms_request import ( SendSMSRequest, @@ -61,4 +74,16 @@ def __getattr__(name: str): ) return UpdateBatchMessageRequest + if name == "UpdateGroupRequest": + from sinch.domains.sms.models.v1.internal.update_group_request import ( + UpdateGroupRequest, + ) + + return UpdateGroupRequest + if name == "GroupRequest": + from sinch.domains.sms.models.v1.internal.group_request import ( + GroupRequest, + ) + + return GroupRequest raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py index e6f951e7..ece7c294 100644 --- a/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py +++ b/sinch/domains/sms/models/v1/internal/get_batch_delivery_report_request.py @@ -1,5 +1,5 @@ -from typing import Optional, List -from pydantic import StrictStr, Field +from typing import Optional +from pydantic import StrictStr, Field, conlist from sinch.domains.sms.models.v1.types import ( DeliveryReceiptStatusCodeType, DeliveryReportType, @@ -16,11 +16,11 @@ class GetBatchDeliveryReportRequest(BaseModelConfigurationRequest): default=None, description="The type of delivery report.", ) - status: Optional[List[DeliveryStatusType]] = Field( + status: Optional[conlist(DeliveryStatusType)] = Field( default=None, description="Comma separated list of delivery_report_statuses to include", ) - code: Optional[List[DeliveryReceiptStatusCodeType]] = Field( + code: Optional[conlist(DeliveryReceiptStatusCodeType)] = Field( default=None, description="Comma separated list of delivery receipt error codes to include", ) diff --git a/sinch/domains/sms/models/v1/internal/group_id_request.py b/sinch/domains/sms/models/v1/internal/group_id_request.py new file mode 100644 index 00000000..4d36be92 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/group_id_request.py @@ -0,0 +1,12 @@ +from pydantic import Field, StrictStr + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class GroupIdRequest(BaseModelConfigurationRequest): + group_id: StrictStr = Field( + default=..., + description="ID of the group.", + ) diff --git a/sinch/domains/sms/models/v1/internal/group_request.py b/sinch/domains/sms/models/v1/internal/group_request.py new file mode 100644 index 00000000..de3a14b4 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/group_request.py @@ -0,0 +1,27 @@ +from typing import Optional + +from pydantic import Field, StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate + + +class GroupRequest(BaseModelConfigurationRequest): + name: Optional[StrictStr] = Field( + default=None, + description="Name of group", + ) + members: Optional[conlist(StrictStr)] = Field( + default=None, + description="Initial list of phone numbers in [E.164 format] for the group.", + ) + child_groups: Optional[conlist(StrictStr)] = Field( + default=None, + description="MSISDNs of child groups will be included in this group. Elements must be group IDs.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/internal/list_groups_request.py b/sinch/domains/sms/models/v1/internal/list_groups_request.py new file mode 100644 index 00000000..426e7b7e --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_groups_request.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pydantic import Field, StrictInt + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListGroupsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) diff --git a/sinch/domains/sms/models/v1/internal/replace_group_request.py b/sinch/domains/sms/models/v1/internal/replace_group_request.py new file mode 100644 index 00000000..18355815 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/replace_group_request.py @@ -0,0 +1,8 @@ +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.shared.group_id_mixin import GroupIdMixin + + +class ReplaceGroupRequest(GroupIdMixin, GroupRequest): + """Request model for replacing a group.""" + + pass diff --git a/sinch/domains/sms/models/v1/internal/update_group_request.py b/sinch/domains/sms/models/v1/internal/update_group_request.py new file mode 100644 index 00000000..e625cc6f --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/update_group_request.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import Field, StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate +from sinch.domains.sms.models.v1.shared.group_id_mixin import GroupIdMixin + + +class UpdateGroupRequest(GroupIdMixin, BaseModelConfigurationRequest): + """Request model for updating a group (incremental changes).""" + + add: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers (MSISDNs) in E.164 format to add to the group.", + ) + remove: Optional[conlist(StrictStr)] = Field( + default=None, + description="List of phone numbers (MSISDNs) in E.164 format to remove from the group.", + ) + name: Optional[StrictStr] = Field( + default=None, + description=( + "Name of the group. Omit to leave the name unchanged; " + "set explicitly to null to remove the existing name." + ), + ) + add_from_group: Optional[StrictStr] = Field( + default=None, + description="Copy the members from another group into this group. Must be a valid group ID.", + ) + remove_from_group: Optional[StrictStr] = Field( + default=None, + description="Remove the members in a specified group from this group. Must be a valid group ID.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/response/__init__.py b/sinch/domains/sms/models/v1/response/__init__.py index 9648cf44..5fee6d37 100644 --- a/sinch/domains/sms/models/v1/response/__init__.py +++ b/sinch/domains/sms/models/v1/response/__init__.py @@ -4,9 +4,16 @@ from sinch.domains.sms.models.v1.response.dry_run_response import ( DryRunResponse, ) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse from sinch.domains.sms.models.v1.response.list_batches_response import ( ListBatchesResponse, ) +from sinch.domains.sms.models.v1.response.list_groups_response import ( + ListGroupsResponse, +) +from sinch.domains.sms.models.v1.response.list_group_members_response import ( + ListGroupMembersResponse, +) from sinch.domains.sms.models.v1.response.recipient_delivery_report import ( RecipientDeliveryReport, ) @@ -14,6 +21,9 @@ __all__ = [ "BatchDeliveryReport", "DryRunResponse", + "GroupResponse", "ListBatchesResponse", + "ListGroupMembersResponse", + "ListGroupsResponse", "RecipientDeliveryReport", ] diff --git a/sinch/domains/sms/models/v1/response/group_response.py b/sinch/domains/sms/models/v1/response/group_response.py new file mode 100644 index 00000000..f5a1b96d --- /dev/null +++ b/sinch/domains/sms/models/v1/response/group_response.py @@ -0,0 +1,40 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictInt, StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.shared.auto_update import AutoUpdate + + +class GroupResponse(BaseModelConfigurationResponse): + id: Optional[StrictStr] = Field( + default=None, + description="The ID used to reference this group.", + ) + name: Optional[StrictStr] = Field( + default=None, + description="Name of group", + ) + size: Optional[StrictInt] = Field( + default=None, + description="The number of members currently in the group.", + ) + created_at: Optional[datetime] = Field( + default=None, + description="Timestamp for group creation. Format: YYYY-MM-DDThh:mm:ss.SSSZ", + ) + modified_at: Optional[datetime] = Field( + default=None, + description="Timestamp for when the group was last updated. Format: YYYY-MM-DDThh:mm:ss.SSSZ", + ) + child_groups: Optional[conlist(StrictStr)] = Field( + default=None, + description="MSISDNs of child groups will be included in this group. Elements must be group IDs.", + ) + auto_update: Optional[AutoUpdate] = Field( + default=None, + description="Configuration for auto-subscription via MO keywords.", + ) diff --git a/sinch/domains/sms/models/v1/response/list_group_members_response.py b/sinch/domains/sms/models/v1/response/list_group_members_response.py new file mode 100644 index 00000000..ba251f50 --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_group_members_response.py @@ -0,0 +1,15 @@ +from typing import List + +from pydantic import StrictStr, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class ListGroupMembersResponse(BaseModelConfigurationResponse): + members: conlist(StrictStr) + + @property + def content(self) -> List[str]: + return self.members diff --git a/sinch/domains/sms/models/v1/response/list_groups_response.py b/sinch/domains/sms/models/v1/response/list_groups_response.py new file mode 100644 index 00000000..dd79568f --- /dev/null +++ b/sinch/domains/sms/models/v1/response/list_groups_response.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field, StrictInt, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +class ListGroupsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of entries matching the given filters.", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of entries returned in this request.", + ) + groups: Optional[conlist(GroupResponse)] = Field( + default=None, + description="The page of groups matching the given filters.", + ) + + @property + def content(self): + """Returns the content of the group list.""" + return self.groups or [] diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 5139c795..67fcc08b 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -1,3 +1,8 @@ +from sinch.domains.sms.models.v1.shared.auto_update import ( + AddKeyword, + AutoUpdate, + RemoveKeyword, +) from sinch.domains.sms.models.v1.shared.binary_request import BinaryRequest from sinch.domains.sms.models.v1.shared.binary_response import BinaryResponse from sinch.domains.sms.models.v1.shared.dry_run_per_recipient_details import ( @@ -13,6 +18,8 @@ from sinch.domains.sms.models.v1.shared.text_response import TextResponse __all__ = [ + "AddKeyword", + "AutoUpdate", "BinaryRequest", "BinaryResponse", "DryRunPerRecipientDetails", @@ -20,6 +27,7 @@ "MediaRequest", "MediaResponse", "MessageDeliveryStatus", + "RemoveKeyword", "TextRequest", "TextResponse", ] diff --git a/sinch/domains/sms/models/v1/shared/auto_update.py b/sinch/domains/sms/models/v1/shared/auto_update.py new file mode 100644 index 00000000..e6e97320 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/auto_update.py @@ -0,0 +1,45 @@ +from typing import Optional +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class AddKeyword(BaseModelConfigurationResponse): + first_word: StrictStr = Field( + default=..., + description="Opt-in keyword like 'JOIN' if auto_update.to is a dedicated long/short number, " + "or unique brand keyword like 'Sinch' if it is a shared short code.", + ) + second_word: Optional[StrictStr] = Field( + default=None, + description="Opt-in keyword like 'JOIN' if auto_update.to is a shared short code.", + ) + + +class RemoveKeyword(BaseModelConfigurationResponse): + first_word: StrictStr = Field( + default=..., + description="Opt-out keyword like 'LEAVE' if auto_update.to is a dedicated long/short number, " + "or unique brand keyword like 'Sinch' if it is a shared short code.", + ) + second_word: Optional[StrictStr] = Field( + default=None, + description="Opt-out keyword like 'LEAVE' if auto_update.to is a shared short code.", + ) + + +class AutoUpdate(BaseModelConfigurationResponse): + to: StrictStr = Field( + default=..., + description="Short code or long number addressed in MO. " + "Must be a valid phone number or short code provisioned by your account manager.", + ) + add: Optional[AddKeyword] = Field( + default=None, + description="Keyword to be sent in MO to add MSISDN to the group.", + ) + remove: Optional[RemoveKeyword] = Field( + default=None, + description="Keyword to be sent in MO to remove MSISDN from the group.", + ) diff --git a/sinch/domains/sms/models/v1/shared/group_id_mixin.py b/sinch/domains/sms/models/v1/shared/group_id_mixin.py new file mode 100644 index 00000000..96e9e3b3 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/group_id_mixin.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field, StrictStr + + +class GroupIdMixin(BaseModel): + """Mixin that adds group_id field to request models.""" + + group_id: StrictStr = Field( + default=..., + description="ID of the group.", + ) diff --git a/sinch/domains/sms/models/v1/types/__init__.py b/sinch/domains/sms/models/v1/types/__init__.py index a52cfcc2..54f33b1f 100644 --- a/sinch/domains/sms/models/v1/types/__init__.py +++ b/sinch/domains/sms/models/v1/types/__init__.py @@ -1,3 +1,4 @@ +from sinch.domains.sms.models.v1.types.auto_update_dict import AutoUpdateDict from sinch.domains.sms.models.v1.types.delivery_receipt_status_code_type import ( DeliveryReceiptStatusCodeType, ) @@ -14,6 +15,7 @@ ) __all__ = [ + "AutoUpdateDict", "BatchResponse", "DeliveryReceiptStatusCodeType", "DeliveryReportType", diff --git a/sinch/domains/sms/models/v1/types/auto_update_dict.py b/sinch/domains/sms/models/v1/types/auto_update_dict.py new file mode 100644 index 00000000..f3f786f8 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/auto_update_dict.py @@ -0,0 +1,18 @@ +from typing import TypedDict +from typing_extensions import NotRequired + + +class AddKeywordDict(TypedDict): + first_word: str + second_word: NotRequired[str] + + +class RemoveKeywordDict(TypedDict): + first_word: str + second_word: NotRequired[str] + + +class AutoUpdateDict(TypedDict): + to: str + add: NotRequired[AddKeywordDict] + remove: NotRequired[RemoveKeywordDict] diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index c312c2de..967cef35 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -2,6 +2,7 @@ Batches, DeliveryReports, ) +from sinch.domains.sms.api.v1.groups_apis import Groups from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent @@ -16,6 +17,7 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + self.groups = Groups(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: """ diff --git a/tests/e2e/sms/features/steps/batches.steps.py b/tests/e2e/sms/features/steps/batches.steps.py index bf59f4a0..d51af0d2 100644 --- a/tests/e2e/sms/features/steps/batches.steps.py +++ b/tests/e2e/sms/features/steps/batches.steps.py @@ -1,46 +1,10 @@ from datetime import datetime, timezone -from behave import given, when, then +from behave import when, then from sinch.domains.sms.models.v1.types import BatchResponse from sinch.domains.sms.models.v1.response.dry_run_response import DryRunResponse from sinch.domains.sms.models.v1.shared.text_response import TextResponse -def _setup_sinch_client(context, use_service_plan_auth=False): - """Helper function to setup Sinch client""" - from sinch import SinchClient - - if use_service_plan_auth: - sinch = SinchClient( - service_plan_id='CappyPremiumPlan', - sms_api_token='HappyCappyToken', - ) - sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' - else: - sinch = SinchClient( - project_id='tinyfrog-jump-high-over-lilypadbasin', - key_id='keyId', - key_secret='keySecret', - ) - - sinch.configuration.auth_origin = 'http://localhost:3011' - sinch.configuration.sms_origin = 'http://localhost:3017' - - context.sinch = sinch - context.sms = sinch.sms - - -@given('the SMS service "Batches" is available') -def step_sms_service_batches_available(context): - """Ensures the Sinch client is initialized""" - _setup_sinch_client(context, use_service_plan_auth=False) - - -@given('the SMS service "Batches" is available and is configured for servicePlanId authentication') -def step_sms_service_batches_available_with_service_plan(context): - """Ensures the Sinch client is initialized with service_plan_id authentication""" - _setup_sinch_client(context, use_service_plan_auth=True) - - @when('I send a request to send a text message') def step_send_text_message(context): """Send a text message""" diff --git a/tests/e2e/sms/features/steps/common.steps.py b/tests/e2e/sms/features/steps/common.steps.py new file mode 100644 index 00000000..6fb073ff --- /dev/null +++ b/tests/e2e/sms/features/steps/common.steps.py @@ -0,0 +1,24 @@ +from behave import given +from sinch.domains.sms.sms import SMS + + +@given('the SMS service "{service_name}" is available') +def step_sms_service_available(context, service_name): + assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms + + +@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') +def step_sms_service_available_with_service_plan(context, service_name): + from sinch import SinchClient + + context.sinch = SinchClient( + service_plan_id='CappyPremiumPlan', + sms_api_token='HappyCappyToken', + ) + context.sinch.configuration.auth_origin = 'http://localhost:3011' + context.sinch.configuration.sms_origin = 'http://localhost:3017' + context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' + assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' + context.sms = context.sinch.sms diff --git a/tests/e2e/sms/features/steps/delivery_reports.steps.py b/tests/e2e/sms/features/steps/delivery_reports.steps.py index 5234723e..a192e971 100644 --- a/tests/e2e/sms/features/steps/delivery_reports.steps.py +++ b/tests/e2e/sms/features/steps/delivery_reports.steps.py @@ -1,32 +1,6 @@ from datetime import datetime, timezone -from behave import given, when, then +from behave import when, then from sinch.domains.sms.models.v1.response import BatchDeliveryReport, RecipientDeliveryReport -from sinch.domains.sms.sms import SMS - - -@given('the SMS service "{service_name}" is available') -def step_sms_service_available(context, service_name): - """Ensures the Sinch client is initialized""" - assert hasattr(context, 'sinch') and context.sinch, 'Sinch client was not initialized' - assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' - context.sms = context.sinch.sms - - -@given('the SMS service "{service_name}" is available and is configured for servicePlanId authentication') -def step_sms_service_available_with_service_plan(context, service_name): - """Ensures the Sinch client is initialized with service_plan_id authentication""" - from sinch import SinchClient - - # Create a new client with service_plan_id authentication - context.sinch = SinchClient( - service_plan_id='CappyPremiumPlan', - sms_api_token='HappyCappyToken', - ) - context.sinch.configuration.auth_origin = 'http://localhost:3011' - context.sinch.configuration.sms_origin = 'http://localhost:3017' - context.sinch.configuration.sms_origin_with_service_plan_id = 'http://localhost:3017' - assert isinstance(context.sinch.sms, SMS), 'SMS service is not available' - context.sms = context.sinch.sms @when('I send a request to retrieve a summary SMS delivery report') diff --git a/tests/e2e/sms/features/steps/groups.steps.py b/tests/e2e/sms/features/steps/groups.steps.py new file mode 100644 index 00000000..cd817f4b --- /dev/null +++ b/tests/e2e/sms/features/steps/groups.steps.py @@ -0,0 +1,173 @@ +from behave import when, then +from datetime import datetime, timezone + + +@when('I send a request to create an SMS group') +def step_create_sms_group(context): + context.response = context.sms.groups.create( + name='Group master', + members=['+12017778888', '+12018887777'], + child_groups=['01W4FFL35P4NC4K35SUBGROUP1'], + ) + +@when('I send a request to retrieve an SMS group') +def step_retrieve_sms_group(context): + context.response = context.sms.groups.get( + group_id='01W4FFL35P4NC4K35SMSGROUP1' + ) + + + +@then('the response contains the SMS group details') +def step_validate_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Group master' + assert data.size == 2 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + + + +@when('I send a request to list the existing SMS groups') +def step_list_existing_sms_groups(context): + context.response = context.sms.groups.list() + +@when('I send a request to list all the SMS groups') +def step_list_all_sms_groups(context): + response = context.sms.groups.list(page_size=2) + groups_list = [] + for group in response.iterator(): + groups_list.append(group) + context.groups_list = groups_list + + +@then('the response contains "{count}" SMS groups') +def step_validate_groups_count(context, count): + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + + +@when('I iterate manually over the SMS groups pages') +def step_iterate_manually_sms_groups(context): + context.list_response = context.sms.groups.list(page_size=2) + context.groups_list = [] + context.pages_iteration = 0 + reached_end_of_pages = False + + while not reached_end_of_pages: + context.groups_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_end_of_pages = True + + +@then('the SMS groups list contains "{count}" SMS groups') +def step_validate_groups_list_count(context, count): + expected_count = int(count) + assert len(context.groups_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.groups_list)}' + + +@then('the SMS groups iteration result contains the data from "{count}" pages') +def step_validate_groups_pages_count(context, count): + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' + + +@when('I send a request to update an SMS group') +def step_update_sms_group(context): + context.response = context.sms.groups.update( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + name='Updated group name', + add=['+12017771111', '+12017772222'], + remove=['+12017773333', '+12017774444'], + add_from_group='01W4FFL35P4NC4K35SMSGROUP2', + remove_from_group='01W4FFL35P4NC4K35SMSGROUP3', + ) + + +@then('the response contains the updated SMS group details') +def step_validate_updated_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Updated group name' + assert data.size == 6 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 9, 19, 58, 147000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + +@when('I send a request to update an SMS group to remove its name') +def step_update_sms_group_remove_name(context): + context.response = context.sms.groups.update( + group_id='01W4FFL35P4NC4K35SMSGROUP2', + name=None, + ) + + +@then('the response contains the updated SMS group details where the name has been removed') +def step_validate_updated_sms_group_name_removed(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP2' + assert data.name is None + assert data.size == 5 + assert data.created_at == datetime(2024, 6, 6, 12, 45, 18, 761000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 6, 6, 13, 12, 5, 137000, tzinfo=timezone.utc) + assert data.child_groups == [] + + +@when('I send a request to replace an SMS group') +def step_replace_sms_group(context): + context.response = context.sms.groups.replace( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + name='Replacement group', + members=['+12018881111', '+12018882222', '+12018883333'], + ) + + +@then('the response contains the replaced SMS group details') +def step_validate_replaced_sms_group_details(context): + from sinch.domains.sms.models.v1.response.group_response import GroupResponse + data: GroupResponse = context.response + assert data.id == '01W4FFL35P4NC4K35SMSGROUP1' + assert data.name == 'Replacement group' + assert data.size == 3 + assert data.created_at == datetime(2024, 6, 6, 8, 59, 22, 156000, tzinfo=timezone.utc) + assert data.modified_at == datetime(2024, 8, 21, 9, 39, 36, 679000, tzinfo=timezone.utc) + assert data.child_groups == ['01W4FFL35P4NC4K35SUBGROUP1'] + + +@when('I send a request to delete an SMS group') +def step_delete_sms_group(context): + context.response = context.sms.groups.delete( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + ) + + +@then('the delete SMS group response contains no data') +def step_validate_delete_sms_group_response(context): + assert context.response is None + + +@when('I send a request to list the members of an SMS group') +def step_list_sms_group_members(context): + context.response = context.sms.groups.list_members( + group_id='01W4FFL35P4NC4K35SMSGROUP1', + ) + + +@then('the response contains the phone numbers of the SMS group') +def step_validate_sms_group_members(context): + assert context.response.has_next_page is False + assert context.response.content() == ['12018881111', '12018882222', '12018883333'] diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py new file mode 100644 index 00000000..275abcdc --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_create_group_endpoint.py @@ -0,0 +1,124 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import CreateGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return GroupRequest(name="Test Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=201, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return CreateGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups" + ) + + +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, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + +def test_request_body_excludes_none_fields(endpoint): + """Test that None fields are excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + assert body["name"] == "Test Group" + assert "members" not in body + assert "child_groups" not in body + assert "auto_update" not in body + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = GroupRequest( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = CreateGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert body["name"] == "Test Group" + assert body["members"] == ["+46701234567", "+46709876543"] + assert body["child_groups"] == ["01FC66621VHDBN119Z8PMV1AHY"] + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" \ No newline at end of file diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py new file mode 100644 index 00000000..93016351 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_delete_group_endpoint.py @@ -0,0 +1,61 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException + +from sinch.domains.sms.api.v1.internal.groups_endpoints import DeleteGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=204, + body={}, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return DeleteGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_returns_none(endpoint, mock_response): + """Test that handle_response returns None for a successful delete.""" + result = endpoint.handle_response(mock_response) + assert result is None + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py new file mode 100644 index 00000000..0260abdb --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_get_group_endpoint.py @@ -0,0 +1,97 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import GetGroupEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +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, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py new file mode 100644 index 00000000..93002408 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_group_members_endpoint.py @@ -0,0 +1,66 @@ +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException + +from sinch.domains.sms.api.v1.internal.groups_endpoints import ListGroupMembersEndpoint +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +@pytest.fixture +def request_data(): + return GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body=["+46701234567", "+46709876543"], + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=404, + body={ + "code": 404, + "text": "Group not found", + "status": "NotFound", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListGroupMembersEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ/members" + ) + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Check if response is handled and mapped to ListGroupMembersResponse correctly.""" + from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + result = endpoint.handle_response(mock_response) + + assert isinstance(result, ListGroupMembersResponse) + assert result.members == ["+46701234567", "+46709876543"] + assert result.content == ["+46701234567", "+46709876543"] + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py new file mode 100644 index 00000000..8d9d38e2 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_list_groups_endpoint.py @@ -0,0 +1,114 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import ListGroupsEndpoint +from sinch.domains.sms.models.v1.internal.list_groups_request import ListGroupsRequest +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + + +@pytest.fixture +def request_data(): + return ListGroupsRequest(page=1, page_size=10) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 1, + "page": 0, + "page_size": 10, + "groups": [{ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListGroupsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups" + ) + + +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, ListGroupsResponse) + assert parsed_response.count == 1 + assert parsed_response.page == 0 + assert parsed_response.page_size == 10 + assert len(parsed_response.groups) == 1 + group = parsed_response.groups[0] + assert group.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert group.name == "Test Group" + assert group.size == 2 + assert group.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert group.auto_update.to == "+15551231234" + assert group.auto_update.add.first_word == "JOIN" + assert group.auto_update.remove.first_word == "LEAVE" + + assert group.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert group.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + + +def test_build_query_params(endpoint): + """Test that query params are built correctly from request data.""" + params = endpoint.build_query_params() + assert params == {"page": 1, "page_size": 10} + diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py new file mode 100644 index 00000000..f1ebba37 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_replace_group_endpoint.py @@ -0,0 +1,126 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import ReplaceGroupEndpoint +from sinch.domains.sms.models.v1.internal.replace_group_request import ReplaceGroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return ReplaceGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name="Test Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Test Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ReplaceGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +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, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Test Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + + assert parsed_response.created_at == datetime( + 2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc + ) + assert parsed_response.modified_at == datetime( + 2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc + ) + + +def test_handle_response_expects_sms_exception_on_error( + endpoint, mock_error_response +): + """ + Test that SmsException is raised when server returns an error. + """ + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + +def test_request_body_excludes_none_fields(endpoint): + """Test that None fields are excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + assert body["name"] == "Test Group" + assert "members" not in body + assert "child_groups" not in body + assert "auto_update" not in body + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = ReplaceGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = ReplaceGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert not body.get("group_id") + assert body["name"] == "Test Group" + assert body["members"] == ["+46701234567", "+46709876543"] + assert body["child_groups"] == ["01FC66621VHDBN119Z8PMV1AHY"] + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" \ No newline at end of file diff --git a/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py new file mode 100644 index 00000000..fa340031 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/groups/test_update_group_endpoint.py @@ -0,0 +1,124 @@ +import json +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from datetime import datetime, timezone + +from sinch.domains.sms.api.v1.internal.groups_endpoints import UpdateGroupEndpoint +from sinch.domains.sms.models.v1.internal.update_group_request import UpdateGroupRequest +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +@pytest.fixture +def request_data(): + return UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name="Updated Group") + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "name": "Updated Group", + "size": 2, + "created_at": "2024-06-06T09:22:14.304Z", + "modified_at": "2024-06-06T09:22:48.054Z", + "child_groups": ["01FC66621VHDBN119Z8PMV1AHY"], + "auto_update": { + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def mock_error_response(): + return HTTPResponse( + status_code=400, + body={ + "code": 400, + "text": "Bad Request", + "status": "BadRequest", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return UpdateGroupEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + """Test that the URL is built correctly.""" + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/groups/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +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, GroupResponse) + assert parsed_response.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed_response.name == "Updated Group" + assert parsed_response.size == 2 + assert parsed_response.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert parsed_response.auto_update.to == "+15551231234" + assert parsed_response.auto_update.add.first_word == "JOIN" + assert parsed_response.auto_update.remove.first_word == "LEAVE" + assert parsed_response.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert parsed_response.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + + +def test_handle_response_expects_sms_exception_on_error(endpoint, mock_error_response): + """Test that SmsException is raised when server returns an error.""" + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(mock_error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.http_response.status_code == 400 + + +def test_request_body_not_excludes_none_fields(endpoint): + """Test that None fields are not excluded from the serialized request body.""" + body = json.loads(endpoint.request_body()) + + assert body["name"] == "Updated Group" + assert "group_id" not in body + assert body["add"] is None + assert body["remove"] is None + assert body["add_from_group"] is None + assert body["remove_from_group"] is None + assert body["auto_update"] is None + + +def test_request_body_expects_correct_serialization(): + """Test that all fields serialize correctly to the request body.""" + request_data = UpdateGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + endpoint = UpdateGroupEndpoint("test_project_id", request_data) + body = json.loads(endpoint.request_body()) + + assert "group_id" not in body + assert body["name"] == "Updated Group" + assert body["add"] == ["+46701234567", "+46709876543"] + assert body["remove"] == ["+46701111111"] + assert body["add_from_group"] == "01FC66621VHDBN119Z8PMV1AHY" + assert body["remove_from_group"] == "01FC66621VHDBN119Z8PMV1AHZ" + assert body["auto_update"]["to"] == "+15551231234" + assert body["auto_update"]["add"]["first_word"] == "JOIN" + assert body["auto_update"]["remove"]["first_word"] == "LEAVE" diff --git a/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py new file mode 100644 index 00000000..724b5bb0 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_group_id_request_model.py @@ -0,0 +1,31 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.group_id_request import GroupIdRequest + + +def test_group_id_request_expects_valid_group_id(): + """Test that the model correctly parses a valid group_id.""" + request = GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert request.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_group_id_request_expects_group_id_as_string(): + """Test that group_id must be a string.""" + with pytest.raises(ValidationError): + GroupIdRequest(group_id=12345) + + with pytest.raises(ValidationError): + GroupIdRequest(group_id=None) + + +def test_group_id_request_expects_model_dump(): + """Test that model_dump correctly serializes the request.""" + request = GroupIdRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + dumped = request.model_dump(by_alias=True) + assert dumped["group_id"] == "01FC66621XXXXX119Z8PMV1QPQ" + + dumped_no_alias = request.model_dump(by_alias=False) + assert dumped_no_alias["group_id"] == "01FC66621XXXXX119Z8PMV1QPQ" diff --git a/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py new file mode 100644 index 00000000..125cf593 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_group_request_model.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.group_request import GroupRequest + + +def test_group_request_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = GroupRequest() + + assert model.name is None + assert model.members is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = GroupRequest( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.name == "Test Group" + assert model.members == ["+46701234567", "+46709876543"] + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_group_request_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + GroupRequest(name=123) + + +def test_group_request_expects_auto_update_nested_parsing(): + """Test that auto_update parses nested add and remove keywords correctly.""" + model = GroupRequest( + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN", "second_word": "NOW"}, + "remove": {"first_word": "LEAVE"}, + } + ) + + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.add.second_word == "NOW" + assert model.auto_update.remove.first_word == "LEAVE" + assert model.auto_update.remove.second_word is None diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py new file mode 100644 index 00000000..0d42dcfc --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_groups_request_model.py @@ -0,0 +1,29 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.list_groups_request import ListGroupsRequest + + +def test_list_groups_request_expects_defaults(): + """Test that all optional fields default to None.""" + model = ListGroupsRequest() + + assert model.page is None + assert model.page_size is None + + +def test_list_groups_request_expects_parsed_input(): + """Test that the model correctly parses page and page_size.""" + model = ListGroupsRequest(page=1, page_size=10) + + assert model.page == 1 + assert model.page_size == 10 + + +def test_list_groups_request_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + ListGroupsRequest(page="one") + + with pytest.raises(ValidationError): + ListGroupsRequest(page_size="ten") diff --git a/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py new file mode 100644 index 00000000..76723f8a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_replace_group_request_model.py @@ -0,0 +1,50 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.replace_group_request import ReplaceGroupRequest + + +def test_replace_group_request_expects_required_group_id(): + """Test that group_id is required.""" + with pytest.raises(ValidationError): + ReplaceGroupRequest() + + +def test_replace_group_request_expects_optional_fields_default_to_none(): + """Test that all optional fields default to None when only group_id is provided.""" + model = ReplaceGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name is None + assert model.members is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_replace_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = ReplaceGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Replaced Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Replaced Group" + assert model.members == ["+46701234567", "+46709876543"] + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_replace_group_request_expects_group_id_as_string(): + """Test that group_id must be a string.""" + with pytest.raises(ValidationError): + ReplaceGroupRequest(group_id=123) diff --git a/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py new file mode 100644 index 00000000..10826d5d --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_update_group_request_model.py @@ -0,0 +1,56 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.internal.update_group_request import UpdateGroupRequest + + +def test_update_group_request_expects_required_group_id(): + """Test that group_id is required.""" + with pytest.raises(ValidationError): + UpdateGroupRequest() + + +def test_update_group_request_expects_optional_fields_default_to_none(): + """Test that all optional fields default to None when only group_id is provided.""" + model = UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.add is None + assert model.remove is None + assert model.name is None + assert model.add_from_group is None + assert model.remove_from_group is None + assert model.auto_update is None + + +def test_update_group_request_expects_parsed_input(): + """Test that the model correctly parses a full valid input.""" + model = UpdateGroupRequest( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Updated Group" + assert model.add == ["+46701234567", "+46709876543"] + assert model.remove == ["+46701111111"] + assert model.add_from_group == "01FC66621VHDBN119Z8PMV1AHY" + assert model.remove_from_group == "01FC66621VHDBN119Z8PMV1AHZ" + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_update_group_request_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + UpdateGroupRequest(group_id="01FC66621XXXXX119Z8PMV1QPQ", name=123) diff --git a/tests/unit/domains/sms/v1/models/response/test_group_response_model.py b/tests/unit/domains/sms/v1/models/response/test_group_response_model.py new file mode 100644 index 00000000..02b07392 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_group_response_model.py @@ -0,0 +1,81 @@ +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.group_response import GroupResponse + + +def test_group_response_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = GroupResponse() + + assert model.id is None + assert model.name is None + assert model.size is None + assert model.created_at is None + assert model.modified_at is None + assert model.child_groups is None + assert model.auto_update is None + + +def test_group_response_expects_valid_input(): + """Test that the model correctly parses a full valid input.""" + model = GroupResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + name="Test Group", + size=2, + created_at="2024-06-06T09:22:14.304Z", + modified_at="2024-06-06T09:22:48.054Z", + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={ + "to": "+15551231234", + "add": {"first_word": "JOIN"}, + "remove": {"first_word": "LEAVE"}, + }, + ) + + assert model.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.name == "Test Group" + assert model.size == 2 + assert model.child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add.first_word == "JOIN" + assert model.auto_update.remove.first_word == "LEAVE" + + +def test_group_response_expects_datetime_parsing(): + """Test that ISO 8601 timestamp strings are parsed to datetime objects.""" + model = GroupResponse( + created_at="2024-06-06T09:22:14.304Z", + modified_at="2024-06-06T09:22:48.054Z", + ) + + assert model.created_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert model.modified_at == datetime(2024, 6, 6, 9, 22, 48, 54000, tzinfo=timezone.utc) + + +def test_group_response_expects_auto_update_without_keywords(): + """Test that auto_update parses correctly when add and remove are omitted.""" + model = GroupResponse( + auto_update={"to": "+15551231234"}, + ) + + assert model.auto_update.to == "+15551231234" + assert model.auto_update.add is None + assert model.auto_update.remove is None + + +def test_group_response_expects_strict_str_rejects_int(): + """Test that StrictStr fields reject integer values.""" + with pytest.raises(ValidationError): + GroupResponse(id=123) + + with pytest.raises(ValidationError): + GroupResponse(name=456) + + +def test_group_response_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + GroupResponse(size="two") diff --git a/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py b/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py new file mode 100644 index 00000000..59f7562b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_list_group_members_response_model.py @@ -0,0 +1,41 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + +def test_list_group_members_response_expects_valid_input(): + """Test that the model correctly parses a list of MSISDNs.""" + model = ListGroupMembersResponse(members=["+46701234567", "+46709876543"]) + + assert model.members == ["+46701234567", "+46709876543"] + + +def test_list_group_members_response_expects_content_returns_members(): + """Test that content property returns the members list.""" + model = ListGroupMembersResponse(members=["+46701234567"]) + + assert model.content == model.members + + +def test_list_group_members_response_expects_empty_members_list(): + """Test that an empty members list is handled correctly.""" + model = ListGroupMembersResponse(members=[]) + + assert model.members == [] + assert model.content == [] + + +def test_list_group_members_response_expects_no_pagination_fields(): + """Test that count, page, page_size are absent so SMSPaginator sets has_next_page=False.""" + model = ListGroupMembersResponse(members=["+46701234567"]) + + assert getattr(model, "count", None) is None + assert getattr(model, "page", None) is None + assert getattr(model, "page_size", None) is None + + +def test_list_group_members_response_expects_strict_str_rejects_non_string(): + """Test that non-string members are rejected.""" + with pytest.raises(ValidationError): + ListGroupMembersResponse(members=[123]) diff --git a/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py b/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py new file mode 100644 index 00000000..929c5824 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_list_groups_response_model.py @@ -0,0 +1,71 @@ +import pytest +from pydantic import ValidationError + +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + + +def test_list_groups_response_expects_all_defaults_to_none(): + """Test that all optional fields default to None.""" + model = ListGroupsResponse() + + assert model.count is None + assert model.page is None + assert model.page_size is None + assert model.groups is None + + +def test_list_groups_response_expects_valid_input(): + """Test that the model correctly parses a full valid input.""" + model = ListGroupsResponse( + count=1, + page=0, + page_size=10, + groups=[{"id": "01FC66621XXXXX119Z8PMV1QPQ", "name": "Test Group", "size": 2}], + ) + + assert model.count == 1 + assert model.page == 0 + assert model.page_size == 10 + assert len(model.groups) == 1 + assert isinstance(model.groups[0], GroupResponse) + assert model.groups[0].id == "01FC66621XXXXX119Z8PMV1QPQ" + assert model.groups[0].name == "Test Group" + assert model.groups[0].size == 2 + + +def test_list_groups_response_expects_empty_groups_list(): + """Test that an empty groups list is handled correctly.""" + model = ListGroupsResponse(count=0, page=0, page_size=10, groups=[]) + + assert model.groups == [] + assert model.count == 0 + + +def test_list_groups_response_expects_content_returns_groups(): + """Test that content property returns the groups list when populated.""" + model = ListGroupsResponse( + groups=[{"id": "01FC66621XXXXX119Z8PMV1QPQ"}], + ) + + assert model.content == model.groups + assert len(model.content) == 1 + + +def test_list_groups_response_expects_content_returns_empty_list_when_no_groups(): + """Test that content property returns [] when groups is None.""" + model = ListGroupsResponse() + + assert model.content == [] + + +def test_list_groups_response_expects_strict_int_rejects_str(): + """Test that StrictInt fields reject string values.""" + with pytest.raises(ValidationError): + ListGroupsResponse(count="one") + + with pytest.raises(ValidationError): + ListGroupsResponse(page="zero") + + with pytest.raises(ValidationError): + ListGroupsResponse(page_size="ten") diff --git a/tests/unit/domains/sms/v1/test_groups.py b/tests/unit/domains/sms/v1/test_groups.py new file mode 100644 index 00000000..569d8cd1 --- /dev/null +++ b/tests/unit/domains/sms/v1/test_groups.py @@ -0,0 +1,215 @@ +import pytest +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.groups_apis import Groups +from sinch.domains.sms.api.v1.internal.groups_endpoints import CreateGroupEndpoint, DeleteGroupEndpoint, GetGroupEndpoint, ListGroupMembersEndpoint, ListGroupsEndpoint, ReplaceGroupEndpoint, UpdateGroupEndpoint +from sinch.domains.sms.models.v1.response.group_response import GroupResponse +from sinch.domains.sms.models.v1.response.list_groups_response import ListGroupsResponse + +@pytest.fixture +def mock_group_response(): + """Sample GroupResponse for testing.""" + return GroupResponse( + id="01FC66621XXXXX119Z8PMV1QPQ", + ) + + +def test_groups_create_correct_request( + mock_sinch_client_sms, mocker, mock_group_response +): + """Test that create sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(CreateGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.create( + name="Test Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].name == "Test Group" + assert kwargs["request_data"].members == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_list_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and handles the response properly.""" + + mock_list_response = ListGroupsResponse(count=0, page=1, page_size=0, groups=[]) + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_list_response + + spy_endpoint = mocker.spy(ListGroupsEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.list( + page=0, + page_size=10 + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 10 + + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_list_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_get_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that get sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(GetGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.get(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_replace_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that replace sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(ReplaceGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.replace( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Replaced Group", + members=["+46701234567", "+46709876543"], + child_groups=["01FC66621VHDBN119Z8PMV1AHY"], + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].name == "Replaced Group" + assert kwargs["request_data"].members == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].child_groups == ["01FC66621VHDBN119Z8PMV1AHY"] + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_update_correct_request(mock_sinch_client_sms, mocker, mock_group_response): + """Test that update sends the correct request and handles the response properly.""" + + mock_sinch_client_sms.configuration.transport.request.return_value = mock_group_response + + spy_endpoint = mocker.spy(UpdateGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + + response = groups.update( + group_id="01FC66621XXXXX119Z8PMV1QPQ", + name="Updated Group", + add=["+46701234567", "+46709876543"], + remove=["+46701111111"], + add_from_group="01FC66621VHDBN119Z8PMV1AHY", + remove_from_group="01FC66621VHDBN119Z8PMV1AHZ", + auto_update={"to": "+15551231234", "add": {"first_word": "JOIN"}, "remove": {"first_word": "LEAVE"}}, + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + assert kwargs["request_data"].name == "Updated Group" + assert kwargs["request_data"].add == ["+46701234567", "+46709876543"] + assert kwargs["request_data"].remove == ["+46701111111"] + assert kwargs["request_data"].add_from_group == "01FC66621VHDBN119Z8PMV1AHY" + assert kwargs["request_data"].remove_from_group == "01FC66621VHDBN119Z8PMV1AHZ" + assert kwargs["request_data"].auto_update.to == "+15551231234" + assert kwargs["request_data"].auto_update.add.first_word == "JOIN" + assert kwargs["request_data"].auto_update.remove.first_word == "LEAVE" + + assert isinstance(response, GroupResponse) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_delete_correct_request(mock_sinch_client_sms, mocker): + """Test that delete sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = None + + spy_endpoint = mocker.spy(DeleteGroupEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.delete(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert response is None + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_groups_list_members_correct_request(mock_sinch_client_sms, mocker): + """Test that list_members sends the correct request and returns an SMSPaginator.""" + from sinch.domains.sms.models.v1.response.list_group_members_response import ListGroupMembersResponse + + mock_members_response = ListGroupMembersResponse(members=["+46701234567", "+46709876543"]) + mock_sinch_client_sms.configuration.transport.request.return_value = mock_members_response + + spy_endpoint = mocker.spy(ListGroupMembersEndpoint, "__init__") + + groups = Groups(mock_sinch_client_sms) + response = groups.list_members(group_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].group_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.has_next_page is False + assert response.result == mock_members_response + assert response.content() == ["+46701234567", "+46709876543"] + mock_sinch_client_sms.configuration.transport.request.assert_called_once() From 11bc931334b68368a7ef25595174bf9a577010f4 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 8 Jun 2026 16:04:01 +0200 Subject: [PATCH 08/12] Feature/devexp 808 sms inbounds (#151) * feat(sms) implement get and list methods for inbounds * feat(sms) fix ruff imports not used * test(sms) add inbounds unit and e2e tests * feat(sms) add inbounds in __init__ * feat(sms) update changelog and migration guide for SMS Inbounds API * feat(sms) change inbound_messages for inbounds --- .github/workflows/ci.yml | 8 +- CHANGELOG.md | 2 + MIGRATION_GUIDE.md | 39 +++++- examples/snippets/sms/inbounds/get/snippet.py | 27 ++++ .../snippets/sms/inbounds/list/snippet.py | 27 ++++ sinch/domains/sms/api/v1/__init__.py | 2 + sinch/domains/sms/api/v1/inbounds_apis.py | 94 +++++++++++++ .../sms/api/v1/internal/inbounds_endpoints.py | 62 +++++++++ .../models/v1/internal/inbound_id_request.py | 12 ++ .../v1/internal/list_inbounds_request.py | 15 +++ .../v1/internal/list_inbounds_response.py | 31 +++++ .../domains/sms/models/v1/shared/__init__.py | 14 ++ .../sms/models/v1/shared/base_mo_message.py | 37 +++++ .../sms/models/v1/shared/mo_binary_message.py | 15 +++ .../sms/models/v1/shared/mo_media_body.py | 20 +++ .../sms/models/v1/shared/mo_media_item.py | 18 +++ .../sms/models/v1/shared/mo_media_message.py | 14 ++ .../sms/models/v1/shared/mo_text_message.py | 13 ++ .../sms/models/v1/types/inbound_message.py | 11 ++ .../sms/sinch_events/v1/events/__init__.py | 15 +-- .../sinch_events/v1/events/sms_sinch_event.py | 104 ++------------- .../sms/sinch_events/v1/internal/__init__.py | 5 - .../sinch_events/v1/internal/sinch_event.py | 7 - .../sms/sinch_events/v1/sms_sinch_event.py | 25 ++-- sinch/domains/sms/sms.py | 2 + .../e2e/sms/features/steps/inbounds.steps.py | 94 +++++++++++++ .../e2e/sms/features/steps/webhooks.steps.py | 6 +- .../inbounds/test_get_inbound_endpoint.py | 89 +++++++++++++ .../inbounds/test_list_inbounds_endpoint.py | 123 +++++++++++++++++ .../internal/test_inbound_id_request_model.py | 18 +++ .../test_list_inbounds_request_model.py | 36 +++++ .../test_list_inbounds_response_model.py | 64 +++++++++ .../response/test_mo_binary_message_model.py | 90 +++++++++++++ .../response/test_mo_media_message_model.py | 95 +++++++++++++ .../response/test_mo_text_message_model.py | 94 +++++++++++++ tests/unit/domains/sms/v1/test_inbounds.py | 126 ++++++++++++++++++ 36 files changed, 1303 insertions(+), 151 deletions(-) create mode 100644 examples/snippets/sms/inbounds/get/snippet.py create mode 100644 examples/snippets/sms/inbounds/list/snippet.py create mode 100644 sinch/domains/sms/api/v1/inbounds_apis.py create mode 100644 sinch/domains/sms/api/v1/internal/inbounds_endpoints.py create mode 100644 sinch/domains/sms/models/v1/internal/inbound_id_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_inbounds_request.py create mode 100644 sinch/domains/sms/models/v1/internal/list_inbounds_response.py create mode 100644 sinch/domains/sms/models/v1/shared/base_mo_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_binary_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_body.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_item.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_media_message.py create mode 100644 sinch/domains/sms/models/v1/shared/mo_text_message.py create mode 100644 sinch/domains/sms/models/v1/types/inbound_message.py delete mode 100644 sinch/domains/sms/sinch_events/v1/internal/__init__.py delete mode 100644 sinch/domains/sms/sinch_events/v1/internal/sinch_event.py create mode 100644 tests/e2e/sms/features/steps/inbounds.steps.py create mode 100644 tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py create mode 100644 tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py create mode 100644 tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py create mode 100644 tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py create mode 100644 tests/unit/domains/sms/v1/test_inbounds.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f97feb3c..cee9dc23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,13 +84,7 @@ jobs: cp sinch-sdk-mockserver/features/numbers/callback-configuration.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/numbers.feature ./tests/e2e/numbers/features/ cp sinch-sdk-mockserver/features/numbers/webhooks.feature ./tests/e2e/numbers/features/ - cp sinch-sdk-mockserver/features/sms/delivery-reports.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/groups.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/groups_servicePlanId.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/delivery-reports_servicePlanId.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/batches.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/batches_servicePlanId.feature ./tests/e2e/sms/features/ - cp sinch-sdk-mockserver/features/sms/webhooks.feature ./tests/e2e/sms/features/ + cp sinch-sdk-mockserver/features/sms/* ./tests/e2e/sms/features/ cp sinch-sdk-mockserver/features/number-lookup/lookups.feature ./tests/e2e/number-lookup/features/ cp sinch-sdk-mockserver/features/conversation/messages.feature ./tests/e2e/conversation/features/ cp sinch-sdk-mockserver/features/conversation/webhooks-events.feature ./tests/e2e/conversation/features/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e0d748a..721b39d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SMS - **[feature]** SMS Groups API: `create`, `list`, `get`, `update`, `replace`, `delete`, and `list_members` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#groups-api)). +- **[feature]** SMS Inbounds API: `get` and `list` operations, with full model, endpoint, and unit test coverage (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#inbounds-api)). +- **[design]** SMS Sinch Events inbound payload models unified with the Inbounds API: `MOTextSinchEvent`, `MOBinarySinchEvent`, `MOMediaSinchEvent`, `MediaBody`, and `MediaItem` removed from `sinch_events`; use `InboundMessage` (and its variants) from `sinch.domains.sms.models.v1.types` instead (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md#sms-sinch-events)). --- diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index b14c6535..326cf343 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -205,9 +205,7 @@ The Conversation HTTP API still expects the JSON field **`callback_url`**. In V2 #### Replacement APIs -The SMS domain API access remains the same: `sinch.sms.batches` and `sinch.sms.delivery_reports`. However, the underlying models and method signatures have changed. - -Note that `sinch.sms.inbounds` is not supported yet and will be available in a future minor version. `sinch.sms.groups` is now available — see [Groups API](#groups-api) below. +The SMS domain API access remains the same: `sinch.sms.batches`, `sinch.sms.delivery_reports`, `sinch.sms.inbounds` and `sinch.sms.groups`. However, the underlying models and method signatures have changed. See the sections below for the full list of changes: [Batches](#batches-api), [Delivery Reports](#delivery-reports-api), [Groups](#groups-api), [Inbounds](#inbounds-api). ##### Batches API @@ -232,8 +230,6 @@ Note that `sinch.sms.inbounds` is not supported yet and will be available in a f ##### Groups API -> **Added in v2.1.0.** `sinch.sms.groups` is now fully supported. - ###### Replacement models | Old class | New class | @@ -265,6 +261,38 @@ Note that `sinch.sms.inbounds` is not supported yet and will be available in a f | `delete()` with `DeleteSMSGroupRequest` | `delete()` with `group_id: str` parameter | | `get_phone_numbers()` / phone number listing | `list_members()` with `group_id: str`. Returns **`Paginator[str]`** | +##### Inbounds API + +###### Replacement models + +| Old class | New class | +|-----------|-----------| +| `sinch.domains.sms.models.inbounds.requests.ListSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.ListInboundsRequest`](sinch/domains/sms/models/v1/internal/list_inbounds_request.py) | +| `sinch.domains.sms.models.inbounds.requests.GetSMSInboundMessageRequest` | [`sinch.domains.sms.models.v1.internal.InboundIdRequest`](sinch/domains/sms/models/v1/internal/inbound_id_request.py) | +| `sinch.domains.sms.models.inbounds.responses.SinchListInboundMessagesResponse` | [`sinch.domains.sms.models.v1.internal.ListInboundsResponse`](sinch/domains/sms/models/v1/internal/list_inbounds_response.py) | +| `sinch.domains.sms.models.inbounds.responses.GetInboundMessagesResponse` | [`sinch.domains.sms.models.v1.types.InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (Union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`) | + +###### Replacement APIs + +| Old method | New method in `sms.inbounds` | +|------------|------------------------------| +| `list()` with `ListSMSInboundMessageRequest` | `list()` with individual parameters: `page`, `page_size`, `to`, `start_date`, `end_date`, `client_reference`. Returns **`Paginator[InboundMessage]`** | +| `get()` with `GetSMSInboundMessageRequest` | `get()` with `inbound_id: str` parameter | + +##### SMS Sinch Events + +The inbound payload models in `sinch_events` have been unified with the Inbounds API models. The following classes have been removed: + +| Removed class | Replacement | +|---------------|-------------| +| `sinch.domains.sms.sinch_events.v1.events.MOTextSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOTextMessage`](sinch/domains/sms/models/v1/shared/mo_text_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOBinarySinchEvent` | [`sinch.domains.sms.models.v1.shared.MOBinaryMessage`](sinch/domains/sms/models/v1/shared/mo_binary_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MOMediaSinchEvent` | [`sinch.domains.sms.models.v1.shared.MOMediaMessage`](sinch/domains/sms/models/v1/shared/mo_media_message.py) | +| `sinch.domains.sms.sinch_events.v1.events.MediaBody` | Embedded in `MOMediaMessage` | +| `sinch.domains.sms.sinch_events.v1.events.MediaItem` | Embedded in `MOMediaMessage` | + +`IncomingSMSSinchEvent` is now a type alias for [`InboundMessage`](sinch/domains/sms/models/v1/types/inbound_message.py) (discriminated union of `MOTextMessage`, `MOBinaryMessage`, `MOMediaMessage`). Code that previously type-checked against `MOTextSinchEvent` and siblings should switch to their `MO*Message` equivalents. + --- ### [`Numbers` (Virtual Numbers)](https://github.com/sinch/sinch-sdk-python/tree/main/sinch/domains/numbers) @@ -279,6 +307,7 @@ Note that `sinch.sms.inbounds` is not supported yet and will be available in a f ##### Replacement models + | Old class | New class | |-----------|-----------| | `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` | diff --git a/examples/snippets/sms/inbounds/get/snippet.py b/examples/snippets/sms/inbounds/get/snippet.py new file mode 100644 index 00000000..126c4a3a --- /dev/null +++ b/examples/snippets/sms/inbounds/get/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + +# The ID of the inbound message to retrieve +inbound_id = "INBOUND_ID" + +response = sinch_client.sms.inbounds.get(inbound_id=inbound_id) + +print(f"Inbound message:\n{response}") diff --git a/examples/snippets/sms/inbounds/list/snippet.py b/examples/snippets/sms/inbounds/list/snippet.py new file mode 100644 index 00000000..b5042671 --- /dev/null +++ b/examples/snippets/sms/inbounds/list/snippet.py @@ -0,0 +1,27 @@ +""" +Sinch Python Snippet + +This snippet is available at https://github.com/sinch/sinch-sdk-python/tree/main/examples/snippets +""" + +import os + +from dotenv import load_dotenv + +from sinch import SinchClient + +load_dotenv() + +sinch_client = SinchClient( + project_id=os.environ.get("SINCH_PROJECT_ID") or "MY_PROJECT_ID", + key_id=os.environ.get("SINCH_KEY_ID") or "MY_KEY_ID", + key_secret=os.environ.get("SINCH_KEY_SECRET") or "MY_KEY_SECRET", + sms_region=os.environ.get("SINCH_SMS_REGION") or "MY_SMS_REGION" +) + + +inbound_messages = sinch_client.sms.inbounds.list() + +print("List of inbound messages:\n") +for message in inbound_messages.iterator(): + print(message) \ No newline at end of file diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py index b322cc84..fa632f0b 100644 --- a/sinch/domains/sms/api/v1/__init__.py +++ b/sinch/domains/sms/api/v1/__init__.py @@ -1,9 +1,11 @@ from sinch.domains.sms.api.v1.batches_apis import Batches from sinch.domains.sms.api.v1.delivery_reports_apis import DeliveryReports +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds from sinch.domains.sms.api.v1.groups_apis import Groups __all__ = [ "Batches", "DeliveryReports", + "Inbounds", "Groups", ] diff --git a/sinch/domains/sms/api/v1/inbounds_apis.py b/sinch/domains/sms/api/v1/inbounds_apis.py new file mode 100644 index 00000000..0d141ed6 --- /dev/null +++ b/sinch/domains/sms/api/v1/inbounds_apis.py @@ -0,0 +1,94 @@ +from datetime import datetime +from typing import List, Optional + +from sinch.core.pagination import Paginator, SMSPaginator +from sinch.domains.sms.api.v1.base.base_sms import BaseSms +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class Inbounds(BaseSms): + def get(self, inbound_id: str, **kwargs) -> InboundMessage: + """ + This operation retrieves a specific inbound message using the provided inbound ID. + + :param inbound_id: The inbound ID found when listing inbound messages. (required) + :type inbound_id: str + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: InboundMessage + :rtype: InboundMessage + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + request_data = InboundIdRequest(inbound_id=inbound_id, **kwargs) + return self._request(GetInboundEndpoint, request_data) + + def list( + self, + page: Optional[int] = None, + page_size: Optional[int] = None, + to: Optional[List[str]] = None, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + client_reference: Optional[str] = None, + **kwargs, + ) -> Paginator[InboundMessage]: + """ + With the list operation, + you can list all inbound messages that you have received. This operation supports pagination. Inbounds are returned in reverse chronological order. + + :param page: The page number starting from 0. (optional) + :type page: Optional[int] + :param page_size: Determines the size of a page (optional) + :type page_size: Optional[int] + :param to: Only list messages sent to this destination. Multiple phone numbers formatted as either + [E.164](https://community.sinch.com/t5/Glossary/E-164/ta-p/7537) or short codes can be comma separated. + (optional) + :type to: Optional[List[str]] + :param start_date: Only list messages received at or after this date/time. Formatted as + [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. Default: Now-24 + (optional) + :type start_date: Optional[datetime] + + :param end_date: Only list messages received before this date/time. Formatted as [ISO-8601](https://en.wikipedia.org/wiki/ISO_8601): `YYYY-MM-DDThh:mm:ss.SSSZ`. (optional) + :type end_date: Optional[datetime] + :param client_reference: Using a client reference in inbound messages requires additional setup on your account. + Contact your [account manager](https://dashboard.sinch.com/settings/account-details) to enable this feature. + Only list inbound messages that are in response to messages with a previously provided client reference. + (optional) + :type client_reference: Optional[str] + :param **kwargs: Additional parameters for the request. + :type **kwargs: dict + + :returns: Paginator[InboundMessage] + :rtype: Paginator[InboundMessage] + + For detailed documentation, visit https://developers.sinch.com/docs/sms/. + """ + endpoint = ListInboundsEndpoint( + project_id=self._get_path_identifier(), + request_data=ListInboundsRequest( + page=page, + page_size=page_size, + to=to, + start_date=start_date, + end_date=end_date, + client_reference=client_reference, + **kwargs, + ), + ) + + endpoint.set_authentication_method(self._sinch) + + return SMSPaginator(sinch=self._sinch, endpoint=endpoint) diff --git a/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py new file mode 100644 index 00000000..f2037bb3 --- /dev/null +++ b/sinch/domains/sms/api/v1/internal/inbounds_endpoints.py @@ -0,0 +1,62 @@ +from sinch.core.enums import HTTPAuthentication, HTTPMethods +from sinch.core.models.http_response import HTTPResponse +from sinch.core.models.utils import model_dump_for_query_params +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.base.sms_endpoint import SmsEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import ( + InboundIdRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ( + ListInboundsRequest, +) +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ( + ListInboundsResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class GetInboundEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds/{inbound_id}" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: InboundIdRequest): + super(GetInboundEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def handle_response(self, response: HTTPResponse) -> InboundMessage: + try: + super(GetInboundEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, InboundMessage) + + +class ListInboundsEndpoint(SmsEndpoint): + ENDPOINT_URL = "{origin}/xms/v1/{project_id}/inbounds" + HTTP_METHOD = HTTPMethods.GET.value + HTTP_AUTHENTICATION = HTTPAuthentication.OAUTH.value + + def __init__(self, project_id: str, request_data: ListInboundsRequest): + super(ListInboundsEndpoint, self).__init__(project_id, request_data) + self.project_id = project_id + self.request_data = request_data + + def build_query_params(self) -> dict: + return model_dump_for_query_params(self.request_data) + + def handle_response(self, response: HTTPResponse) -> ListInboundsResponse: + try: + super(ListInboundsEndpoint, self).handle_response(response) + except SmsException as e: + raise SmsException( + message=e.args[0], + response=e.http_response, + is_from_server=e.is_from_server, + ) + return self.process_response_model(response.body, ListInboundsResponse) diff --git a/sinch/domains/sms/models/v1/internal/inbound_id_request.py b/sinch/domains/sms/models/v1/internal/inbound_id_request.py new file mode 100644 index 00000000..050c86de --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/inbound_id_request.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class InboundIdRequest(BaseModelConfigurationRequest): + inbound_id: str = Field( + default=..., + description="The unique identifier of the inbound message.", + ) diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_request.py b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py new file mode 100644 index 00000000..409f3062 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_request.py @@ -0,0 +1,15 @@ +from typing import Optional +from datetime import datetime +from pydantic import StrictInt, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationRequest, +) + + +class ListInboundsRequest(BaseModelConfigurationRequest): + page: Optional[StrictInt] = None + page_size: Optional[StrictInt] = None + to: Optional[conlist(StrictStr)] = None + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + client_reference: Optional[StrictStr] = None diff --git a/sinch/domains/sms/models/v1/internal/list_inbounds_response.py b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py new file mode 100644 index 00000000..61bb3bc1 --- /dev/null +++ b/sinch/domains/sms/models/v1/internal/list_inbounds_response.py @@ -0,0 +1,31 @@ +from typing import Optional + +from pydantic import Field, StrictInt, conlist + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage + + +class ListInboundsResponse(BaseModelConfigurationResponse): + count: Optional[StrictInt] = Field( + default=None, + description="The total number of inbounds matching the given filters", + ) + page: Optional[StrictInt] = Field( + default=None, description="The requested page." + ) + inbounds: Optional[conlist(InboundMessage)] = Field( + default=None, + description="The page of inbounds matching the given filters.", + ) + page_size: Optional[StrictInt] = Field( + default=None, + description="The number of inbounds returned in this request.", + ) + + @property + def content(self): + """Returns the content of the inbounds list.""" + return self.inbounds or [] diff --git a/sinch/domains/sms/models/v1/shared/__init__.py b/sinch/domains/sms/models/v1/shared/__init__.py index 67fcc08b..e5cc9163 100644 --- a/sinch/domains/sms/models/v1/shared/__init__.py +++ b/sinch/domains/sms/models/v1/shared/__init__.py @@ -16,6 +16,14 @@ ) from sinch.domains.sms.models.v1.shared.text_request import TextRequest from sinch.domains.sms.models.v1.shared.text_response import TextResponse +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage __all__ = [ "AddKeyword", @@ -30,4 +38,10 @@ "RemoveKeyword", "TextRequest", "TextResponse", + "BaseMOMessage", + "MOTextMessage", + "MOBinaryMessage", + "MOMediaItem", + "MOMediaBody", + "MOMediaMessage", ] diff --git a/sinch/domains/sms/models/v1/shared/base_mo_message.py b/sinch/domains/sms/models/v1/shared/base_mo_message.py new file mode 100644 index 00000000..7b5d6252 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/base_mo_message.py @@ -0,0 +1,37 @@ +from datetime import datetime +from typing import Optional + +from pydantic import Field, StrictStr + +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class BaseMOMessage(BaseModelConfigurationResponse): + from_: StrictStr = Field( + ..., + alias="from", + description="The phone number that sent the message.", + ) + id: StrictStr = Field(..., description="The ID of this inbound message.") + received_at: datetime = Field( + ..., + description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) + to: StrictStr = Field( + ..., + description="The Sinch phone number or short code to which the message was sent.", + ) + client_reference: Optional[StrictStr] = Field( + default=None, + description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", + ) + operator_id: Optional[StrictStr] = Field( + default=None, + description="The MCC/MNC of the sender's operator if known.", + ) + sent_at: Optional[datetime] = Field( + default=None, + description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_binary_message.py b/sinch/domains/sms/models/v1/shared/mo_binary_message.py new file mode 100644 index 00000000..50c17623 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_binary_message.py @@ -0,0 +1,15 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOBinaryMessage(BaseMOMessage): + body: StrictStr = Field( + ..., description="The incoming message body (Base64 encoded)." + ) + type: Literal["mo_binary"] = Field( + ..., description="The type of incoming message. Binary SMS." + ) + udh: StrictStr = Field( + ..., description="The UDH header of a binary message HEX encoded." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_body.py b/sinch/domains/sms/models/v1/shared/mo_media_body.py new file mode 100644 index 00000000..6e35ccbc --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_body.py @@ -0,0 +1,20 @@ +from typing import Optional +from pydantic import Field, StrictStr, conlist +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) +from sinch.domains.sms.models.v1.shared.mo_media_item import MOMediaItem + + +class MOMediaBody(BaseModelConfigurationResponse): + subject: Optional[StrictStr] = Field( + default=None, description="The subject of the MMS media message." + ) + message: Optional[StrictStr] = Field( + default=None, + description="The text message content of the MMS media message.", + ) + media: Optional[conlist(MOMediaItem)] = Field( + default=None, + description="Collection of attachments in incoming message.", + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_media_item.py b/sinch/domains/sms/models/v1/shared/mo_media_item.py new file mode 100644 index 00000000..47d72547 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_item.py @@ -0,0 +1,18 @@ +from typing import Literal, Optional, Union +from pydantic import Field, StrictStr, StrictInt +from sinch.domains.sms.models.v1.internal.base import ( + BaseModelConfigurationResponse, +) + + +class MOMediaItem(BaseModelConfigurationResponse): + url: Optional[StrictStr] = Field( + default=None, description="URL to the media file." + ) + content_type: StrictStr = Field( + ..., description="Content type of the media file." + ) + status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( + ..., description="Status of the media upload." + ) + code: StrictInt = Field(..., description="The result code.") diff --git a/sinch/domains/sms/models/v1/shared/mo_media_message.py b/sinch/domains/sms/models/v1/shared/mo_media_message.py new file mode 100644 index 00000000..4d3f6be8 --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_media_message.py @@ -0,0 +1,14 @@ +from typing import Literal +from pydantic import Field +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage +from sinch.domains.sms.models.v1.shared.mo_media_body import MOMediaBody + + +class MOMediaMessage(BaseMOMessage): + body: MOMediaBody = Field( + ..., + description="The media message body.", + ) + type: Literal["mo_media"] = Field( + ..., description="The type of incoming message. MMS." + ) diff --git a/sinch/domains/sms/models/v1/shared/mo_text_message.py b/sinch/domains/sms/models/v1/shared/mo_text_message.py new file mode 100644 index 00000000..42b9f6db --- /dev/null +++ b/sinch/domains/sms/models/v1/shared/mo_text_message.py @@ -0,0 +1,13 @@ +from typing import Literal +from pydantic import Field, StrictStr +from sinch.domains.sms.models.v1.shared.base_mo_message import BaseMOMessage + + +class MOTextMessage(BaseMOMessage): + body: StrictStr = Field( + ..., + description="The incoming message body. Maximum 2000 characters.", + ) + type: Literal["mo_text"] = Field( + ..., description="The type of incoming message. Regular SMS." + ) diff --git a/sinch/domains/sms/models/v1/types/inbound_message.py b/sinch/domains/sms/models/v1/types/inbound_message.py new file mode 100644 index 00000000..c2f511f2 --- /dev/null +++ b/sinch/domains/sms/models/v1/types/inbound_message.py @@ -0,0 +1,11 @@ +from typing import Annotated, Union +from pydantic import Field +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage +from sinch.domains.sms.models.v1.shared.mo_binary_message import ( + MOBinaryMessage, +) +from sinch.domains.sms.models.v1.shared.mo_media_message import MOMediaMessage + +_InboundMessageUnion = Union[MOTextMessage, MOBinaryMessage, MOMediaMessage] + +InboundMessage = Annotated[_InboundMessageUnion, Field(discriminator="type")] diff --git a/sinch/domains/sms/sinch_events/v1/events/__init__.py b/sinch/domains/sms/sinch_events/v1/events/__init__.py index 00aba842..4ad09541 100644 --- a/sinch/domains/sms/sinch_events/v1/events/__init__.py +++ b/sinch/domains/sms/sinch_events/v1/events/__init__.py @@ -1,17 +1,6 @@ from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, - MediaBody, - MediaItem, + SmsSinchEventPayload, ) -__all__ = [ - "IncomingSMSSinchEvent", - "MOTextSinchEvent", - "MOBinarySinchEvent", - "MOMediaSinchEvent", - "MediaBody", - "MediaItem", -] +__all__ = ["IncomingSMSSinchEvent", "SmsSinchEventPayload"] diff --git a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py index fc87e608..a376c4da 100644 --- a/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/events/sms_sinch_event.py @@ -1,97 +1,15 @@ -from datetime import datetime -from typing import Optional, Union, Literal, Annotated -from pydantic import Field, StrictStr, StrictInt, conlist -from sinch.domains.sms.sinch_events.v1.internal import SinchEvent +from typing import Union +from sinch.domains.sms.models.v1.response import ( + BatchDeliveryReport, + RecipientDeliveryReport, +) +from sinch.domains.sms.models.v1.types.inbound_message import InboundMessage -class MediaItem(SinchEvent): - url: StrictStr = Field(..., description="URL to the media file") - content_type: StrictStr = Field( - ..., description="Content type of the media file" - ) - status: Union[Literal["Uploaded", "Failed"], StrictStr] = Field( - ..., description="Status of the media upload" - ) - code: StrictInt = Field(..., description="Status code") +IncomingSMSSinchEvent = InboundMessage - -class MediaBody(SinchEvent): - subject: Optional[StrictStr] = Field( - default=None, description="The subject text" - ) - message: Optional[StrictStr] = Field( - default=None, description="The message text" - ) - media: conlist(MediaItem) = Field(..., description="Array of media items") - - -class BaseIncomingSMSSinchEvent(SinchEvent): - from_: StrictStr = Field( - ..., - alias="from", - description="The phone number that sent the message.", - ) - id: StrictStr = Field(..., description="The ID of this inbound message.") - received_at: datetime = Field( - ..., - description="When the system received the message. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - to: StrictStr = Field( - ..., - description="The Sinch phone number or short code to which the message was sent.", - ) - client_reference: Optional[StrictStr] = Field( - default=None, - description="If this inbound message is in response to a previously sent message that contained a client reference, then this field contains that client reference. Utilizing this feature requires additional setup on your account.", - ) - operator_id: Optional[StrictStr] = Field( - default=None, - description="The MCC/MNC of the sender's operator if known.", - ) - sent_at: Optional[datetime] = Field( - default=None, - description="When the message left the originating device. Only available if provided by operator. Formatted as ISO-8601: YYYY-MM-DDThh:mm:ss.SSSZ.", - ) - - -class MOTextSinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., - description="The incoming message body. Maximum 2000 characters.", - ) - type: Literal["mo_text"] = Field( - ..., description="The type of incoming message. Regular SMS." - ) - - -class MOBinarySinchEvent(BaseIncomingSMSSinchEvent): - body: StrictStr = Field( - ..., description="The incoming message body (Base64 encoded)." - ) - type: Literal["mo_binary"] = Field( - ..., description="The type of incoming message. Binary SMS." - ) - udh: StrictStr = Field( - ..., description="The UDH header of a binary message HEX encoded." - ) - - -class MOMediaSinchEvent(BaseIncomingSMSSinchEvent): - body: MediaBody = Field( - ..., - description="The media message body containing subject, message, and media items.", - ) - type: Literal["mo_media"] = Field( - ..., description="The type of incoming message. MMS." - ) - - -# Union type for isinstance checks -_IncomingSMSSinchEventUnion = Union[ - MOTextSinchEvent, MOBinarySinchEvent, MOMediaSinchEvent -] - -# Discriminated union for validation -IncomingSMSSinchEvent = Annotated[ - _IncomingSMSSinchEventUnion, Field(discriminator="type") +SmsSinchEventPayload = Union[ + InboundMessage, + BatchDeliveryReport, + RecipientDeliveryReport, ] diff --git a/sinch/domains/sms/sinch_events/v1/internal/__init__.py b/sinch/domains/sms/sinch_events/v1/internal/__init__.py deleted file mode 100644 index 43b3a8dd..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from sinch.domains.sms.sinch_events.v1.internal.sinch_event import ( - SinchEvent, -) - -__all__ = ["SinchEvent"] diff --git a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py b/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py deleted file mode 100644 index 184012f9..00000000 --- a/sinch/domains/sms/sinch_events/v1/internal/sinch_event.py +++ /dev/null @@ -1,7 +0,0 @@ -from sinch.domains.sms.models.v1.internal.base import ( - BaseModelConfigurationResponse, -) - - -class SinchEvent(BaseModelConfigurationResponse): - pass diff --git a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py index 03f52892..2fd5e3f3 100644 --- a/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py +++ b/sinch/domains/sms/sinch_events/v1/sms_sinch_event.py @@ -1,33 +1,24 @@ import json -from typing import Any, Dict, Union, Optional +from typing import Any, Dict, Optional, Union + from pydantic import TypeAdapter + from sinch.domains.authentication.sinch_events.v1.authentication_validation import ( validate_sinch_event_signature_with_nonce, ) from sinch.domains.authentication.sinch_events.v1.sinch_event_utils import ( decode_payload, - parse_json, normalize_iso_timestamp, -) -from sinch.domains.sms.sinch_events.v1.events import ( - IncomingSMSSinchEvent, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, + parse_json, ) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, ) - - -SmsSinchEventPayload = Union[ - BatchDeliveryReport, - RecipientDeliveryReport, - MOTextSinchEvent, - MOBinarySinchEvent, - MOMediaSinchEvent, -] +from sinch.domains.sms.sinch_events.v1.events.sms_sinch_event import ( + IncomingSMSSinchEvent, + SmsSinchEventPayload, +) class SmsSinchEvent: diff --git a/sinch/domains/sms/sms.py b/sinch/domains/sms/sms.py index 967cef35..3e762142 100644 --- a/sinch/domains/sms/sms.py +++ b/sinch/domains/sms/sms.py @@ -3,6 +3,7 @@ DeliveryReports, ) from sinch.domains.sms.api.v1.groups_apis import Groups +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent @@ -17,6 +18,7 @@ def __init__(self, sinch): self.batches = Batches(self._sinch) self.delivery_reports = DeliveryReports(self._sinch) + self.inbounds = Inbounds(self._sinch) self.groups = Groups(self._sinch) def sinch_events(self, sinch_event_secret: str) -> SmsSinchEvent: diff --git a/tests/e2e/sms/features/steps/inbounds.steps.py b/tests/e2e/sms/features/steps/inbounds.steps.py new file mode 100644 index 00000000..b40d71df --- /dev/null +++ b/tests/e2e/sms/features/steps/inbounds.steps.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +from behave import when, then +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@when('I send a request to retrieve an inbound message') +def step_retrieve_inbound_message(context): + """Retrieve a single inbound message by ID""" + context.response = context.sms.inbounds.get( + inbound_id='01W4FFL35P4NC4K35INBOUND01' + ) + + +@then('the response contains the inbound message details') +def step_validate_inbound_message(context): + """Validate the inbound message response""" + data: MOTextMessage = context.response + assert isinstance(data, MOTextMessage) + assert data.id == '01W4FFL35P4NC4K35INBOUND01' + assert data.from_ == '12015555555' + assert data.to == '12017777777' + assert data.body == 'Hello John!' + assert data.type == 'mo_text' + assert data.operator_id == '311071' + assert data.received_at == datetime(2024, 6, 6, 14, 16, 54, 777000, tzinfo=timezone.utc) + + +@when('I send a request to list the inbound messages') +def step_list_inbound_messages(context): + """List a page of inbound messages""" + context.response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + +@then('the response contains "{count}" inbound messages') +def step_validate_inbound_messages_count(context, count): + """Validate the count of inbound messages in response""" + expected_count = int(count) + assert len(context.response.content()) == expected_count, \ + f'Expected {expected_count}, got {len(context.response.content())}' + + +@when('I send a request to list all the inbound messages') +def step_list_all_inbound_messages(context): + """List all inbound messages using iterator""" + response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + inbound_messages_list = [] + + for inbound_message in response.iterator(): + inbound_messages_list.append(inbound_message) + + context.inbound_messages_list = inbound_messages_list + + +@then('the inbound messages list contains "{count}" inbound messages') +def step_validate_inbound_messages_list_count(context, count): + """Validate the count of inbound messages in the full list""" + expected_count = int(count) + assert len(context.inbound_messages_list) == expected_count, \ + f'Expected {expected_count}, got {len(context.inbound_messages_list)}' + + +@when('I iterate manually over the inbound messages pages') +def step_iterate_manually_inbound_messages(context): + """Manually iterate over inbound messages pages""" + context.list_response = context.sms.inbounds.list( + page_size=2, + to=['12017777777', '12018888888'] + ) + + context.inbound_messages_list = [] + context.pages_iteration = 0 + reached_last_page = False + + while not reached_last_page: + context.inbound_messages_list.extend(context.list_response.content()) + context.pages_iteration += 1 + if context.list_response.has_next_page: + context.list_response = context.list_response.next_page() + else: + reached_last_page = True + + +@then('the inbound messages iteration result contains the data from "{count}" pages') +def step_validate_inbound_messages_pages_count(context, count): + """Validate the count of pages in the iteration result""" + expected_pages_count = int(count) + assert context.pages_iteration == expected_pages_count, \ + f'Expected {expected_pages_count} pages, got {context.pages_iteration}' diff --git a/tests/e2e/sms/features/steps/webhooks.steps.py b/tests/e2e/sms/features/steps/webhooks.steps.py index 99907fc4..16c85332 100644 --- a/tests/e2e/sms/features/steps/webhooks.steps.py +++ b/tests/e2e/sms/features/steps/webhooks.steps.py @@ -1,10 +1,8 @@ import requests from datetime import datetime, timezone from behave import given, when, then +from sinch.domains.sms.models.v1.shared.mo_text_message import MOTextMessage from sinch.domains.sms.sinch_events.v1.sms_sinch_event import SmsSinchEvent -from sinch.domains.sms.sinch_events.v1.events import ( - MOTextSinchEvent, -) from sinch.domains.sms.models.v1.response import ( BatchDeliveryReport, RecipientDeliveryReport, @@ -36,7 +34,7 @@ def step_check_valid_signature(context, event_type, status=None): @then('the SMS event describes an "incoming SMS" event') def step_check_incoming_sms_event(context): - incoming_sms_event: MOTextSinchEvent = context.event + incoming_sms_event: MOTextMessage = context.event assert incoming_sms_event.id == '01W4FFL35P4NC4K35SMSBATCH8' assert incoming_sms_event.from_ == '12015555555' assert incoming_sms_event.to == '12017777777' diff --git a/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py new file mode 100644 index 00000000..b8d1a8f3 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_get_inbound_endpoint.py @@ -0,0 +1,89 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import GetInboundEndpoint +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.shared import MOBinaryMessage, MOTextMessage + + +@pytest.fixture +def request_data(): + return InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + +@pytest.fixture +def mock_mo_text_response(): + return HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return GetInboundEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds/01FC66621XXXXX119Z8PMV1QPQ" + ) + + +def test_handle_response_expects_mo_text_message(endpoint, mock_mo_text_response): + """Test that the response is correctly parsed as MOTextMessage.""" + parsed = endpoint.handle_response(mock_mo_text_response) + + assert isinstance(parsed, MOTextMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.from_ == "+46701234567" + assert parsed.to == "+46709876543" + assert parsed.body == "Test inbound message" + assert parsed.type == "mo_text" + assert parsed.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_handle_response_expects_mo_binary_message(request_data): + """Test that the response is correctly parsed as MOBinaryMessage.""" + mock_binary_response = HTTPResponse( + status_code=200, + body={ + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + }, + headers={"Content-Type": "application/json"}, + ) + endpoint = GetInboundEndpoint("test_project_id", request_data) + parsed = endpoint.handle_response(mock_binary_response) + + assert isinstance(parsed, MOBinaryMessage) + assert parsed.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert parsed.body == "SGVsbG8gV29ybGQ=" + assert parsed.udh == "050003010201" + assert parsed.type == "mo_binary" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py new file mode 100644 index 00000000..c5c39ef6 --- /dev/null +++ b/tests/unit/domains/sms/v1/endpoints/inbounds/test_list_inbounds_endpoint.py @@ -0,0 +1,123 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ListInboundsEndpoint +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def request_data(): + return ListInboundsRequest( + page=0, + page_size=2, + to=["+46709876543"], + client_reference="ref123", + ) + + +@pytest.fixture +def mock_response(): + return HTTPResponse( + status_code=200, + body={ + "count": 1, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Test inbound message", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + ], + }, + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture +def endpoint(request_data): + return ListInboundsEndpoint("test_project_id", request_data) + + +def test_build_url(endpoint, mock_sinch_client_sms): + assert ( + endpoint.build_url(mock_sinch_client_sms) + == "https://zt.eu.sms.api.sinch.com/xms/v1/test_project_id/inbounds" + ) + + +def test_build_query_params_expects_all_params(endpoint): + query_params = endpoint.build_query_params() + + assert query_params["page"] == 0 + assert query_params["page_size"] == 2 + assert query_params["to"] == "+46709876543" + assert query_params["client_reference"] == "ref123" + + +def test_build_query_params_expects_excludes_none_values(): + """Test that None values are excluded from query parameters.""" + endpoint = ListInboundsEndpoint( + "test_project_id", ListInboundsRequest() + ) + query_params = endpoint.build_query_params() + + assert len(query_params) == 0 + assert "page" not in query_params + assert "page_size" not in query_params + assert "to" not in query_params + assert "start_date" not in query_params + assert "end_date" not in query_params + assert "client_reference" not in query_params + + +def test_build_query_params_expects_date_filters(): + """Test that date filters are included when provided.""" + request_data = ListInboundsRequest( + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + ) + endpoint = ListInboundsEndpoint("test_project_id", request_data) + query_params = endpoint.build_query_params() + + assert "start_date" in query_params + assert "end_date" in query_params + + +def test_handle_response_expects_correct_mapping(endpoint, mock_response): + """Test that the response is handled and mapped to the appropriate fields correctly.""" + parsed = endpoint.handle_response(mock_response) + + assert isinstance(parsed, ListInboundsResponse) + assert parsed.count == 1 + assert parsed.page == 0 + assert parsed.page_size == 2 + assert parsed.inbounds is not None + assert len(parsed.inbounds) == 1 + + first_inbound = parsed.inbounds[0] + assert isinstance(first_inbound, MOTextMessage) + assert first_inbound.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first_inbound.from_ == "+46701234567" + assert first_inbound.body == "Test inbound message" + assert first_inbound.type == "mo_text" + + +def test_handle_response_expects_sms_exception_on_error(endpoint): + """Test that SmsException is raised when server returns an error.""" + error_response = HTTPResponse(status_code=404, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 404" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 404 diff --git a/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py new file mode 100644 index 00000000..a5980ddc --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_inbound_id_request_model.py @@ -0,0 +1,18 @@ +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest + + +def test_inbound_id_request_expects_valid_inbound_id(): + """Test that the model correctly parses a valid inbound ID.""" + model = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert model.inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + +def test_inbound_id_request_expects_validation_error_for_missing_inbound_id(): + """Test that missing required inbound_id field raises a ValidationError.""" + with pytest.raises(ValidationError) as exc_info: + InboundIdRequest() + + assert "inbound_id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py new file mode 100644 index 00000000..0140fe8a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_request_model.py @@ -0,0 +1,36 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_request import ListInboundsRequest + + +def test_list_inbounds_request_expects_defaults(): + """Test that the model correctly sets default values.""" + model = ListInboundsRequest() + + assert model.page is None + assert model.page_size is None + assert model.to is None + assert model.start_date is None + assert model.end_date is None + assert model.client_reference is None + + +def test_list_inbounds_request_expects_parsed_input(): + """Test that the model correctly parses input with all parameters.""" + start = datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + end = datetime(2025, 1, 8, 12, 0, 0, tzinfo=timezone.utc) + + model = ListInboundsRequest( + page=1, + page_size=50, + to=["+46701234567", "+46709876543"], + start_date=start, + end_date=end, + client_reference="my-client-ref", + ) + + assert model.page == 1 + assert model.page_size == 50 + assert model.to == ["+46701234567", "+46709876543"] + assert model.start_date == start + assert model.end_date == end + assert model.client_reference == "my-client-ref" diff --git a/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py new file mode 100644 index 00000000..4f606cd7 --- /dev/null +++ b/tests/unit/domains/sms/v1/models/internal/test_list_inbounds_response_model.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +def test_list_inbounds_response_empty_content_expects_empty_list(): + """Test that empty inbounds list returns empty content.""" + model = ListInboundsResponse(count=0, page=0, page_size=30, inbounds=None) + + assert model.count == 0 + assert model.page == 0 + assert model.page_size == 30 + assert model.content == [] + + +def test_list_inbounds_response_expects_correct_mapping(): + """Test that response is handled and mapped to the appropriate fields correctly.""" + data = { + "count": 2, + "page": 0, + "page_size": 2, + "inbounds": [ + { + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello from test", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "ref-001", + }, + { + "id": "01FC66621XXXXX119Z8PMV1QPR", + "from": "+46701234568", + "to": "+46709876543", + "body": "Second message", + "type": "mo_text", + "received_at": "2024-06-06T09:25:00.000Z", + }, + ], + } + response = ListInboundsResponse(**data) + + assert response.count == 2 + assert response.page == 0 + assert response.page_size == 2 + + content = response.content + assert isinstance(content, list) + assert len(content) == 2 + + first = content[0] + assert isinstance(first, MOTextMessage) + assert first.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert first.from_ == "+46701234567" + assert first.body == "Hello from test" + assert first.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + assert first.client_reference == "ref-001" + + second = content[1] + assert isinstance(second, MOTextMessage) + assert second.id == "01FC66621XXXXX119Z8PMV1QPR" + assert second.body == "Second message" + assert second.client_reference is None diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py new file mode 100644 index 00000000..3b26cfed --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_binary_message_model.py @@ -0,0 +1,90 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOBinaryMessage + + +@pytest.fixture +def sample_mo_binary_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_binary_message_expects_valid_input(sample_mo_binary_data): + """Test that the model correctly parses valid input.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "SGVsbG8gV29ybGQ=" + assert msg.udh == "050003010201" + assert msg.type == "mo_binary" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_binary_message_expects_optional_fields_none(sample_mo_binary_data): + """Test that optional fields default to None when not provided.""" + msg = MOBinaryMessage(**sample_mo_binary_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_binary_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + } + msg = MOBinaryMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + + +def test_mo_binary_message_expects_validation_error_for_missing_udh(): + """Test that missing required udh field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "SGVsbG8gV29ybGQ=", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "udh" in str(exc_info.value) + + +def test_mo_binary_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "udh": "050003010201", + "type": "mo_binary", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOBinaryMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py new file mode 100644 index 00000000..6e89f49a --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_media_message_model.py @@ -0,0 +1,95 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOMediaBody, MOMediaItem, MOMediaMessage + + +@pytest.fixture +def sample_mo_media_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "MMS subject", + "message": "Hello media", + "media": [ + { + "url": "https://example.com/img.jpg", + "content_type": "image/jpeg", + "status": "Uploaded", + "code": 200, + } + ], + }, + } + + +def test_mo_media_message_expects_valid_input(sample_mo_media_data): + """Test that the model correctly parses valid input including nested body.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.type == "mo_media" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + assert isinstance(msg.body, MOMediaBody) + assert msg.body.subject == "MMS subject" + assert msg.body.message == "Hello media" + assert msg.body.media is not None + assert len(msg.body.media) == 1 + + item = msg.body.media[0] + assert isinstance(item, MOMediaItem) + assert item.url == "https://example.com/img.jpg" + assert item.content_type == "image/jpeg" + assert item.status == "Uploaded" + assert item.code == 200 + + +def test_mo_media_message_expects_body_with_no_media(): + """Test that body with no media attachment is valid.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + "body": { + "subject": "Hi", + "message": "Text only MMS", + }, + } + msg = MOMediaMessage(**data) + + assert msg.body.subject == "Hi" + assert msg.body.message == "Text only MMS" + assert msg.body.media is None + + +def test_mo_media_message_expects_optional_fields_none(sample_mo_media_data): + """Test that optional base fields default to None.""" + msg = MOMediaMessage(**sample_mo_media_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_media_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_media", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOMediaMessage(**data) + + assert "body" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py new file mode 100644 index 00000000..59dac69b --- /dev/null +++ b/tests/unit/domains/sms/v1/models/response/test_mo_text_message_model.py @@ -0,0 +1,94 @@ +from datetime import datetime, timezone +import pytest +from pydantic import ValidationError +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def sample_mo_text_data(): + return { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + + +def test_mo_text_message_expects_valid_input(sample_mo_text_data): + """Test that the model correctly parses valid input.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.id == "01FC66621XXXXX119Z8PMV1QPQ" + assert msg.from_ == "+46701234567" + assert msg.to == "+46709876543" + assert msg.body == "Hello world" + assert msg.type == "mo_text" + assert msg.received_at == datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_optional_fields_none(sample_mo_text_data): + """Test that optional fields default to None when not provided.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.client_reference is None + assert msg.operator_id is None + assert msg.sent_at is None + + +def test_mo_text_message_expects_from_alias(sample_mo_text_data): + """Test that the model accepts 'from' alias and exposes it as 'from_'.""" + msg = MOTextMessage(**sample_mo_text_data) + + assert msg.from_ == "+46701234567" + + +def test_mo_text_message_expects_optional_fields_populated(): + """Test that optional fields are parsed correctly when provided.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + "client_reference": "my-client-ref", + "operator_id": "24001", + "sent_at": "2024-06-06T09:20:00.000Z", + } + msg = MOTextMessage(**data) + + assert msg.client_reference == "my-client-ref" + assert msg.operator_id == "24001" + assert msg.sent_at == datetime(2024, 6, 6, 9, 20, 0, tzinfo=timezone.utc) + + +def test_mo_text_message_expects_validation_error_for_missing_body(): + """Test that missing required body field raises a ValidationError.""" + data = { + "from": "+46701234567", + "id": "01FC66621XXXXX119Z8PMV1QPQ", + "to": "+46709876543", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "body" in str(exc_info.value) + + +def test_mo_text_message_expects_validation_error_for_missing_id(): + """Test that missing required id field raises a ValidationError.""" + data = { + "from": "+46701234567", + "to": "+46709876543", + "body": "Hello world", + "type": "mo_text", + "received_at": "2024-06-06T09:22:14.304Z", + } + with pytest.raises(ValidationError) as exc_info: + MOTextMessage(**data) + + assert "id" in str(exc_info.value) diff --git a/tests/unit/domains/sms/v1/test_inbounds.py b/tests/unit/domains/sms/v1/test_inbounds.py new file mode 100644 index 00000000..97588e3b --- /dev/null +++ b/tests/unit/domains/sms/v1/test_inbounds.py @@ -0,0 +1,126 @@ +from datetime import datetime, timezone +import pytest +from sinch.core.models.http_response import HTTPResponse +from sinch.core.pagination import SMSPaginator +from sinch.domains.sms.api.v1.inbounds_apis import Inbounds +from sinch.domains.sms.api.v1.exceptions import SmsException +from sinch.domains.sms.api.v1.internal.inbounds_endpoints import ( + GetInboundEndpoint, + ListInboundsEndpoint, +) +from sinch.domains.sms.models.v1.internal.inbound_id_request import InboundIdRequest +from sinch.domains.sms.models.v1.internal.list_inbounds_response import ListInboundsResponse +from sinch.domains.sms.models.v1.shared import MOTextMessage + + +@pytest.fixture +def mock_mo_text_response(): + """Sample MOTextMessage for testing.""" + return MOTextMessage( + id="01FC66621XXXXX119Z8PMV1QPQ", + from_="+46701234567", + to="+46709876543", + body="Test inbound message", + type="mo_text", + received_at=datetime(2024, 6, 6, 9, 22, 14, 304000, tzinfo=timezone.utc), + ) + + +def test_inbounds_get_correct_request( + mock_sinch_client_sms, mocker, mock_mo_text_response +): + """Test that get sends the correct request and handles the response properly.""" + mock_sinch_client_sms.configuration.transport.request.return_value = mock_mo_text_response + + spy_endpoint = mocker.spy(GetInboundEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].inbound_id == "01FC66621XXXXX119Z8PMV1QPQ" + + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_inbounds_list_correct_request(mock_sinch_client_sms, mocker): + """Test that list sends the correct request and handles the response properly.""" + mock_response = ListInboundsResponse(count=1, page=0, page_size=2, inbounds=[]) + mock_sinch_client_sms.configuration.transport.request.return_value = mock_response + + spy_endpoint = mocker.spy(ListInboundsEndpoint, "__init__") + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.list( + page=0, + page_size=2, + to=["+46709876543"], + start_date=datetime(2025, 1, 1, tzinfo=timezone.utc), + end_date=datetime(2025, 1, 31, tzinfo=timezone.utc), + client_reference="test_client_ref", + ) + + spy_endpoint.assert_called_once() + _, kwargs = spy_endpoint.call_args + + assert kwargs["project_id"] == "test_project_id" + assert kwargs["request_data"].page == 0 + assert kwargs["request_data"].page_size == 2 + assert kwargs["request_data"].to == ["+46709876543"] + assert kwargs["request_data"].start_date == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert kwargs["request_data"].end_date == datetime(2025, 1, 31, tzinfo=timezone.utc) + assert kwargs["request_data"].client_reference == "test_client_ref" + + assert isinstance(response, SMSPaginator) + assert hasattr(response, "has_next_page") + assert response.result == mock_response + mock_sinch_client_sms.configuration.transport.request.assert_called_once() + + +def test_sms_endpoint_handle_response_raises_exception_on_error( + mock_sinch_client_sms, +): + """ + Test that SmsEndpoint.handle_response raises SmsException when status_code >= 400. + """ + request_data = InboundIdRequest(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + endpoint = GetInboundEndpoint("test_project_id", request_data) + + error_response = HTTPResponse(status_code=400, body=1, headers={}) + + with pytest.raises(SmsException) as exc_info: + endpoint.handle_response(error_response) + + assert exc_info.value.args[0] == "Error 400" + assert exc_info.value.http_response == error_response + assert exc_info.value.is_from_server is True + assert exc_info.value.response_status_code == 400 + + +def test_inbounds_expects_validation_recalculates_auth_method_when_credentials_change( + mock_sinch_client_sms, mock_mo_text_response +): + """ + Test that SMS requests validate authentication and recalculate auth method + when credentials change after initialization. + """ + config = mock_sinch_client_sms.configuration + + assert config.authentication_method == "project_auth" + + config.transport.request.return_value = mock_mo_text_response + config.sms_api_token = "test_sms_token" + + assert config.authentication_method == "project_auth" + + inbounds = Inbounds(mock_sinch_client_sms) + response = inbounds.get(inbound_id="01FC66621XXXXX119Z8PMV1QPQ") + + assert config.authentication_method == "sms_auth" + assert isinstance(response, MOTextMessage) + assert response.id == "01FC66621XXXXX119Z8PMV1QPQ" From e3c52a7bc5715a758cb9b82907c140b04ce66a36 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Mon, 15 Jun 2026 15:48:43 +0200 Subject: [PATCH 09/12] Bugfix/devexp 765 syncronize token managers (#156) * fix: avoid race condition one token creation and token renewal * refactor: rename send method to send_request and update deprecation warnings * fix: HTTPTransport legacy tests * fix: update CHANGELOG * fix: change readme example to send_request --- CHANGELOG.md | 4 + README.md | 6 +- .../core/adapters/requests_http_transport.py | 25 +- sinch/core/ports/http_transport.py | 227 +++++++++--- sinch/core/token_manager.py | 139 ++++++- tests/unit/http_transport_tests.py | 253 ------------- tests/unit/test_http_transport.py | 342 ++++++++++++++++++ tests/unit/test_token_manager.py | 78 ++++ 8 files changed, 743 insertions(+), 331 deletions(-) delete mode 100644 tests/unit/http_transport_tests.py create mode 100644 tests/unit/test_http_transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 721b39d2..5982e2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). +- **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). +- **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). +- **[deprecation notice]** `HTTPTransport.send(endpoint)` is deprecated in favour of `send_request(request_data)`; the legacy method still works for backward compatibility, but will be removed in 3.0 (#156). +- **[deprecation notice]** `TokenManagerBase.invalidate_expired_token()` and `handle_invalid_token()` (and the `TokenState.EXPIRED` value) are deprecated and will be removed in 3.0, as token renewal now goes through `refresh_auth_token()` (#156). ### SMS diff --git a/README.md b/README.md index 92811ffa..1339db45 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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_request(self, request_data: HttpRequest) -> HTTPResponse: body = request_data.request_body response = self.http_client.request( diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 6fc62a77..926791e8 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -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_request(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}" + "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", + request_data.http_method, request_data.headers, request_data.request_body, request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -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}" + "Sync HTTP response %s with headers: %s and body: %s from URL: %s", + response.status_code, response.headers, response_body, request_data.url ) return HTTPResponse( diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index ec0edafc..6e97cf08 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,70 +1,119 @@ -from abc import ABC, abstractmethod +import warnings +from abc import ABC 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_request` 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. + + .. deprecated:: 2.1 + Overriding :meth:`send` (the old ``send(endpoint)`` hook) is still + honored but deprecated; implement :meth:`send_request` instead. The + ``send`` override path will be removed in 3.0. """ def __init__(self, sinch): self.sinch = sinch + self._legacy_send = self._uses_legacy_send() + if self._legacy_send: + warnings.warn( + f"{type(self).__name__} overrides `send(endpoint)`, which is deprecated and " + "will be removed in 3.0. Implement `send_request(request_data)` instead.", + DeprecationWarning, + stacklevel=2, + ) - # ------------------------------------------------------------------ - # Subclass contract - # ------------------------------------------------------------------ + def send_request(self, request_data: HttpRequest) -> HTTPResponse: + """ + Performs a single HTTP round-trip for an already-prepared, authenticated request. - @abstractmethod + :param request_data: The prepared request to send. + :type request_data: HttpRequest + :returns: The HTTP response. + :rtype: HTTPResponse + """ + raise NotImplementedError( + "Transport subclasses must implement `send_request(request_data)`." + ) + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - """Execute a single HTTP round-trip and return the response. + """ + Prepares, authenticates and performs a single round-trip for an endpoint. + + .. deprecated:: 2.1 + This hook is deprecated. Implement :meth:`send_request` instead; + the ``send`` override path will be removed in 3.0. - 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``. + :param endpoint: The endpoint to call. + :type endpoint: HTTPEndpoint + :returns: The HTTP response. + :rtype: HTTPResponse + """ + raise NotImplementedError( + "`send(endpoint)` is deprecated. " + "Implement `send_request(request_data)` instead." + ) + + def _uses_legacy_send(self) -> bool: + """ + Returns True when a subclass overrides the deprecated ``send`` hook but + not the new ``send_request`` hook. """ + cls = type(self) + return cls.send is not HTTPTransport.send and cls.send_request is HTTPTransport.send_request - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ 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) + if self._legacy_send: + return self._legacy_request(endpoint) + + request_data = self.prepare_request(endpoint) + request_data = self.authenticate(endpoint, request_data) + http_response = self.send_request(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(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 @@ -87,10 +136,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( @@ -101,14 +147,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( @@ -124,7 +175,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() @@ -138,12 +198,75 @@ def deserialize_json_response(response): response_body = {} return response_body + + def _legacy_request(self, endpoint: HTTPEndpoint) -> HTTPResponse: + """ + Backward-compatible request loop for subclasses that override ``send``. + + On an expired-token 401 the cached token is renewed through + :meth:`TokenManagerBase.refresh_auth_token`, which dedupes concurrent + renewals. The legacy ``send(endpoint)`` re-prepares and re-authenticates + on every call, so the second ``send`` picks up the refreshed token from + the cache automatically. + + :param endpoint: The endpoint to call. + :type endpoint: HTTPEndpoint + :returns: The handled HTTP response. + :rtype: HTTPResponse + """ + token_before = self.sinch.configuration.token_manager.token + http_response = self.send(endpoint) + + if self._should_refresh_token(endpoint, http_response): + used_token = token_before.access_token if token_before else None + self.sinch.configuration.token_manager.refresh_auth_token(used_token) + http_response = self.send(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 - ) + 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" + }) diff --git a/sinch/core/token_manager.py b/sinch/core/token_manager.py index b66bba85..000cb19e 100644 --- a/sinch/core/token_manager.py +++ b/sinch/core/token_manager.py @@ -1,38 +1,113 @@ +import warnings from enum import Enum from abc import ABC, abstractmethod +import threading from sinch.domains.authentication.models.v1.authentication import OAuthToken from sinch.domains.authentication.endpoints.v1.oauth import OAuthEndpoint from sinch.core.exceptions import ValidationException class TokenState(Enum): + """ + Lifecycle state of the cached OAuth token. + """ + VALID = "VALID" + """ + A usable token is currently cached. + """ INVALID = "INVALID" + """ + No token has been obtained yet. + """ EXPIRED = "EXPIRED" + """ + .. deprecated:: 2.1 + Kept for backward compatibility; will be removed in 3.0. No longer used + by the SDK's own renewal path (see :meth:`TokenManager.refresh_auth_token`). + """ class TokenManagerBase(ABC): + """ + Base class for OAuth token managers. + + Holds the cached access token together with the lock that guards every + token mutation. + """ + def __init__(self, sinch): self.sinch = sinch - self.token = None - self.token_state = TokenState.INVALID + self.token: OAuthToken | None = None + self.token_state: TokenState = TokenState.INVALID + self._lock: threading.Lock = threading.Lock() @abstractmethod def get_auth_token(self) -> OAuthToken: pass - def invalidate_expired_token(self): + def refresh_auth_token(self, used_token: str) -> OAuthToken: + """ + Renews the token after an expired-token 401, deduping concurrent renewals. + + :param used_token: The access token used by the request that received the 401. + :type used_token: str + :returns: A valid token. + :rtype: OAuthToken + """ + with self._lock: + if self.token is not None and self.token.access_token != used_token: + return self.token + token = self._request_token() + self._set_valid_token(token) + return token + + def invalidate_expired_token(self) -> None: + """ + .. deprecated:: 2.1 + Token renewal is handled by :meth:`refresh_auth_token`; this method + will be removed in 3.0. + + Clears the cached token so the next call fetches a new one. + """ + warnings.warn( + "TokenManagerBase.invalidate_expired_token() is deprecated and will be " + "removed in 3.0. Token renewal is handled by refresh_auth_token().", + DeprecationWarning, + stacklevel=2, + ) self.token = None self.token_state = TokenState.EXPIRED - def handle_invalid_token(self, http_response): - if http_response.headers.get("www-authenticate") and "expired" in http_response.headers["www-authenticate"]: - self.invalidate_expired_token() + def handle_invalid_token(self, http_response) -> None: + """ + .. deprecated:: 2.1 + Expired-token handling now lives in the HTTP transport's request loop; + this method will be removed in 3.0. + + Invalidates the cached token if the response signals an expired token. + """ + warnings.warn( + "TokenManagerBase.handle_invalid_token() is deprecated and will be " + "removed in 3.0. Expired-token handling now lives in the transport.", + DeprecationWarning, + stacklevel=2, + ) + www_authenticate = http_response.headers.get("www-authenticate") or "" + if "expired" in www_authenticate: + self.token = None + self.token_state = TokenState.EXPIRED + + def set_auth_token(self, token: dict): + """ + Sets the OAuth token and marks the token_state as VALID. - def set_auth_token(self, token) -> None: + :param token: The token fields. + :type token: dict + :raises ValidationException: If the fields do not match the OAuthToken structure. + """ try: - self.token = OAuthToken(**token) - self.token_state = TokenState.VALID + self._set_valid_token(OAuthToken(**token)) except TypeError: raise ValidationException( "Invalid authentication token structure", @@ -40,12 +115,50 @@ def set_auth_token(self, token) -> None: response=None ) + def _request_token(self) -> OAuthToken: + """ + Requests a fresh token from the OAuth endpoint. No side effects. + + :returns: The freshly fetched token. + :rtype: OAuthToken + """ + return self.sinch.configuration.transport.request(OAuthEndpoint()) + + def _set_valid_token(self, token: OAuthToken) -> None: + """ + Caches the given token as the current valid one. + + :param token: The token to cache. + :type token: OAuthToken + """ + self.token = token + self.token_state = TokenState.VALID + class TokenManager(TokenManagerBase): + """ + Thread-safe synchronous OAuth token manager. + """ + def get_auth_token(self) -> OAuthToken: - if self.token: + """ + Returns a valid token, fetching and caching one if none is cached yet. + + This function is not a pure getter (not idempotent): on the first call it + requests a token from the OAuth endpoint and stores it on the instance. + Subsequent calls return the cached token. Caching is guarded by + double-checked locking, so concurrent callers share a single token + request instead of each issuing their own. + + :returns: A valid OAuth token. + :rtype: OAuthToken + """ + if self.token is not None: return self.token - self.token = self.sinch.configuration.transport.request(OAuthEndpoint()) - self.token_state = TokenState.VALID - return self.token + with self._lock: + if self.token is not None: + return self.token + token = self._request_token() + self._set_valid_token(token) + return token diff --git a/tests/unit/http_transport_tests.py b/tests/unit/http_transport_tests.py deleted file mode 100644 index c567dcb9..00000000 --- a/tests/unit/http_transport_tests.py +++ /dev/null @@ -1,253 +0,0 @@ -import pytest -from unittest.mock import Mock, call -from sinch.core.enums import HTTPAuthentication -from sinch.core.exceptions import ValidationException -from sinch.core.models.http_request import HttpRequest -from sinch.core.endpoint import HTTPEndpoint -from sinch.core.models.http_response import HTTPResponse -from sinch.core.ports.http_transport import HTTPTransport -from sinch.core.token_manager import TokenState - - -# Mock classes and fixtures -def _make_mock_endpoint(auth_type, error_on_4xx=False): - """Create a MockEndpoint that satisfies the abstract property contract.""" - - class _Endpoint(HTTPEndpoint): - HTTP_AUTHENTICATION = auth_type - HTTP_METHOD = "GET" - - def __init__(self): - # Skip super().__init__ — we don't need project_id / request_data - pass - - def build_url(self, sinch): - return "api.sinch.com/test" - - def get_url_without_origin(self, sinch): - return "/test" - - def request_body(self): - return {} - - def build_query_params(self): - return {} - - def handle_response(self, response: HTTPResponse): - if error_on_4xx and response.status_code >= 400: - raise ValidationException( - message=f"HTTP {response.status_code}", - is_from_server=True, - response=response, - ) - return response - - return _Endpoint() - - -@pytest.fixture -def mock_sinch(): - sinch = Mock() - sinch.configuration = Mock() - sinch.configuration.key_id = "test_key_id" - sinch.configuration.key_secret = "test_key_secret" - sinch.configuration.project_id = "test_project_id" - sinch.configuration.sms_api_token = "test_sms_token" - sinch.configuration.service_plan_id = "test_service_plan" - return sinch - - -@pytest.fixture -def base_request(): - return HttpRequest( - headers={}, - url="https://api.sinch.com/test", - http_method="GET", - request_body={}, - query_params={}, - auth=() - ) - - -class MockHTTPTransport(HTTPTransport): - """Transport whose send() returns from a pre-configured list of responses.""" - - def __init__(self, sinch, responses=None): - super().__init__(sinch) - self._responses = list(responses or []) - self._call_count = 0 - - def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: - if self._call_count < len(self._responses): - resp = self._responses[self._call_count] - else: - resp = HTTPResponse(status_code=200, body={}, headers={}) - self._call_count += 1 - return resp - - @property - def call_count(self): - return self._call_count - - -# Synchronous Transport Tests -class TestHTTPTransport: - @pytest.mark.parametrize("auth_type", [ - HTTPAuthentication.BASIC.value, - HTTPAuthentication.OAUTH.value, - HTTPAuthentication.SMS_TOKEN.value - ]) - def test_authenticate(self, mock_sinch, base_request, auth_type): - transport = MockHTTPTransport(mock_sinch) - endpoint = _make_mock_endpoint(auth_type) - - if auth_type == HTTPAuthentication.BASIC.value: - result = transport.authenticate(endpoint, base_request) - assert result.auth == ("test_key_id", "test_key_secret") - - elif auth_type == HTTPAuthentication.OAUTH.value: - mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" - result = transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_token" - assert result.headers["Content-Type"] == "application/json" - - elif auth_type == HTTPAuthentication.SMS_TOKEN.value: - result = transport.authenticate(endpoint, base_request) - assert result.headers["Authorization"] == "Bearer test_sms_token" - assert result.headers["Content-Type"] == "application/json" - - @pytest.mark.parametrize("auth_type,missing_creds", [ - (HTTPAuthentication.BASIC.value, {"key_id": None}), - (HTTPAuthentication.OAUTH.value, {"key_secret": None}), - (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) - ]) - def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): - transport = MockHTTPTransport(mock_sinch) - endpoint = _make_mock_endpoint(auth_type) - - for cred, value in missing_creds.items(): - setattr(mock_sinch.configuration, cred, value) - - with pytest.raises(ValidationException): - transport.authenticate(endpoint, base_request) - - -class TestTokenRefreshRetry: - """Tests for the automatic token refresh on 401 expired responses.""" - - @staticmethod - def _expired_401(): - return HTTPResponse( - status_code=401, - body={"error": "token expired"}, - headers={"www-authenticate": "Bearer error=\"expired\""}, - ) - - @staticmethod - def _non_expired_401(): - return HTTPResponse( - status_code=401, - body={"error": "unauthorized"}, - headers={"www-authenticate": "Bearer error=\"invalid_token\""}, - ) - - @staticmethod - def _ok_200(): - return HTTPResponse(status_code=200, body={"ok": True}, headers={}) - - def test_retry_succeeds_after_expired_token(self, mock_sinch): - """A single 401-expired followed by a 200 should retry once and succeed.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - def mark_expired(http_response): - token_manager.token_state = TokenState.EXPIRED - - token_manager.handle_invalid_token.side_effect = mark_expired - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401(), self._ok_200()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) - - result = transport.request(endpoint) - - assert result.status_code == 200 - assert transport.call_count == 2 - token_manager.handle_invalid_token.assert_called_once() - - def test_no_infinite_loop_on_persistent_401(self, mock_sinch): - """Two consecutive 401-expired must NOT cause infinite retries. - - The second 401 should be handed to the endpoint's error handler - and send() should be called at most twice. - """ - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - def mark_expired(http_response): - token_manager.token_state = TokenState.EXPIRED - - token_manager.handle_invalid_token.side_effect = mark_expired - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401(), self._expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - # send() must have been called exactly twice: initial + one retry - assert transport.call_count == 2 - - def test_no_retry_when_401_is_not_expired(self, mock_sinch): - """A 401 without 'expired' in WWW-Authenticate should NOT trigger a retry.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - token_manager.token_state = TokenState.VALID - - # handle_invalid_token inspects the header but does NOT set EXPIRED - # because the header says "invalid_token", not "expired" - token_manager.handle_invalid_token.side_effect = lambda r: None - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._non_expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - # send() called only once — no retry - assert transport.call_count == 1 - - def test_no_retry_for_non_oauth_endpoint(self, mock_sinch): - """A 401 on a BASIC-auth endpoint should NOT trigger token refresh.""" - from sinch.core.token_manager import TokenManager - - token_manager = Mock(spec=TokenManager) - mock_sinch.configuration.token_manager = token_manager - - transport = MockHTTPTransport( - mock_sinch, - responses=[self._expired_401()], - ) - endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, error_on_4xx=True) - - with pytest.raises(ValidationException, match="401"): - transport.request(endpoint) - - assert transport.call_count == 1 - token_manager.handle_invalid_token.assert_not_called() diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py new file mode 100644 index 00000000..2636e9ce --- /dev/null +++ b/tests/unit/test_http_transport.py @@ -0,0 +1,342 @@ +import json +import pytest +from unittest.mock import Mock +from sinch.core.enums import HTTPAuthentication +from sinch.core.exceptions import ValidationException, SinchException +from sinch.core.models.http_request import HttpRequest +from sinch.core.endpoint import HTTPEndpoint +from sinch.core.models.http_response import HTTPResponse +from sinch.core.adapters.requests_http_transport import HTTPTransportRequests +from sinch.core.ports.http_transport import HTTPTransport +from sinch.core.token_manager import TokenManager +from sinch.domains.authentication.models.v1.authentication import OAuthToken + + +# Mock classes and fixtures +def _make_mock_endpoint(auth_type, error_on_4xx=False): + """Create a MockEndpoint that satisfies the abstract property contract.""" + + class _Endpoint(HTTPEndpoint): + HTTP_AUTHENTICATION = auth_type + HTTP_METHOD = "GET" + + def __init__(self): + # Skip super().__init__ — we don't need project_id / request_data + pass + + def build_url(self, sinch): + return "api.sinch.com/test" + + def get_url_without_origin(self, sinch): + return "/test" + + def request_body(self): + return {} + + def build_query_params(self): + return {} + + def handle_response(self, response: HTTPResponse): + if error_on_4xx and response.status_code >= 400: + raise ValidationException( + message=f"HTTP {response.status_code}", + is_from_server=True, + response=response, + ) + return response + + return _Endpoint() + + +def _requests_response(status_code, body=None, headers=None): + """Fake of a requests.Response, just enough for deserialize_json_response.""" + resp = Mock() + resp.status_code = status_code + resp.content = json.dumps(body or {}).encode() + resp.json.return_value = body or {} + resp.headers = headers or {} + return resp + + +def _server_rejecting_expired_token(accepted_token): + """Fake http_session.request: 200 only when the request carries `accepted_token`, + otherwise a 401-expired — like a server that rejects the stale token.""" + def respond(*args, **kwargs): + if kwargs["headers"].get("Authorization") == accepted_token: + return _requests_response(200, body={"ok": True}) + return _requests_response(401, headers={"www-authenticate": 'Bearer error="expired"'}) + return respond + + +def _token_manager(mock_sinch, *, old="old", new="new"): + """Mock TokenManager that hands out `old` and renews to `new`.""" + token_manager = Mock(spec=TokenManager) + token_manager.refresh_auth_token.return_value = OAuthToken( + access_token=new, expires_in=3599, scope="", token_type="bearer" + ) + mock_sinch.configuration.token_manager = token_manager + # authenticate() reads the initial token via sinch.authentication.get_auth_token() + mock_sinch.authentication.get_auth_token.return_value.access_token = old + return token_manager + + +@pytest.fixture +def mock_sinch(): + sinch = Mock() + sinch.configuration = Mock() + sinch.configuration.key_id = "test_key_id" + sinch.configuration.key_secret = "test_key_secret" + sinch.configuration.project_id = "test_project_id" + sinch.configuration.sms_api_token = "test_sms_token" + sinch.configuration.service_plan_id = "test_service_plan" + return sinch + + +@pytest.fixture +def base_request(): + return HttpRequest( + headers={}, + url="https://api.sinch.com/test", + http_method="GET", + request_body={}, + query_params={}, + auth=() + ) + + +class TestHTTPTransport: + @pytest.mark.parametrize("auth_type", [ + HTTPAuthentication.BASIC.value, + HTTPAuthentication.OAUTH.value, + HTTPAuthentication.SMS_TOKEN.value + ]) + def test_authenticate(self, mock_sinch, base_request, auth_type): + transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(auth_type) + + if auth_type == HTTPAuthentication.BASIC.value: + result = transport.authenticate(endpoint, base_request) + assert result.auth == ("test_key_id", "test_key_secret") + + elif auth_type == HTTPAuthentication.OAUTH.value: + mock_sinch.authentication.get_auth_token.return_value.access_token = "test_token" + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_token" + assert result.headers["Content-Type"] == "application/json" + + elif auth_type == HTTPAuthentication.SMS_TOKEN.value: + result = transport.authenticate(endpoint, base_request) + assert result.headers["Authorization"] == "Bearer test_sms_token" + assert result.headers["Content-Type"] == "application/json" + + @pytest.mark.parametrize("auth_type,missing_creds", [ + (HTTPAuthentication.BASIC.value, {"key_id": None}), + (HTTPAuthentication.OAUTH.value, {"key_secret": None}), + (HTTPAuthentication.SMS_TOKEN.value, {"sms_api_token": None}) + ]) + def test_authenticate_missing_credentials(self, mock_sinch, base_request, auth_type, missing_creds): + transport = HTTPTransportRequests(mock_sinch) + endpoint = _make_mock_endpoint(auth_type) + + for cred, value in missing_creds.items(): + setattr(mock_sinch.configuration, cred, value) + + with pytest.raises(ValidationException): + transport.authenticate(endpoint, base_request) + + +class TestSend: + def test_send_maps_requests_response(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(200, body={"x": 1}) + ) + + result = transport.send_request(base_request) + + assert isinstance(result, HTTPResponse) + assert result.status_code == 200 + assert result.body == {"x": 1} + + def test_send_empty_body_returns_empty_dict(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=Mock(status_code=204, content=b"", headers={}) + ) + + result = transport.send_request(base_request) + + assert result.status_code == 204 + assert result.body == {} + + def test_send_raises_on_invalid_json(self, mock_sinch, base_request): + transport = HTTPTransportRequests(mock_sinch) + bad_response = Mock(status_code=200, content=b"not json", headers={}) + bad_response.json.side_effect = ValueError("bad json") + transport.http_session.request = Mock(return_value=bad_response) + + with pytest.raises(SinchException): + transport.send_request(base_request) + + +class TestTokenRefreshRetry: + """Tests for the automatic token refresh on 401-expired responses.""" + + @staticmethod + def _expired_401(): + return _requests_response( + 401, + body={"error": "token expired"}, + headers={"www-authenticate": 'Bearer error="expired"'}, + ) + + @staticmethod + def _non_expired_401(): + return _requests_response( + 401, + body={"error": "unauthorized"}, + headers={"www-authenticate": 'Bearer error="invalid_token"'}, + ) + + def test_retry_succeeds_after_expired_token(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + # The server accepts only the renewed token, so a 200 proves the retry re-stamped it. + transport.http_session.request = Mock(side_effect=_server_rejecting_expired_token("Bearer new")) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 2 + token_manager.refresh_auth_token.assert_called_once_with("old") + + def test_no_retry_when_401_is_not_expired(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._non_expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_no_retry_for_non_oauth_endpoint(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_only_one_retry_on_persistent_401(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[self._expired_401(), self._expired_401()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 2 + token_manager.refresh_auth_token.assert_called_once() + + def test_no_refresh_on_successful_request(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(200, body={"ok": True}) + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + def test_no_refresh_on_401_without_www_authenticate(self, mock_sinch): + token_manager = _token_manager(mock_sinch) + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock( + return_value=_requests_response(401, body={}) + ) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value, error_on_4xx=True) + + with pytest.raises(ValidationException, match="401"): + transport.request(endpoint) + + assert transport.http_session.request.call_count == 1 + token_manager.refresh_auth_token.assert_not_called() + + +class _LegacyTransport(HTTPTransport): + """A pre-2.1 transport that overrides the deprecated ``send(endpoint)`` hook.""" + + def __init__(self, sinch, responses): + super().__init__(sinch) + self._responses = list(responses) + self.send_calls = 0 + + def send(self, endpoint: HTTPEndpoint) -> HTTPResponse: + self.send_calls += 1 + return self._responses.pop(0) + + +class _NoHookTransport(HTTPTransport): + """A transport that implements neither ``send`` nor ``send_request``.""" + pass + + +class TestLegacySend: + """Tests for the deprecated ``send(endpoint)`` override path (_legacy_request).""" + + @staticmethod + def _expired_401(): + return HTTPResponse( + status_code=401, + headers={"www-authenticate": 'Bearer error="expired"'}, + body={"error": "token expired"}, + ) + + @staticmethod + def _ok_200(): + return HTTPResponse(status_code=200, headers={}, body={"ok": True}) + + def test_legacy_send_emits_deprecation_warning(self, mock_sinch): + with pytest.warns(DeprecationWarning, match="send"): + _LegacyTransport(mock_sinch, [self._ok_200()]) + + def test_legacy_request_retries_on_expired_token(self, mock_sinch): + token_manager = Mock() + token_manager.token = OAuthToken( + access_token="old", expires_in=3599, scope="", token_type="bearer" + ) + token_manager.refresh_auth_token.return_value = OAuthToken( + access_token="new", expires_in=3599, scope="", token_type="bearer" + ) + mock_sinch.configuration.token_manager = token_manager + + with pytest.warns(DeprecationWarning): + transport = _LegacyTransport(mock_sinch, [self._expired_401(), self._ok_200()]) + endpoint = _make_mock_endpoint(HTTPAuthentication.OAUTH.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + + assert transport.send_calls == 2 + token_manager.refresh_auth_token.assert_called_once_with("old") + + def test_request_raises_when_send_request_not_implemented(self, mock_sinch): + transport = _NoHookTransport(mock_sinch) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + with pytest.raises(NotImplementedError, match="send_request"): + transport.request(endpoint) diff --git a/tests/unit/test_token_manager.py b/tests/unit/test_token_manager.py index 502d5ad1..9219cd75 100644 --- a/tests/unit/test_token_manager.py +++ b/tests/unit/test_token_manager.py @@ -1,3 +1,5 @@ +import threading +import time import pytest from unittest.mock import Mock @@ -30,3 +32,79 @@ def test_get_auth_token_and_check_if_cached(sinch_client_sync, auth_token): assert isinstance(access_token, OAuthToken) assert token_manager.token is auth_token + + +def test_get_auth_token_fetches_once_under_concurrency(auth_token): + num_threads = 20 + barrier = threading.Barrier(num_threads) + sinch = Mock() + + def slow_fetch(endpoint): + time.sleep(0.05) + return auth_token + + sinch.configuration.transport.request.side_effect = slow_fetch + + token_manager = TokenManager(sinch) + + results = [] + + def worker(): + barrier.wait() + results.append(token_manager.get_auth_token()) + + threads = [threading.Thread(target=worker) for _ in range(num_threads)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert sinch.configuration.transport.request.call_count == 1 + assert all(result is auth_token for result in results) + + +def test_refresh_auth_token_renews_once_under_concurrency(auth_token): + num_threads = 20 + barrier = threading.Barrier(num_threads) + sinch = Mock() + + def slow_fetch(endpoint): + time.sleep(0.05) + return auth_token + + sinch.configuration.transport.request.side_effect = slow_fetch + token_manager = TokenManager(sinch) + token_manager.token = OAuthToken( + access_token="old", expires_in=1, scope="", token_type="bearer" + ) + + results = [] + + def worker(): + barrier.wait() + results.append(token_manager.refresh_auth_token("old")) + + threads = [threading.Thread(target=worker) for _ in range(num_threads)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + assert sinch.configuration.transport.request.call_count == 1 + assert all(result is auth_token for result in results) + + +def test_invalidate_expired_token_emits_deprecation_warning(sinch_client_sync): + token_manager = TokenManager(sinch_client_sync) + + with pytest.warns(DeprecationWarning, match="invalidate_expired_token"): + token_manager.invalidate_expired_token() + + +def test_handle_invalid_token_emits_deprecation_warning(sinch_client_sync): + token_manager = TokenManager(sinch_client_sync) + response = Mock() + response.headers = {} + + with pytest.warns(DeprecationWarning, match="handle_invalid_token"): + token_manager.handle_invalid_token(response) From f7596033ee0c21643a275c4815d5d401fc1d2b9d Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Wed, 17 Jun 2026 16:12:04 +0200 Subject: [PATCH 10/12] feature(core): Implement HTTP 429 retry logic with backoff in HTTPTransport --- sinch/core/ports/http_transport.py | 112 +++++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 5 deletions(-) diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 6e97cf08..7dd7f46f 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -1,7 +1,11 @@ +import random +import time import warnings from abc import ABC +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime from platform import python_version -from typing import Optional +from typing import Optional, Union, overload from requests import Response from sinch.core.endpoint import HTTPEndpoint @@ -26,6 +30,11 @@ class HTTPTransport(ABC): ``send`` override path will be removed in 3.0. """ + MAX_RETRIES = 3 + RETRYABLE_STATUS_CODES = frozenset({429}) + BACKOFF_BASE_SECONDS = 1.0 + BACKOFF_GROWTH = 4 + def __init__(self, sinch): self.sinch = sinch self._legacy_send = self._uses_legacy_send() @@ -91,15 +100,108 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: request_data = self.prepare_request(endpoint) request_data = self.authenticate(endpoint, request_data) - http_response = self.send_request(request_data) + + http_response = self._send_with_retries(request_data) if self._should_refresh_token(endpoint, http_response): 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(request_data) + http_response = self._send_with_retries(request_data) return endpoint.handle_response(http_response) + + + def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: + """ + Sends a request, retrying rate-limited (HTTP 429) responses up to + MAX_RETRIES times with backoff between attempts. + + :param request_data: The prepared request to send, or, on the legacy + ``send`` path, the endpoint to call. + :type request_data: Union[HttpRequest, HTTPEndpoint] + :returns: The HTTP response from the last attempt. + :rtype: HTTPResponse + """ + num_retries = 0 + while True: + if isinstance(request_data, HTTPEndpoint): + http_response = self.send(request_data) + else: + http_response = self.send_request(request_data) + + if self._should_retry(http_response, num_retries): + time.sleep(self._compute_backoff(http_response, num_retries)) + num_retries += 1 + continue + else: + return http_response + + def _should_retry(self, http_response: HTTPResponse, num_retries: int) -> bool: + """ + Returns True when the response is a transient error and + retries remain. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: Whether the request should be retried. + :rtype: bool + """ + if num_retries >= self.MAX_RETRIES: + return False + return http_response.status_code in self.RETRYABLE_STATUS_CODES + + def _compute_backoff(self, http_response: HTTPResponse, num_retries: int) -> float: + """ + Computes how long to wait before the next retry. + + :param http_response: The response received. + :type http_response: HTTPResponse + :param num_retries: Number of retries already performed. + :type num_retries: int + :returns: The delay in seconds. + :rtype: float + """ + + headers = {key.lower(): value for key, value in http_response.headers.items()} + retry_after_seconds = self._parse_retry_after(headers.get("retry-after")) + if retry_after_seconds is not None: + return retry_after_seconds + random.uniform(0, 0.25) + + max_delay = self.BACKOFF_BASE_SECONDS * (self.BACKOFF_GROWTH ** num_retries) + return random.uniform(0, max_delay) + + @staticmethod + def _parse_retry_after(value: Optional[str]) -> Optional[float]: + """ + Parses a ``Retry-After`` header (delta-seconds or HTTP-date) into a + delay in seconds, or None if absent/unparseable. + + :param value: The raw header value. + :type value: Optional[str] + :returns: The delay in seconds, or None if absent/invalid. + :rtype: Optional[float] + """ + if not value: + return None + + try: + seconds = float(value) + return seconds if seconds >= 0 else None + except ValueError: + pass + + try: + retry_at = parsedate_to_datetime(value) + except (TypeError, ValueError): + return None + if retry_at is None: + return None + if retry_at.tzinfo is None: + retry_at = retry_at.replace(tzinfo=timezone.utc) + return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) def authenticate(self, endpoint: HTTPEndpoint, request_data: HttpRequest) -> HttpRequest: @@ -215,12 +317,12 @@ def _legacy_request(self, endpoint: HTTPEndpoint) -> HTTPResponse: :rtype: HTTPResponse """ token_before = self.sinch.configuration.token_manager.token - http_response = self.send(endpoint) + http_response = self._send_with_retries(endpoint) if self._should_refresh_token(endpoint, http_response): used_token = token_before.access_token if token_before else None self.sinch.configuration.token_manager.refresh_auth_token(used_token) - http_response = self.send(endpoint) + http_response = self._send_with_retries(endpoint) return endpoint.handle_response(http_response) From 0c47d31cf220495621f4b6a336432b987d06d8f4 Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 18 Jun 2026 10:08:57 +0200 Subject: [PATCH 11/12] feature(test): Add tests for HTTP 429 retry logic with backoff handling --- .../core/adapters/requests_http_transport.py | 9 +- sinch/core/ports/http_transport.py | 9 +- tests/unit/test_http_transport.py | 129 ++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index 926791e8..a61f1743 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -21,8 +21,9 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: :rtype: HTTPResponse """ self.sinch.configuration.logger.debug( - "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", - request_data.http_method, request_data.headers, request_data.request_body, request_data.url + "Sync HTTP request %s %s", + request_data.http_method, + request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -37,8 +38,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: response_body = self.deserialize_json_response(response) self.sinch.configuration.logger.debug( - "Sync HTTP response %s with headers: %s and body: %s from URL: %s", - response.status_code, response.headers, response_body, request_data.url + "Sync HTTP response %s", + response.status_code ) return HTTPResponse( diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py index 7dd7f46f..ca27ee60 100644 --- a/sinch/core/ports/http_transport.py +++ b/sinch/core/ports/http_transport.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from platform import python_version -from typing import Optional, Union, overload +from typing import Optional, Union from requests import Response from sinch.core.endpoint import HTTPEndpoint @@ -114,7 +114,7 @@ def request(self, endpoint: HTTPEndpoint) -> HTTPResponse: def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> HTTPResponse: """ - Sends a request, retrying rate-limited (HTTP 429) responses up to + Sends a request, retrying rate-limited responses up to MAX_RETRIES times with backoff between attempts. :param request_data: The prepared request to send, or, on the legacy @@ -133,7 +133,6 @@ def _send_with_retries(self, request_data: Union[HttpRequest, HTTPEndpoint]) -> if self._should_retry(http_response, num_retries): time.sleep(self._compute_backoff(http_response, num_retries)) num_retries += 1 - continue else: return http_response @@ -176,7 +175,7 @@ def _compute_backoff(self, http_response: HTTPResponse, num_retries: int) -> flo @staticmethod def _parse_retry_after(value: Optional[str]) -> Optional[float]: """ - Parses a ``Retry-After`` header (delta-seconds or HTTP-date) into a + Parses the Retry-After header into a delay in seconds, or None if absent/unparseable. :param value: The raw header value. @@ -197,8 +196,6 @@ def _parse_retry_after(value: Optional[str]) -> Optional[float]: retry_at = parsedate_to_datetime(value) except (TypeError, ValueError): return None - if retry_at is None: - return None if retry_at.tzinfo is None: retry_at = retry_at.replace(tzinfo=timezone.utc) return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds()) diff --git a/tests/unit/test_http_transport.py b/tests/unit/test_http_transport.py index 2636e9ce..cca39c5c 100644 --- a/tests/unit/test_http_transport.py +++ b/tests/unit/test_http_transport.py @@ -1,4 +1,6 @@ import json +import random +import time import pytest from unittest.mock import Mock from sinch.core.enums import HTTPAuthentication @@ -92,6 +94,12 @@ def mock_sinch(): return sinch +@pytest.fixture +def no_sleep(mocker): + mocker.patch.object(random, "uniform", return_value=0.0) + return mocker.patch.object(time, "sleep") + + @pytest.fixture def base_request(): return HttpRequest( @@ -276,6 +284,127 @@ def test_no_refresh_on_401_without_www_authenticate(self, mock_sinch): token_manager.refresh_auth_token.assert_not_called() +class TestRetryWithBackoff: + """Tests for the automatic retry-with-backoff on rate-limited (429) responses.""" + + @staticmethod + def _rate_limited(headers=None): + return _requests_response(429, body={"error": "rate limited"}, headers=headers) + + def test_retries_on_429_then_succeeds(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(), + self._rate_limited(), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 3 + assert no_sleep.call_count == 2 + + def test_gives_up_and_returns_last_response_after_max_retries(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=self._rate_limited()) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 429 + assert transport.http_session.request.call_count == HTTPTransport.MAX_RETRIES + 1 + assert no_sleep.call_count == HTTPTransport.MAX_RETRIES + + def test_no_retry_on_success(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(200, body={"ok": True})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 200 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_no_retry_on_non_retryable_status(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(return_value=_requests_response(400, body={"error": "bad"})) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + result = transport.request(endpoint) + + assert result.status_code == 400 + assert transport.http_session.request.call_count == 1 + no_sleep.assert_not_called() + + def test_honors_retry_after_header(self, mock_sinch, no_sleep): + transport = HTTPTransportRequests(mock_sinch) + transport.http_session.request = Mock(side_effect=[ + self._rate_limited(headers={"Retry-After": "7"}), + _requests_response(200, body={"ok": True}), + ]) + endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value) + + transport.request(endpoint) + + no_sleep.assert_called_once_with(7.0) + + +class TestShouldRetry: + def test_retries_429_while_attempts_remain(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is True + + def test_stops_when_max_retries_reached(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert transport._should_retry(response, num_retries=HTTPTransport.MAX_RETRIES) is False + + def test_does_not_retry_non_retryable_status(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=200, headers={}, body={}) + + assert transport._should_retry(response, num_retries=0) is False + + +class TestComputeBackoff: + def test_uses_retry_after_header_when_present(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={"Retry-After": "5"}, body={}) + + backoff = transport._compute_backoff(response, num_retries=0) + + assert 5.0 <= backoff < 5.25 + + def test_exponential_growth_when_no_header(self, mock_sinch): + transport = HTTPTransportRequests(mock_sinch) + response = HTTPResponse(status_code=429, headers={}, body={}) + + assert 0.0 <= transport._compute_backoff(response, num_retries=0) <= 1.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=1) <= 4.0 + assert 0.0 <= transport._compute_backoff(response, num_retries=2) <= 16.0 + + +class TestParseRetryAfter: + @pytest.mark.parametrize("value,expected", [ + ("5", 5.0), + ("0", 0.0), + ("-3", None), + ("abc", None), + ("", None), + (None, None), + ("Wed, 21 Oct 2015 07:28:00 GMT", 0.0), + ("Wed, 21 Oct 2015 07:28:00", 0.0), + ]) + def test_parse_retry_after(self, value, expected): + assert HTTPTransport._parse_retry_after(value) == expected + + class _LegacyTransport(HTTPTransport): """A pre-2.1 transport that overrides the deprecated ``send(endpoint)`` hook.""" From 3d383b263fc30b35d7eada6b5b74151cb1ede01b Mon Sep 17 00:00:00 2001 From: Marcos Lozano Romero Date: Thu, 18 Jun 2026 10:21:25 +0200 Subject: [PATCH 12/12] chore: update changelog --- CHANGELOG.md | 1 + sinch/core/adapters/requests_http_transport.py | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5982e2b9..1a467864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file. ### SDK +- **[feature]** `HTTPTransport` now automatically retries rate-limited (`HTTP 429`) responses up to 3 times with backoff. When the server sends a `Retry-After` header it is honoured; otherwise an exponential backoff with full jitter is used. If all retries are exhausted the last response is returned to the caller (#158). - **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities (#152). - **[fix]** Fixed a race condition in OAuth token creation and renewal under concurrent requests: `TokenManagerBase` now uses a lock with double-checked locking so the initial token is fetched exactly once, and a new `refresh_auth_token(used_token)` deduplicates concurrent renewals by only fetching when the stale token still matches the cached one (#156). - **[refactor]** `HTTPTransport` now prepares and authenticates requests in `request()`, so the new `send_request(request_data)` receives an already-prepared `HttpRequest` and acts as a pure I/O primitive, simplifying subclassing (#156). diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py index a61f1743..926791e8 100644 --- a/sinch/core/adapters/requests_http_transport.py +++ b/sinch/core/adapters/requests_http_transport.py @@ -21,9 +21,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: :rtype: HTTPResponse """ self.sinch.configuration.logger.debug( - "Sync HTTP request %s %s", - request_data.http_method, - request_data.url + "Sync HTTP request %s call with headers: %s and body: %s to URL: %s", + request_data.http_method, request_data.headers, request_data.request_body, request_data.url ) response = self.http_session.request( method=request_data.http_method, @@ -38,8 +37,8 @@ def send_request(self, request_data: HttpRequest) -> HTTPResponse: response_body = self.deserialize_json_response(response) self.sinch.configuration.logger.debug( - "Sync HTTP response %s", - response.status_code + "Sync HTTP response %s with headers: %s and body: %s from URL: %s", + response.status_code, response.headers, response_body, request_data.url ) return HTTPResponse(