sendithq 0.1.0__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.
- sendithq/__init__.py +52 -0
- sendithq/_apikey.py +32 -0
- sendithq/_async.py +213 -0
- sendithq/_base.py +112 -0
- sendithq/_core.py +165 -0
- sendithq/_signing.py +89 -0
- sendithq/_sync.py +226 -0
- sendithq/_verify.py +43 -0
- sendithq/_version.py +2 -0
- sendithq/duration.py +27 -0
- sendithq/errors.py +38 -0
- sendithq/models.py +211 -0
- sendithq/py.typed +0 -0
- sendithq-0.1.0.dist-info/METADATA +144 -0
- sendithq-0.1.0.dist-info/RECORD +17 -0
- sendithq-0.1.0.dist-info/WHEEL +4 -0
- sendithq-0.1.0.dist-info/licenses/LICENSE +21 -0
sendithq/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""SendItWhenever Python SDK — 초정밀 예약 웹훅을 3줄로.
|
|
2
|
+
|
|
3
|
+
from sendithq import SendIt
|
|
4
|
+
sendit = SendIt("sw_live_xxx")
|
|
5
|
+
sendit.schedule(url="https://api.myapp.com/hook", in_="2h", payload={"id": 42})
|
|
6
|
+
|
|
7
|
+
비동기는 ``AsyncSendIt`` 를 쓴다. HMAC 검증은 ``SendIt.verify_signature`` / 모듈 함수.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._async import AsyncSendIt
|
|
13
|
+
from ._signing import SIGNATURE_HEADER, verify_webhook
|
|
14
|
+
from ._sync import SendIt
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .duration import parse_duration
|
|
17
|
+
from .errors import SendItError, SendItErrorCode
|
|
18
|
+
from .models import (
|
|
19
|
+
BulkAccepted,
|
|
20
|
+
BulkRejected,
|
|
21
|
+
BulkScheduleResult,
|
|
22
|
+
DeliveryAttempt,
|
|
23
|
+
HttpMethod,
|
|
24
|
+
ListResult,
|
|
25
|
+
Schedule,
|
|
26
|
+
ScheduleRef,
|
|
27
|
+
ScheduleStatus,
|
|
28
|
+
SigningSecret,
|
|
29
|
+
SigningSecretPair,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"__version__",
|
|
34
|
+
"SendIt",
|
|
35
|
+
"AsyncSendIt",
|
|
36
|
+
"SendItError",
|
|
37
|
+
"SendItErrorCode",
|
|
38
|
+
"parse_duration",
|
|
39
|
+
"verify_webhook",
|
|
40
|
+
"SIGNATURE_HEADER",
|
|
41
|
+
"Schedule",
|
|
42
|
+
"ScheduleRef",
|
|
43
|
+
"DeliveryAttempt",
|
|
44
|
+
"SigningSecret",
|
|
45
|
+
"SigningSecretPair",
|
|
46
|
+
"BulkAccepted",
|
|
47
|
+
"BulkRejected",
|
|
48
|
+
"BulkScheduleResult",
|
|
49
|
+
"ListResult",
|
|
50
|
+
"HttpMethod",
|
|
51
|
+
"ScheduleStatus",
|
|
52
|
+
]
|
sendithq/_apikey.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# API Key 포맷 선검증(생성자에서 첫 요청 전에 빈/형식오류를 표면화). 값 자체의 유효성(폐기·오타)은
|
|
2
|
+
# 서버가 UNAUTHORIZED 로 최종 판정한다. 서버와 단일 규칙을 공유하기 위해 KEY_PATTERN 을 포팅한다.
|
|
3
|
+
# 미러: packages/shared/src/crypto/api-key.ts (parseApiKey)
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Literal, Optional, cast
|
|
10
|
+
|
|
11
|
+
ApiKeyEnv = Literal["live", "test"]
|
|
12
|
+
|
|
13
|
+
__all__ = ["ParsedApiKey", "parse_api_key"]
|
|
14
|
+
|
|
15
|
+
_KEY_PATTERN = re.compile(r"^sw_(live|test)_([A-Za-z0-9_-]{8,})$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class ParsedApiKey:
|
|
20
|
+
env: ApiKeyEnv
|
|
21
|
+
prefix: str
|
|
22
|
+
last4: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_api_key(key: str) -> Optional[ParsedApiKey]:
|
|
26
|
+
"""키 포맷을 검증·파싱한다. 형식이 잘못되면 None."""
|
|
27
|
+
match = _KEY_PATTERN.match(key)
|
|
28
|
+
if match is None:
|
|
29
|
+
return None
|
|
30
|
+
env = cast(ApiKeyEnv, match.group(1))
|
|
31
|
+
token = match.group(2)
|
|
32
|
+
return ParsedApiKey(env=env, prefix=f"sw_{env}", last4=token[-4:])
|
sendithq/_async.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# 비동기 클라이언트(AsyncSendIt) — httpx.AsyncClient 래퍼. 동기판(_sync.SendIt)과 표면 동일,
|
|
2
|
+
# transport 만 await. 미러: packages/sdk/src/index.ts
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any, List, Mapping, Optional, Sequence, Type, Union
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._base import _BaseSendIt, clean_query
|
|
13
|
+
from ._core import build_clone_body, build_schedule_body, is_retriable, resolve_fire_at
|
|
14
|
+
from .errors import SendItError
|
|
15
|
+
from .models import (
|
|
16
|
+
BulkScheduleResult,
|
|
17
|
+
DeliveryAttempt,
|
|
18
|
+
HttpMethod,
|
|
19
|
+
ListResult,
|
|
20
|
+
Schedule,
|
|
21
|
+
ScheduleRef,
|
|
22
|
+
ScheduleStatus,
|
|
23
|
+
SigningSecretPair,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = ["AsyncSendIt"]
|
|
27
|
+
|
|
28
|
+
FireAt = Union[str, Any]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _AsyncSigningSecrets:
|
|
32
|
+
"""signing_secrets.get()/rotate() 네임스페이스(코루틴)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: "AsyncSendIt") -> None:
|
|
35
|
+
self._client = client
|
|
36
|
+
|
|
37
|
+
async def get(self) -> SigningSecretPair:
|
|
38
|
+
raw = await self._client._request("GET", "v1/signing-secrets", retryable=True)
|
|
39
|
+
return SigningSecretPair.from_wire(raw)
|
|
40
|
+
|
|
41
|
+
async def rotate(self) -> SigningSecretPair:
|
|
42
|
+
raw = await self._client._request("POST", "v1/signing-secrets/rotate", retryable=False)
|
|
43
|
+
return SigningSecretPair.from_wire(raw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AsyncSendIt(_BaseSendIt):
|
|
47
|
+
"""SendItWhenever 비동기 클라이언트.
|
|
48
|
+
|
|
49
|
+
from sendithq import AsyncSendIt
|
|
50
|
+
async with AsyncSendIt("sw_live_xxx") as sendit:
|
|
51
|
+
ref = await sendit.schedule(url="https://api.myapp.com/hook", in_="2h")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, api_key: str, **kwargs: Any) -> None:
|
|
55
|
+
super().__init__(api_key, **kwargs)
|
|
56
|
+
self._http = httpx.AsyncClient(timeout=self._timeout, follow_redirects=False)
|
|
57
|
+
self.signing_secrets = _AsyncSigningSecrets(self)
|
|
58
|
+
|
|
59
|
+
# ── lifecycle ────────────────────────────────────────────────────────────
|
|
60
|
+
async def aclose(self) -> None:
|
|
61
|
+
await self._http.aclose()
|
|
62
|
+
|
|
63
|
+
async def __aenter__(self) -> "AsyncSendIt":
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(
|
|
67
|
+
self,
|
|
68
|
+
exc_type: Optional[Type[BaseException]],
|
|
69
|
+
exc: Optional[BaseException],
|
|
70
|
+
tb: Optional[TracebackType],
|
|
71
|
+
) -> None:
|
|
72
|
+
await self.aclose()
|
|
73
|
+
|
|
74
|
+
# ── transport ──────────────────────────────────────────────────────────
|
|
75
|
+
async def _request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
81
|
+
body: Optional[Any] = None,
|
|
82
|
+
retryable: bool = False,
|
|
83
|
+
) -> Any:
|
|
84
|
+
url = self._url(path)
|
|
85
|
+
headers = self._headers(body is not None)
|
|
86
|
+
content = self._encode(body)
|
|
87
|
+
max_attempts = self._max_retries + 1 if retryable else 1
|
|
88
|
+
last: Optional[SendItError] = None
|
|
89
|
+
|
|
90
|
+
for attempt in range(max_attempts):
|
|
91
|
+
try:
|
|
92
|
+
resp = await self._http.request(
|
|
93
|
+
method, url, params=clean_query(query), content=content, headers=headers
|
|
94
|
+
)
|
|
95
|
+
return self._process(resp.status_code, resp.text, resp.is_success)
|
|
96
|
+
except SendItError as err:
|
|
97
|
+
last = err
|
|
98
|
+
except httpx.RequestError as err:
|
|
99
|
+
last = SendItError("NETWORK", str(err) or "request failed")
|
|
100
|
+
|
|
101
|
+
if attempt < max_attempts - 1 and is_retriable(last):
|
|
102
|
+
await asyncio.sleep(self._backoff_seconds(attempt))
|
|
103
|
+
continue
|
|
104
|
+
raise last
|
|
105
|
+
raise last if last is not None else SendItError("NETWORK", "request failed")
|
|
106
|
+
|
|
107
|
+
# ── methods ──────────────────────────────────────────────────────────────
|
|
108
|
+
async def schedule(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
url: str,
|
|
112
|
+
fire_at: Optional[FireAt] = None,
|
|
113
|
+
in_: Optional[str] = None,
|
|
114
|
+
payload: Any = None,
|
|
115
|
+
method: Optional[HttpMethod] = None,
|
|
116
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
117
|
+
idempotency_key: Optional[str] = None,
|
|
118
|
+
) -> ScheduleRef:
|
|
119
|
+
body = build_schedule_body(
|
|
120
|
+
url=url,
|
|
121
|
+
fire_at=fire_at,
|
|
122
|
+
in_=in_,
|
|
123
|
+
payload=payload,
|
|
124
|
+
method=method,
|
|
125
|
+
headers=headers,
|
|
126
|
+
idempotency_key=idempotency_key,
|
|
127
|
+
)
|
|
128
|
+
raw = await self._request(
|
|
129
|
+
"POST", "v1/schedules", body=body, retryable=idempotency_key is not None
|
|
130
|
+
)
|
|
131
|
+
return ScheduleRef.from_wire(raw)
|
|
132
|
+
|
|
133
|
+
async def schedule_many(self, items: Sequence[Mapping[str, Any]]) -> BulkScheduleResult:
|
|
134
|
+
if not items:
|
|
135
|
+
raise SendItError("VALIDATION", "schedule_many: items must not be empty")
|
|
136
|
+
bodies = [
|
|
137
|
+
build_schedule_body(
|
|
138
|
+
url=it["url"],
|
|
139
|
+
fire_at=it.get("fire_at"),
|
|
140
|
+
in_=it.get("in_"),
|
|
141
|
+
payload=it.get("payload"),
|
|
142
|
+
method=it.get("method"),
|
|
143
|
+
headers=it.get("headers"),
|
|
144
|
+
idempotency_key=it.get("idempotency_key"),
|
|
145
|
+
)
|
|
146
|
+
for it in items
|
|
147
|
+
]
|
|
148
|
+
retryable = all(it.get("idempotency_key") is not None for it in items)
|
|
149
|
+
raw = await self._request(
|
|
150
|
+
"POST", "v1/schedules/bulk", body={"items": bodies}, retryable=retryable
|
|
151
|
+
)
|
|
152
|
+
return BulkScheduleResult.from_wire(raw)
|
|
153
|
+
|
|
154
|
+
async def list(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
status: Optional[ScheduleStatus] = None,
|
|
158
|
+
q: Optional[str] = None,
|
|
159
|
+
limit: Optional[int] = None,
|
|
160
|
+
offset: Optional[int] = None,
|
|
161
|
+
) -> ListResult:
|
|
162
|
+
raw = await self._request(
|
|
163
|
+
"GET",
|
|
164
|
+
"v1/schedules",
|
|
165
|
+
query={"status": status, "q": q, "limit": limit, "offset": offset},
|
|
166
|
+
retryable=True,
|
|
167
|
+
)
|
|
168
|
+
return ListResult.from_wire(raw)
|
|
169
|
+
|
|
170
|
+
async def get(self, schedule_id: str) -> Schedule:
|
|
171
|
+
raw = await self._request("GET", f"v1/schedules/{schedule_id}", retryable=True)
|
|
172
|
+
return Schedule.from_wire(raw)
|
|
173
|
+
|
|
174
|
+
async def get_attempts(self, schedule_id: str) -> List[DeliveryAttempt]:
|
|
175
|
+
raw = await self._request("GET", f"v1/schedules/{schedule_id}/attempts", retryable=True)
|
|
176
|
+
return [DeliveryAttempt.from_wire(r) for r in raw.get("items", [])]
|
|
177
|
+
|
|
178
|
+
async def reschedule(
|
|
179
|
+
self,
|
|
180
|
+
schedule_id: str,
|
|
181
|
+
*,
|
|
182
|
+
fire_at: Optional[FireAt] = None,
|
|
183
|
+
in_: Optional[str] = None,
|
|
184
|
+
) -> Schedule:
|
|
185
|
+
raw = await self._request(
|
|
186
|
+
"PATCH",
|
|
187
|
+
f"v1/schedules/{schedule_id}",
|
|
188
|
+
body={"fireAt": resolve_fire_at(fire_at, in_)},
|
|
189
|
+
retryable=False,
|
|
190
|
+
)
|
|
191
|
+
return Schedule.from_wire(raw)
|
|
192
|
+
|
|
193
|
+
async def replay(self, schedule_id: str) -> ScheduleRef:
|
|
194
|
+
raw = await self._request("POST", f"v1/schedules/{schedule_id}/replay", retryable=False)
|
|
195
|
+
return ScheduleRef.from_wire(raw)
|
|
196
|
+
|
|
197
|
+
async def clone(
|
|
198
|
+
self,
|
|
199
|
+
schedule_id: str,
|
|
200
|
+
*,
|
|
201
|
+
payload: Any,
|
|
202
|
+
fire_at: Optional[FireAt] = None,
|
|
203
|
+
in_: Optional[str] = None,
|
|
204
|
+
) -> ScheduleRef:
|
|
205
|
+
body = build_clone_body(payload=payload, fire_at=fire_at, in_=in_)
|
|
206
|
+
raw = await self._request(
|
|
207
|
+
"POST", f"v1/schedules/{schedule_id}/clone", body=body, retryable=False
|
|
208
|
+
)
|
|
209
|
+
return ScheduleRef.from_wire(raw)
|
|
210
|
+
|
|
211
|
+
async def cancel(self, schedule_id: str) -> Schedule:
|
|
212
|
+
raw = await self._request("DELETE", f"v1/schedules/{schedule_id}", retryable=True)
|
|
213
|
+
return Schedule.from_wire(raw)
|
sendithq/_base.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# sync/async 클라이언트 공통 베이스 — 설정·인증헤더·URL·응답처리·서명검증. transport(httpx Client vs
|
|
2
|
+
# AsyncClient)와 재시도 루프만 서브클래스가 구현한다. 미러: packages/sdk/src/{index,client}.ts
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
8
|
+
|
|
9
|
+
from ._apikey import parse_api_key
|
|
10
|
+
from ._core import DASHBOARD_URL, to_error
|
|
11
|
+
from ._verify import verify_signature as _verify_signature
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
from .errors import SendItError
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.sendit-whenever.com"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _assert_api_key(api_key: str) -> None:
|
|
19
|
+
"""API Key 를 선검증한다 — 빈 값/형식 오류를 첫 요청 전에 친절히 표면화한다.
|
|
20
|
+
|
|
21
|
+
포맷 판정은 서버와 단일 규칙(parse_api_key)을 공유한다. 값 자체의 유효성(폐기·오타)은
|
|
22
|
+
서버가 UNAUTHORIZED 로 최종 판정한다.
|
|
23
|
+
"""
|
|
24
|
+
if not api_key:
|
|
25
|
+
raise SendItError(
|
|
26
|
+
"UNAUTHORIZED",
|
|
27
|
+
f"SendIt: an API key is required. Create one in your dashboard at {DASHBOARD_URL}",
|
|
28
|
+
)
|
|
29
|
+
if parse_api_key(api_key) is None:
|
|
30
|
+
raise SendItError(
|
|
31
|
+
"UNAUTHORIZED",
|
|
32
|
+
'SendIt: malformed API key (expected "sw_live_…" or "sw_test_…"). '
|
|
33
|
+
f"Check or create your key at {DASHBOARD_URL}",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clean_query(query: Optional[Mapping[str, Any]]) -> Dict[str, str]:
|
|
38
|
+
"""None 값을 버리고 문자열화한 query 파라미터."""
|
|
39
|
+
if not query:
|
|
40
|
+
return {}
|
|
41
|
+
return {k: str(v) for k, v in query.items() if v is not None}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _BaseSendIt:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
api_key: str,
|
|
48
|
+
*,
|
|
49
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
50
|
+
timeout: float = 30.0,
|
|
51
|
+
max_retries: int = 2,
|
|
52
|
+
signing_secret: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
_assert_api_key(api_key)
|
|
55
|
+
self._base_url = base_url.rstrip("/")
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self._max_retries = max_retries
|
|
58
|
+
self._signing_secret = signing_secret
|
|
59
|
+
self._auth_header = f"Bearer {api_key}"
|
|
60
|
+
|
|
61
|
+
# ── request helpers (transport-agnostic) ─────────────────────────────────
|
|
62
|
+
def _url(self, path: str) -> str:
|
|
63
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
64
|
+
|
|
65
|
+
def _headers(self, has_body: bool) -> Dict[str, str]:
|
|
66
|
+
headers = {
|
|
67
|
+
"authorization": self._auth_header,
|
|
68
|
+
"user-agent": f"sendithq-python/{__version__}",
|
|
69
|
+
}
|
|
70
|
+
if has_body:
|
|
71
|
+
headers["content-type"] = "application/json"
|
|
72
|
+
return headers
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _encode(body: Optional[Any]) -> Optional[bytes]:
|
|
76
|
+
if body is None:
|
|
77
|
+
return None
|
|
78
|
+
return json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _process(status_code: int, text: str, is_success: bool) -> Any:
|
|
82
|
+
"""2xx → 파싱된 JSON(빈 본문은 None), 그 외 → SendItError raise."""
|
|
83
|
+
if is_success:
|
|
84
|
+
return None if text == "" else json.loads(text)
|
|
85
|
+
raise to_error(status_code, text)
|
|
86
|
+
|
|
87
|
+
def _backoff_seconds(self, attempt: int) -> float:
|
|
88
|
+
# 200ms, 400ms, 800ms… (지수백오프). 미러: client.ts sleep(2**attempt * 200).
|
|
89
|
+
return 0.2 * (2.0**attempt)
|
|
90
|
+
|
|
91
|
+
# ── signature verification (sync; no IO) ─────────────────────────────────
|
|
92
|
+
def verify_signature(
|
|
93
|
+
self,
|
|
94
|
+
body: Union[str, bytes],
|
|
95
|
+
headers: Mapping[str, str],
|
|
96
|
+
*,
|
|
97
|
+
secrets: Optional[List[str]] = None,
|
|
98
|
+
secret: Optional[str] = None,
|
|
99
|
+
tolerance_sec: int = 300,
|
|
100
|
+
) -> bool:
|
|
101
|
+
"""나가는 웹훅 서명 검증(HMAC). 서명 대상은 **raw 본문**이므로 파싱 전 raw body 를 넘긴다.
|
|
102
|
+
|
|
103
|
+
우선순위: ``secrets``(2키 권장) → ``secret`` → 생성자 ``signing_secret``.
|
|
104
|
+
"""
|
|
105
|
+
return _verify_signature(
|
|
106
|
+
body,
|
|
107
|
+
headers,
|
|
108
|
+
secrets=secrets,
|
|
109
|
+
secret=secret,
|
|
110
|
+
default_secret=self._signing_secret,
|
|
111
|
+
tolerance_sec=tolerance_sec,
|
|
112
|
+
)
|
sendithq/_core.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# 순수 로직 — sync/async 클라이언트가 공유한다(transport 만 다름). 요청 바디 빌드, fire_at 해석,
|
|
2
|
+
# 에러 매핑, 재시도 판정. API 요청 바디는 **camelCase 키**(targetUrl/fireAt/idempotencyKey)를
|
|
3
|
+
# 쓴다(서버 zod 스키마 packages/shared/src/schemas/index.ts 와 일치). 응답은 snake_case 라
|
|
4
|
+
# models.from_wire 가 그대로 매핑한다. 미러: packages/sdk/src/{index,client}.ts
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
11
|
+
|
|
12
|
+
from .duration import parse_duration
|
|
13
|
+
from .errors import SendItError, SendItErrorCode
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"KNOWN_CODES",
|
|
17
|
+
"DASHBOARD_URL",
|
|
18
|
+
"get_header",
|
|
19
|
+
"resolve_fire_at",
|
|
20
|
+
"build_schedule_body",
|
|
21
|
+
"build_clone_body",
|
|
22
|
+
"to_error",
|
|
23
|
+
"is_retriable",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# API 가 반환하는 코드(NETWORK 는 로컬 전용이라 제외). 미상 코드는 status 기반 폴백.
|
|
27
|
+
KNOWN_CODES = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"UNAUTHORIZED",
|
|
30
|
+
"FORBIDDEN",
|
|
31
|
+
"NOT_FOUND",
|
|
32
|
+
"VALIDATION",
|
|
33
|
+
"CONFLICT",
|
|
34
|
+
"RATE_LIMITED",
|
|
35
|
+
"INTERNAL",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# 키 발급·관리 안내 링크(생성자 검증 메시지에 첨부, DX).
|
|
40
|
+
DASHBOARD_URL = "https://www.sendit-whenever.com"
|
|
41
|
+
|
|
42
|
+
FireAt = Union[str, datetime]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_header(headers: Mapping[str, str], name: str) -> Optional[str]:
|
|
46
|
+
"""헤더 맵에서 대소문자 무시 조회."""
|
|
47
|
+
target = name.lower()
|
|
48
|
+
for key, value in headers.items():
|
|
49
|
+
if key.lower() == target:
|
|
50
|
+
return value
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _to_iso8601(dt: datetime) -> str:
|
|
55
|
+
"""tz-aware UTC ISO-8601(밀리초 + 'Z'). naive datetime 은 UTC 로 간주한다.
|
|
56
|
+
|
|
57
|
+
서버 스키마가 `.datetime({ offset: true })` 이므로 offset 이 반드시 있어야 한다.
|
|
58
|
+
"""
|
|
59
|
+
if dt.tzinfo is None:
|
|
60
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
61
|
+
return dt.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_fire_at(fire_at: Optional[FireAt], in_: Optional[str]) -> str:
|
|
65
|
+
"""fire_at(절대) | in_(상대) 중 정확히 하나 → ISO-8601 문자열."""
|
|
66
|
+
if fire_at is not None and in_ is not None:
|
|
67
|
+
raise SendItError("VALIDATION", "provide either fire_at or in_, not both")
|
|
68
|
+
if in_ is not None:
|
|
69
|
+
return _to_iso8601(datetime.now(timezone.utc) + parse_duration(in_))
|
|
70
|
+
if fire_at is not None:
|
|
71
|
+
if isinstance(fire_at, datetime):
|
|
72
|
+
return _to_iso8601(fire_at)
|
|
73
|
+
try:
|
|
74
|
+
parsed = datetime.fromisoformat(fire_at.strip().replace("Z", "+00:00"))
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise SendItError("VALIDATION", f"invalid fire_at: {fire_at!r}") from exc
|
|
77
|
+
return _to_iso8601(parsed)
|
|
78
|
+
raise SendItError("VALIDATION", "fire_at or in_ is required")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _serialize_payload(
|
|
82
|
+
payload: Any, headers: Dict[str, str]
|
|
83
|
+
) -> Optional[str]:
|
|
84
|
+
"""payload 직렬화: 문자열은 그대로, 그 외(객체)는 JSON + content-type 자동 주입.
|
|
85
|
+
|
|
86
|
+
``headers`` 는 제자리에서 변형될 수 있다(유저 미지정 시 content-type 추가).
|
|
87
|
+
"""
|
|
88
|
+
if payload is None:
|
|
89
|
+
return None
|
|
90
|
+
if isinstance(payload, str):
|
|
91
|
+
return payload
|
|
92
|
+
serialized = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
93
|
+
if get_header(headers, "content-type") is None:
|
|
94
|
+
headers["content-type"] = "application/json"
|
|
95
|
+
return serialized
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_schedule_body(
|
|
99
|
+
*,
|
|
100
|
+
url: str,
|
|
101
|
+
fire_at: Optional[FireAt],
|
|
102
|
+
in_: Optional[str],
|
|
103
|
+
payload: Any,
|
|
104
|
+
method: Optional[str],
|
|
105
|
+
headers: Optional[Mapping[str, str]],
|
|
106
|
+
idempotency_key: Optional[str],
|
|
107
|
+
) -> Dict[str, Any]:
|
|
108
|
+
"""schedule()/schedule_many() 공통 요청 바디(camelCase 키)."""
|
|
109
|
+
resolved_fire_at = resolve_fire_at(fire_at, in_)
|
|
110
|
+
hdrs: Dict[str, str] = dict(headers or {})
|
|
111
|
+
serialized = _serialize_payload(payload, hdrs)
|
|
112
|
+
|
|
113
|
+
body: Dict[str, Any] = {"targetUrl": url, "fireAt": resolved_fire_at}
|
|
114
|
+
if method is not None:
|
|
115
|
+
body["method"] = method
|
|
116
|
+
if hdrs:
|
|
117
|
+
body["headers"] = hdrs
|
|
118
|
+
if serialized is not None:
|
|
119
|
+
body["payload"] = serialized
|
|
120
|
+
if idempotency_key is not None:
|
|
121
|
+
body["idempotencyKey"] = idempotency_key
|
|
122
|
+
return body
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_clone_body(
|
|
126
|
+
*, payload: Any, fire_at: Optional[FireAt], in_: Optional[str]
|
|
127
|
+
) -> Dict[str, Any]:
|
|
128
|
+
"""/clone 요청 바디. payload 직렬화 + 선택 fireAt(없으면 즉시 발사).
|
|
129
|
+
|
|
130
|
+
헤더·메서드는 서버가 원본에서 승계하므로 content-type 자동주입은 하지 않는다.
|
|
131
|
+
"""
|
|
132
|
+
if payload is None:
|
|
133
|
+
raise SendItError("VALIDATION", "clone: payload is required")
|
|
134
|
+
serialized = payload if isinstance(payload, str) else json.dumps(
|
|
135
|
+
payload, separators=(",", ":"), ensure_ascii=False
|
|
136
|
+
)
|
|
137
|
+
body: Dict[str, Any] = {"payload": serialized}
|
|
138
|
+
if fire_at is not None or in_ is not None:
|
|
139
|
+
body["fireAt"] = resolve_fire_at(fire_at, in_)
|
|
140
|
+
return body
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def to_error(status: int, text: str) -> SendItError:
|
|
144
|
+
"""에러 응답 바디({ error: { code, message } })를 SendItError 로 매핑."""
|
|
145
|
+
code: SendItErrorCode = "INTERNAL" if status >= 500 else "VALIDATION"
|
|
146
|
+
message = f"request failed with status {status}"
|
|
147
|
+
try:
|
|
148
|
+
parsed = json.loads(text)
|
|
149
|
+
err = parsed.get("error") if isinstance(parsed, dict) else None
|
|
150
|
+
if isinstance(err, dict):
|
|
151
|
+
c = err.get("code")
|
|
152
|
+
if isinstance(c, str) and c in KNOWN_CODES:
|
|
153
|
+
code = c # type: ignore[assignment]
|
|
154
|
+
m = err.get("message")
|
|
155
|
+
if isinstance(m, str):
|
|
156
|
+
message = m
|
|
157
|
+
except (ValueError, AttributeError):
|
|
158
|
+
# 비-JSON 에러 바디(프록시 5xx 등)는 기본 코드/메시지 유지.
|
|
159
|
+
pass
|
|
160
|
+
return SendItError(code, message, status)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_retriable(err: SendItError) -> bool:
|
|
164
|
+
"""재시도 대상: 네트워크 실패 또는 5xx 일시 장애만. 4xx 는 즉시 표면화."""
|
|
165
|
+
return err.code == "NETWORK" or (err.status is not None and err.status >= 500)
|
sendithq/_signing.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# 나가는 웹훅 HMAC-SHA256 서명 검증 — TS 단일 구현처(packages/shared/src/crypto/hmac.ts)의
|
|
2
|
+
# 크로스랭귀지 포팅이다. Python SDK 는 TS shared 를 import 할 수 없으므로 알고리즘을 포팅하되,
|
|
3
|
+
# 두 언어가 동일한 골든 벡터(packages/shared/src/crypto/__fixtures__/hmac-vectors.json)에 묶여
|
|
4
|
+
# 드리프트가 CI 에서 즉시 깨지도록 한다(CLAUDE.md §8 정신 보존).
|
|
5
|
+
#
|
|
6
|
+
# 헤더: `X-SendIt-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`
|
|
7
|
+
# 서명 대상: `"{t}.{raw_body}"`, 키 = 유저별 Signing Secret(평문).
|
|
8
|
+
# timestamp 포함 → 리플레이 차단(검증 측에서 허용 윈도우 초과 거부).
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Dict, Optional, Union
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"SIGNATURE_HEADER",
|
|
19
|
+
"sign_webhook",
|
|
20
|
+
"parse_signature_header",
|
|
21
|
+
"verify_webhook",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# 서명 헤더 이름(상수 — 오타로 인한 드리프트 방지).
|
|
25
|
+
SIGNATURE_HEADER = "X-SendIt-Signature"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _to_bytes(body: Union[str, bytes]) -> bytes:
|
|
29
|
+
return body if isinstance(body, bytes) else body.encode("utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _compute_signature(secret: str, t: int, body: Union[str, bytes]) -> str:
|
|
33
|
+
"""`"{t}.{body}"` 에 대한 HMAC-SHA256 hex 다이제스트."""
|
|
34
|
+
message = f"{t}.".encode("utf-8") + _to_bytes(body)
|
|
35
|
+
return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def sign_webhook(
|
|
39
|
+
secret: str, body: Union[str, bytes], now_ms: Optional[int] = None
|
|
40
|
+
) -> str:
|
|
41
|
+
"""웹훅 본문에 서명해 헤더 값(`t=..,v1=..`)을 만든다.
|
|
42
|
+
|
|
43
|
+
주로 테스트·골든 벡터 생성용. ``now_ms`` 로 결정성 주입 가능(기본 현재시각).
|
|
44
|
+
"""
|
|
45
|
+
now = time.time() * 1000 if now_ms is None else now_ms
|
|
46
|
+
t = int(now // 1000)
|
|
47
|
+
v1 = _compute_signature(secret, t, body)
|
|
48
|
+
return f"t={t},v1={v1}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_signature_header(header: str) -> Optional[Dict[str, Any]]:
|
|
52
|
+
"""헤더 문자열 `t=..,v1=..` 을 파싱한다. 형식 불량이면 None."""
|
|
53
|
+
t: Optional[int] = None
|
|
54
|
+
v1: Optional[str] = None
|
|
55
|
+
for part in header.split(","):
|
|
56
|
+
key, _, val = part.partition("=")
|
|
57
|
+
if key == "t" and val:
|
|
58
|
+
try:
|
|
59
|
+
t = int(val)
|
|
60
|
+
except ValueError:
|
|
61
|
+
continue
|
|
62
|
+
elif key == "v1" and val:
|
|
63
|
+
v1 = val
|
|
64
|
+
if t is None or v1 is None:
|
|
65
|
+
return None
|
|
66
|
+
return {"t": t, "v1": v1}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_webhook(
|
|
70
|
+
secret: str,
|
|
71
|
+
body: Union[str, bytes],
|
|
72
|
+
header: str,
|
|
73
|
+
*,
|
|
74
|
+
tolerance_sec: int = 300,
|
|
75
|
+
now_ms: Optional[int] = None,
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""수신 측 검증. 서명 일치 + timestamp 허용 윈도우(기본 5분) 검사.
|
|
78
|
+
|
|
79
|
+
상수시간 비교(:func:`hmac.compare_digest`)로 타이밍 누수를 차단한다.
|
|
80
|
+
"""
|
|
81
|
+
now = time.time() * 1000 if now_ms is None else now_ms
|
|
82
|
+
now_sec = int(now // 1000)
|
|
83
|
+
parsed = parse_signature_header(header)
|
|
84
|
+
if parsed is None:
|
|
85
|
+
return False
|
|
86
|
+
if abs(now_sec - parsed["t"]) > tolerance_sec:
|
|
87
|
+
return False
|
|
88
|
+
expected = _compute_signature(secret, parsed["t"], body)
|
|
89
|
+
return hmac.compare_digest(expected, parsed["v1"])
|