heleket-sdk 0.3.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.
- heleket_sdk/__init__.py +72 -0
- heleket_sdk/_base_client.py +157 -0
- heleket_sdk/_cli.py +137 -0
- heleket_sdk/_config.py +43 -0
- heleket_sdk/_debug.py +63 -0
- heleket_sdk/_errors.py +67 -0
- heleket_sdk/_signature.py +46 -0
- heleket_sdk/_status.py +128 -0
- heleket_sdk/_transport.py +93 -0
- heleket_sdk/_version.py +11 -0
- heleket_sdk/payment.py +184 -0
- heleket_sdk/payout.py +110 -0
- heleket_sdk/py.typed +0 -0
- heleket_sdk/types.py +417 -0
- heleket_sdk/webhook.py +234 -0
- heleket_sdk-0.3.0.dist-info/METADATA +128 -0
- heleket_sdk-0.3.0.dist-info/RECORD +20 -0
- heleket_sdk-0.3.0.dist-info/WHEEL +4 -0
- heleket_sdk-0.3.0.dist-info/entry_points.txt +3 -0
- heleket_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
heleket_sdk/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Heleket Python SDK.
|
|
2
|
+
|
|
3
|
+
A reference implementation of the `Heleket <https://heleket.com>`_
|
|
4
|
+
cryptocurrency payment API. Covers payments, payouts, balance, services,
|
|
5
|
+
exchange rates, and webhook verification.
|
|
6
|
+
|
|
7
|
+
Public entry points::
|
|
8
|
+
|
|
9
|
+
from heleket_sdk import HeleketPayment, HeleketPayout
|
|
10
|
+
from heleket_sdk import ClientOptions
|
|
11
|
+
from heleket_sdk import WebhookVerifier
|
|
12
|
+
from heleket_sdk import PaymentStatus, PayoutStatus
|
|
13
|
+
from heleket_sdk import HeleketError, ApiError, ValidationError, HttpError, SignatureError
|
|
14
|
+
|
|
15
|
+
See ``docs/`` for the full reference.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from ._config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, ClientOptions
|
|
21
|
+
from ._debug import DebugEntry, DebugSink, stderr_sink
|
|
22
|
+
from ._errors import (
|
|
23
|
+
ApiError,
|
|
24
|
+
HeleketError,
|
|
25
|
+
HttpError,
|
|
26
|
+
SignatureError,
|
|
27
|
+
ValidationError,
|
|
28
|
+
)
|
|
29
|
+
from ._signature import constant_time_equal, sign
|
|
30
|
+
from ._status import AmlLinkStatus, CourseSource, PaymentStatus, PayoutStatus
|
|
31
|
+
from ._transport import RequestsTransport, Transport, TransportResponse
|
|
32
|
+
from ._version import __version__
|
|
33
|
+
from .payment import HeleketPayment
|
|
34
|
+
from .payout import HeleketPayout
|
|
35
|
+
from .webhook import WebhookPayload, WebhookVerifier
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
# Clients
|
|
39
|
+
"HeleketPayment",
|
|
40
|
+
"HeleketPayout",
|
|
41
|
+
# Configuration
|
|
42
|
+
"ClientOptions",
|
|
43
|
+
"DEFAULT_BASE_URL",
|
|
44
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
45
|
+
# Webhook
|
|
46
|
+
"WebhookVerifier",
|
|
47
|
+
"WebhookPayload",
|
|
48
|
+
# Enums
|
|
49
|
+
"PaymentStatus",
|
|
50
|
+
"PayoutStatus",
|
|
51
|
+
"AmlLinkStatus",
|
|
52
|
+
"CourseSource",
|
|
53
|
+
# Errors
|
|
54
|
+
"HeleketError",
|
|
55
|
+
"ApiError",
|
|
56
|
+
"ValidationError",
|
|
57
|
+
"HttpError",
|
|
58
|
+
"SignatureError",
|
|
59
|
+
# Transport
|
|
60
|
+
"Transport",
|
|
61
|
+
"TransportResponse",
|
|
62
|
+
"RequestsTransport",
|
|
63
|
+
# Debug
|
|
64
|
+
"DebugEntry",
|
|
65
|
+
"DebugSink",
|
|
66
|
+
"stderr_sink",
|
|
67
|
+
# Low-level signature
|
|
68
|
+
"sign",
|
|
69
|
+
"constant_time_equal",
|
|
70
|
+
# Metadata
|
|
71
|
+
"__version__",
|
|
72
|
+
]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Shared HTTP plumbing for :class:`HeleketPayment` and :class:`HeleketPayout`.
|
|
2
|
+
|
|
3
|
+
Encodes the request, signs it, dispatches via the configured ``Transport``,
|
|
4
|
+
dumps to the debug sink when enabled, and translates the response into either
|
|
5
|
+
a typed result or an exception.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from ._config import ClientOptions
|
|
16
|
+
from ._debug import DebugEntry
|
|
17
|
+
from ._errors import ApiError, HttpError, ValidationError
|
|
18
|
+
from ._signature import sign
|
|
19
|
+
from ._transport import RequestsTransport, Transport
|
|
20
|
+
from ._version import __version__
|
|
21
|
+
|
|
22
|
+
__all__ = ["BaseClient"]
|
|
23
|
+
|
|
24
|
+
_USER_AGENT = f"heleket-python-sdk/{__version__}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BaseClient:
|
|
28
|
+
"""Common base for the payment and payout clients.
|
|
29
|
+
|
|
30
|
+
Carries the merchant UUID, API key, and the resolved :class:`ClientOptions`.
|
|
31
|
+
Subclasses call :meth:`_post` with the path and request body.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
merchant_id: str,
|
|
38
|
+
api_key: str,
|
|
39
|
+
options: ClientOptions | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
if not merchant_id or not merchant_id.strip():
|
|
42
|
+
raise ValueError("merchant_id must not be empty")
|
|
43
|
+
if not api_key or not api_key.strip():
|
|
44
|
+
raise ValueError("api_key must not be empty")
|
|
45
|
+
|
|
46
|
+
self._merchant_id = merchant_id.strip()
|
|
47
|
+
self._api_key = api_key.strip()
|
|
48
|
+
self._options = options if options is not None else ClientOptions()
|
|
49
|
+
self._transport: Transport = self._options.transport or RequestsTransport()
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ #
|
|
52
|
+
# internal helpers
|
|
53
|
+
# ------------------------------------------------------------------ #
|
|
54
|
+
|
|
55
|
+
def _post(self, path: str, params: Any = None) -> Any:
|
|
56
|
+
"""Send a signed POST request and return the decoded ``result`` field."""
|
|
57
|
+
return self._send("POST", path, self._encode_body(params))
|
|
58
|
+
|
|
59
|
+
def _get(self, path: str) -> Any:
|
|
60
|
+
"""Send a signed GET request and return the decoded ``result`` field.
|
|
61
|
+
|
|
62
|
+
For read-only endpoints that carry all their input in the path (e.g.
|
|
63
|
+
exchange rates). There is no request body, so the signed bytes are the
|
|
64
|
+
empty string — exactly like a POST with no params: ``sign = md5(api_key)``.
|
|
65
|
+
"""
|
|
66
|
+
return self._send("GET", path, "")
|
|
67
|
+
|
|
68
|
+
def _send(self, method: str, path: str, body: str) -> Any:
|
|
69
|
+
"""Sign the exact body bytes, attach the auth headers, dispatch via the
|
|
70
|
+
transport, and translate the response.
|
|
71
|
+
|
|
72
|
+
Shared by :meth:`_post` and :meth:`_get` so the signing and error
|
|
73
|
+
handling live in exactly one place.
|
|
74
|
+
"""
|
|
75
|
+
signature = sign(body, self._api_key)
|
|
76
|
+
url = self._options.base_url + path
|
|
77
|
+
|
|
78
|
+
headers = {
|
|
79
|
+
"merchant": self._merchant_id,
|
|
80
|
+
"sign": signature,
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
"User-Agent": _USER_AGENT,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if self._options.debug:
|
|
86
|
+
self._options.logger(DebugEntry.request(method, url, body))
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
response = self._transport.round_trip(method, url, headers, body, self._options.timeout)
|
|
90
|
+
except HttpError:
|
|
91
|
+
raise
|
|
92
|
+
except Exception as exc: # noqa: BLE001 — wrap unexpected failures
|
|
93
|
+
raise HttpError(f"transport failed: {exc}") from exc
|
|
94
|
+
|
|
95
|
+
if self._options.debug:
|
|
96
|
+
self._options.logger(DebugEntry.response(response.status_code, response.body))
|
|
97
|
+
|
|
98
|
+
return self._parse_response(response.status_code, response.body)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _encode_body(params: Any) -> str:
|
|
102
|
+
"""Encode params to JSON, returning ``""`` for null/empty payloads."""
|
|
103
|
+
if params is None:
|
|
104
|
+
return ""
|
|
105
|
+
if isinstance(params, BaseModel):
|
|
106
|
+
# by_alias=True so snake_case wire names beat the Pydantic field
|
|
107
|
+
# names; exclude_none=True so optional fields don't serialise.
|
|
108
|
+
data = params.model_dump(by_alias=True, exclude_none=True)
|
|
109
|
+
elif isinstance(params, dict):
|
|
110
|
+
data = {k: v for k, v in params.items() if v is not None}
|
|
111
|
+
else:
|
|
112
|
+
# Last-ditch — let json.dumps try.
|
|
113
|
+
data = params
|
|
114
|
+
|
|
115
|
+
if not data and not isinstance(data, int | float | bool):
|
|
116
|
+
return ""
|
|
117
|
+
return json.dumps(data, separators=(",", ":"))
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _parse_response(status_code: int, body: str) -> Any:
|
|
121
|
+
envelope: dict[str, Any] | None
|
|
122
|
+
if body:
|
|
123
|
+
try:
|
|
124
|
+
envelope = json.loads(body)
|
|
125
|
+
if not isinstance(envelope, dict):
|
|
126
|
+
envelope = None
|
|
127
|
+
except json.JSONDecodeError:
|
|
128
|
+
envelope = None
|
|
129
|
+
else:
|
|
130
|
+
envelope = None
|
|
131
|
+
|
|
132
|
+
if status_code == 422:
|
|
133
|
+
fields: dict[str, list[str]] = {}
|
|
134
|
+
if envelope and isinstance(envelope.get("errors"), dict):
|
|
135
|
+
for key, value in envelope["errors"].items():
|
|
136
|
+
if isinstance(value, list):
|
|
137
|
+
fields[key] = [str(v) for v in value]
|
|
138
|
+
else:
|
|
139
|
+
fields[key] = [str(value)]
|
|
140
|
+
summary = _summarise_fields(fields)
|
|
141
|
+
raise ValidationError(f"validation failed: {summary}", status_code, fields, body)
|
|
142
|
+
|
|
143
|
+
if envelope is None:
|
|
144
|
+
raise ApiError(f"non-JSON response (HTTP {status_code})", status_code, body)
|
|
145
|
+
|
|
146
|
+
state = envelope.get("state")
|
|
147
|
+
if state != 0:
|
|
148
|
+
message = envelope.get("message", f"api error (state={state})")
|
|
149
|
+
raise ApiError(str(message), status_code, body)
|
|
150
|
+
|
|
151
|
+
return envelope.get("result")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _summarise_fields(fields: dict[str, list[str]]) -> str:
|
|
155
|
+
if not fields:
|
|
156
|
+
return "(no field details)"
|
|
157
|
+
return "; ".join(f"{k} ({', '.join(v)})" for k, v in fields.items())
|
heleket_sdk/_cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""``heleket-webhook-inspect`` — verify a captured webhook payload.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
# Preferred: pass the API key via env var so it doesn't land in shell
|
|
6
|
+
# history or appear in `ps` output.
|
|
7
|
+
export HELEKET_API_KEY=$HELEKET_PAYMENT_KEY
|
|
8
|
+
echo '{"type":"payment",...}' | heleket-webhook-inspect
|
|
9
|
+
|
|
10
|
+
# Or pass it explicitly (less safe — recorded in shell history).
|
|
11
|
+
heleket-webhook-inspect --key=$KEY --file=webhook.json
|
|
12
|
+
heleket-webhook-inspect --key=$KEY --type=payout --file=webhook.json
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
|
|
16
|
+
* ``0`` — payload valid, signature verified
|
|
17
|
+
* ``1`` — payload could not be parsed
|
|
18
|
+
* ``2`` — signature does not match
|
|
19
|
+
* ``3`` — missing arguments
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ._errors import SignatureError
|
|
32
|
+
from .webhook import WebhookVerifier
|
|
33
|
+
|
|
34
|
+
_COLOR_RESET = "\x1b[0m"
|
|
35
|
+
_COLOR_RED = "\x1b[31m"
|
|
36
|
+
_COLOR_GREEN = "\x1b[32m"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str] | None = None) -> int:
|
|
40
|
+
parser = argparse.ArgumentParser(
|
|
41
|
+
prog="heleket-webhook-inspect",
|
|
42
|
+
description="Verify a captured Heleket webhook payload.",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--key",
|
|
46
|
+
required=False,
|
|
47
|
+
help=(
|
|
48
|
+
"API key used to sign the webhook. Falls back to the HELEKET_API_KEY "
|
|
49
|
+
"env var if omitted (preferred — keeps the key out of shell history)."
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument("--file", help="Path to a webhook JSON file. Defaults to stdin.")
|
|
53
|
+
parser.add_argument("--type", help="Expected payload type (payment | payout | wallet)")
|
|
54
|
+
args = parser.parse_args(argv)
|
|
55
|
+
|
|
56
|
+
api_key = args.key or os.environ.get("HELEKET_API_KEY", "")
|
|
57
|
+
if not api_key:
|
|
58
|
+
sys.stderr.write(
|
|
59
|
+
"Usage: heleket-webhook-inspect [--key=<api-key>] "
|
|
60
|
+
"[--file=<path>] [--type=payment|payout]\n"
|
|
61
|
+
"Set HELEKET_API_KEY in the environment to avoid passing the key on "
|
|
62
|
+
"the command line.\n"
|
|
63
|
+
)
|
|
64
|
+
return 3
|
|
65
|
+
|
|
66
|
+
body = Path(args.file).read_text(encoding="utf-8") if args.file else sys.stdin.read()
|
|
67
|
+
|
|
68
|
+
# Trim trailing newline added by shells / editors — Heleket signs the exact
|
|
69
|
+
# bytes it sends, which never have a trailing newline.
|
|
70
|
+
body = body.rstrip("\r\n")
|
|
71
|
+
|
|
72
|
+
if not body:
|
|
73
|
+
sys.stderr.write("No input received. Pipe a JSON payload to stdin or pass --file=<path>.\n")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
parsed: dict[str, Any] = json.loads(body)
|
|
78
|
+
except json.JSONDecodeError as error:
|
|
79
|
+
sys.stderr.write(f"Input is not valid JSON: {error}\n")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
print("Heleket webhook inspector")
|
|
83
|
+
print("-" * 40)
|
|
84
|
+
for field in ("type", "uuid", "order_id", "status", "amount", "network", "txid"):
|
|
85
|
+
_print_field(field, parsed.get(field))
|
|
86
|
+
|
|
87
|
+
if "is_final" in parsed:
|
|
88
|
+
value = parsed["is_final"]
|
|
89
|
+
text = "yes" if value is True else "no" if value is False else str(value)
|
|
90
|
+
print(f" {'is_final':<10} {text}")
|
|
91
|
+
else:
|
|
92
|
+
print(f" {'is_final':<10} (missing)")
|
|
93
|
+
|
|
94
|
+
sign_value = parsed.get("sign")
|
|
95
|
+
if isinstance(sign_value, str):
|
|
96
|
+
truncated = sign_value if len(sign_value) <= 16 else sign_value[:16] + "…"
|
|
97
|
+
print(f" {'sign':<10} {truncated}")
|
|
98
|
+
else:
|
|
99
|
+
print(f" {'sign':<10} (missing)")
|
|
100
|
+
|
|
101
|
+
if args.type and parsed.get("type") and args.type != parsed["type"]:
|
|
102
|
+
sys.stderr.write(f"\nWarning: --type={args.type} but payload type is {parsed['type']}\n")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
WebhookVerifier(api_key).verify_raw(body)
|
|
106
|
+
except SignatureError as error:
|
|
107
|
+
print()
|
|
108
|
+
print(f"signature: {_COLOR_RED}INVALID{_COLOR_RESET} — {error.reason}")
|
|
109
|
+
print("Hints:")
|
|
110
|
+
print(" - Are you using the same API key that was configured when the webhook was sent?")
|
|
111
|
+
print(
|
|
112
|
+
" - Payment webhooks sign with the payment key; "
|
|
113
|
+
"payout webhooks sign with the payout key."
|
|
114
|
+
)
|
|
115
|
+
print(
|
|
116
|
+
" - If you re-serialised the payload before passing it here, "
|
|
117
|
+
"the slash escaping may have been lost."
|
|
118
|
+
)
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
print()
|
|
122
|
+
print(f"signature: {_COLOR_GREEN}valid{_COLOR_RESET}")
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _print_field(name: str, value: Any) -> None:
|
|
127
|
+
label = f" {name:<10}"
|
|
128
|
+
if value is None:
|
|
129
|
+
print(f"{label} (missing)")
|
|
130
|
+
elif isinstance(value, str):
|
|
131
|
+
print(f"{label} {value}")
|
|
132
|
+
else:
|
|
133
|
+
print(f"{label} {json.dumps(value)}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
sys.exit(main())
|
heleket_sdk/_config.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Client options for :class:`HeleketPayment` / :class:`HeleketPayout`.
|
|
2
|
+
|
|
3
|
+
:class:`ClientOptions` is a plain dataclass — not a Pydantic model — because
|
|
4
|
+
it can hold non-serialisable values like a ``DebugSink`` callable or a
|
|
5
|
+
custom transport.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from ._debug import DebugSink, stderr_sink
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._transport import Transport
|
|
17
|
+
|
|
18
|
+
__all__ = ["ClientOptions", "DEFAULT_BASE_URL", "DEFAULT_TIMEOUT_SECONDS"]
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.heleket.com"
|
|
21
|
+
DEFAULT_TIMEOUT_SECONDS = 30.0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ClientOptions:
|
|
26
|
+
"""Optional settings passed to ``HeleketPayment`` / ``HeleketPayout``.
|
|
27
|
+
|
|
28
|
+
``transport`` defaults to a fresh :class:`RequestsTransport` lazily, so
|
|
29
|
+
constructing ``ClientOptions()`` doesn't open a TCP socket pool.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
base_url: str = DEFAULT_BASE_URL
|
|
33
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS
|
|
34
|
+
debug: bool = False
|
|
35
|
+
logger: DebugSink = field(default=stderr_sink)
|
|
36
|
+
transport: Transport | None = None
|
|
37
|
+
|
|
38
|
+
def __post_init__(self) -> None:
|
|
39
|
+
# Normalise the base URL (drop trailing slashes) so callers can pass
|
|
40
|
+
# either form interchangeably.
|
|
41
|
+
self.base_url = self.base_url.rstrip("/")
|
|
42
|
+
if self.timeout <= 0:
|
|
43
|
+
raise ValueError("timeout must be > 0")
|
heleket_sdk/_debug.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Debug logging for the Heleket SDK.
|
|
2
|
+
|
|
3
|
+
A :class:`DebugSink` receives :class:`DebugEntry` objects when the client is
|
|
4
|
+
constructed with ``debug=True``. The default sink writes one line per entry
|
|
5
|
+
to ``stderr``; plug in your own to route to ``logging``, ``structlog``, or
|
|
6
|
+
anywhere else.
|
|
7
|
+
|
|
8
|
+
**Security:** entries never carry the API key or the ``sign`` header.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Literal
|
|
17
|
+
|
|
18
|
+
__all__ = ["DebugEntry", "DebugSink", "stderr_sink"]
|
|
19
|
+
|
|
20
|
+
EntryKind = Literal["request", "response", "error"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class DebugEntry:
|
|
25
|
+
"""A single debug event.
|
|
26
|
+
|
|
27
|
+
Use the ``request``/``response``/``error`` classmethods rather than the
|
|
28
|
+
constructor when calling from your own code.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
kind: EntryKind
|
|
32
|
+
method: str | None = None
|
|
33
|
+
url: str | None = None
|
|
34
|
+
body: str | None = None
|
|
35
|
+
status: int | None = None
|
|
36
|
+
message: str | None = None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def request(cls, method: str, url: str, body: str) -> DebugEntry:
|
|
40
|
+
return cls(kind="request", method=method, url=url, body=body)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def response(cls, status: int, body: str) -> DebugEntry:
|
|
44
|
+
return cls(kind="response", status=status, body=body)
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def error(cls, message: str) -> DebugEntry:
|
|
48
|
+
return cls(kind="error", message=message)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DebugSink = Callable[[DebugEntry], None]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def stderr_sink(entry: DebugEntry) -> None:
|
|
55
|
+
"""Default :class:`DebugSink`. Writes a one-line summary per entry to ``stderr``."""
|
|
56
|
+
if entry.kind == "request":
|
|
57
|
+
body = entry.body if entry.body else "(empty body)"
|
|
58
|
+
print(f"[heleket][→ {entry.method} {entry.url}] {body}", file=sys.stderr)
|
|
59
|
+
elif entry.kind == "response":
|
|
60
|
+
body = entry.body if entry.body else "(empty body)"
|
|
61
|
+
print(f"[heleket][← {entry.status}] {body}", file=sys.stderr)
|
|
62
|
+
elif entry.kind == "error":
|
|
63
|
+
print(f"[heleket][error] {entry.message}", file=sys.stderr)
|
heleket_sdk/_errors.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Exception hierarchy for the Heleket SDK.
|
|
2
|
+
|
|
3
|
+
All exceptions extend :class:`HeleketError`, which is a plain ``Exception`` —
|
|
4
|
+
they're unchecked in the Python sense. Catch the specific subclass you care
|
|
5
|
+
about, or :class:`HeleketError` to handle the SDK's whole surface uniformly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"HeleketError",
|
|
12
|
+
"ApiError",
|
|
13
|
+
"ValidationError",
|
|
14
|
+
"HttpError",
|
|
15
|
+
"SignatureError",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class HeleketError(Exception):
|
|
20
|
+
"""Base class for every exception raised by the SDK."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(HeleketError):
|
|
24
|
+
"""Raised when Heleket responds with ``state != 0`` (business error).
|
|
25
|
+
|
|
26
|
+
Carries the HTTP status and raw response body so callers can log or
|
|
27
|
+
surface the exact server reply.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, http_status: int = 0, raw_body: str = "") -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.http_status = http_status
|
|
33
|
+
self.raw_body = raw_body
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ValidationError(ApiError):
|
|
37
|
+
"""Raised on HTTP 422. Carries a ``fields`` dict mapping field → messages."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
message: str,
|
|
42
|
+
http_status: int,
|
|
43
|
+
fields: dict[str, list[str]],
|
|
44
|
+
raw_body: str = "",
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(message, http_status, raw_body)
|
|
47
|
+
self.fields = fields
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HttpError(HeleketError):
|
|
51
|
+
"""Raised when the HTTP transport itself fails.
|
|
52
|
+
|
|
53
|
+
DNS, TCP, TLS, timeout, broken pipe. The API was never reached, or no
|
|
54
|
+
response was received.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SignatureError(HeleketError):
|
|
59
|
+
"""Raised by :class:`WebhookVerifier` when the signature does not match.
|
|
60
|
+
|
|
61
|
+
Treat as a hard security failure — never trust the payload contents in
|
|
62
|
+
this case.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, reason: str) -> None:
|
|
66
|
+
super().__init__(f"heleket signature error: {reason}")
|
|
67
|
+
self.reason = reason
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Heleket request and webhook signature.
|
|
2
|
+
|
|
3
|
+
Formula (per https://doc.heleket.com/general/request-format):
|
|
4
|
+
|
|
5
|
+
sign = md5( base64(json_body) + api_key )
|
|
6
|
+
|
|
7
|
+
For requests with no parameters, pass an empty bytes object — base64-encoding it
|
|
8
|
+
yields the empty string, so the hash collapses to ``md5(api_key)``.
|
|
9
|
+
|
|
10
|
+
The body argument MUST be the exact bytes that will be sent over the wire.
|
|
11
|
+
The same routine is used to produce outgoing request signatures and to verify
|
|
12
|
+
incoming webhook signatures.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import hashlib
|
|
19
|
+
import hmac
|
|
20
|
+
|
|
21
|
+
__all__ = ["sign", "constant_time_equal"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sign(body: bytes | str, api_key: str) -> str:
|
|
25
|
+
"""Compute the Heleket signature for the given body and API key.
|
|
26
|
+
|
|
27
|
+
Both ``bytes`` and ``str`` bodies are accepted. Strings are UTF-8 encoded.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(body, str):
|
|
30
|
+
body = body.encode("utf-8")
|
|
31
|
+
encoded = base64.b64encode(body).decode("ascii")
|
|
32
|
+
return hashlib.md5((encoded + api_key).encode("utf-8")).hexdigest() # noqa: S324
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def constant_time_equal(expected: str | None, actual: str | None) -> bool:
|
|
36
|
+
"""Constant-time equality check for hex signatures.
|
|
37
|
+
|
|
38
|
+
Returns ``False`` if either side is ``None`` or the strings differ in
|
|
39
|
+
length. Use this in webhook verification — equality with ``==`` would
|
|
40
|
+
leak timing information.
|
|
41
|
+
"""
|
|
42
|
+
if expected is None or actual is None:
|
|
43
|
+
return False
|
|
44
|
+
if len(expected) != len(actual):
|
|
45
|
+
return False
|
|
46
|
+
return hmac.compare_digest(expected, actual)
|