diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0534969d..9b4ead71 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,18 +1,6 @@
name: Test Python SDK
on: [ push ]
-env:
- AUTH_ORIGIN: ${{ secrets.AUTH_ORIGIN }}
- CONVERSATION_ORIGIN: ${{ secrets.CONVERSATION_ORIGIN }}
- DISABLE_SSL: ${{ secrets.DISABLE_SSL }}
- KEY_ID: ${{ secrets.KEY_ID }}
- KEY_SECRET: ${{ secrets.KEY_SECRET }}
- NUMBERS_ORIGIN: ${{ secrets.NUMBERS_ORIGIN }}
- PROJECT_ID: ${{ secrets.PROJECT_ID }}
- SERVICE_PLAN_ID: ${{ secrets.SERVICE_PLAN_ID }}
- SMS_ORIGIN: ${{ secrets.SMS_ORIGIN }}
- TEMPLATES_ORIGIN: ${{ secrets.TEMPLATES_ORIGIN }}
-
jobs:
build:
runs-on: ubuntu-latest
@@ -45,6 +33,10 @@ jobs:
run: |
pip install python-dotenv
python scripts/check_snippet_coverage.py
+
+ - name: Generate Documentation
+ run: |
+ make docs
- name: Lint and format check with Ruff
run: |
@@ -69,7 +61,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: sinch/sinch-sdk-mockserver
- token: ${{ secrets.PAT_CI }}
+ token: ${{ secrets.MOCKSERVER_REPO_PAT_CI }}
fetch-depth: 0
path: sinch-sdk-mockserver
@@ -84,11 +76,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/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/.github/workflows/documentation-upload.yaml b/.github/workflows/documentation-upload.yaml
new file mode 100644
index 00000000..05f3ce93
--- /dev/null
+++ b/.github/workflows/documentation-upload.yaml
@@ -0,0 +1,82 @@
+name: Generate and upload documentation
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to use for the documentation package in semver format (e.g. 1.2.3)'
+ required: true
+
+jobs:
+ upload-documentation:
+ runs-on: ubuntu-latest
+ env:
+ SDK_NAME: sinch-sdk-python
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+
+ - name: Resolve Version
+ id: version
+ run: |
+ if [ "${{ github.event_name }}" = "release" ]; then
+ VERSION="${{ github.event.release.tag_name }}"
+ else
+ VERSION="${{ inputs.version }}"
+ fi
+ # Strip leading 'v' if present (e.g. v1.2.3 → 1.2.3)
+ VERSION="${VERSION#v}"
+ echo "value=${VERSION}" >> "$GITHUB_OUTPUT"
+
+ - name: Validate Version Format
+ run: |
+ VERSION="${{ steps.version.outputs.value }}"
+ SEMVER_REGEX='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((alpha|beta|preview)(\.[0-9]+)?))?$'
+ if [[ ! "$VERSION" =~ $SEMVER_REGEX ]]; then
+ echo "::error::Invalid version format: '$VERSION'. Expected semver (e.g. 1.2.3, 1.2.3-alpha, 1.2.3-beta.1, 1.2.3-preview)"
+ exit 1
+ fi
+ echo "Version '$VERSION' is valid"
+
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install dev dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements-dev.txt
+
+ - name: Generate Documentation
+ run: |
+ make docs
+
+ - name: Package Documentation
+ run: |
+ cd docs/build/html
+ zip -r "../../../${{ env.SDK_NAME }}-${{ steps.version.outputs.value }}.zip" .
+
+ - name: Upload to GitLab Registry
+ run: |
+ echo "Uploading documentation package to GitLab Registry..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --header "PRIVATE-TOKEN: ${{ secrets.GITLAB_REGISTRY_UPLOAD_DOC_TOKEN }}" \
+ --upload-file "./${{ env.SDK_NAME }}-${VERSION}.zip" \
+ "https://gitlab.com/api/v4/projects/63164411/packages/generic/${{ env.SDK_NAME }}/${VERSION}/${{ env.SDK_NAME }}-${VERSION}.zip"
+ echo "Documentation package for version ${VERSION} uploaded to GitLab Registry"
+
+ - name: Trigger Downstream GitLab Pipeline
+ run: |
+ echo "Triggering downstream GitLab pipeline to notify about new documentation package version..."
+ VERSION="${{ steps.version.outputs.value }}"
+ curl --fail --show-error --location --request POST \
+ --form "token=${{ secrets.GITLAB_NOTIFY_REGISTRY_UPLOADED_DOC_TOKEN }}" \
+ --form "ref=main" \
+ --form "variables[UPSTREAM_PACKAGE_NAME]=${{ env.SDK_NAME }}" \
+ --form "variables[UPSTREAM_PACKAGE_VERSION]=${VERSION}" \
+ "https://gitlab.com/api/v4/projects/63164411/trigger/pipeline"
+ echo "Documentation repo notified about new package version ${VERSION}"
diff --git a/.gitignore b/.gitignore
index 3246a258..9441fa0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,6 +51,7 @@ coverage.xml
.hypothesis/
.pytest_cache/
cover/
+lcov.info
# E2E features
*.feature
@@ -73,7 +74,9 @@ instance/
.scrapy
# Sphinx documentation
-docs/_build/
+docs/build/
+docs/api/
+
# PyBuilder
.pybuilder/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ea401a01..08cb5e6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,11 +16,39 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
---
+## v2.1.0 – 2026-06-30
+
+### SDK
+
+- **[feature]** OAuth token requests are now automatically retried (up to 3 times, with exponential backoff and jitter) when the authentication service is rate-limited (`HTTP 429`).
+- **[dependency]** Set up minimum version for `requests` to `>=2.0.0` to prevent pulling in versions with known vulnerabilities.
+- **[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.
+- **[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.
+- **[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.
+- **[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()`.
+- **[tech]** Removed unused GitHub environment secrets from CI workflow and simplified test fixtures to use hardcoded test values.
+- **[refactor]** Consolidated the duplicated per-domain `BaseModelConfiguration` classes into three shared base classes in `sinch.core.models.internal` (`BaseConfigModel`, `SnakeCaseExtrasModel`, `CamelCaseDumpModel`).
+- **[doc]** Improve README structure and content.
+
+
+### 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)).
+
+
+### Numbers
+
+- **[feature]** `NumberSinchEvent` synchronized with the Numbers OAS spec: new `internal_failure_code` field, additional `status` values (`IN_REVIEW`, `BLOCKED`, `COMPLETED`, `REJECTED`, `EXPIRED`), a new `NUMBER_ORDER_PROCESSING` event type, and corrected event type `DEPROVISIONING_FROM_VOICE_PLATFORM` (previously `DEPROVISIONING_TO_VOICE_PLATFORM`).
+
+---
+
## v2.0.1 – 2026-06-02
### SMS
-- **[fix]** SMS paginator fix (#145).
+- **[fix]** SMS paginator fix.
---
@@ -32,52 +60,52 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### SDK
-- **[design]** Requires explicit `sms_region` and `conversation_region` on `SinchClient` before using SMS and Conversation APIs (no silent US/EU defaults); runtime failure if unset (#49, #110).
-- **[design]** The v1 asynchronous client and httpx-based async stack were removed; only synchronous `SinchClient` is supported (#55).
-- **[feature]** Automatic pagination via a shared `Paginator` pattern for paged list APIs (#46, #54).
-- **[doc]** README and configuration tests clarify which credentials apply to each API (#48).
+- **[design]** Requires explicit `sms_region` and `conversation_region` on `SinchClient` before using SMS and Conversation APIs (no silent US/EU defaults); runtime failure if unset.
+- **[design]** The v1 asynchronous client and httpx-based async stack were removed; only synchronous `SinchClient` is supported.
+- **[feature]** Automatic pagination via a shared `Paginator` pattern for paged list APIs.
+- **[doc]** README and configuration tests clarify which credentials apply to each API.
### Conversation
-- **[feature]** Messages API refresh: convenience send methods (`send_text_message()`, `send_card_message()`, `send_carousel_message()`, `send_choice_message()`, `send_contact_info_message()`, `send_list_message()`, `send_location_message()`, `send_media_message()`, `send_template_message()`), `list()` as a paginator, `update()`, `event_destination_target` (wires `callback_url`), and a `sinch_client.conversation.sinch_events(...)` helper for inbound event handling (#109–#120).
-- **[feature]** Conversation Sinch Events (webhooks) support (#122, #131, #132, #133).
-- **[design]** Conversation webhook REST client removed; handle inbound traffic via Sinch Events (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)) (#131, #132, #133).
+- **[feature]** Messages API refresh: convenience send methods (`send_text_message()`, `send_card_message()`, `send_carousel_message()`, `send_choice_message()`, `send_contact_info_message()`, `send_list_message()`, `send_location_message()`, `send_media_message()`, `send_template_message()`), `list()` as a paginator, `update()`, `event_destination_target` (wires `callback_url`), and a `sinch_client.conversation.sinch_events(...)` helper for inbound event handling.
+- **[feature]** Conversation Sinch Events (webhooks) support.
+- **[design]** Conversation webhook REST client removed; handle inbound traffic via Sinch Events (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)).
### Numbers
-- **[feature]** Flatter API (`rent`, `list`, `event_destinations`, and related entry points), `event_destination_target`, and a Numbers Sinch Events helper (#44, #53, #57, #58, #59, #62).
-- **[feature]** Available Regions endpoint (#56).
-- **[feature]** Webhook helper validates the signing secret in `validate_signature_header()` (#61).
-- **[tech]** Numbers Events payloads are passed through without client-side pre-processing (#63).
+- **[feature]** Flatter API (`rent`, `list`, `event_destinations`, and related entry points), `event_destination_target`, and a Numbers Sinch Events helper.
+- **[feature]** Available Regions endpoint.
+- **[feature]** Webhook helper validates the signing secret in `validate_signature_header()`.
+- **[tech]** Numbers Events payloads are passed through without client-side pre-processing.
### Number Lookup
-- **[feature]** Number Lookup v1 (lookup API, models, snippets, and E2E coverage) (#99, #101, #104).
+- **[feature]** Number Lookup v1 (lookup API, models, snippets, and E2E coverage).
### SMS
-- **[design]** SMS client configuration and authentication paths refactored for project vs service-plan credentials, including delivery-report flows (#90).
-- **[feature]** SMS delivery reports models and pagination (#87). `groups` and `inbounds` are planned for a future release (see migration guide).
-- **[feature]** SMS Sinch Events (webhooks) support (#103).
-- **[doc]** SMS migration guide and Sinch events quickstart material (#107, #108).
+- **[design]** SMS client configuration and authentication paths refactored for project vs service-plan credentials, including delivery-report flows.
+- **[feature]** SMS delivery reports models and pagination. `groups` and `inbounds` are planned for a future release (see migration guide).
+- **[feature]** SMS Sinch Events (webhooks) support.
+- **[doc]** SMS migration guide and Sinch events quickstart material.
### Verification
-- **[design]** Verification V1 APIs are removed (#124). V2 Verification support is planned in a future release (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)).
+- **[design]** Verification V1 APIs are removed. V2 Verification support is planned in a future release (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)).
### Voice
-- **[design]** Voice V1 APIs are removed (#124). V2 Voice support is planned in a future release (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)).
+- **[design]** Voice V1 APIs are removed. V2 Voice support is planned in a future release (see [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md)).
### Others
- **[dependency]** Pydantic v2 (`pydantic = ">=2.0.0"`).
-- **[releasing]** Ruff linter adoption (#84) and CI workflow updates (#121).
-- **[doc]** Snippets and Getting Started live under `examples/` (#95, #98, #105, #106, #115, #118, #125, #127).
-- **[doc]** Docstring updates across Numbers, SMS, and Conversation (#64, #65, #67, #96, #97).
-- **[refactor]** Model, type, and class renames (#60, #70, #82).
-- **[tech]** Conversation, Numbers, and SMS models and endpoints resynchronized with OpenAPI specification (#68, #75, #123, #128, #129, #134, #135).
-- **[test]** E2E test infrastructure and refactoring (#45, #66, #102); SMS batches test coverage (#91, #93, #94).
+- **[releasing]** Ruff linter adoption and CI workflow updates.
+- **[doc]** Snippets and Getting Started live under `examples/`.
+- **[doc]** Docstring updates across Numbers, SMS, and Conversation.
+- **[refactor]** Model, type, and class renames.
+- **[tech]** Conversation, Numbers, and SMS models and endpoints resynchronized with OpenAPI specification.
+- **[test]** E2E test infrastructure and refactoring; SMS batches test coverage.
---
@@ -85,8 +113,8 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### SDK
-- **[feature]** Python 3.13 and 3.14 support (#86, #89).
-- **[releasing]** CI updates (#86, #89).
+- **[feature]** Python 3.13 and 3.14 support.
+- **[releasing]** CI updates.
---
@@ -94,7 +122,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### Voice
-- **[fix]** DTMF conference fix (#79).
+- **[fix]** DTMF conference fix.
---
@@ -103,11 +131,11 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### Test
- **[test]** End-to-end tests disabled.
-- **[test]** Tests and CI improvements (#41, #42, #43, #74).
+- **[test]** Tests and CI improvements.
### SDK
-- **[fix]** Async authentication fixes (#41, #42, #43, #74).
+- **[fix]** Async authentication fixes.
### Chore
@@ -120,7 +148,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### SDK
-- **[fix]** Remove aiohttp leftovers (#40).
+- **[fix]** Remove aiohttp leftovers.
---
@@ -128,7 +156,7 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### Chore
-- **[releasing]** CI strategy updates (#34, #36, #29, #17).
+- **[releasing]** CI strategy updates.
### Verification
@@ -148,9 +176,9 @@ All notable changes to the **Sinch Python SDK** are documented in this file.
### SDK
-- **[feature]** Verification API (#26, #27, #28).
-- **[feature]** Voice API (#26, #27, #28).
+- **[feature]** Verification API.
+- **[feature]** Voice API.
### Chore
-- **[doc]** General availability README updates (#26, #27, #28).
+- **[doc]** General availability README updates.
diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md
index 19148549..326cf343 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.
@@ -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.groups` and `sinch.sms.inbounds` are not supported yet and will be available in future minor versions.
+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
@@ -230,6 +228,71 @@ 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
+
+###### 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]`** |
+
+##### 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)
@@ -244,6 +307,7 @@ Note that `sinch.sms.groups` and `sinch.sms.inbounds` are not supported yet and
##### Replacement models
+
| Old class | New class |
|-----------|-----------|
| `UpdateNumbersCallbackConfigurationRequest` | `UpdateEventDestinationRequest` |
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..9d123075
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,21 @@
+.PHONY: docs
+
+docs:
+ rm -rf docs/build
+ rm -rf docs/api
+ sphinx-apidoc --force --separate --no-toc --maxdepth 2 \
+ --templatedir docs/_templates/apidoc -o docs/api sinch \
+ "sinch/core/models/base_model.py" \
+ "sinch/core/models/utils.py" \
+ "sinch/core/deserializers.py" \
+ "sinch/core/endpoint.py" \
+ "sinch/core/enums.py" \
+ "sinch/core/types.py" \
+ "sinch/domains/sms/enums.py" \
+ "sinch/*/internal" "sinch/*/internal/*" \
+ "sinch/*/api/v1/base" "sinch/*/api/v1/base/*" \
+ "sinch/*/api/v1/utils" "sinch/*/api/v1/utils/*" \
+ "sinch/domains/authentication/endpoints" "sinch/domains/authentication/endpoints/*" \
+ "sinch/domains/authentication/sinch_events" "sinch/domains/authentication/sinch_events/*" \
+ "sinch/domains/numbers/models/v1/utils" "sinch/domains/numbers/models/v1/utils/*"
+ sphinx-build -b html docs docs/build/html
diff --git a/README.md b/README.md
index 92811ffa..aa9a1c6c 100644
--- a/README.md
+++ b/README.md
@@ -1,46 +1,60 @@
# Sinch Python SDK
-[](https://github.com/sinch/sinch-sdk-python/blob/main/LICENSE)
-
-
-[](https://www.python.org/downloads/release/python-390/)
-[](https://www.python.org/downloads/release/python-3100/)
-[](https://www.python.org/downloads/release/python-3110/)
-[](https://www.python.org/downloads/release/python-3120/)
-[](https://www.python.org/downloads/release/python-3130/)
-[](https://www.python.org/downloads/release/python-3140/)
-
+[](https://www.python.org/) [](https://pypi.org/project/sinch/) [](https://github.com/sinch/sinch-sdk-python/blob/main/LICENSE)
Here you'll find documentation related to the Sinch Python SDK, including how to install it, initialize it, and start developing Python code using Sinch services.
To use Sinch services, you'll need a Sinch account and access keys. You can sign up for an account and create access keys at [dashboard.sinch.com](https://dashboard.sinch.com).
-For more information on the Sinch APIs on which this SDK is based, refer to the official [developer documentation portal](https://developers.sinch.com).
+For more information on the SDK, refer to the dedicated [Python SDK documentation](https://developers.sinch.com/docs/sdks/python) section, and for the Sinch APIs on which this SDK is based, refer to the official [developer documentation portal](https://developers.sinch.com).
+## Table of contents:
+
- [Prerequisites](#prerequisites)
- [Installation](#installation)
+- [Supported APIs](#supported-apis)
- [Getting started](#getting-started)
- [Logging](#logging)
+- [Handling Exceptions](#handling-exceptions)
+- [Custom HTTP client implementation](#custom-http-client-implementation)
+- [Third-party dependencies](#third-party-dependencies)
+- [Examples](#examples)
+- [Changelog and Migration](#changelog--migration)
+- [License](#license)
+- [Contact](#contact)
+
## Prerequisites
-- Python in one of the supported versions - 3.9, 3.10, 3.11, 3.12, 3.13, 3.14
-- pip
-- Sinch account
+- [Python](https://www.python.org/) in one of the supported versions - [3.9](https://www.python.org/downloads/release/python-390/), [3.10](https://www.python.org/downloads/release/python-3100/), [3.11](https://www.python.org/downloads/release/python-3110/), [3.12](https://www.python.org/downloads/release/python-3120/), [3.13](https://www.python.org/downloads/release/python-3130/), [3.14](https://www.python.org/downloads/release/python-3140/)
+- [pip](https://pip.pypa.io/en/stable/)
+- [Sinch account](https://dashboard.sinch.com/)
+
+> **Warning**:
+> This SDK is intended for server-side (backend) use only. Do not use it in front-end or client-side applications (web, mobile, or desktop), regardless of language or framework. Doing so can expose your Sinch credentials to end-users.
## Installation
-You can install this package by typing:
-`pip install sinch`
+Run the following command to install the SDK:
+
+```bash
+pip install sinch
+```
+
+
+## Supported APIs
-## Products
-The Sinch client provides access to the following Sinch products:
-- Numbers API
-- SMS API
-- Conversation API (beta release)
+| API Category | API Name |
+|-------------------|-----------------------------|
+| Messaging | [Conversation API](https://developers.sinch.com/docs/conversation/) |
+| | [SMS API](https://developers.sinch.com/docs/sms/) |
+| Numbers | [Numbers API](https://developers.sinch.com/docs/numbers/) |
+| Verification | [Number Lookup API](https://developers.sinch.com/docs/number-lookup/) |
+
+> **Note:** The SMS API is end-of-sale. New integrations should use the [Conversation API](https://developers.sinch.com/docs/conversation/) instead, which supports SMS and many other channels.
## Getting started
@@ -48,117 +62,166 @@ The Sinch client provides access to the following Sinch products:
### Client initialization
-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.
+To start using the SDK, initialize the main client class. This client gives you access to all the SDK services:
+```python
+import os
+from sinch import SinchClient
-#### SMS API
+# Warning: not all APIs support project authentication. Check the section for each API before using this snippet.
-The SMS API supports two authentication methods. `sms_region` is required for both and has no default.
+sinch_client = SinchClient(
+ project_id=os.environ["SINCH_PROJECT_ID"],
+ key_id=os.environ["SINCH_KEY_ID"],
+ key_secret=os.environ["SINCH_KEY_SECRET"],
+)
+```
-**Project auth (OAuth2)**
+Get `project_id`, `key_id` and `key_secret` from the [Access keys](https://dashboard.sinch.com/settings/access-keys) page in your Sinch dashboard (`key_secret` is shown only once, at creation time). It's highly recommended to not hardcode these credentials: load them from environment variables for local development, and from a secret manager in production.
-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`.
+This snippet is the common starting point for every API. Some APIs have a different initialization or need extra parameters (for example, a region), see the section for each API.
-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`.
+### Conversation API
-> **Note:** the `keySecret` is visible only when you create the Access Key. Store it safely and create a new Access Key if you have lost it.
+The Conversation API is regionalized. To use this API, the `conversation_region` parameter is required:
```python
-from sinch import SinchClient
-
sinch_client = SinchClient(
- project_id="project_id",
- key_id="key_id",
- key_secret="key_secret",
- sms_region="us"
+ project_id=os.environ["SINCH_PROJECT_ID"],
+ key_id=os.environ["SINCH_KEY_ID"],
+ key_secret=os.environ["SINCH_KEY_SECRET"],
+ conversation_region="eu",
)
```
-**Service Plan ID auth (legacy)**
-
-Uses a static bearer token that never expires.
-Support all regions: `us`, `eu`, `br`, `ca`, `au`.
+#### Sinch Events
-In your [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services), you will find your `servicePlanId` and `apiToken` (bearer token).
+The Conversation API delivers asynchronous Sinch Events to the Event Destination URL you configure for your app in the [Conversation dashboard](https://dashboard.sinch.com/convapi/apps). `validate_authentication_header` confirms a request comes from Sinch and `parse_event` turns its payload into a typed event object; `headers` and `raw_body` are the incoming request's headers and raw body:
```python
-from sinch import SinchClient
+sinch_events = sinch_client.conversation.sinch_events(SINCH_EVENT_SECRET)
+is_valid = sinch_events.validate_authentication_header(headers=headers, json_payload=raw_body)
+event = sinch_events.parse_event(raw_body, headers)
+```
+
+`SINCH_EVENT_SECRET` is optional and set per app in the [Conversation dashboard](https://dashboard.sinch.com/convapi/apps). `parse_event` works without validating the request, but then its origin can't be verified, so calling `validate_authentication_header` (which returns `True`/`False`) is recommended in production.
+
+You can find a complete example in [examples/sinch_events/conversation_api](./examples/sinch_events/conversation_api).
+
+### SMS API
+
+> **Warning:** the SMS API is end-of-sale. For new integrations, prefer the [Conversation API](#conversation-api).
+
+The SMS API is regionalized: set `sms_region` to the region where your SMS account is hosted. The accepted values are `us`, `eu`, `au`, `br` and `ca`, and the region also determines which credentials you can use:
+- **Project access keys** — available only in the `us` and `eu` regions. Use the same `project_id`, `key_id` and `key_secret` as the common client, plus `sms_region`:
+
+```python
sinch_client = SinchClient(
- service_plan_id="service_plan_id",
- sms_api_token="api_token",
- sms_region="us"
+ project_id=os.environ["SINCH_PROJECT_ID"],
+ key_id=os.environ["SINCH_KEY_ID"],
+ key_secret=os.environ["SINCH_KEY_SECRET"],
+ sms_region="us",
)
```
-#### Conversation API - Project auth (OAuth2)
+> **SMS authentication for new projects**
+>
+> Projects created after the SMS API end-of-sale (`15/04/26`) cannot use
+> project access keys — the SMS API requests return `401 Unauthorized`.
+>
+> If you encounter this issue, consider the following options:
+>
+> 1. Use service plan credentials (`service_plan_id` + `sms_api_token`)
+> 2. Use the Conversation API, which works with project access keys.
+> 3. Contact your account manager
-`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.
+- **Service plan** — available in all regions (`us`, `eu`, `au`, `br`, `ca`). Use a `service_plan_id` and `sms_api_token`, both available on the [Service APIs dashboard](https://dashboard.sinch.com/sms/api/services):
```python
-from sinch import SinchClient
-
sinch_client = SinchClient(
- project_id="project_id",
- key_id="key_id",
- key_secret="key_secret",
- conversation_region="eu"
+ service_plan_id=os.environ["SINCH_SERVICE_PLAN_ID"],
+ sms_api_token=os.environ["SINCH_SMS_API_TOKEN"],
+ sms_region="us",
)
```
-> **SMS integration note:** If you also use the SMS API, `sms_region` and `conversation_region` **must match**. Mismatched regions will cause delivery failures.
+> **Note:** if you use both the SMS and the [Conversation API](#conversation-api)
+> from the same client, set `sms_region` and `conversation_region` to the same
+> region. Mismatched regions cause delivery failures.
-#### Other APIs - Project auth (OAuth2)
+#### Sinch Events
-These APIs are not regionalized and use project-based auth.
+The SMS API delivers asynchronous Sinch Events to an Event Destination, whose URL is set per batch with the `event_destination_target` parameter on the send, update and replace operations (for example `sinch_client.sms.batches.send_sms`). `validate_authentication_header` confirms a request comes from Sinch and `parse_event` turns its payload into a typed event object; `headers` and `raw_body` are the incoming request's headers and raw body:
```python
-from sinch import SinchClient
+sinch_events = sinch_client.sms.sinch_events(SINCH_EVENT_SECRET)
+is_valid = sinch_events.validate_authentication_header(headers=headers, json_payload=raw_body)
+event = sinch_events.parse_event(raw_body, headers)
+```
-sinch_client = SinchClient(
- project_id="project_id",
- key_id="key_id",
- key_secret="key_secret",
-)
+Signature authentication for SMS events must be enabled for your account by your account manager; until then the signature headers are absent and `parse_event` can be used on its own. See the [SMS events documentation](https://developers.sinch.com/docs/sms/api-reference/sms/tag/Webhooks/#tag/Webhooks/section/Callbacks).
+
+You can find a complete example in [examples/sinch_events/sms_api](./examples/sinch_events/sms_api).
+
+### Numbers API
+
+The Numbers API needs no extra parameters, use the [common client](#client-initialization) based in project authentication shown above.
+
+#### Sinch Events
+
+The Numbers API delivers asynchronous Sinch Events to the Event Destination you configure through `sinch_client.numbers.event_destinations`. `validate_authentication_header` confirms a request comes from Sinch and `parse_event` turns its payload into a typed event object; `headers` and `raw_body` are the incoming request's headers and raw body:
+
+```python
+sinch_events = sinch_client.numbers.sinch_events(SINCH_EVENT_SECRET)
+is_valid = sinch_events.validate_authentication_header(headers=headers, json_payload=raw_body)
+event = sinch_events.parse_event(raw_body, headers)
```
-## Logging
+`SINCH_EVENT_SECRET` is the value configured on the Event Destination. `parse_event` works without validating the request, but then its origin can't be verified, so calling `validate_authentication_header` is recommended in production.
-Logging configuration for this SDK utilizes following hierarchy:
-1. If no configuration was provided via `logger_name` or `logger` configurable, SDK will inherit configuration from the root logger with the `Sinch` prefix.
-2. If `logger_name` configurable was provided, SDK will use logger related to that name. For example: `myapp.sinch` will inherit configuration from the `myapp` logger.
-3. If `logger` (logger instance) configurable was provided, SDK will use that particular logger for all its logging operations.
+You can find a complete example in [examples/sinch_events/numbers_api](./examples/sinch_events/numbers_api).
-If all logging returned by this SDK needs to be disabled, usage of `NullHandler` provided by the standard `logging` module is advised.
+### Number Lookup API
+
+The Number Lookup API needs no extra parameters, use the [common client](#client-initialization) based in project authentication shown above.
-
-## Sample apps
-Usage example of the Numbers API via [`VirtualNumbers`](sinch/domains/numbers/virtual_numbers.py) on the client (`sinch_client.numbers`)—`list()` returns your project’s active virtual numbers:
+### Your First Request
+
+Once your client is configured, you can send your first message. The example below uses the Conversation API to send a simple text message over SMS. Replace CONVERSATION_APP_ID with your app ID and RECIPIENT_PHONE_NUMBER with the recipient's phone number:
```python
-paginator = sinch_client.numbers.list(
- region_code="US",
- number_type="LOCAL",
+response = sinch_client.conversation.messages.send(
+ app_id="CONVERSATION_APP_ID",
+ message={
+ "text_message": {
+ "text": "[Python SDK: Conversation Message] Sample text message",
+ },
+ },
+ recipient_identities=[
+ {
+ "channel": "SMS",
+ "identity": "RECIPIENT_PHONE_NUMBER",
+ }
+ ],
)
-for active_number in paginator.iterator():
- print(active_number)
+
+print(f"Successfully sent message.\n{response}")
```
-Returned values are [Pydantic](https://docs.pydantic.dev/) model instances (for example [`ActiveNumber`](sinch/domains/numbers/models/v1/response/active_number.py)), including fields such as `phone_number`, `region_code`, `type`, and `capabilities`.
+## Logging
+
+Logging configuration for this SDK utilizes following hierarchy:
+1. If no configuration was provided via `logger_name` or `logger` configurable, SDK will inherit configuration from the root logger with the `Sinch` prefix.
+2. If `logger_name` configurable was provided, SDK will use logger related to that name. For example: `myapp.sinch` will inherit configuration from the `myapp` logger.
+3. If `logger` (logger instance) configurable was provided, SDK will use that particular logger for all its logging operations.
-More examples live under [examples/snippets](examples/snippets) on the `main` branch.
+If all logging returned by this SDK needs to be disabled, usage of `NullHandler` provided by the standard `logging` module is advised.
-### Handling exceptions
+## Handling exceptions
Each API throws a custom, API related exception for an unsuccessful backed call.
@@ -178,7 +241,6 @@ except NumbersException as err:
For handling all possible exceptions thrown by this SDK use `SinchException` (superclass of all Sinch exceptions) from `sinch.core.exceptions`.
-
## Custom HTTP client implementation
By default, the HTTP implementation uses the `requests` library.
@@ -193,7 +255,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 +266,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(
@@ -241,9 +301,35 @@ sinch_client.configuration.transport = MyHTTPImplementation(
)
```
-Note: Asynchronous HTTP clients are not supported.
-The transport must be a synchronous implementation.
+> **Note:** Asynchronous HTTP clients are not supported. The transport must be
+> a synchronous implementation.
+
+
+## Third-party dependencies
+The SDK relies on the following third-party dependencies:
+- [requests](https://requests.readthedocs.io/): HTTP client used as the default transport for all API calls.
+- [pydantic](https://docs.pydantic.dev/): Data validation and serialization for request and response models.
+
+## Examples
+
+You can find:
+ - a Python example of each request in the [examples/snippets](./examples/snippets) folder.
+ - getting started guides for specific use cases in the [examples/getting-started](./examples/getting-started) folder.
+ - server-side event handling examples in the [examples/sinch_events](./examples/sinch_events) folder.
+
+## Changelog & Migration
+
+For information about the latest changes in the SDK, please refer to the [CHANGELOG](CHANGELOG.md) file
+and the [MIGRATION_GUIDE](MIGRATION_GUIDE.md) for instructions on how to update your code when upgrading to a new major version of the SDK.
## License
-This project is licensed under the Apache License. See the [LICENSE](LICENSE) file for the license text.
+This project is licensed under the Apache License.
+
+See the [LICENSE](LICENSE) file for the license text.
+
+
+## Contact
+
+Developer Experience engineering team: [team-developer-experience@sinch.com](mailto:team-developer-experience@sinch.com)
+
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
new file mode 100644
index 00000000..91609f05
--- /dev/null
+++ b/docs/_static/custom.css
@@ -0,0 +1,21 @@
+/* ---------------------------------------------------------------------------
+ Multi-line signatures (one parameter per line).
+
+ When a signature is wrapped, Sphinx renders each parameter as a
inside
+ a nested . The Read the Docs theme styles every /- with borders,
+ background tint and vertical margins, which leak into the signature and draw
+ ugly "lines" between parameters. Flatten that nested list so the parameters
+ read as a clean indented column.
+--------------------------------------------------------------------------- */
+.rst-content .sig dl,
+.rst-content .sig dd {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: none;
+}
+
+/* Keep each parameter indented under the opening parenthesis. */
+.rst-content .sig dd {
+ margin-left: 2em;
+}
diff --git a/docs/_templates/apidoc/module.rst.jinja b/docs/_templates/apidoc/module.rst.jinja
new file mode 100644
index 00000000..c694aaee
--- /dev/null
+++ b/docs/_templates/apidoc/module.rst.jinja
@@ -0,0 +1,8 @@
+{%- if show_headings %}
+{{- [basename, "module"] | join(" ") | e | heading }}
+
+{% endif -%}
+.. automodule:: {{ qualname }}
+{%- for option in automodule_options %}
+ :{{ option }}:
+{%- endfor %}
diff --git a/docs/_templates/apidoc/package.rst.jinja b/docs/_templates/apidoc/package.rst.jinja
new file mode 100644
index 00000000..4e34d501
--- /dev/null
+++ b/docs/_templates/apidoc/package.rst.jinja
@@ -0,0 +1,39 @@
+{%- macro automodule(modname, options) -%}
+.. automodule:: {{ modname }}
+{%- for option in options %}
+ :{{ option }}:
+{%- endfor %}
+{%- endmacro %}
+
+{%- macro toctree(docnames) -%}
+.. toctree::
+ :maxdepth: {{ maxdepth }}
+{% for docname in docnames %}
+ {{ docname }}
+{%- endfor %}
+{%- endmacro %}
+
+{{- [pkgname, "package"] | join(" ") | e | heading }}
+
+{%- if is_namespace %}
+.. py:module:: {{ pkgname }}
+{% endif %}
+
+{%- if subpackages %}
+{{ toctree(subpackages) }}
+{% endif %}
+
+{%- if submodules %}
+{% if separatemodules %}
+{{ toctree(submodules) }}
+{% else %}
+{% for submodule in submodules %}
+{{ [submodule.split(".")[-1], "module"] | join(" ") | e | heading(2) }}
+{{ automodule(submodule, automodule_options) }}
+{% endfor %}
+{%- endif %}
+{%- endif %}
+
+{%- if not is_namespace %}
+{{ automodule(pkgname, automodule_options) }}
+{% endif %}
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 00000000..5d8b159e
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,62 @@
+import os
+import sys
+
+# Allow autodoc to import the sinch package from the project root
+sys.path.insert(0, os.path.abspath(".."))
+from sinch import __version__
+
+# -- Project information -------------------------------------------------------
+
+project = "Sinch Python SDK"
+copyright = "2026, Sinch Developer Experience Team"
+author = "Sinch Developer Experience Team"
+release = __version__
+
+# -- General configuration -----------------------------------------------------
+
+extensions = [
+ # Pulls docstrings from Python source into the generated .rst files
+ "sphinx.ext.autodoc",
+ # Adds [source] links that open the highlighted source file
+ "sphinx.ext.viewcode",
+]
+
+# The .rst files under api/ are generated by the `sphinx-apidoc` CLI invoked
+# from the Makefile (`make docs`), using the custom templates in
+# _templates/apidoc/ to strip the "package" suffix and the
+# "Subpackages"/"Submodules"/"Module contents" headings. The
+# sphinx.ext.apidoc extension is intentionally NOT used because it ignores
+# the template directory.
+
+# -- sphinx.ext.autodoc --------------------------------------------------------
+
+autodoc_default_options = {
+ # Document all public members (methods, attributes, nested classes)
+ "members": True,
+ 'undoc-members': True,
+ # Show the class inheritance chain
+ "show-inheritance": True,
+ # Preserve the order in which members appear in the source file
+ "member-order": "bysource",
+}
+
+# Render type hints as part of the parameter/return descriptions, not the signature
+autodoc_typehints = "both"
+python_maximum_signature_line_length = 88
+
+# -- HTML output ---------------------------------------------------------------
+
+html_theme = "sphinx_rtd_theme"
+
+html_theme_options = {
+ # Maximum depth of the navigation sidebar (-1 = unlimited)
+ "navigation_depth": -1,
+ # Keep all navigation entries expanded by default
+ "collapse_navigation": False,
+}
+
+# Directory with extra static files (custom CSS, etc.), relative to this conf.py
+html_static_path = ["_static"]
+
+# Extra stylesheets loaded after the theme's own CSS
+html_css_files = ["custom.css"]
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 00000000..9cb6c3fe
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,8 @@
+Sinch Python SDK
+================
+
+.. toctree::
+ :maxdepth: 3
+ :caption: API Reference
+
+ api/sinch
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/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/pyproject.toml b/pyproject.toml
index 289f153f..b82833df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[tool.poetry]
name = "sinch"
description = "Sinch SDK for Python programming language"
-version = "2.0.1"
+version = "2.1.0"
license = "Apache 2.0"
readme = "README.md"
authors = [
@@ -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..9f83aafa 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -8,7 +8,12 @@ behave
ruff
# HTTP Libraries
-requests
+requests>=2.0.0
# Data Validation
-pydantic >= 2.0.0
\ No newline at end of file
+pydantic >= 2.0.0
+
+# Documentation
+# Sphinx 7.1 introduced python_maximum_signature_line_length and 7.x is also the last series supporting Python 3.9.
+sphinx >= 7.1
+sphinx-rtd-theme >= 2.0
diff --git a/sinch/__init__.py b/sinch/__init__.py
index a3df16eb..14ccc3e0 100644
--- a/sinch/__init__.py
+++ b/sinch/__init__.py
@@ -1,5 +1,5 @@
""" Sinch Python SDK"""
-__version__ = "2.0.1"
+__version__ = "2.1.0"
from sinch.core.clients.sinch_client_sync import SinchClient
diff --git a/sinch/core/adapters/requests_http_transport.py b/sinch/core/adapters/requests_http_transport.py
index 62c0a3cb..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} and body: {request_data.request_body} 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/endpoint.py b/sinch/core/endpoint.py
index 30b4b6f7..bd498d53 100644
--- a/sinch/core/endpoint.py
+++ b/sinch/core/endpoint.py
@@ -4,6 +4,7 @@
class HTTPEndpoint(ABC):
ENDPOINT_URL = None
+ IS_RETRYABLE = False
@property
@abstractmethod
diff --git a/sinch/domains/sms/enums.py b/sinch/core/models/internal/__init__.py
similarity index 100%
rename from sinch/domains/sms/enums.py
rename to sinch/core/models/internal/__init__.py
diff --git a/sinch/core/models/internal/base_model_config.py b/sinch/core/models/internal/base_model_config.py
new file mode 100644
index 00000000..956352bc
--- /dev/null
+++ b/sinch/core/models/internal/base_model_config.py
@@ -0,0 +1,95 @@
+import re
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, SerializationInfo, model_serializer
+from pydantic.functional_serializers import SerializerFunctionWrapHandler
+
+
+def _to_camel_case(snake_str: str) -> str:
+ """Convert ``snake_case`` to ``camelCase`` preserving consecutive underscores.
+
+ :param snake_str: The snake_case input string.
+ :returns: The camelCase form, or the input unchanged when it contains no
+ underscore.
+ """
+ if not snake_str or "_" not in snake_str:
+ return snake_str
+ components = snake_str.split("_")
+ return components[0].lower() + "".join(
+ (x.capitalize() if x else "_") for x in components[1:]
+ )
+
+
+def _to_snake_case(camel_str: str) -> str:
+ """Convert ``camelCase`` to ``snake_case``.
+
+ :param camel_str: The camelCase input string.
+ :returns: The snake_case form.
+ """
+ return re.sub(r"(? Any:
+ """Recursively camelize dict keys, walking into nested dicts and lists.
+
+ :param value: An arbitrary JSON-like value.
+ :returns: The same structure with every ``snake_case`` dict key converted
+ to ``camelCase``.
+ """
+ if isinstance(value, dict):
+ return {_to_camel_case(k): _camelize_keys(v) for k, v in value.items()}
+ if isinstance(value, list):
+ return [_camelize_keys(item) for item in value]
+ return value
+
+
+class _SnakifyExtrasOnInit:
+ """Normalize ``__pydantic_extra__`` keys to ``snake_case`` at validation time."""
+
+ def model_post_init(self, __context: Any) -> None:
+ extra = self.__pydantic_extra__
+ if extra:
+ self.__pydantic_extra__ = {_to_snake_case(k): v for k, v in extra.items()}
+
+
+class _CamelizeKeysOnDump:
+ """Recursively rewrite every dict key in the serialized output to
+ ``camelCase`` when ``by_alias=True``.
+ """
+
+ @model_serializer(mode="wrap")
+ def _serialize_with_camel_extras(
+ self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
+ ) -> dict:
+ data = handler(self)
+ if info.by_alias:
+ data = _camelize_keys(data)
+ return data
+
+
+class BaseConfigModel(BaseModel):
+ """Base model that allows any extra attributes.
+
+ Use for models that do not need automatic case normalization.
+ """
+
+ model_config = ConfigDict(populate_by_name=True, extra="allow")
+
+
+class SnakeCaseExtrasModel(_SnakifyExtrasOnInit, BaseConfigModel):
+ """Base model that normalizes extra attributes to ``snake_case`` at validation time.
+
+ Use for response models where extra attributes received from the API
+ should be normalized to ``snake_case`` regardless of the format returned
+ by the server.
+ """
+
+
+class CamelCaseDumpModel(_CamelizeKeysOnDump, BaseConfigModel):
+ """Base model that recursively rewrites ``snake_case`` keys to ``camelCase`` in
+ the serialized output when ``by_alias=True``.
+
+ Use for request models targeting ``camelCase`` APIs, so that extra
+ attributes are emitted as ``camelCase`` in the outgoing request payload.
+ """
+
diff --git a/sinch/core/pagination.py b/sinch/core/pagination.py
index 326a4c9c..5c1bad78 100644
--- a/sinch/core/pagination.py
+++ b/sinch/core/pagination.py
@@ -5,7 +5,10 @@
class Paginator(ABC, Generic[BM]):
"""
- Pagination response object.
+ Public interface for paginated list responses.
+
+ Use :meth:`content`, :meth:`next_page` and :meth:`iterator` to traverse the
+ result set without dealing with the underlying pagination scheme.
"""
def __init__(self, sinch, endpoint, result: BM):
self._sinch = sinch
@@ -19,16 +22,34 @@ def __repr__(self):
# TODO: Make content() method abstract in Parent class as we implement in the other domains:
# - Refactor pydantic models in other domains to have a content property.
- def content(self):
+ def content(self) -> list[BM]:
+ """
+ Return the items contained in the current page.
+
+ :returns: The list of items in the current page.
+ :rtype: list[BM]
+ """
pass
# TODO: Make iterator() method abstract in Parent class as we implement in the other domains:
# - Refactor pydantic models in other domains to have a content property.
def iterator(self) -> Iterator[BM]:
+ """
+ Iterate over individual items across all pages, fetching each page on demand.
+
+ :returns: An iterator over every item in the result set.
+ :rtype: Iterator[BM]
+ """
pass
@abstractmethod
def next_page(self):
+ """
+ Advance to the next page of results.
+
+ :returns: This paginator positioned on the next page, or ``None`` if there is no further page.
+ :rtype: Paginator | None
+ """
pass
@abstractmethod
@@ -42,7 +63,10 @@ def _initialize(cls, sinch, endpoint):
class SMSPaginator(Paginator[BM]):
- """Base paginator for integer-based pagination with explicit page navigation and metadata."""
+ """Base paginator for integer-based pagination with explicit page navigation and metadata.
+
+ :meta private:
+ """
def __init__(self, sinch, endpoint, result=None):
super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint))
@@ -107,7 +131,10 @@ def _initialize(cls, sinch, endpoint):
class TokenBasedPaginator(Paginator[BM]):
- """Base paginator for token-based pagination with explicit page navigation and metadata."""
+ """Base paginator for token-based pagination with explicit page navigation and metadata.
+
+ :meta private:
+ """
def __init__(self, sinch, endpoint, result=None):
super().__init__(sinch, endpoint, result or sinch.configuration.transport.request(endpoint))
diff --git a/sinch/core/ports/http_transport.py b/sinch/core/ports/http_transport.py
index ec0edafc..71369ba6 100644
--- a/sinch/core/ports/http_transport.py
+++ b/sinch/core/ports/http_transport.py
@@ -1,70 +1,228 @@
-from abc import ABC, abstractmethod
+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 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 :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.
- 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.
+ .. 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.
"""
+ MAX_RETRIES = 3
+ RETRYABLE_STATUS_CODES = frozenset({429})
+ BACKOFF_BASE_SECONDS = 1.0
+ BACKOFF_GROWTH = 4
+ RETRY_AFTER_JITTER = 0.25
+
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_with_retries(endpoint, 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_with_retries(endpoint, request_data)
return endpoint.handle_response(http_response)
+
+
+ def _send_with_retries(
+ self, endpoint: HTTPEndpoint, request_data: Optional[HttpRequest] = None
+ ) -> HTTPResponse:
+ """
+ Sends a request, retrying rate-limited responses (up to MAX_RETRIES,
+ with backoff) for endpoints that opt in via :attr:`HTTPEndpoint.IS_RETRYABLE`.
+
+ :param endpoint: The endpoint being called.
+ :type endpoint: HTTPEndpoint
+ :param request_data: The prepared request, or ``None`` on the legacy ``send`` path.
+ :type request_data: Optional[HttpRequest]
+ :returns: The HTTP response from the last attempt.
+ :rtype: HTTPResponse
+ """
+ num_retries = 0
+ while True:
+ if request_data is None:
+ http_response = self.send(endpoint)
+ else:
+ http_response = self.send_request(request_data)
+
+ if self._should_retry(endpoint, http_response, num_retries):
+ time.sleep(self._compute_backoff(http_response, num_retries))
+ num_retries += 1
+ else:
+ return http_response
+
+ def _should_retry(
+ self, endpoint: HTTPEndpoint, http_response: HTTPResponse, num_retries: int
+ ) -> bool:
+ """
+ Returns True when the endpoint opts into retries, the response is a
+ transient error and retries remain.
- # ------------------------------------------------------------------
- # Internals
- # ------------------------------------------------------------------
+ :param endpoint: The endpoint being called.
+ :type endpoint: HTTPEndpoint
+ :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 not endpoint.IS_RETRYABLE:
+ return False
+ 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, self.RETRY_AFTER_JITTER)
+
+ 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 the Retry-After header into a
+ delay in seconds, or None if absent/unparseable.
- def authenticate(self, endpoint, request_data):
+ :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.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:
+ """
+ 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 +245,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 +256,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 +284,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 +307,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_with_retries(endpoint, request_data=None)
+
+ 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_with_retries(endpoint, request_data=None)
+
+ 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/sinch/domains/authentication/endpoints/v1/oauth.py b/sinch/domains/authentication/endpoints/v1/oauth.py
index b8b867c5..f45d5602 100644
--- a/sinch/domains/authentication/endpoints/v1/oauth.py
+++ b/sinch/domains/authentication/endpoints/v1/oauth.py
@@ -9,6 +9,7 @@ class OAuthEndpoint(HTTPEndpoint):
ENDPOINT_URL = "{origin}/oauth2/token"
HTTP_METHOD = HTTPMethods.POST.value
HTTP_AUTHENTICATION = HTTPAuthentication.BASIC.value
+ IS_RETRYABLE = True
def __init__(self):
pass
diff --git a/sinch/domains/conversation/models/v1/messages/__init__.py b/sinch/domains/conversation/models/v1/messages/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py
index 200cf35e..0e174152 100644
--- a/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py
+++ b/sinch/domains/conversation/models/v1/messages/internal/base/base_model_configuration.py
@@ -1,32 +1,3 @@
-import re
-from typing import Any
-from pydantic import BaseModel, ConfigDict
+from sinch.core.models.internal.base_model_config import SnakeCaseExtrasModel
-
-class BaseModelConfiguration(BaseModel):
- """
- Base model for all conversation message models.
- Both request and response use snake_case in the Conversation API.
- """
-
- model_config = ConfigDict(
- # Allows using both alias (camelCase) and field name (snake_case)
- populate_by_name=True,
- # Allows extra values in input
- extra="allow",
- )
-
- @staticmethod
- def _to_snake_case(camel_str: str) -> str:
- """Helper to convert camelCase string to snake_case."""
- return re.sub(r"(? None:
- """Converts unknown fields from camelCase to snake_case."""
- if self.__pydantic_extra__:
- converted_extra = {
- self._to_snake_case(key): value
- for key, value in self.__pydantic_extra__.items()
- }
- self.__pydantic_extra__.clear()
- self.__pydantic_extra__.update(converted_extra)
+BaseModelConfiguration = SnakeCaseExtrasModel
diff --git a/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py b/sinch/domains/conversation/models/v1/sinch_events/events/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/api/__init__.py b/sinch/domains/number_lookup/api/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/models/v1/__init__.py b/sinch/domains/number_lookup/models/v1/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py
index 204ea49d..a3cb8354 100644
--- a/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py
+++ b/sinch/domains/number_lookup/models/v1/internal/base/base_model_configuration.py
@@ -1,44 +1,7 @@
-import re
-from typing import Any
-from pydantic import BaseModel, ConfigDict
+from sinch.core.models.internal.base_model_config import (
+ BaseConfigModel,
+ SnakeCaseExtrasModel,
+)
-
-class BaseModelConfigurationRequest(BaseModel):
- """
- A base model that allows extra fields and converts snake_case to camelCase.
- """
-
- model_config = ConfigDict(
- # Allows using both alias (camelCase) and field name (snake_case)
- populate_by_name=True,
- # Allows extra values in input
- extra="allow",
- )
-
-
-class BaseModelConfigurationResponse(BaseModel):
- """
- A base model that allows extra fields and converts camelCase to snake_case
- """
-
- @staticmethod
- def _to_snake_case(camel_str: str) -> str:
- """Helper to convert camelCase string to snake_case."""
- return re.sub(r"(? None:
- """Converts unknown fields from camelCase to snake_case."""
- if self.__pydantic_extra__:
- converted_extra = {
- self._to_snake_case(key): value
- for key, value in self.__pydantic_extra__.items()
- }
- self.__pydantic_extra__.clear()
- self.__pydantic_extra__.update(converted_extra)
+BaseModelConfigurationRequest = BaseConfigModel
+BaseModelConfigurationResponse = SnakeCaseExtrasModel
diff --git a/sinch/domains/numbers/models/v1/errors/not_found_error.py b/sinch/domains/numbers/models/v1/errors/not_found_error.py
index da52f032..d4ed8a9b 100644
--- a/sinch/domains/numbers/models/v1/errors/not_found_error.py
+++ b/sinch/domains/numbers/models/v1/errors/not_found_error.py
@@ -1,5 +1,5 @@
from typing import Optional
-from pydantic import ConfigDict, conlist, Field, StrictInt, StrictStr
+from pydantic import conlist, Field, StrictInt, StrictStr
from sinch.domains.numbers.models.v1.internal.base import (
BaseModelConfigurationResponse,
)
@@ -13,8 +13,3 @@ class NotFoundError(BaseModelConfigurationResponse):
message: Optional[StrictStr] = Field(default=None)
status: Optional[StrictStr] = Field(default=None)
details: Optional[conlist(NotFoundErrorDetails)] = Field(default=None)
-
- model_config = ConfigDict(
- populate_by_name=True,
- alias_generator=BaseModelConfigurationResponse._to_snake_case,
- )
diff --git a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py
index 8e9d179b..079affda 100644
--- a/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py
+++ b/sinch/domains/numbers/models/v1/internal/base/base_model_configuration.py
@@ -1,112 +1,7 @@
-import re
-from typing import Any
-from pydantic import BaseModel, ConfigDict
+from sinch.core.models.internal.base_model_config import (
+ CamelCaseDumpModel,
+ SnakeCaseExtrasModel,
+)
-
-class BaseModelConfigurationRequest(BaseModel):
- """
- A base model that allows extra fields and converts snake_case to camelCase.
- """
-
- @staticmethod
- def _to_camel_case(snake_str: str) -> str:
- """Converts snake_case to camelCase while preserving multiple underscores."""
- if not snake_str or "_" not in snake_str:
- return snake_str
- components = snake_str.split("_")
- return components[0].lower() + "".join(
- (x.capitalize() if x else "_") for x in components[1:]
- )
-
- @classmethod
- def _convert_dict_keys(cls, obj):
- """Recursively convert dictionary keys to camelCase."""
- if isinstance(obj, dict):
- new_dict = {}
- for key, value in obj.items():
- # Convert dict key to camelCase
- camel_key = cls._to_camel_case(key)
- # Recurse on the value
- new_dict[camel_key] = cls._convert_dict_keys(value)
- return new_dict
- elif isinstance(obj, list):
- # Recurse through any list elements (they might be dicts too)
- return [cls._convert_dict_keys(item) for item in obj]
- else:
- return obj
-
- model_config = ConfigDict(
- # Allows using both alias (camelCase) and field name (snake_case)
- populate_by_name=True,
- # Allows extra values in input
- extra="allow",
- )
-
- def _convert_dict_to_camel_case(self, data):
- if isinstance(data, dict):
- return {
- self._to_camel_case(k): self._convert_dict_to_camel_case(v)
- for k, v in data.items()
- }
- elif isinstance(data, list):
- return [self._convert_dict_to_camel_case(i) for i in data]
- return data
-
- def model_dump(self, **kwargs) -> dict:
- """Converts extra fields from snake_case to camelCase when dumping the model in endpoint."""
- # Get the standard model dump.
- data = super().model_dump(**kwargs)
- if not kwargs or kwargs["by_alias"]:
- data = self._convert_dict_to_camel_case(data)
-
- # Get extra fields
- extra_data = self.__pydantic_extra__ or {}
-
- # Merge known + unknown into one dictionary first
- combined = {**data, **extra_data}
-
- final_dict = {}
-
- for key, value in combined.items():
- if key in extra_data:
- # This is an unknown field to be converted
- new_key = self._to_camel_case(key)
- else:
- # Known field - keep the top-level key as given
- new_key = key
-
- # Recursively convert any nested dict keys
- converted_value = self._convert_dict_keys(value)
-
- # Add to final dictionary
- final_dict[new_key] = converted_value
-
- return final_dict
-
-
-class BaseModelConfigurationResponse(BaseModel):
- """
- A base model that allows extra fields and converts camelCase to snake_case
- """
-
- @staticmethod
- def _to_snake_case(camel_str: str) -> str:
- """Helper to convert camelCase string to snake_case."""
- return re.sub(r"(? None:
- """Converts unknown fields from camelCase to snake_case."""
- if self.__pydantic_extra__:
- converted_extra = {
- self._to_snake_case(key): value
- for key, value in self.__pydantic_extra__.items()
- }
- self.__pydantic_extra__.clear()
- self.__pydantic_extra__.update(converted_extra)
+BaseModelConfigurationRequest = CamelCaseDumpModel
+BaseModelConfigurationResponse = SnakeCaseExtrasModel
diff --git a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py
index f832beb5..4b877009 100644
--- a/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py
+++ b/sinch/domains/numbers/models/v1/internal/list_active_numbers_request.py
@@ -1,13 +1,16 @@
from typing import Optional
-from pydantic import Field, StrictInt, StrictStr, field_validator, conlist
+
+from pydantic import Field, StrictInt, StrictStr, conlist, field_validator
+
+from sinch.core.models.internal.base_model_config import _to_camel_case
from sinch.domains.numbers.models.v1.internal.base import (
BaseModelConfigurationRequest,
)
from sinch.domains.numbers.models.v1.types import (
CapabilityType,
- OrderByType,
NumberSearchPatternType,
NumberType,
+ OrderByType,
)
@@ -33,5 +36,5 @@ class ListActiveNumbersRequest(BaseModelConfigurationRequest):
@classmethod
def convert_order_by(cls, value):
if isinstance(value, str):
- return cls._to_camel_case(value)
+ return _to_camel_case(value)
return value
diff --git a/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py
index 6f2e5abc..0ceb7a02 100644
--- a/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py
+++ b/sinch/domains/numbers/models/v1/internal/update_event_destination_request.py
@@ -6,4 +6,8 @@
class UpdateEventDestinationRequest(BaseModelConfigurationRequest):
- hmac_secret: Optional[StrictStr] = Field(default=None, alias="hmacSecret")
+ hmac_secret: Optional[StrictStr] = Field(
+ default=None,
+ alias="hmacSecret",
+ description="The HMAC secret to be updated for the specified project",
+ )
diff --git a/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py
index 43633e2c..030b845a 100644
--- a/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py
+++ b/sinch/domains/numbers/sinch_events/v1/events/number_sinch_event.py
@@ -1,31 +1,65 @@
from datetime import datetime
-from typing import Optional, Union, Literal
+from typing import Literal, Optional, Union
+
from pydantic import Field, StrictStr
+
from sinch.domains.numbers.sinch_events.v1.internal import SinchEvent
class NumberSinchEvent(SinchEvent):
- event_id: Optional[StrictStr] = Field(default=None, alias="eventId")
- timestamp: Optional[datetime] = Field(default=None)
- project_id: Optional[StrictStr] = Field(default=None, alias="projectId")
- resource_id: Optional[StrictStr] = Field(default=None, alias="resourceId")
+ event_id: Optional[StrictStr] = Field(
+ default=None, alias="eventId", description="The ID of the event."
+ )
+ timestamp: Optional[datetime] = Field(
+ default=None,
+ description="The date and time when the callback was created and added to the callbacks queue.",
+ )
+ project_id: Optional[StrictStr] = Field(
+ default=None,
+ alias="projectId",
+ description="The ID of the project to which the event belongs.",
+ )
+ resource_id: Optional[StrictStr] = Field(
+ default=None,
+ alias="resourceId",
+ description="The unique identifier of the resource, depending on the resource type. For example, a phone number, a hosting order ID, or a brand ID.",
+ )
resource_type: Optional[Union[Literal["ACTIVE_NUMBER"], StrictStr]] = (
Field(default=None, alias="resourceType")
)
event_type: Optional[
Union[
Literal[
- "PROVISIONING_TO_CAMPAIGN",
- "DEPROVISIONING_FROM_CAMPAIGN",
"PROVISIONING_TO_SMS_PLATFORM",
"DEPROVISIONING_FROM_SMS_PLATFORM",
+ "PROVISIONING_TO_CAMPAIGN",
+ "DEPROVISIONING_FROM_CAMPAIGN",
"PROVISIONING_TO_VOICE_PLATFORM",
- "DEPROVISIONING_TO_VOICE_PLATFORM",
+ "DEPROVISIONING_FROM_VOICE_PLATFORM",
+ "NUMBER_ORDER_PROCESSING",
],
StrictStr,
]
- ] = Field(default=None, alias="eventType")
- status: Optional[Union[Literal["SUCCEEDED", "FAILED"], StrictStr]] = None
+ ] = Field(
+ default=None, alias="eventType", description="The type of the event."
+ )
+ status: Optional[
+ Union[
+ Literal[
+ "SUCCEEDED",
+ "FAILED",
+ "IN_REVIEW",
+ "BLOCKED",
+ "COMPLETED",
+ "REJECTED",
+ "EXPIRED",
+ ],
+ StrictStr,
+ ]
+ ] = Field(
+ default=None,
+ description="The status of the event or the state transition it represents.",
+ )
failure_code: Optional[
Union[
Literal[
@@ -47,4 +81,13 @@ class NumberSinchEvent(SinchEvent):
],
StrictStr,
]
- ] = Field(default=None, alias="failureCode")
+ ] = Field(
+ default=None,
+ alias="failureCode",
+ description="If the status is FAILED, a failure code will be provided. For numbers provisioning to SMS platform, there won't be any extra `failureCode`, as the result is binary. For campaign provisioning-related failures, refer to the list for the possible values.",
+ )
+ internal_failure_code: Optional[StrictStr] = Field(
+ default=None,
+ alias="internalFailureCode",
+ description="If the status is FAILED, certain processes (eg. number to campaign provisioning) will have an internalFailureCode in the payload. The details of these codes can be found in our dedicated [Provisioning errors](https://developers.sinch.com/docs/numbers/api-reference/error-codes/provisioning-errors) documentation.",
+ )
diff --git a/sinch/domains/sms/api/v1/__init__.py b/sinch/domains/sms/api/v1/__init__.py
index db903927..fa632f0b 100644
--- a/sinch/domains/sms/api/v1/__init__.py
+++ b/sinch/domains/sms/api/v1/__init__.py
@@ -1,7 +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/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/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/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/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/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/base/base_model_configuration.py b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py
index 204ea49d..a3cb8354 100644
--- a/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py
+++ b/sinch/domains/sms/models/v1/internal/base/base_model_configuration.py
@@ -1,44 +1,7 @@
-import re
-from typing import Any
-from pydantic import BaseModel, ConfigDict
+from sinch.core.models.internal.base_model_config import (
+ BaseConfigModel,
+ SnakeCaseExtrasModel,
+)
-
-class BaseModelConfigurationRequest(BaseModel):
- """
- A base model that allows extra fields and converts snake_case to camelCase.
- """
-
- model_config = ConfigDict(
- # Allows using both alias (camelCase) and field name (snake_case)
- populate_by_name=True,
- # Allows extra values in input
- extra="allow",
- )
-
-
-class BaseModelConfigurationResponse(BaseModel):
- """
- A base model that allows extra fields and converts camelCase to snake_case
- """
-
- @staticmethod
- def _to_snake_case(camel_str: str) -> str:
- """Helper to convert camelCase string to snake_case."""
- return re.sub(r"(? None:
- """Converts unknown fields from camelCase to snake_case."""
- if self.__pydantic_extra__:
- converted_extra = {
- self._to_snake_case(key): value
- for key, value in self.__pydantic_extra__.items()
- }
- self.__pydantic_extra__.clear()
- self.__pydantic_extra__.update(converted_extra)
+BaseModelConfigurationRequest = BaseConfigModel
+BaseModelConfigurationResponse = SnakeCaseExtrasModel
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/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_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/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/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..e5cc9163 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 (
@@ -11,8 +16,18 @@
)
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",
+ "AutoUpdate",
"BinaryRequest",
"BinaryResponse",
"DryRunPerRecipientDetails",
@@ -20,6 +35,13 @@
"MediaRequest",
"MediaResponse",
"MessageDeliveryStatus",
+ "RemoveKeyword",
"TextRequest",
"TextResponse",
+ "BaseMOMessage",
+ "MOTextMessage",
+ "MOBinaryMessage",
+ "MOMediaItem",
+ "MOMediaBody",
+ "MOMediaMessage",
]
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/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/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/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/__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/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/__init__.py b/sinch/domains/sms/sinch_events/__init__.py
new file mode 100644
index 00000000..e69de29b
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 c312c2de..3e762142 100644
--- a/sinch/domains/sms/sms.py
+++ b/sinch/domains/sms/sms.py
@@ -2,6 +2,8 @@
Batches,
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
@@ -16,6 +18,8 @@ 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/conftest.py b/tests/conftest.py
index 321893a9..1681756b 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -34,72 +34,21 @@ class TokenBasedPaginationRequest(BaseModel):
page_token: str = None
-def configure_origin(
- sinch_client,
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin
-):
- if auth_origin:
- sinch_client.configuration.auth_origin = auth_origin
-
- if numbers_origin:
- sinch_client.configuration.numbers_origin = numbers_origin
-
- if conversation_origin:
- sinch_client.configuration.conversation_origin = conversation_origin
-
- if sms_origin:
- sinch_client.configuration.sms_origin = sms_origin
- sinch_client.configuration.sms_origin_with_service_plan_id = sms_origin
-
- return sinch_client
-
-
@pytest.fixture
def key_id():
- return os.getenv("KEY_ID")
-
+ return "test_key_id"
@pytest.fixture
def key_secret():
- return os.getenv("KEY_SECRET")
-
+ return "test_key_secret"
@pytest.fixture
def project_id():
- return os.getenv("PROJECT_ID")
-
-
-@pytest.fixture
-def numbers_origin():
- return os.getenv("NUMBERS_ORIGIN")
-
-
-@pytest.fixture
-def conversation_origin():
- return os.getenv("CONVERSATION_ORIGIN")
-
-
-@pytest.fixture
-def auth_origin():
- return os.getenv("AUTH_ORIGIN")
-
-
-@pytest.fixture
-def sms_origin():
- return os.getenv("SMS_ORIGIN")
-
-
-@pytest.fixture
-def disable_ssl():
- return os.getenv("DISABLE_SSL")
-
+ return "test_project_id"
@pytest.fixture
def service_plan_id():
- return os.getenv("SERVICE_PLAN_ID")
+ return "test_service_plan_id"
@pytest.fixture
def http_response():
@@ -155,29 +104,17 @@ def sms_pagination_request_data_with_page_and_page_size_none():
return SMSBasePaginationRequest()
-
@pytest.fixture
def sinch_client_sync(
key_id,
key_secret,
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin,
project_id
):
- return configure_origin(
- SinchClient(
+ return SinchClient(
key_id=key_id,
key_secret=key_secret,
project_id=project_id
- ),
- numbers_origin,
- conversation_origin,
- auth_origin,
- sms_origin
- )
-
+ )
@pytest.fixture
def mock_sinch_client_numbers():
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/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/core/models/internal/test_base_model_config.py b/tests/unit/core/models/internal/test_base_model_config.py
new file mode 100644
index 00000000..3e2710c7
--- /dev/null
+++ b/tests/unit/core/models/internal/test_base_model_config.py
@@ -0,0 +1,231 @@
+"""Tests for the core base model configuration.
+
+Covers the module-level case helpers, the private mixins and the three
+public model classes (BaseConfigModel, SnakeCaseExtrasModel,
+CamelCaseDumpModel) including composition behavior.
+"""
+
+import json
+from typing import Any, Dict, Optional
+
+import pytest
+from pydantic import Field
+
+from sinch.core.models.internal.base_model_config import (
+ BaseConfigModel,
+ CamelCaseDumpModel,
+ SnakeCaseExtrasModel,
+ _camelize_keys,
+ _to_camel_case,
+ _to_snake_case,
+)
+
+
+# ---------------------------------------------------------------------------
+# Module-level helpers
+# ---------------------------------------------------------------------------
+
+
+class TestToCamelCase:
+ @pytest.mark.parametrize(
+ "snake, camel",
+ [
+ ("foo_bar", "fooBar"),
+ ("hello_world", "helloWorld"),
+ ("this_is_a_test", "thisIsATest"),
+ ("PHONE_NUMBER", "phoneNumber"),
+ ("appId", "appId"),
+ ],
+ )
+ def test_standard_cases(self, snake, camel):
+ assert _to_camel_case(snake) == camel
+
+ @pytest.mark.parametrize(
+ "snake, camel",
+ [
+ # Documented deviation from pydantic.alias_generators.to_camel:
+ # consecutive underscores are preserved rather than collapsed.
+ ("foo__bar", "foo_Bar"),
+ ("foo___bar", "foo__Bar"),
+ ("trailing_", "trailing_"),
+ ],
+ )
+ def test_edge_cases_preserve_consecutive_underscores(self, snake, camel):
+ assert _to_camel_case(snake) == camel
+
+ def test_empty_string(self):
+ assert _to_camel_case("") == ""
+
+ @pytest.mark.parametrize("word", ["word", "single"])
+ def test_single_word_returns_unchanged(self, word):
+ assert _to_camel_case(word) == word
+
+
+class TestToSnakeCase:
+ @pytest.mark.parametrize(
+ "camel, snake",
+ [
+ ("camelCase", "camel_case"),
+ ("fooBar", "foo_bar"),
+ ("simpleField", "simple_field"),
+ ("already_snake", "already_snake"),
+ ("lowercase", "lowercase"),
+ ],
+ )
+ def test_standard_cases(self, camel, snake):
+ assert _to_snake_case(camel) == snake
+
+
+class TestCamelizeKeys:
+ def test_flat_dict(self):
+ assert _camelize_keys({"foo_bar": 1, "baz_qux": 2}) == {
+ "fooBar": 1,
+ "bazQux": 2,
+ }
+
+ def test_nested_dict(self):
+ assert _camelize_keys({"outer_field": {"inner_field": 42}}) == {
+ "outerField": {"innerField": 42}
+ }
+
+ def test_list_of_dicts(self):
+ assert _camelize_keys([{"foo_bar": 1}, {"baz_qux": 2}]) == [
+ {"fooBar": 1},
+ {"bazQux": 2},
+ ]
+
+ def test_scalars_unchanged(self):
+ assert _camelize_keys(42) == 42
+ assert _camelize_keys("foo_bar") == "foo_bar"
+ assert _camelize_keys(None) is None
+
+
+# ---------------------------------------------------------------------------
+# BaseConfigModel — permissive base, no normalization
+# ---------------------------------------------------------------------------
+
+
+class TestBaseConfigModel:
+ def test_extras_pass_through_unchanged_in_either_case(self):
+ model = BaseConfigModel(extraField="x", another_extra="y")
+ assert getattr(model, "extraField") == "x"
+ assert model.another_extra == "y"
+
+ def test_dump_emits_extras_unchanged(self):
+ model = BaseConfigModel(camelKey="a", snake_key="b")
+ assert model.model_dump(by_alias=True) == {
+ "camelKey": "a",
+ "snake_key": "b",
+ }
+
+
+# ---------------------------------------------------------------------------
+# SnakeCaseExtrasModel — normalize extras on validation
+# ---------------------------------------------------------------------------
+
+
+class TestSnakeCaseExtrasModel:
+ def test_extras_normalized_camel_to_snake_on_init(self):
+ model = SnakeCaseExtrasModel(extraField="x", anotherCamel=42)
+ assert model.extra_field == "x"
+ assert model.another_camel == 42
+
+ def test_snake_extras_are_idempotent(self):
+ model = SnakeCaseExtrasModel(already_snake="value")
+ assert model.already_snake == "value"
+
+ def test_dump_emits_normalized_snake_keys(self):
+ model = SnakeCaseExtrasModel(extraField="x")
+ assert model.model_dump(by_alias=True) == {"extra_field": "x"}
+
+
+# ---------------------------------------------------------------------------
+# CamelCaseDumpModel — recursive camelize on dump (by_alias only)
+# ---------------------------------------------------------------------------
+
+
+class TestCamelCaseDumpModel:
+ def test_extras_emit_as_camel_with_by_alias_true(self):
+ model = CamelCaseDumpModel(snake_extra="value", another_snake=42)
+ assert model.model_dump(by_alias=True) == {
+ "snakeExtra": "value",
+ "anotherSnake": 42,
+ }
+
+ def test_extras_kept_as_snake_when_by_alias_false(self):
+ # Mirrors the previous Numbers override: conversion is gated on by_alias.
+ model = CamelCaseDumpModel(snake_extra="value")
+ assert model.model_dump(by_alias=False) == {"snake_extra": "value"}
+
+ def test_nested_dict_keys_camelized_recursively(self):
+ class Outer(CamelCaseDumpModel):
+ sms_configuration: Optional[Dict[str, Any]] = Field(
+ default=None, alias="smsConfiguration"
+ )
+ voice_configuration: Optional[Dict[str, Any]] = Field(
+ default=None, alias="voiceConfiguration"
+ )
+
+ model = Outer(
+ sms_configuration={"service_plan_id": "X"},
+ voice_configuration={"appId": "Y", "type": "RTC"},
+ )
+ assert model.model_dump(by_alias=True) == {
+ "smsConfiguration": {"servicePlanId": "X"},
+ "voiceConfiguration": {"appId": "Y", "type": "RTC"},
+ }
+
+ def test_model_dump_json_also_camelizes(self):
+ model = CamelCaseDumpModel(snake_extra="value")
+ assert json.loads(model.model_dump_json(by_alias=True)) == {
+ "snakeExtra": "value",
+ }
+
+
+# ---------------------------------------------------------------------------
+# Composition — each model handles its own extras independently
+# ---------------------------------------------------------------------------
+
+
+class TestComposition:
+ def test_nested_snake_extras_models_normalize_at_every_level(self):
+ class Inner(SnakeCaseExtrasModel):
+ foo_bar: int
+
+ class Outer(SnakeCaseExtrasModel):
+ inner: Inner
+ name: int
+
+ outer = Outer(
+ inner={"foo_bar": 12, "extraField": 123},
+ name=112,
+ extraAtRoot="extra",
+ )
+ assert outer.inner.foo_bar == 12
+ assert outer.inner.extra_field == 123
+ assert outer.extra_at_root == "extra"
+
+ def test_snake_extras_inner_in_base_config_outer_keeps_outer_extras_raw(self):
+ class Common(SnakeCaseExtrasModel):
+ foo_bar: int
+
+ class Outer(BaseConfigModel):
+ common: Common
+ name: int
+
+ outer = Outer(
+ common={"foo_bar": 12, "extraField": 123},
+ name=112,
+ extraAtRoot="extra",
+ )
+ assert outer.common.extra_field == 123
+ assert getattr(outer, "extraAtRoot") == "extra"
+
+ def test_camel_dump_outer_camelizes_nested_free_form_dict_keys(self):
+ class Outer(CamelCaseDumpModel):
+ payload: Dict[str, Any]
+
+ outer = Outer(payload={"user_id": "abc", "nested_obj": {"item_id": 1}})
+ assert outer.model_dump(by_alias=True) == {
+ "payload": {"userId": "abc", "nestedObj": {"itemId": 1}}
+ }
diff --git a/tests/unit/core/models/test_utils.py b/tests/unit/core/models/test_utils.py
new file mode 100644
index 00000000..a5456076
--- /dev/null
+++ b/tests/unit/core/models/test_utils.py
@@ -0,0 +1,44 @@
+"""Tests for sinch.core.models.utils helpers."""
+
+from typing import List, Optional
+
+from pydantic import BaseModel
+
+from sinch.core.models.utils import model_dump_for_query_params
+
+
+class TestModelDumpForQueryParams:
+ def test_simple_fields_returned_as_is(self):
+ class M(BaseModel):
+ batch_id: str
+ status: Optional[str] = None
+
+ result = model_dump_for_query_params(
+ M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered")
+ )
+ assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ"
+ assert result["status"] == "delivered"
+
+ def test_lists_converted_to_comma_separated_strings(self):
+ class M(BaseModel):
+ status: Optional[List[str]] = None
+ code: Optional[List[int]] = None
+
+ result = model_dump_for_query_params(
+ M(status=["Delivered", "Failed"], code=[15, 0])
+ )
+ assert result["status"] == "Delivered,Failed"
+ assert result["code"] == "15,0"
+
+ def test_empty_values_filtered_out(self):
+ class M(BaseModel):
+ batch_id: str
+ status: str = ""
+ code: List[int] = []
+
+ result = model_dump_for_query_params(
+ M(batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[])
+ )
+ assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ"
+ assert "status" not in result
+ assert "code" not in result
diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py
deleted file mode 100644
index c6cf1fca..00000000
--- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_requests.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationRequest
-
-
-def test_to_camel_case_expects_parsed_standard_cases():
- """
- Test standard snake_case to camelCase conversion.
- """
- assert BaseModelConfigurationRequest._to_camel_case("foo_bar") == "fooBar"
- assert BaseModelConfigurationRequest._to_camel_case("hello_world") == "helloWorld"
- assert BaseModelConfigurationRequest._to_camel_case("this_is_a_test") == "thisIsATest"
- assert BaseModelConfigurationRequest._to_camel_case("PHONE_NUMBER") == "phoneNumber"
- assert BaseModelConfigurationRequest._to_camel_case("appId") == "appId"
-
-
-def test_to_camel_case_expects_parsed_edge_cases():
- """
- Test edge cases like leading/trailing underscores and multiple underscores.
- """
- assert BaseModelConfigurationRequest._to_camel_case("foo__bar") == "foo_Bar"
- assert BaseModelConfigurationRequest._to_camel_case("foo___bar") == "foo__Bar"
- assert BaseModelConfigurationRequest._to_camel_case("trailing_") == "trailing_"
-
-
-def test_to_camel_case_expects_empty_string():
- """
- Test empty string case.
- """
- assert BaseModelConfigurationRequest._to_camel_case("") == ""
-
-
-def test_to_camel_case_expects_single_word():
- """
- Test single-word cases.
- """
- assert BaseModelConfigurationRequest._to_camel_case("word") == "word"
- assert BaseModelConfigurationRequest._to_camel_case("single") == "single"
-
-
-def test_dict_expects_camel_case_input():
- """
- Test that the model correctly handles camelCase input.
- """
- data = {
- "sms_configuration": {"service_plan_id": "YOUR_SMS_servicePlanId"},
- "voice_configuration": {
- "appId": "YOUR_voice_appID",
- "type": "RTC"
- }
- }
- request = BaseModelConfigurationRequest(**data)
- response = request.model_dump()
-
- assert response == {
- 'smsConfiguration': {'servicePlanId': 'YOUR_SMS_servicePlanId'},
- 'voiceConfiguration': {'appId': 'YOUR_voice_appID', 'type': 'RTC'}
- }
diff --git a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py b/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py
deleted file mode 100644
index 2b2767cf..00000000
--- a/tests/unit/domains/numbers/v1/models/internal/base/test_base_model_response.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from sinch.domains.numbers.models.v1.internal.base import BaseModelConfigurationResponse
-
-
-def test_base_model_response_expects_unrecognized_fields_snake_case():
- """
- Expects unrecognized fields to be dynamically added as snake_case attributes.
- """
- data = {
- "unexpectedField": "unexpectedValue",
- "anotherExtraField": 42,
- }
- response = BaseModelConfigurationResponse(**data)
-
- # Assert unrecognized fields are dynamically added
- assert response.unexpected_field == "unexpectedValue"
- assert response.another_extra_field == 42
diff --git a/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py
index 775ac95b..6766f533 100644
--- a/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py
+++ b/tests/unit/domains/numbers/v1/sinch_events/events/test_number_sinch_event_model.py
@@ -69,6 +69,7 @@ def test_number_sinch_event_response_missing_optional_fields_expects_parsed_data
assert response.event_type is None
assert response.status is None
assert response.failure_code is None
+ assert response.internal_failure_code is None
def test_number_sinch_event_response_invalid_data_expects_validation_error(invalid_data):
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/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/base/test_base_model_configuration.py b/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py
deleted file mode 100644
index 2ead9c16..00000000
--- a/tests/unit/domains/sms/v1/models/base/test_base_model_configuration.py
+++ /dev/null
@@ -1,59 +0,0 @@
-from sinch.core.models.utils import model_dump_for_query_params
-from sinch.domains.sms.models.v1.internal.base import (
- BaseModelConfigurationRequest,
-)
-
-
-def test_model_dump_for_query_params_expects_simple_fields():
- """
- Test that simple fields are returned as-is.
- """
-
- class TestModel(BaseModelConfigurationRequest):
- batch_id: str
- status: str = None
-
- model = TestModel(
- batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="delivered"
- )
- result = model_dump_for_query_params(model)
-
- assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ"
- assert result["status"] == "delivered"
-
-
-def test_model_dump_for_query_params_expects_list_to_comma_separated_string():
- """
- Test that lists are converted to comma-separated strings.
- """
-
- class TestModel(BaseModelConfigurationRequest):
- status: list[str] = None
- code: list[int] = None
-
- model = TestModel(status=["Delivered", "Failed"], code=[15, 0])
- result = model_dump_for_query_params(model)
-
- assert result["status"] == "Delivered,Failed"
- assert result["code"] == "15,0"
-
-
-def test_model_dump_for_query_params_expects_empty_values_filtered():
- """
- Test that empty strings and empty lists are filtered out.
- """
-
- class TestModel(BaseModelConfigurationRequest):
- batch_id: str
- status: str = ""
- code: list[int] = []
-
- model = TestModel(
- batch_id="01FC66621XXXXX119Z8PMV1QPQ", status="", code=[]
- )
- result = model_dump_for_query_params(model)
-
- assert "batch_id" in result
- assert result["batch_id"] == "01FC66621XXXXX119Z8PMV1QPQ"
- assert "status" not in result
- assert "code" not in result
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_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_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_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/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/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_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()
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"
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..b01c88db
--- /dev/null
+++ b/tests/unit/test_http_transport.py
@@ -0,0 +1,503 @@
+import json
+import random
+import time
+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, is_retryable=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
+
+ _Endpoint.IS_RETRYABLE = is_retryable
+ 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 no_sleep(mocker):
+ return mocker.patch.object(time, "sleep")
+
+@pytest.fixture
+def no_jitter(mocker):
+ return mocker.patch.object(random, "uniform", return_value=0.0)
+
+@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 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, no_jitter):
+ 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, is_retryable=True)
+
+ 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, no_jitter):
+ transport = HTTPTransportRequests(mock_sinch)
+ transport.http_session.request = Mock(return_value=self._rate_limited())
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True)
+
+ 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, no_jitter):
+ 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, no_jitter):
+ 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, no_jitter):
+ 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, is_retryable=True)
+
+ transport.request(endpoint)
+
+ no_sleep.assert_called_once_with(7.0)
+
+ def test_no_retry_when_endpoint_not_retryable(self, mock_sinch, no_sleep, no_jitter):
+ transport = HTTPTransportRequests(mock_sinch)
+ transport.http_session.request = Mock(return_value=self._rate_limited())
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=False)
+
+ result = transport.request(endpoint)
+
+ assert result.status_code == 429
+ assert transport.http_session.request.call_count == 1
+ no_sleep.assert_not_called()
+
+
+class TestShouldRetry:
+ def test_retries_429_while_attempts_remain(self, mock_sinch):
+ transport = HTTPTransportRequests(mock_sinch)
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True)
+ response = HTTPResponse(status_code=429, headers={}, body={})
+
+ assert transport._should_retry(endpoint, response, num_retries=0) is True
+
+ def test_stops_when_max_retries_reached(self, mock_sinch):
+ transport = HTTPTransportRequests(mock_sinch)
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True)
+ response = HTTPResponse(status_code=429, headers={}, body={})
+
+ assert transport._should_retry(endpoint, response, num_retries=HTTPTransport.MAX_RETRIES) is False
+
+ def test_does_not_retry_non_retryable_status(self, mock_sinch):
+ transport = HTTPTransportRequests(mock_sinch)
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=True)
+ response = HTTPResponse(status_code=200, headers={}, body={})
+
+ assert transport._should_retry(endpoint, response, num_retries=0) is False
+
+ def test_does_not_retry_when_endpoint_not_retryable(self, mock_sinch):
+ transport = HTTPTransportRequests(mock_sinch)
+ endpoint = _make_mock_endpoint(HTTPAuthentication.BASIC.value, is_retryable=False)
+ response = HTTPResponse(status_code=429, headers={}, body={})
+
+ assert transport._should_retry(endpoint, 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.0 + HTTPTransport.RETRY_AFTER_JITTER
+
+ def test_retry_after_jitter_is_within_bounds(self, mock_sinch):
+ transport = HTTPTransportRequests(mock_sinch)
+ response = HTTPResponse(status_code=429, headers={"Retry-After": "5"}, body={})
+
+ for _ in range(100): # run many times to exercise the random range
+ backoff = transport._compute_backoff(response, num_retries=0)
+ assert 5.0 <= backoff <= 5.0 + HTTPTransport.RETRY_AFTER_JITTER
+
+ 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."""
+
+ 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)