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
19 changes: 12 additions & 7 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
# Runs lint, type checks, and tests on every push and pull request to main.

name: Python application

Expand All @@ -20,14 +19,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v3
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Test with pytest
pip install -r requirements.txt
pip install ruff mypy
- name: Lint with ruff
run: |
pytest
ruff check .
ruff format --check .
- name: Type-check with mypy
run: mypy apis entities models push services utils main.py database.py
- name: Test with pytest
run: pytest
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
Empty file removed __init__.py
Empty file.
16 changes: 8 additions & 8 deletions apis/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,42 +11,42 @@


@router.post("/register", response_model=Device)
def register_device(device: Device, deviceService: DeviceService = Depends()):
def register_device(device: Device, device_service: DeviceService = Depends()):
"""
Registers a new device with the push notification framework.

Args:
device (Device): The device to register.
deviceService (DeviceService): An instance of the DeviceService class. Injected by FastAPI.
device_service (DeviceService): An instance of the DeviceService class. Injected by FastAPI.

Returns:
Device: The registered device.
"""
return deviceService.register_device(device)
return device_service.register_device(device)


@router.get("/all", response_model=list[Device])
def get_registered_devices(
deviceService: DeviceService = Depends(),
device_service: DeviceService = Depends(),
):
"""
Retrieve a list of all registered devices.

Args:
deviceService (DeviceService): An instance of the DeviceService class. Injected by FastAPI.
device_service (DeviceService): An instance of the DeviceService class. Injected by FastAPI.

Returns:
list[Device]: A list of Device objects representing all registered devices.
"""
return deviceService.get_registered_devices()
return device_service.get_registered_devices()


# FOR TESTING PURPOSES ONLY
@router.get("/clear", response_model=None)
def clear_registered_devices(
deviceService: DeviceService = Depends(),
device_service: DeviceService = Depends(),
):
"""
Clears all registered devices from the device service.
"""
return deviceService.clear_registered_devices()
return device_service.clear_registered_devices()
6 changes: 3 additions & 3 deletions apis/push.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@


@router.post("/send", response_model=dict[str, str])
def send_push(message: Message, pushService: PushService = Depends()):
def send_push(message: Message, push_service: PushService = Depends()):
"""
Sends a push notification to each recipient.

Args:
message (Message): The message to send.
pushService (PushService): The push service to use. Injected by FastAPI.
push_service (PushService): The push service to use. Injected by FastAPI.

Returns:
dict[str, str]: A mapping of each device token to "Success" or the
APNs failure reason.
"""
return pushService.send_push(message)
return push_service.send_push(message)
3 changes: 2 additions & 1 deletion database.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def _engine_str(name: str = getenv("DB_NAME")) -> str:
Helper function for reading settings from environment variables to produce connection string.

Arguments:
name (str): The name of the database. Defaults to the value of the "DB_NAME" environment variable.
name (str): The name of the database. Defaults to the value of the
"DB_NAME" environment variable.

Returns:
str: The connection string for the database.
Expand Down
2 changes: 2 additions & 0 deletions entities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@

from .device_entity import DeviceEntity
from .entity_base import EntityBase

__all__ = ["DeviceEntity", "EntityBase"]
25 changes: 15 additions & 10 deletions entities/device_entity.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from sqlalchemy import Column, DateTime, Integer, String, func
from datetime import datetime

from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column

from models import Device

Expand All @@ -23,15 +26,17 @@ class DeviceEntity(EntityBase):

__tablename__ = "devices"

id = Column(Integer, primary_key=True, index=True)
token = Column(String(255), nullable=False, unique=True)
name = Column(String(255), nullable=True)
systemName = Column(String(255), nullable=True)
systemVersion = Column(String(255), nullable=True)
model = Column(String(255), nullable=True)
localizedModel = Column(String(255), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
id: Mapped[int] = mapped_column(primary_key=True, index=True)
token: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str | None] = mapped_column(String(255))
systemName: Mapped[str | None] = mapped_column(String(255))
systemVersion: Mapped[str | None] = mapped_column(String(255))
model: Mapped[str | None] = mapped_column(String(255))
localizedModel: Mapped[str | None] = mapped_column(String(255))
created_at: Mapped[datetime | None] = mapped_column(DateTime, default=func.now())
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=func.now(), onupdate=func.now()
)

def to_model(self) -> Device:
return Device(
Expand Down
2 changes: 2 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@

from .device import Device
from .message import Message

__all__ = ["Device", "Message"]
2 changes: 2 additions & 0 deletions push/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
"""

from .handler import PushHandler, get_push_handler, shutdown_push_handler

__all__ = ["PushHandler", "get_push_handler", "shutdown_push_handler"]
2 changes: 2 additions & 0 deletions push/apn_handler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from .client import APNsClient, Notification
from .credentials import Credentials, TokenCredentials
from .payload import Payload

__all__ = ["APNsClient", "Credentials", "Notification", "Payload", "TokenCredentials"]
41 changes: 26 additions & 15 deletions push/apn_handler/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class NotificationType(Enum):
Complication = "complication"
FileProvider = "fileprovider"
MDM = "mdm"
LiveActivity = "liveactivity"
Location = "location"
Widgets = "widgets"
Controls = "controls"
PushToTalk = "pushtotalk"


Notification = collections.namedtuple("Notification", ["token", "payload"])
Expand All @@ -33,7 +38,7 @@ class NotificationType(Enum):


class APNsClient:
SANDBOX_SERVER = "api.development.push.apple.com"
SANDBOX_SERVER = "api.sandbox.push.apple.com"
LIVE_SERVER = "api.push.apple.com"

DEFAULT_PORT = 443
Expand All @@ -51,14 +56,13 @@ def __init__(
proxy_port: int | None = None,
heartbeat_period: float | None = None,
) -> None:
self.__credentials: Credentials
if isinstance(credentials, str):
self.__credentials = CertificateCredentials(credentials, password)
else:
self.__credentials = credentials

self._init_connection(
use_sandbox, use_alternative_port, proto, proxy_host, proxy_port
)
self._init_connection(use_sandbox, use_alternative_port, proto, proxy_host, proxy_port)

if heartbeat_period:
raise NotImplementedError("heartbeat not supported")
Expand All @@ -68,9 +72,7 @@ def __init__(
# APNs expects providers to keep connections open across requests;
# opening one per notification is treated as abusive by Apple.
ssl_context = self.__credentials.ssl_context
self.__http_client = httpx.Client(
http2=True, verify=ssl_context if ssl_context else True
)
self.__http_client = httpx.Client(http2=True, verify=ssl_context if ssl_context else True)

def _init_connection(
self,
Expand All @@ -81,9 +83,7 @@ def _init_connection(
proxy_port: int | None,
) -> None:
self.__server = self.SANDBOX_SERVER if use_sandbox else self.LIVE_SERVER
self.__port = (
self.ALTERNATIVE_PORT if use_alternative_port else self.DEFAULT_PORT
)
self.__port = self.ALTERNATIVE_PORT if use_alternative_port else self.DEFAULT_PORT

def send_notification(
self,
Expand Down Expand Up @@ -128,15 +128,25 @@ def send_notification_sync(

headers = {}

inferred_push_type = None # type: Optional[str]
inferred_push_type: str | None = None
if topic is not None:
headers["apns-topic"] = topic
if topic.endswith(".voip"):
if topic.endswith(".voip-ptt"):
inferred_push_type = NotificationType.PushToTalk.value
elif topic.endswith(".voip"):
inferred_push_type = NotificationType.VoIP.value
elif topic.endswith(".complication"):
inferred_push_type = NotificationType.Complication.value
elif topic.endswith(".pushkit.fileprovider"):
inferred_push_type = NotificationType.FileProvider.value
elif topic.endswith(".push-type.liveactivity"):
inferred_push_type = NotificationType.LiveActivity.value
elif topic.endswith(".location-query"):
inferred_push_type = NotificationType.Location.value
elif topic.endswith(".push-type.widgets"):
inferred_push_type = NotificationType.Widgets.value
elif topic.endswith(".push-type.controls"):
inferred_push_type = NotificationType.Controls.value
elif any(
[
notification.alert is not None,
Expand All @@ -158,7 +168,7 @@ def send_notification_sync(
headers["apns-priority"] = priority.value

if expiration is not None:
headers["apns-expiration"] = "%d" % expiration
headers["apns-expiration"] = str(expiration)

if isinstance(self.__credentials, TokenCredentials):
auth_header = self.__credentials.get_authorization_header(topic)
Expand All @@ -177,8 +187,9 @@ def _extract_reason(response: httpx.Response) -> str:
"""Extract the 'reason' field from an APNs error response body.

Bodies without a parseable reason (e.g. from an intermediary proxy) are
never returned verbatim: a 410 is always Unregistered per the APNs spec,
and anything else is reduced to a generic status marker.
never returned verbatim: per the APNs spec a 410 always means the token
is gone, so it maps to Unregistered; anything else is reduced to a
generic status marker.
"""
if response.status_code == 200:
return ""
Expand Down
8 changes: 3 additions & 5 deletions push/apn_handler/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ def get_authorization_header(self, topic: str | None) -> str | None:

# Credentials subclass for certificate authentication
class CertificateCredentials(Credentials):
def __init__(
self, cert_file: str | None = None, password: str | None = None
) -> None:
def __init__(self, cert_file: str, password: str | None = None) -> None:
ssl_context = ssl.create_default_context()
ssl_context.load_cert_chain(cert_file, password=password)
super().__init__(ssl_context)
Expand All @@ -43,14 +41,14 @@ def __init__(
self.__encryption_algorithm = encryption_algorithm
self.__token_lifetime = token_lifetime

self.__jwt_token = None # type: Optional[Tuple[float, str]]
self.__jwt_token: tuple[float, str] | None = None

# Use the default constructor because we don't have an SSL context
super().__init__()

def get_authorization_header(self, topic: str | None) -> str:
token = self._get_or_create_topic_token()
return "bearer %s" % token
return f"bearer {token}"

def _is_expired_token(self, issue_date: float) -> bool:
return time.time() > issue_date + self.__token_lifetime
Expand Down
30 changes: 28 additions & 2 deletions push/apn_handler/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,8 @@ class InvalidProviderToken(APNsException):


class MissingProviderToken(APNsException):
"""No provider certificate was used to connect to APNs and Authorization header was missing or no provider token
was specified."""
"""No provider certificate was used to connect to APNs and Authorization
header was missing or no provider token was specified."""


class BadPath(APNsException):
Expand All @@ -113,6 +113,28 @@ def __init__(self, *args, timestamp: str | None = None) -> None:
self.timestamp = timestamp


class ExpiredToken(APNsException):
"""The device token has expired."""

def __init__(self, *args, timestamp: str | None = None) -> None:
super().__init__(*args)

self.timestamp = timestamp


class InvalidPushType(BadPayloadException):
"""The apns-push-type value is invalid."""


class BadEnvironmentKeyIdInToken(APNsException):
"""The key ID in the provider token does not match the environment."""


class UnrelatedKeyIdInToken(APNsException):
"""The key ID in the provider token is not related to the key ID used in
the first push of this connection."""


class PayloadTooLarge(BadPayloadException):
"""The message payload was too large. The maximum payload size is 4096 bytes."""

Expand Down Expand Up @@ -166,6 +188,10 @@ def exception_class_for_reason(reason: str) -> type[APNsException]:
"BadPath": BadPath,
"MethodNotAllowed": MethodNotAllowed,
"Unregistered": Unregistered,
"ExpiredToken": ExpiredToken,
"InvalidPushType": InvalidPushType,
"BadEnvironmentKeyIdInToken": BadEnvironmentKeyIdInToken,
"UnrelatedKeyIdInToken": UnrelatedKeyIdInToken,
"PayloadTooLarge": PayloadTooLarge,
"TooManyProviderTokenUpdates": TooManyProviderTokenUpdates,
"TooManyRequests": TooManyRequests,
Expand Down
4 changes: 2 additions & 2 deletions push/apn_handler/payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def __init__(
self.launch_image = launch_image

def dict(self) -> dict[str, Any]:
result = {} # type: Dict[str, Any]
result: dict[str, Any] = {}

if self.title:
result["title"] = self.title
Expand Down Expand Up @@ -92,7 +92,7 @@ def __init__(
self.thread_id = thread_id

def dict(self) -> dict[str, Any]:
result = {"aps": {}} # type: Dict[str, Any]
result: dict[str, Any] = {"aps": {}}

if self.alert is not None:
if isinstance(self.alert, PayloadAlert):
Expand Down
Loading
Loading