nitroping 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nitroping/__init__.py +75 -0
- nitroping/_client.py +204 -0
- nitroping/_devices.py +61 -0
- nitroping/_http.py +158 -0
- nitroping/_notifications.py +94 -0
- nitroping/errors.py +110 -0
- nitroping/py.typed +0 -0
- nitroping/types.py +100 -0
- nitroping/webhooks.py +160 -0
- nitroping-0.1.3.dist-info/METADATA +426 -0
- nitroping-0.1.3.dist-info/RECORD +12 -0
- nitroping-0.1.3.dist-info/WHEEL +4 -0
nitroping/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Zero-dependency Python SDK for `nitroping <https://nitroping.dev>`_.
|
|
2
|
+
|
|
3
|
+
Send push notifications, register devices, verify webhook signatures.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from nitroping import Nitroping
|
|
8
|
+
|
|
9
|
+
np = Nitroping(api_key="np_live_...")
|
|
10
|
+
np.notifications.send(
|
|
11
|
+
title="Order #4129 shipped",
|
|
12
|
+
body="On its way",
|
|
13
|
+
target={"all": True},
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
See the project README for the full API reference, framework recipes,
|
|
17
|
+
and error table.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from ._client import AsyncNitroping, Nitroping
|
|
23
|
+
from ._devices import DevicesClient
|
|
24
|
+
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
25
|
+
from ._notifications import NotificationsClient
|
|
26
|
+
from .errors import (
|
|
27
|
+
ApiError,
|
|
28
|
+
InvalidSignatureError,
|
|
29
|
+
MissingSignatureHeaderError,
|
|
30
|
+
NetworkError,
|
|
31
|
+
NitropingError,
|
|
32
|
+
TimestampOutOfRangeError,
|
|
33
|
+
)
|
|
34
|
+
from .types import (
|
|
35
|
+
DeactivateDeviceResult,
|
|
36
|
+
NotificationAction,
|
|
37
|
+
NotificationResult,
|
|
38
|
+
NotificationTarget,
|
|
39
|
+
Platform,
|
|
40
|
+
RegisterDeviceResult,
|
|
41
|
+
SendOptions,
|
|
42
|
+
TargetAll,
|
|
43
|
+
TargetDeviceIds,
|
|
44
|
+
TargetUserIds,
|
|
45
|
+
WebhookEvent,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
__version__ = "0.1.0"
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"DEFAULT_BASE_URL",
|
|
52
|
+
"ApiError",
|
|
53
|
+
"AsyncNitroping",
|
|
54
|
+
"DeactivateDeviceResult",
|
|
55
|
+
"DevicesClient",
|
|
56
|
+
"HttpClient",
|
|
57
|
+
"InvalidSignatureError",
|
|
58
|
+
"MissingSignatureHeaderError",
|
|
59
|
+
"NetworkError",
|
|
60
|
+
"Nitroping",
|
|
61
|
+
"NitropingError",
|
|
62
|
+
"NotificationAction",
|
|
63
|
+
"NotificationResult",
|
|
64
|
+
"NotificationTarget",
|
|
65
|
+
"NotificationsClient",
|
|
66
|
+
"Platform",
|
|
67
|
+
"RegisterDeviceResult",
|
|
68
|
+
"SendOptions",
|
|
69
|
+
"TargetAll",
|
|
70
|
+
"TargetDeviceIds",
|
|
71
|
+
"TargetUserIds",
|
|
72
|
+
"TimestampOutOfRangeError",
|
|
73
|
+
"WebhookEvent",
|
|
74
|
+
"__version__",
|
|
75
|
+
]
|
nitroping/_client.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Top-level SDK entry points: :class:`Nitroping` and :class:`AsyncNitroping`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ._devices import DevicesClient
|
|
10
|
+
from ._http import DEFAULT_BASE_URL, HttpClient
|
|
11
|
+
from ._notifications import NotificationsClient
|
|
12
|
+
from .errors import NitropingError
|
|
13
|
+
from .types import (
|
|
14
|
+
DeactivateDeviceResult,
|
|
15
|
+
NotificationAction,
|
|
16
|
+
NotificationResult,
|
|
17
|
+
NotificationTarget,
|
|
18
|
+
Platform,
|
|
19
|
+
RegisterDeviceResult,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Nitroping:
|
|
24
|
+
"""Synchronous server-side SDK client.
|
|
25
|
+
|
|
26
|
+
Example::
|
|
27
|
+
|
|
28
|
+
from nitroping import Nitroping
|
|
29
|
+
|
|
30
|
+
np = Nitroping(api_key="np_live_...")
|
|
31
|
+
result = np.notifications.send(
|
|
32
|
+
title="Order #4129 shipped",
|
|
33
|
+
body="On its way",
|
|
34
|
+
target={"all": True},
|
|
35
|
+
)
|
|
36
|
+
print(result["id"], result["status"])
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
#: ``notifications`` resource — send, get.
|
|
40
|
+
notifications: NotificationsClient
|
|
41
|
+
#: ``devices`` resource — register, deactivate.
|
|
42
|
+
devices: DevicesClient
|
|
43
|
+
#: Internal HTTP client. Exposed for advanced use (custom requests).
|
|
44
|
+
http: HttpClient
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
api_key: str | None = None,
|
|
49
|
+
*,
|
|
50
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
51
|
+
timeout: float = 30.0,
|
|
52
|
+
user_agent: str | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
resolved_key = api_key if api_key is not None else os.environ.get(
|
|
55
|
+
"NITROPING_API_KEY"
|
|
56
|
+
)
|
|
57
|
+
if not resolved_key:
|
|
58
|
+
raise NitropingError(
|
|
59
|
+
"api_key is required. Pass it to Nitroping(api_key=...) or set "
|
|
60
|
+
"the NITROPING_API_KEY environment variable.",
|
|
61
|
+
code="invalid_argument",
|
|
62
|
+
)
|
|
63
|
+
self.http = HttpClient(
|
|
64
|
+
api_key=resolved_key,
|
|
65
|
+
base_url=base_url,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
user_agent=user_agent,
|
|
68
|
+
)
|
|
69
|
+
self.notifications = NotificationsClient(self.http)
|
|
70
|
+
self.devices = DevicesClient(self.http)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class _AsyncNotificationsClient:
|
|
74
|
+
"""Awaitable façade over :class:`NotificationsClient`.
|
|
75
|
+
|
|
76
|
+
Each call shells out to the sync client on the default thread pool
|
|
77
|
+
via :func:`asyncio.get_running_loop().run_in_executor`. This is a
|
|
78
|
+
pragmatic best-effort — for high-throughput async fanout, run the
|
|
79
|
+
underlying HTTP calls in a real async client (``httpx``, ``aiohttp``)
|
|
80
|
+
and skip this wrapper.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, inner: NotificationsClient) -> None:
|
|
84
|
+
self._inner = inner
|
|
85
|
+
|
|
86
|
+
async def send(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
target: NotificationTarget,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
body: str | None = None,
|
|
92
|
+
template: str | None = None,
|
|
93
|
+
vars: dict[str, Any] | None = None,
|
|
94
|
+
data: dict[str, Any] | None = None,
|
|
95
|
+
icon: str | None = None,
|
|
96
|
+
image: str | None = None,
|
|
97
|
+
click_action: str | None = None,
|
|
98
|
+
deep_link: str | None = None,
|
|
99
|
+
actions: list[NotificationAction] | None = None,
|
|
100
|
+
scheduled_at: str | None = None,
|
|
101
|
+
expires_at: str | None = None,
|
|
102
|
+
idempotency_key: str | None = None,
|
|
103
|
+
) -> NotificationResult:
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
return await loop.run_in_executor(
|
|
106
|
+
None,
|
|
107
|
+
lambda: self._inner.send(
|
|
108
|
+
target=target,
|
|
109
|
+
title=title,
|
|
110
|
+
body=body,
|
|
111
|
+
template=template,
|
|
112
|
+
vars=vars,
|
|
113
|
+
data=data,
|
|
114
|
+
icon=icon,
|
|
115
|
+
image=image,
|
|
116
|
+
click_action=click_action,
|
|
117
|
+
deep_link=deep_link,
|
|
118
|
+
actions=actions,
|
|
119
|
+
scheduled_at=scheduled_at,
|
|
120
|
+
expires_at=expires_at,
|
|
121
|
+
idempotency_key=idempotency_key,
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
async def get(self, notification_id: str) -> dict[str, Any]:
|
|
126
|
+
loop = asyncio.get_running_loop()
|
|
127
|
+
return await loop.run_in_executor(
|
|
128
|
+
None, self._inner.get, notification_id
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _AsyncDevicesClient:
|
|
133
|
+
"""Awaitable façade over :class:`DevicesClient`."""
|
|
134
|
+
|
|
135
|
+
def __init__(self, inner: DevicesClient) -> None:
|
|
136
|
+
self._inner = inner
|
|
137
|
+
|
|
138
|
+
async def register(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
platform: Platform,
|
|
142
|
+
token: str,
|
|
143
|
+
user_id: str | None = None,
|
|
144
|
+
web_push_p256dh: str | None = None,
|
|
145
|
+
web_push_auth: str | None = None,
|
|
146
|
+
metadata: dict[str, Any] | None = None,
|
|
147
|
+
) -> RegisterDeviceResult:
|
|
148
|
+
loop = asyncio.get_running_loop()
|
|
149
|
+
return await loop.run_in_executor(
|
|
150
|
+
None,
|
|
151
|
+
lambda: self._inner.register(
|
|
152
|
+
platform=platform,
|
|
153
|
+
token=token,
|
|
154
|
+
user_id=user_id,
|
|
155
|
+
web_push_p256dh=web_push_p256dh,
|
|
156
|
+
web_push_auth=web_push_auth,
|
|
157
|
+
metadata=metadata,
|
|
158
|
+
),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def deactivate(self, device_id: str) -> DeactivateDeviceResult:
|
|
162
|
+
loop = asyncio.get_running_loop()
|
|
163
|
+
return await loop.run_in_executor(
|
|
164
|
+
None, self._inner.deactivate, device_id
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AsyncNitroping:
|
|
169
|
+
"""Async server-side SDK client.
|
|
170
|
+
|
|
171
|
+
Wraps the sync :class:`Nitroping` client and runs each HTTP call on
|
|
172
|
+
the default thread pool. Convenient when you want to ``await`` an
|
|
173
|
+
SDK method from inside an async framework (FastAPI, aiohttp,
|
|
174
|
+
Starlette) without pulling in a separate async HTTP dependency.
|
|
175
|
+
|
|
176
|
+
For real-world high-fanout (thousands of concurrent sends) prefer a
|
|
177
|
+
purpose-built async HTTP client and call the API directly — this
|
|
178
|
+
wrapper still bottlenecks on the executor pool size.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
notifications: _AsyncNotificationsClient
|
|
182
|
+
devices: _AsyncDevicesClient
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
api_key: str | None = None,
|
|
187
|
+
*,
|
|
188
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
189
|
+
timeout: float = 30.0,
|
|
190
|
+
user_agent: str | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
self._sync = Nitroping(
|
|
193
|
+
api_key=api_key,
|
|
194
|
+
base_url=base_url,
|
|
195
|
+
timeout=timeout,
|
|
196
|
+
user_agent=user_agent,
|
|
197
|
+
)
|
|
198
|
+
self.notifications = _AsyncNotificationsClient(self._sync.notifications)
|
|
199
|
+
self.devices = _AsyncDevicesClient(self._sync.devices)
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def http(self) -> HttpClient:
|
|
203
|
+
"""Underlying sync HTTP client (advanced use)."""
|
|
204
|
+
return self._sync.http
|
nitroping/_devices.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""``devices`` resource client.
|
|
2
|
+
|
|
3
|
+
Mounted on :class:`nitroping.Nitroping` as ``np.devices``. Wraps
|
|
4
|
+
``POST /api/v1/devices`` and ``DELETE /api/v1/devices/:id``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from ._http import HttpClient
|
|
13
|
+
from .types import DeactivateDeviceResult, Platform, RegisterDeviceResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DevicesClient:
|
|
17
|
+
"""Register and deactivate device rows."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, http: HttpClient) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
|
|
22
|
+
def register(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
platform: Platform,
|
|
26
|
+
token: str,
|
|
27
|
+
user_id: str | None = None,
|
|
28
|
+
web_push_p256dh: str | None = None,
|
|
29
|
+
web_push_auth: str | None = None,
|
|
30
|
+
metadata: dict[str, Any] | None = None,
|
|
31
|
+
) -> RegisterDeviceResult:
|
|
32
|
+
"""Register (or update) a device with the secret API key.
|
|
33
|
+
|
|
34
|
+
Idempotent on ``(app_id, token, user_id)``. Returns
|
|
35
|
+
``{"id": ..., "created": True}`` when a new row was inserted,
|
|
36
|
+
``{"id": ..., "created": False}`` when an existing device matched.
|
|
37
|
+
"""
|
|
38
|
+
wire: dict[str, Any] = {"token": token, "platform": platform}
|
|
39
|
+
if user_id is not None:
|
|
40
|
+
wire["user_id"] = user_id
|
|
41
|
+
if web_push_p256dh is not None:
|
|
42
|
+
wire["web_push_p256dh"] = web_push_p256dh
|
|
43
|
+
if web_push_auth is not None:
|
|
44
|
+
wire["web_push_auth"] = web_push_auth
|
|
45
|
+
if metadata is not None:
|
|
46
|
+
wire["metadata"] = metadata
|
|
47
|
+
|
|
48
|
+
response = self._http.request("POST", "/api/v1/devices", body=wire)
|
|
49
|
+
return cast(RegisterDeviceResult, response)
|
|
50
|
+
|
|
51
|
+
def deactivate(self, device_id: str) -> DeactivateDeviceResult:
|
|
52
|
+
"""Soft-delete a device (sets ``status = inactive``).
|
|
53
|
+
|
|
54
|
+
Returns ``{"id": ..., "status": "inactive"}``. Raises
|
|
55
|
+
:class:`~nitroping.errors.ApiError` with ``code = "not_found"``
|
|
56
|
+
if the id doesn't belong to your app.
|
|
57
|
+
"""
|
|
58
|
+
response = self._http.request(
|
|
59
|
+
"DELETE", f"/api/v1/devices/{quote(device_id, safe='')}"
|
|
60
|
+
)
|
|
61
|
+
return cast(DeactivateDeviceResult, response)
|
nitroping/_http.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Internal stdlib HTTP wrapper.
|
|
2
|
+
|
|
3
|
+
Adds the ``Authorization`` header, JSON-serializes the body, parses the
|
|
4
|
+
JSON response, and maps non-2xx envelopes (``{"error": {"code",
|
|
5
|
+
"message", "details"}}``) into :class:`ApiError`. Underlying transport
|
|
6
|
+
failure (DNS / TLS / offline / abort) becomes :class:`NetworkError`.
|
|
7
|
+
|
|
8
|
+
Zero runtime deps — :mod:`urllib.request` only.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any, NoReturn
|
|
18
|
+
|
|
19
|
+
from .errors import ApiError, NetworkError, NitropingError
|
|
20
|
+
|
|
21
|
+
#: Default base URL pointing at the hosted nitroping service.
|
|
22
|
+
DEFAULT_BASE_URL = "https://nitroping.dev"
|
|
23
|
+
|
|
24
|
+
#: SDK version — bumped via ``pyproject.toml``. Used in the User-Agent
|
|
25
|
+
#: header so requests can be attributed during incident investigation.
|
|
26
|
+
SDK_VERSION = "0.1.0"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HttpClient:
|
|
30
|
+
"""Internal structured HTTP client.
|
|
31
|
+
|
|
32
|
+
Not part of the public surface — use :class:`nitroping.Nitroping`
|
|
33
|
+
instead. Exposed for advanced cases where callers need to make raw
|
|
34
|
+
requests against undocumented endpoints.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
base_url: str
|
|
38
|
+
api_key: str
|
|
39
|
+
timeout: float
|
|
40
|
+
user_agent: str
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
api_key: str,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
user_agent: str | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
if not api_key:
|
|
51
|
+
raise NitropingError("api_key is required", code="invalid_argument")
|
|
52
|
+
self.api_key = api_key
|
|
53
|
+
self.base_url = base_url.rstrip("/")
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
self.user_agent = user_agent or f"nitroping-python/{SDK_VERSION}"
|
|
56
|
+
|
|
57
|
+
def request(
|
|
58
|
+
self,
|
|
59
|
+
method: str,
|
|
60
|
+
path: str,
|
|
61
|
+
*,
|
|
62
|
+
body: Any = None,
|
|
63
|
+
headers: Mapping[str, str] | None = None,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Perform an HTTP request and parse the JSON envelope.
|
|
66
|
+
|
|
67
|
+
Returns the decoded JSON body (``dict`` for object responses,
|
|
68
|
+
``list`` / ``str`` / ``None`` for everything else). Raises
|
|
69
|
+
:class:`ApiError` on non-2xx with a server envelope,
|
|
70
|
+
:class:`NetworkError` on transport failure.
|
|
71
|
+
"""
|
|
72
|
+
url = self._build_url(path)
|
|
73
|
+
body_bytes: bytes | None = None
|
|
74
|
+
|
|
75
|
+
all_headers: dict[str, str] = {
|
|
76
|
+
"Authorization": f"ApiKey {self.api_key}",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
"User-Agent": self.user_agent,
|
|
79
|
+
}
|
|
80
|
+
if headers:
|
|
81
|
+
for k, v in headers.items():
|
|
82
|
+
all_headers[k] = v
|
|
83
|
+
|
|
84
|
+
if body is not None:
|
|
85
|
+
body_bytes = json.dumps(body).encode("utf-8")
|
|
86
|
+
all_headers["Content-Type"] = "application/json"
|
|
87
|
+
|
|
88
|
+
req = urllib.request.Request(
|
|
89
|
+
url=url, data=body_bytes, method=method, headers=all_headers
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
94
|
+
raw = response.read()
|
|
95
|
+
except urllib.error.HTTPError as http_err:
|
|
96
|
+
# Non-2xx — try to decode the JSON error envelope, fall back
|
|
97
|
+
# to ``http_<status>`` if the body is not JSON.
|
|
98
|
+
err_body = http_err.read()
|
|
99
|
+
_raise_for_error(http_err.code, err_body)
|
|
100
|
+
except urllib.error.URLError as url_err:
|
|
101
|
+
raise NetworkError(
|
|
102
|
+
f"Request to {url} failed: {url_err.reason}", cause=url_err
|
|
103
|
+
) from url_err
|
|
104
|
+
except TimeoutError as timeout_err:
|
|
105
|
+
raise NetworkError(
|
|
106
|
+
f"Request to {url} timed out", cause=timeout_err
|
|
107
|
+
) from timeout_err
|
|
108
|
+
except OSError as os_err:
|
|
109
|
+
raise NetworkError(
|
|
110
|
+
f"Request to {url} failed: {os_err}", cause=os_err
|
|
111
|
+
) from os_err
|
|
112
|
+
|
|
113
|
+
return _decode_body(raw)
|
|
114
|
+
|
|
115
|
+
def _build_url(self, path: str) -> str:
|
|
116
|
+
suffix = path if path.startswith("/") else f"/{path}"
|
|
117
|
+
return f"{self.base_url}{suffix}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _decode_body(raw: bytes) -> Any:
|
|
121
|
+
if not raw:
|
|
122
|
+
return None
|
|
123
|
+
text = raw.decode("utf-8", errors="replace")
|
|
124
|
+
try:
|
|
125
|
+
return json.loads(text)
|
|
126
|
+
except json.JSONDecodeError:
|
|
127
|
+
# 2xx with a non-JSON body — pass through as a string. We never
|
|
128
|
+
# hit this for the documented endpoints but it keeps the path
|
|
129
|
+
# forward-compatible.
|
|
130
|
+
return text
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _raise_for_error(status: int, raw: bytes) -> NoReturn:
|
|
134
|
+
"""Translate an HTTPError body into an :class:`ApiError` with the
|
|
135
|
+
server's machine-readable code + per-field details."""
|
|
136
|
+
code = f"http_{status}"
|
|
137
|
+
message = f"HTTP {status}"
|
|
138
|
+
details: Any = None
|
|
139
|
+
|
|
140
|
+
if raw:
|
|
141
|
+
text = raw.decode("utf-8", errors="replace")
|
|
142
|
+
try:
|
|
143
|
+
envelope = json.loads(text)
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
message = f"HTTP {status}: {text[:200]}"
|
|
146
|
+
else:
|
|
147
|
+
if isinstance(envelope, dict):
|
|
148
|
+
err = envelope.get("error")
|
|
149
|
+
if isinstance(err, dict):
|
|
150
|
+
raw_code = err.get("code")
|
|
151
|
+
if isinstance(raw_code, str) and raw_code:
|
|
152
|
+
code = raw_code
|
|
153
|
+
raw_message = err.get("message")
|
|
154
|
+
if isinstance(raw_message, str) and raw_message:
|
|
155
|
+
message = raw_message
|
|
156
|
+
details = err.get("details")
|
|
157
|
+
|
|
158
|
+
raise ApiError(message, status=status, code=code, details=details)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""``notifications`` resource client.
|
|
2
|
+
|
|
3
|
+
Mounted on :class:`nitroping.Nitroping` as ``np.notifications``. Wraps
|
|
4
|
+
``POST /api/v1/notifications`` and ``GET /api/v1/notifications/:id``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from ._http import HttpClient
|
|
13
|
+
from .types import NotificationAction, NotificationResult, NotificationTarget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NotificationsClient:
|
|
17
|
+
"""Send and inspect notifications."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, http: HttpClient) -> None:
|
|
20
|
+
self._http = http
|
|
21
|
+
|
|
22
|
+
def send(
|
|
23
|
+
self,
|
|
24
|
+
*,
|
|
25
|
+
target: NotificationTarget,
|
|
26
|
+
title: str | None = None,
|
|
27
|
+
body: str | None = None,
|
|
28
|
+
template: str | None = None,
|
|
29
|
+
vars: dict[str, Any] | None = None,
|
|
30
|
+
data: dict[str, Any] | None = None,
|
|
31
|
+
icon: str | None = None,
|
|
32
|
+
image: str | None = None,
|
|
33
|
+
click_action: str | None = None,
|
|
34
|
+
deep_link: str | None = None,
|
|
35
|
+
actions: list[NotificationAction] | None = None,
|
|
36
|
+
scheduled_at: str | None = None,
|
|
37
|
+
expires_at: str | None = None,
|
|
38
|
+
idempotency_key: str | None = None,
|
|
39
|
+
) -> NotificationResult:
|
|
40
|
+
"""Enqueue a new notification.
|
|
41
|
+
|
|
42
|
+
Either ``title + body`` (raw payload) or ``template + vars``
|
|
43
|
+
(Pro plan). Mixing the two is a 422.
|
|
44
|
+
|
|
45
|
+
Returns ``{"id": ..., "status": ...}`` on ``201 Created``. On
|
|
46
|
+
non-2xx the SDK raises :class:`~nitroping.errors.ApiError`
|
|
47
|
+
carrying the server's ``code``, ``message``, and (for validation
|
|
48
|
+
failures) the per-field ``details`` map.
|
|
49
|
+
"""
|
|
50
|
+
wire: dict[str, Any] = {"target": dict(target)}
|
|
51
|
+
if title is not None:
|
|
52
|
+
wire["title"] = title
|
|
53
|
+
if body is not None:
|
|
54
|
+
wire["body"] = body
|
|
55
|
+
if template is not None:
|
|
56
|
+
wire["template"] = template
|
|
57
|
+
if vars is not None:
|
|
58
|
+
wire["vars"] = vars
|
|
59
|
+
if data is not None:
|
|
60
|
+
wire["data"] = data
|
|
61
|
+
if icon is not None:
|
|
62
|
+
wire["icon"] = icon
|
|
63
|
+
if image is not None:
|
|
64
|
+
wire["image"] = image
|
|
65
|
+
if click_action is not None:
|
|
66
|
+
wire["click_action"] = click_action
|
|
67
|
+
if deep_link is not None:
|
|
68
|
+
wire["deep_link"] = deep_link
|
|
69
|
+
if actions is not None:
|
|
70
|
+
wire["actions"] = actions
|
|
71
|
+
if scheduled_at is not None:
|
|
72
|
+
wire["scheduled_at"] = scheduled_at
|
|
73
|
+
if expires_at is not None:
|
|
74
|
+
wire["expires_at"] = expires_at
|
|
75
|
+
|
|
76
|
+
headers: dict[str, str] = {}
|
|
77
|
+
if idempotency_key is not None:
|
|
78
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
79
|
+
|
|
80
|
+
response = self._http.request(
|
|
81
|
+
"POST", "/api/v1/notifications", body=wire, headers=headers
|
|
82
|
+
)
|
|
83
|
+
return cast(NotificationResult, response)
|
|
84
|
+
|
|
85
|
+
def get(self, notification_id: str) -> dict[str, Any]:
|
|
86
|
+
"""Fetch a previously-enqueued notification by id.
|
|
87
|
+
|
|
88
|
+
Returns the full row (including counters: ``total_sent``,
|
|
89
|
+
``total_delivered``, ``total_failed``, etc.).
|
|
90
|
+
"""
|
|
91
|
+
response = self._http.request(
|
|
92
|
+
"GET", f"/api/v1/notifications/{quote(notification_id, safe='')}"
|
|
93
|
+
)
|
|
94
|
+
return cast(dict[str, Any], response)
|
nitroping/errors.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Error hierarchy for the nitroping SDK.
|
|
2
|
+
|
|
3
|
+
All public functions raise subclasses of :class:`NitropingError`. Catch the
|
|
4
|
+
base class to handle every error, or narrow by ``isinstance`` on the
|
|
5
|
+
specific subclass when you want to switch on a known failure mode (e.g.
|
|
6
|
+
retry on :class:`NetworkError`, surface an HTTP 400 on
|
|
7
|
+
:class:`InvalidSignatureError`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NitropingError(Exception):
|
|
16
|
+
"""Base error raised by every SDK function.
|
|
17
|
+
|
|
18
|
+
Subclasses set :attr:`code` and may add structured fields
|
|
19
|
+
(:attr:`status`, :attr:`details`).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
#: Optional HTTP status if this error originated from a response.
|
|
23
|
+
status: int | None
|
|
24
|
+
|
|
25
|
+
#: Stable machine-readable code, mirrored from the server envelope
|
|
26
|
+
#: (``error.code``). Examples: ``"invalid_api_key"``,
|
|
27
|
+
#: ``"validation_failed"``, ``"quota_exceeded"``. SDK-internal failures
|
|
28
|
+
#: use codes like ``"network_error"`` or ``"invalid_signature"``.
|
|
29
|
+
code: str
|
|
30
|
+
|
|
31
|
+
#: Free-form details object — typically the server's ``error.details``
|
|
32
|
+
#: (field-level validation errors).
|
|
33
|
+
details: Any
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
message: str,
|
|
38
|
+
*,
|
|
39
|
+
status: int | None = None,
|
|
40
|
+
code: str = "error",
|
|
41
|
+
details: Any = None,
|
|
42
|
+
cause: BaseException | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status = status
|
|
46
|
+
self.code = code
|
|
47
|
+
self.details = details
|
|
48
|
+
if cause is not None:
|
|
49
|
+
self.__cause__ = cause
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ApiError(NitropingError):
|
|
53
|
+
"""Raised when the server returns a non-2xx response.
|
|
54
|
+
|
|
55
|
+
Carries the server's ``code``, ``message``, and (for validation
|
|
56
|
+
failures) the per-field ``details`` map.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
message: str,
|
|
62
|
+
*,
|
|
63
|
+
status: int,
|
|
64
|
+
code: str = "error",
|
|
65
|
+
details: Any = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(message, status=status, code=code, details=details)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class NetworkError(NitropingError):
|
|
71
|
+
"""Raised when the HTTP transport itself fails.
|
|
72
|
+
|
|
73
|
+
Wraps DNS / TLS / offline / abort errors. The underlying exception is
|
|
74
|
+
attached via :attr:`__cause__`.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
|
|
78
|
+
super().__init__(message, code="network_error", cause=cause)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InvalidSignatureError(NitropingError):
|
|
82
|
+
"""Raised by :func:`nitroping.webhooks.verify` when the HMAC does not
|
|
83
|
+
match the request body, or the signature header is malformed."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self, message: str = "Webhook signature does not match request body"
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(message, code="invalid_signature")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TimestampOutOfRangeError(NitropingError):
|
|
92
|
+
"""Raised by :func:`nitroping.webhooks.verify` when the signature is
|
|
93
|
+
well-formed and matches the body, but its ``t=`` timestamp is outside
|
|
94
|
+
the tolerance window. Defends against signature replay."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
message: str = "Webhook timestamp is outside the allowed tolerance",
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(message, code="timestamp_out_of_range")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MissingSignatureHeaderError(NitropingError):
|
|
104
|
+
"""Raised by :func:`nitroping.webhooks.verify` when the
|
|
105
|
+
``X-Nitroping-Signature`` header is absent."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self, message: str = "Missing X-Nitroping-Signature header"
|
|
109
|
+
) -> None:
|
|
110
|
+
super().__init__(message, code="missing_signature_header")
|
nitroping/py.typed
ADDED
|
File without changes
|