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 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"])