diff --git a/Makefile b/Makefile index 48a61ca..61fbd05 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ VERSION=$(shell grep '__version__' $(PACKAGE_NAME)/__init__.py | cut -d '"' -f 2 # Install pipenv and project dependencies install: - pipenv install --dev + pipenv install --dev --categories encryption # Run tests with pytest or unittest test: diff --git a/README.md b/README.md index 290fff4..64b71a3 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Optional: pip install android_sms_gateway ``` -You can also install with preferred http client: +You can also install it with the preferred HTTP client: ```bash pip install android_sms_gateway[requests] @@ -101,20 +101,28 @@ implement the same interface and can be used as context managers. ### Methods -There are two methods: +There are two groups of methods: + +**Messages** - `send(message: domain.Message) -> domain.MessageState`: Send a new SMS message. - `get_state(_id: str) -> domain.MessageState`: Retrieve the state of a previously sent message by its ID. +**Webhooks** + +- `get_webhooks() -> list[domain.Webhook]`: Retrieve a list of all webhooks registered for the account. +- `create_webhook(webhook: domain.Webhook) -> domain.Webhook`: Create a new webhook. +- `delete_webhook(_id: str)`: Delete a webhook by its ID. + ## HTTP Client -The API clients abstract away the HTTP client used to make requests. The library includes support for some popular HTTP clients and trys to discover them automatically: +The API clients abstract away the HTTP client used to make requests. The library includes support for some popular HTTP clients and tries to discover them automatically: - [requests](https://pypi.org/project/requests/) - `APIClient` only - [aiohttp](https://pypi.org/project/aiohttp/) - `AsyncAPIClient` only - [httpx](https://pypi.org/project/httpx/) - `APIClient` and `AsyncAPIClient` -Also you can implement your own HTTP client that conforms to the `http.HttpClient` or `ahttp.HttpClient` protocol. +You can also implement your own HTTP client that conforms to the `http.HttpClient` or `ahttp.HttpClient` protocol. # Contributing @@ -122,4 +130,4 @@ Contributions are welcome! Please submit a pull request or create an issue for a # License -This library is open-sourced software licensed under the [Apache-2.0 license](LICENSE). \ No newline at end of file +This library is open-sourced software licensed under the [Apache-2.0 license](LICENSE). diff --git a/android_sms_gateway/ahttp.py b/android_sms_gateway/ahttp.py index 8c8c013..eb095f8 100644 --- a/android_sms_gateway/ahttp.py +++ b/android_sms_gateway/ahttp.py @@ -13,6 +13,21 @@ async def post( self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: ... + @abc.abstractmethod + async def delete( + self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None + ) -> None: + """ + Sends a DELETE request to the specified URL. + + Args: + url: The URL to send the DELETE request to. + headers: Optional dictionary of HTTP headers to send with the request. + + Returns: + None + """ + async def __aenter__(self): pass @@ -39,16 +54,21 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._session is None: + return + await self._session.close() self._session = None async def get( self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: - response = await self._session.get(url, headers=headers) - response.raise_for_status() + if self._session is None: + raise ValueError("Session not initialized") - return await response.json() + async with self._session.get(url, headers=headers) as response: + response.raise_for_status() + return await response.json() async def post( self, @@ -57,10 +77,23 @@ async def post( *, headers: t.Optional[t.Dict[str, str]] = None, ) -> dict: - response = await self._session.post(url, headers=headers, json=payload) - response.raise_for_status() + if self._session is None: + raise ValueError("Session not initialized") + + async with self._session.post( + url, headers=headers, json=payload + ) as response: + response.raise_for_status() + return await response.json() - return await response.json() + async def delete( + self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None + ) -> None: + if self._session is None: + raise ValueError("Session not initialized") + + async with self._session.delete(url, headers=headers) as response: + response.raise_for_status() DEFAULT_CLIENT = AiohttpAsyncHttpClient except ImportError: @@ -82,12 +115,18 @@ async def __aenter__(self): return self async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._client is None: + return + await self._client.aclose() self._client = None async def get( self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: + if self._client is None: + raise ValueError("Client not initialized") + response = await self._client.get(url, headers=headers) return response.raise_for_status().json() @@ -99,10 +138,22 @@ async def post( *, headers: t.Optional[t.Dict[str, str]] = None, ) -> dict: + if self._client is None: + raise ValueError("Client not initialized") + response = await self._client.post(url, headers=headers, json=payload) return response.raise_for_status().json() + async def delete( + self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None + ) -> None: + if self._client is None: + raise ValueError("Client not initialized") + + response = await self._client.delete(url, headers=headers) + response.raise_for_status() + DEFAULT_CLIENT = HttpxAsyncHttpClient except ImportError: pass diff --git a/android_sms_gateway/client.py b/android_sms_gateway/client.py index 8e93315..3e87ef5 100644 --- a/android_sms_gateway/client.py +++ b/android_sms_gateway/client.py @@ -82,20 +82,27 @@ def __init__( ) -> None: super().__init__(login, password, base_url=base_url, encryptor=encryptor) self.http = http + self.default_http = None def __enter__(self): if self.http is not None: - raise ValueError("HTTP client already initialized") + return self - self.http = http.get_client().__enter__() + self.http = self.default_http = http.get_client().__enter__() return self def __exit__(self, exc_type, exc_val, exc_tb): - self.http.__exit__(exc_type, exc_val, exc_tb) - self.http = None + if self.default_http is None: + return + + self.default_http.__exit__(exc_type, exc_val, exc_tb) + self.http = self.default_http = None def send(self, message: domain.Message) -> domain.MessageState: + if self.http is None: + raise ValueError("HTTP client not initialized") + message = self._encrypt(message) return self._decrypt( domain.MessageState.from_dict( @@ -108,12 +115,68 @@ def send(self, message: domain.Message) -> domain.MessageState: ) def get_state(self, _id: str) -> domain.MessageState: + if self.http is None: + raise ValueError("HTTP client not initialized") + return self._decrypt( domain.MessageState.from_dict( self.http.get(f"{self.base_url}/message/{_id}", headers=self.headers) ) ) + def get_webhooks(self) -> t.List[domain.Webhook]: + """ + Retrieves a list of all webhooks registered for the account. + + Returns: + A list of Webhook instances. + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + return [ + domain.Webhook.from_dict(webhook) + for webhook in self.http.get( + f"{self.base_url}/webhooks", headers=self.headers + ) + ] + + def create_webhook(self, webhook: domain.Webhook) -> domain.Webhook: + """ + Creates a new webhook. + + Args: + webhook: The webhook to create. + + Returns: + The created webhook. + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + return domain.Webhook.from_dict( + self.http.post( + f"{self.base_url}/webhooks", + payload=webhook.asdict(), + headers=self.headers, + ) + ) + + def delete_webhook(self, _id: str) -> None: + """ + Deletes a webhook. + + Args: + _id: The ID of the webhook to delete. + + Returns: + None + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers) + class AsyncAPIClient(BaseClient): def __init__( @@ -127,20 +190,27 @@ def __init__( ) -> None: super().__init__(login, password, base_url=base_url, encryptor=encryptor) self.http = http_client + self.default_http = None async def __aenter__(self): if self.http is not None: - raise ValueError("HTTP client already initialized") + return self - self.http = await ahttp.get_client().__aenter__() + self.http = self.default_http = await ahttp.get_client().__aenter__() return self async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.http.__aexit__(exc_type, exc_val, exc_tb) - self.http = None + if self.default_http is None: + return + + await self.default_http.__aexit__(exc_type, exc_val, exc_tb) + self.http = self.default_http = None async def send(self, message: domain.Message) -> domain.MessageState: + if self.http is None: + raise ValueError("HTTP client not initialized") + message = self._encrypt(message) return self._decrypt( domain.MessageState.from_dict( @@ -153,6 +223,9 @@ async def send(self, message: domain.Message) -> domain.MessageState: ) async def get_state(self, _id: str) -> domain.MessageState: + if self.http is None: + raise ValueError("HTTP client not initialized") + return self._decrypt( domain.MessageState.from_dict( await self.http.get( @@ -160,3 +233,56 @@ async def get_state(self, _id: str) -> domain.MessageState: ) ) ) + + async def get_webhooks(self) -> t.List[domain.Webhook]: + """ + Retrieves a list of all webhooks registered for the account. + + Returns: + A list of Webhook instances. + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + return [ + domain.Webhook.from_dict(webhook) + for webhook in await self.http.get( + f"{self.base_url}/webhooks", headers=self.headers + ) + ] + + async def create_webhook(self, webhook: domain.Webhook) -> domain.Webhook: + """ + Creates a new webhook. + + Args: + webhook: The webhook to create. + + Returns: + The created webhook. + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + return domain.Webhook.from_dict( + await self.http.post( + f"{self.base_url}/webhooks", + payload=webhook.asdict(), + headers=self.headers, + ) + ) + + async def delete_webhook(self, _id: str) -> None: + """ + Deletes a webhook. + + Args: + _id: The ID of the webhook to delete. + + Returns: + None + """ + if self.http is None: + raise ValueError("HTTP client not initialized") + + await self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers) diff --git a/android_sms_gateway/domain.py b/android_sms_gateway/domain.py index 5b2e7e2..41d0d98 100644 --- a/android_sms_gateway/domain.py +++ b/android_sms_gateway/domain.py @@ -1,7 +1,7 @@ import dataclasses import typing as t -from .enums import ProcessState +from .enums import ProcessState, WebhookEvent def snake_to_camel(snake_str): @@ -63,3 +63,43 @@ def from_dict(cls, payload: t.Dict[str, t.Any]) -> "MessageState": is_hashed=payload.get("isHashed", False), is_encrypted=payload.get("isEncrypted", False), ) + + +@dataclasses.dataclass(frozen=True) +class Webhook: + """A webhook configuration.""" + + id: t.Optional[str] + """The unique identifier of the webhook.""" + url: str + """The URL the webhook will be sent to.""" + event: WebhookEvent + """The type of event the webhook is triggered for.""" + + @classmethod + def from_dict(cls, payload: t.Dict[str, t.Any]) -> "Webhook": + """Creates a Webhook instance from a dictionary. + + Args: + payload: A dictionary containing the webhook's data. + + Returns: + A Webhook instance. + """ + return cls( + id=payload.get("id"), + url=payload["url"], + event=WebhookEvent(payload["event"]), + ) + + def asdict(self) -> t.Dict[str, t.Any]: + """Returns a dictionary representation of the webhook. + + Returns: + A dictionary containing the webhook's data. + """ + return { + "id": self.id, + "url": self.url, + "event": self.event.value, + } diff --git a/android_sms_gateway/enums.py b/android_sms_gateway/enums.py index 01263a5..dfc190c 100644 --- a/android_sms_gateway/enums.py +++ b/android_sms_gateway/enums.py @@ -7,3 +7,24 @@ class ProcessState(enum.Enum): Sent = "Sent" Delivered = "Delivered" Failed = "Failed" + + +class WebhookEvent(enum.Enum): + """ + Webhook events that can be sent by the server. + """ + + SMS_RECEIVED = "sms:received" + """Triggered when an SMS is received.""" + + SMS_SENT = "sms:sent" + """Triggered when an SMS is sent.""" + + SMS_DELIVERED = "sms:delivered" + """Triggered when an SMS is delivered.""" + + SMS_FAILED = "sms:failed" + """Triggered when an SMS processing fails.""" + + SYSTEM_PING = "system:ping" + """Triggered when the device pings the server.""" diff --git a/android_sms_gateway/http.py b/android_sms_gateway/http.py index f912c9a..4bfed3c 100644 --- a/android_sms_gateway/http.py +++ b/android_sms_gateway/http.py @@ -13,6 +13,19 @@ def post( self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: ... + @abc.abstractmethod + def delete(self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None) -> None: + """ + Sends a DELETE request to the specified URL. + + Args: + url: The URL to send the DELETE request to. + headers: Optional dictionary of HTTP headers to send with the request. + + Returns: + None + """ + def __enter__(self): pass @@ -38,12 +51,18 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + if self._session is None: + return + self._session.close() self._session = None def get( self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: + if self._session is None: + raise ValueError("Session not initialized") + return self._process_response(self._session.get(url, headers=headers)) def post( @@ -53,10 +72,21 @@ def post( *, headers: t.Optional[t.Dict[str, str]] = None, ) -> dict: + if self._session is None: + raise ValueError("Session not initialized") + return self._process_response( self._session.post(url, headers=headers, json=payload) ) + def delete( + self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None + ) -> None: + if self._session is None: + raise ValueError("Session not initialized") + + self._session.delete(url, headers=headers).raise_for_status() + def _process_response(self, response: requests.Response) -> dict: response.raise_for_status() return response.json() @@ -81,12 +111,18 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + if self._client is None: + return + self._client.close() self._client = None def get( self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None ) -> dict: + if self._client is None: + raise ValueError("Client not initialized") + return self._client.get(url, headers=headers).raise_for_status().json() def post( @@ -96,12 +132,23 @@ def post( *, headers: t.Optional[t.Dict[str, str]] = None, ) -> dict: + if self._client is None: + raise ValueError("Client not initialized") + return ( self._client.post(url, headers=headers, json=payload) .raise_for_status() .json() ) + def delete( + self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None + ) -> None: + if self._client is None: + raise ValueError("Client not initialized") + + self._client.delete(url, headers=headers).raise_for_status() + DEFAULT_CLIENT = HttpxHttpClient except ImportError: pass diff --git a/pyproject.toml b/pyproject.toml index ed0efc5..a0a0be7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,4 @@ dev = ["setuptools", "pytest", "black", "flake8", "wheel"] requests = ["requests"] httpx = ["httpx"] aiohttp = ["aiohttp"] +encryption = ["pycryptodome"] diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..951698c --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,118 @@ +import os +import pytest +from requests import HTTPError + +from android_sms_gateway.client import APIClient +from android_sms_gateway.constants import DEFAULT_URL +from android_sms_gateway.domain import Webhook +from android_sms_gateway.enums import WebhookEvent +from android_sms_gateway.http import RequestsHttpClient + + +@pytest.fixture +def client(): + """ + A fixture providing an instance of `APIClient` for use in tests. + + The client is created using the values of the following environment variables: + + - `API_LOGIN` (defaults to `"test"`) + - `API_PASSWORD` (defaults to `"test"`) + - `API_BASE_URL` (defaults to `constants.DEFAULT_URL`) + + The client is yielded from the fixture, and automatically closed when the + test is finished. + + :yields: An instance of `APIClient`. + """ + with RequestsHttpClient() as h, APIClient( + os.environ.get("API_LOGIN") or "test", + os.environ.get("API_PASSWORD") or "test", + base_url=os.environ.get("API_BASE_URL") or DEFAULT_URL, + http=h, + ) as c: + yield c + + +@pytest.mark.skipif( + not all( + [ + os.environ.get("API_LOGIN"), + os.environ.get("API_PASSWORD"), + ] + ), + reason="API credentials are not set in the environment variables", +) +class TestAPIClient: + def test_webhook_create(self, client: APIClient): + """ + Tests that a webhook can be successfully created using the client. + + It creates a webhook, and then asserts that the created webhook matches the + expected values. + + :param client: An instance of `APIClient`. + """ + item = Webhook( + id="webhook_123", + url="https://example.com/webhook", + event=WebhookEvent.SMS_RECEIVED, + ) + + created = client.create_webhook(item) + + assert created.id == "webhook_123" + assert created.url == "https://example.com/webhook" + assert created.event == WebhookEvent.SMS_RECEIVED + + def test_webhook_create_invalid_url(self, client: APIClient): + """ + Tests that attempting to create a webhook with an invalid URL raises an + `HTTPError`. + + The test creates a webhook with an invalid URL, and then asserts that an + `HTTPError` is raised. + + :param client: An instance of `APIClient`. + """ + with pytest.raises(HTTPError): + client.create_webhook( + Webhook(None, url="not_a_url", event=WebhookEvent.SMS_RECEIVED) + ) + + def test_webhook_get(self, client: APIClient): + """ + Tests that the `get_webhooks` method retrieves a non-empty list of webhooks + and that it contains a webhook with the expected ID, URL, and event type. + + :param client: An instance of `APIClient`. + """ + + webhooks = client.get_webhooks() + + assert len(webhooks) > 0 + + assert any( + [ + webhook.id == "webhook_123" + and webhook.url == "https://example.com/webhook" + and webhook.event == WebhookEvent.SMS_RECEIVED + for webhook in webhooks + ] + ) + + def test_webhook_delete(self, client: APIClient): + """ + Tests that a webhook can be successfully deleted using the client. + + It deletes a webhook with a specific ID and then asserts that the list of + webhooks does not contain a webhook with that ID. + + :param client: An instance of `APIClient`. + """ + + client.delete_webhook("webhook_123") + + webhooks = client.get_webhooks() + + assert not any([webhook.id == "webhook_123" for webhook in webhooks]) diff --git a/tests/test_domain.py b/tests/test_domain.py index 42c1caf..50fc108 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -1,6 +1,7 @@ import pytest -from android_sms_gateway.domain import MessageState, RecipientState +from android_sms_gateway.enums import WebhookEvent +from android_sms_gateway.domain import MessageState, RecipientState, Webhook # Test for successful instantiation from a dictionary @@ -79,3 +80,58 @@ def test_message_state_from_dict_incorrect_types(): Exception ): # Replace Exception with the specific exception you expect MessageState.from_dict(incorrect_payload) + + +def test_webhook_from_dict(): + """ + Tests that a Webhook instance can be successfully instantiated from a dictionary + representation of a webhook. + """ + payload = { + "id": "webhook_123", + "url": "https://example.com/webhook", + "event": "sms:received", + } + + webhook = Webhook.from_dict(payload) + + assert webhook.id == payload["id"] + assert webhook.url == payload["url"] + assert webhook.event == WebhookEvent(payload["event"]) + + +def test_webhook_asdict(): + """ + Tests that a Webhook instance can be successfully converted to a dictionary + representation and that the fields match the expected values. + + This test ensures that the asdict method of the Webhook class returns a dictionary + with the correct keys and values. + """ + webhook = Webhook( + id="webhook_123", + url="https://example.com/webhook", + event=WebhookEvent.SMS_RECEIVED, + ) + + expected_dict = { + "id": "webhook_123", + "url": "https://example.com/webhook", + "event": "sms:received", + } + + assert webhook.asdict() == expected_dict + + webhook = Webhook( + id=None, + url="https://example.com/webhook", + event=WebhookEvent.SMS_RECEIVED, + ) + + expected_dict = { + "id": None, + "url": "https://example.com/webhook", + "event": "sms:received", + } + + assert webhook.asdict() == expected_dict