Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
- name: Install dependencies
run: |
pipenv sync --dev
pipenv sync --categories encryption
pipenv sync --categories requests,httpx,aiohttp,encryption

- name: Lint with flake8
run: pipenv run flake8 android_sms_gateway tests
Expand Down
5 changes: 5 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"

[pipenv]
sort_pipfile = true

[packages]

[dev-packages]
Expand All @@ -14,6 +17,8 @@ wheel = "*"
twine = "*"
build = "*"
importlib-metadata = "*"
typing-extensions = "*"
pytest-asyncio = "*"

[requires]
python_version = "3"
Expand Down
1,913 changes: 1,079 additions & 834 deletions Pipfile.lock

Large diffs are not rendered by default.

129 changes: 120 additions & 9 deletions android_sms_gateway/ahttp.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import abc
from contextlib import suppress
import typing as t

from .errors import (
error_from_status,
)


class AsyncHttpClient(t.Protocol):
@abc.abstractmethod
Expand All @@ -13,6 +18,16 @@ async def post(
self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None
) -> dict: ...

@abc.abstractmethod
async def put(
self, url: str, payload: dict, *, headers: t.Optional[t.Dict[str, str]] = None
) -> dict: ...

@abc.abstractmethod
async def patch(
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
Expand Down Expand Up @@ -60,15 +75,35 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._session.close()
self._session = None

async def _process_response(self, response: aiohttp.ClientResponse) -> dict:
try:
response.raise_for_status()
if response.status == 204:
return {}

return await response.json()
except aiohttp.ContentTypeError:
return {}
except aiohttp.ClientResponseError as e:
# Extract error message from response if available
error_data = {}
with suppress(ValueError, aiohttp.ContentTypeError):
error_data = await response.json()

# Use the error mapping to create appropriate exception
error_message = str(e) or "HTTP request failed"
raise error_from_status(
error_message, response.status, error_data
) from e

async def get(
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
) -> dict:
if self._session is None:
raise ValueError("Session not initialized")

async with self._session.get(url, headers=headers) as response:
response.raise_for_status()
return await response.json()
return await self._process_response(response)

async def post(
self,
Expand All @@ -83,8 +118,37 @@ async def post(
async with self._session.post(
url, headers=headers, json=payload
) as response:
response.raise_for_status()
return await response.json()
return await self._process_response(response)

async def put(
self,
url: str,
payload: dict,
*,
headers: t.Optional[t.Dict[str, str]] = None,
) -> dict:
if self._session is None:
raise ValueError("Session not initialized")

async with self._session.put(
url, headers=headers, json=payload
) as response:
return await self._process_response(response)

async def patch(
self,
url: str,
payload: dict,
*,
headers: t.Optional[t.Dict[str, str]] = None,
) -> dict:
if self._session is None:
raise ValueError("Session not initialized")

async with self._session.patch(
url, headers=headers, json=payload
) as response:
return await self._process_response(response)

async def delete(
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
Expand All @@ -93,7 +157,7 @@ async def delete(
raise ValueError("Session not initialized")

async with self._session.delete(url, headers=headers) as response:
response.raise_for_status()
await self._process_response(response)

DEFAULT_CLIENT = AiohttpAsyncHttpClient
except ImportError:
Expand Down Expand Up @@ -121,15 +185,37 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._client.aclose()
self._client = None

async def _process_response(self, response: httpx.Response) -> dict:
try:
response.raise_for_status()
if response.status_code == 204 or not response.content:
return {}

return response.json()
except httpx.HTTPStatusError as e:
# Extract error message from response if available
error_data = {}
try:
if response.content:
error_data = response.json()
except ValueError:
# Response is not JSON
pass

# Use the error mapping to create appropriate exception
error_message = str(e) or "HTTP request failed"
raise error_from_status(
error_message, response.status_code, error_data
) from e

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()
return await self._process_response(response)

async def post(
self,
Expand All @@ -142,8 +228,33 @@ async def post(
raise ValueError("Client not initialized")

response = await self._client.post(url, headers=headers, json=payload)
return await self._process_response(response)

async def put(
self,
url: str,
payload: dict,
*,
headers: t.Optional[t.Dict[str, str]] = None,
) -> dict:
if self._client is None:
raise ValueError("Client not initialized")

response = await self._client.put(url, headers=headers, json=payload)
return await self._process_response(response)

async def patch(
self,
url: str,
payload: dict,
*,
headers: t.Optional[t.Dict[str, str]] = None,
) -> dict:
if self._client is None:
raise ValueError("Client not initialized")

return response.raise_for_status().json()
response = await self._client.patch(url, headers=headers, json=payload)
return await self._process_response(response)

async def delete(
self, url: str, *, headers: t.Optional[t.Dict[str, str]] = None
Expand All @@ -152,7 +263,7 @@ async def delete(
raise ValueError("Client not initialized")

response = await self._client.delete(url, headers=headers)
response.raise_for_status()
await self._process_response(response)

DEFAULT_CLIENT = HttpxAsyncHttpClient
except ImportError:
Expand Down
68 changes: 67 additions & 1 deletion android_sms_gateway/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,21 @@ def _encrypt(self, message: domain.Message) -> domain.Message:
message = dataclasses.replace(
message,
is_encrypted=True,
message=self.encryptor.encrypt(message.message),
text_message=(
domain.TextMessage(
text=self.encryptor.encrypt(message.text_message.text)
)
if message.text_message
else None
),
data_message=(
domain.DataMessage(
data=self.encryptor.encrypt(message.data_message.data),
port=message.data_message.port,
)
if message.data_message
else None
),
phone_numbers=[
self.encryptor.encrypt(phone) for phone in message.phone_numbers
],
Expand Down Expand Up @@ -177,6 +191,32 @@ def delete_webhook(self, _id: str) -> None:

self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers)

def list_devices(self) -> t.List[domain.Device]:
"""Lists all devices."""
if self.http is None:
raise ValueError("HTTP client not initialized")

return [
domain.Device.from_dict(device)
for device in self.http.get(
f"{self.base_url}/devices", headers=self.headers
)
]

def remove_device(self, _id: str) -> None:
"""Removes a device."""
if self.http is None:
raise ValueError("HTTP client not initialized")

self.http.delete(f"{self.base_url}/devices/{_id}", headers=self.headers)

def health_check(self) -> dict:
"""Performs a health check."""
if self.http is None:
raise ValueError("HTTP client not initialized")

return self.http.get(f"{self.base_url}/health", headers=self.headers)


class AsyncAPIClient(BaseClient):
def __init__(
Expand Down Expand Up @@ -286,3 +326,29 @@ async def delete_webhook(self, _id: str) -> None:
raise ValueError("HTTP client not initialized")

await self.http.delete(f"{self.base_url}/webhooks/{_id}", headers=self.headers)

async def list_devices(self) -> t.List[domain.Device]:
"""Lists all devices."""
if self.http is None:
raise ValueError("HTTP client not initialized")

return [
domain.Device.from_dict(device)
for device in await self.http.get(
f"{self.base_url}/devices", headers=self.headers
)
]

async def remove_device(self, _id: str) -> None:
"""Removes a device."""
if self.http is None:
raise ValueError("HTTP client not initialized")

await self.http.delete(f"{self.base_url}/devices/{_id}", headers=self.headers)

async def health_check(self) -> dict:
"""Performs a health check."""
if self.http is None:
raise ValueError("HTTP client not initialized")

return await self.http.get(f"{self.base_url}/health", headers=self.headers)
Loading