linkbridge 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.
- linkbridge/__init__.py +25 -0
- linkbridge/client.py +322 -0
- linkbridge/errors.py +66 -0
- linkbridge/webhook.py +97 -0
- linkbridge-0.1.0.dist-info/METADATA +103 -0
- linkbridge-0.1.0.dist-info/RECORD +8 -0
- linkbridge-0.1.0.dist-info/WHEEL +5 -0
- linkbridge-0.1.0.dist-info/top_level.txt +1 -0
linkbridge/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Linkbridge Python SDK — public surface.
|
|
2
|
+
|
|
3
|
+
Stable imports for downstream callers; everything else is implementation
|
|
4
|
+
detail and may change without a major version bump.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import LinkbridgeClient, SDK_VERSION
|
|
8
|
+
from .errors import APIError, SignatureError
|
|
9
|
+
from .webhook import (
|
|
10
|
+
MAX_WEBHOOK_SKEW_SECONDS,
|
|
11
|
+
SIGNATURE_HEADER,
|
|
12
|
+
verify_webhook,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"LinkbridgeClient",
|
|
17
|
+
"APIError",
|
|
18
|
+
"SignatureError",
|
|
19
|
+
"verify_webhook",
|
|
20
|
+
"SIGNATURE_HEADER",
|
|
21
|
+
"MAX_WEBHOOK_SKEW_SECONDS",
|
|
22
|
+
"SDK_VERSION",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = SDK_VERSION
|
linkbridge/client.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""LinkbridgeClient — the SDK entry point.
|
|
2
|
+
|
|
3
|
+
Design notes
|
|
4
|
+
------------
|
|
5
|
+
|
|
6
|
+
* Stdlib-only HTTP via ``urllib.request``. We deliberately avoid the
|
|
7
|
+
``requests``/``httpx`` dependency so the package installs cleanly
|
|
8
|
+
into AWS Lambda layers and air-gapped POS firmware.
|
|
9
|
+
* OAuth2 client-credentials flow with lazy, thread-safe token refresh.
|
|
10
|
+
Tokens are refreshed 60 seconds before expiry; concurrent callers
|
|
11
|
+
block on a single in-flight refresh via a threading.Lock.
|
|
12
|
+
* Mirrors the resource grouping of the Node and Go SDKs:
|
|
13
|
+
``client.invoices.{submit, get, list, transmit, update_status}``,
|
|
14
|
+
``client.webhooks.{create, list, delete}``, ``client.lookups.*``.
|
|
15
|
+
* The ``transport`` constructor argument lets tests inject a fake
|
|
16
|
+
request handler (see ``tests/test_client.py``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import secrets
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from typing import Any, Callable, Dict, Iterable, Mapping, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
from .errors import APIError
|
|
31
|
+
|
|
32
|
+
SDK_VERSION = "0.1.0"
|
|
33
|
+
|
|
34
|
+
# A Transport is a pluggable HTTP function. Returning the tuple
|
|
35
|
+
# (status, headers, body_bytes) keeps the interface trivially mockable.
|
|
36
|
+
Transport = Callable[[str, str, Mapping[str, str], Optional[bytes]], Tuple[int, Mapping[str, str], bytes]]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stdlib_transport(
|
|
40
|
+
method: str,
|
|
41
|
+
url: str,
|
|
42
|
+
headers: Mapping[str, str],
|
|
43
|
+
body: Optional[bytes],
|
|
44
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
45
|
+
"""Default transport built on urllib. Captures error responses so
|
|
46
|
+
callers (and ``APIError.from_response``) can decode the body even
|
|
47
|
+
on 4xx/5xx — urllib normally treats those as exceptions."""
|
|
48
|
+
req = urllib.request.Request(url, data=body, method=method, headers=dict(headers))
|
|
49
|
+
try:
|
|
50
|
+
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310 — trusted scheme
|
|
51
|
+
return resp.status, dict(resp.headers), resp.read()
|
|
52
|
+
except urllib.error.HTTPError as exc:
|
|
53
|
+
return exc.code, dict(exc.headers or {}), exc.read()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LinkbridgeClient:
|
|
57
|
+
"""Synchronous Linkbridge API client."""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
base_url: str,
|
|
63
|
+
client_id: Optional[str] = None,
|
|
64
|
+
client_secret: Optional[str] = None,
|
|
65
|
+
static_token: Optional[str] = None,
|
|
66
|
+
scopes: Optional[Iterable[str]] = None,
|
|
67
|
+
user_agent: Optional[str] = None,
|
|
68
|
+
transport: Optional[Transport] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
if not base_url:
|
|
71
|
+
raise ValueError("linkbridge: base_url is required")
|
|
72
|
+
if not static_token and not (client_id and client_secret):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"linkbridge: either static_token or client_id+client_secret is required"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
self._base_url = base_url.rstrip("/")
|
|
78
|
+
self._client_id = client_id
|
|
79
|
+
self._client_secret = client_secret
|
|
80
|
+
self._static_token = static_token
|
|
81
|
+
self._scopes = list(scopes) if scopes else ["invoices:write", "invoices:read"]
|
|
82
|
+
self._user_agent_suffix = user_agent or ""
|
|
83
|
+
self._transport: Transport = transport or _stdlib_transport
|
|
84
|
+
|
|
85
|
+
self._token_cache: Optional[Tuple[str, float]] = None # (token, expires_at_epoch)
|
|
86
|
+
self._token_lock = threading.Lock()
|
|
87
|
+
|
|
88
|
+
self.invoices = InvoicesAPI(self)
|
|
89
|
+
self.webhooks = WebhooksAPI(self)
|
|
90
|
+
self.lookups = LookupsAPI(self)
|
|
91
|
+
|
|
92
|
+
# ----- public helpers -------------------------------------------------
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def idempotency_key() -> str:
|
|
96
|
+
"""Return a fresh URL-safe Idempotency-Key (32 hex chars + prefix)."""
|
|
97
|
+
return "lb-" + secrets.token_hex(16)
|
|
98
|
+
|
|
99
|
+
def user_agent(self) -> str:
|
|
100
|
+
base = f"linkbridge-python/{SDK_VERSION}"
|
|
101
|
+
return f"{base} {self._user_agent_suffix}".rstrip()
|
|
102
|
+
|
|
103
|
+
# ----- token management ----------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _token(self) -> str:
|
|
106
|
+
if self._static_token:
|
|
107
|
+
return self._static_token
|
|
108
|
+
with self._token_lock:
|
|
109
|
+
if self._token_cache and self._token_cache[1] - time.time() > 60:
|
|
110
|
+
return self._token_cache[0]
|
|
111
|
+
self._refresh_token_locked()
|
|
112
|
+
assert self._token_cache is not None
|
|
113
|
+
return self._token_cache[0]
|
|
114
|
+
|
|
115
|
+
def _refresh_token_locked(self) -> None:
|
|
116
|
+
body = json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"client_id": self._client_id,
|
|
119
|
+
"client_secret": self._client_secret,
|
|
120
|
+
"grant_type": "client_credentials",
|
|
121
|
+
"scope": " ".join(self._scopes),
|
|
122
|
+
}
|
|
123
|
+
).encode("utf-8")
|
|
124
|
+
status, _headers, raw = self._transport(
|
|
125
|
+
"POST",
|
|
126
|
+
f"{self._base_url}/v1/oauth/token",
|
|
127
|
+
{
|
|
128
|
+
"content-type": "application/json",
|
|
129
|
+
"accept": "application/json",
|
|
130
|
+
"user-agent": self.user_agent(),
|
|
131
|
+
},
|
|
132
|
+
body,
|
|
133
|
+
)
|
|
134
|
+
if status // 100 != 2:
|
|
135
|
+
raise APIError.from_response(status, raw)
|
|
136
|
+
try:
|
|
137
|
+
decoded = json.loads(raw)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
raise APIError(
|
|
140
|
+
status=status,
|
|
141
|
+
code="invalid_token_response",
|
|
142
|
+
message="non-JSON token response",
|
|
143
|
+
raw=raw,
|
|
144
|
+
) from exc
|
|
145
|
+
token = decoded.get("access_token")
|
|
146
|
+
ttl = int(decoded.get("expires_in") or 300)
|
|
147
|
+
if not token:
|
|
148
|
+
raise APIError(
|
|
149
|
+
status=status,
|
|
150
|
+
code="invalid_token_response",
|
|
151
|
+
message="empty access_token",
|
|
152
|
+
raw=raw,
|
|
153
|
+
)
|
|
154
|
+
self._token_cache = (token, time.time() + ttl)
|
|
155
|
+
|
|
156
|
+
# ----- request plumbing ----------------------------------------------
|
|
157
|
+
|
|
158
|
+
def request(
|
|
159
|
+
self,
|
|
160
|
+
method: str,
|
|
161
|
+
path: str,
|
|
162
|
+
*,
|
|
163
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
164
|
+
body: Any = None,
|
|
165
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
166
|
+
expect_json: bool = True,
|
|
167
|
+
) -> Any:
|
|
168
|
+
"""Authenticated request. Returns the decoded JSON body on
|
|
169
|
+
2xx (or ``None`` for 204 / empty bodies). Raises :class:`APIError`
|
|
170
|
+
otherwise."""
|
|
171
|
+
url = self._base_url + path
|
|
172
|
+
if query:
|
|
173
|
+
cleaned = {k: str(v) for k, v in query.items() if v is not None and v != ""}
|
|
174
|
+
if cleaned:
|
|
175
|
+
url = f"{url}?{urllib.parse.urlencode(cleaned)}"
|
|
176
|
+
|
|
177
|
+
headers: Dict[str, str] = {
|
|
178
|
+
"authorization": "Bearer " + self._token(),
|
|
179
|
+
"accept": "application/json",
|
|
180
|
+
"user-agent": self.user_agent(),
|
|
181
|
+
}
|
|
182
|
+
raw_body: Optional[bytes] = None
|
|
183
|
+
if body is not None:
|
|
184
|
+
headers["content-type"] = "application/json"
|
|
185
|
+
raw_body = json.dumps(body).encode("utf-8")
|
|
186
|
+
if extra_headers:
|
|
187
|
+
for k, v in extra_headers.items():
|
|
188
|
+
headers[k.lower()] = v
|
|
189
|
+
|
|
190
|
+
status, _resp_headers, payload = self._transport(method, url, headers, raw_body)
|
|
191
|
+
if status // 100 != 2:
|
|
192
|
+
raise APIError.from_response(status, payload)
|
|
193
|
+
if not expect_json or status == 204 or not payload:
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
return json.loads(payload)
|
|
197
|
+
except ValueError as exc:
|
|
198
|
+
raise APIError(
|
|
199
|
+
status=status,
|
|
200
|
+
code="invalid_json_response",
|
|
201
|
+
message="server returned non-JSON body",
|
|
202
|
+
raw=payload,
|
|
203
|
+
) from exc
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
# Resource handles. Each one is a thin facade over LinkbridgeClient.request
|
|
208
|
+
# so callers get IDE-friendly grouping (`client.invoices.submit(...)`).
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class InvoicesAPI:
|
|
213
|
+
def __init__(self, client: LinkbridgeClient) -> None:
|
|
214
|
+
self._c = client
|
|
215
|
+
|
|
216
|
+
def submit(
|
|
217
|
+
self,
|
|
218
|
+
invoice: Mapping[str, Any],
|
|
219
|
+
*,
|
|
220
|
+
idempotency_key: Optional[str] = None,
|
|
221
|
+
mode: Optional[str] = None,
|
|
222
|
+
) -> Dict[str, Any]:
|
|
223
|
+
"""POST /v1/invoices. Returns the InvoiceAccepted envelope."""
|
|
224
|
+
headers = {
|
|
225
|
+
"idempotency-key": idempotency_key or LinkbridgeClient.idempotency_key(),
|
|
226
|
+
}
|
|
227
|
+
return self._c.request(
|
|
228
|
+
"POST",
|
|
229
|
+
"/v1/invoices",
|
|
230
|
+
query={"mode": mode} if mode else None,
|
|
231
|
+
body=invoice,
|
|
232
|
+
extra_headers=headers,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def get(self, irn: str) -> Dict[str, Any]:
|
|
236
|
+
"""GET /v1/invoices/{irn}."""
|
|
237
|
+
return self._c.request("GET", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}")
|
|
238
|
+
|
|
239
|
+
def list(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
cursor: Optional[str] = None,
|
|
243
|
+
limit: Optional[int] = None,
|
|
244
|
+
status: Optional[str] = None,
|
|
245
|
+
) -> Dict[str, Any]:
|
|
246
|
+
"""GET /v1/invoices. Returns ``{"data": [...], "next_cursor": ...}``."""
|
|
247
|
+
return self._c.request(
|
|
248
|
+
"GET",
|
|
249
|
+
"/v1/invoices",
|
|
250
|
+
query={"cursor": cursor, "limit": limit, "status": status},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def transmit(self, irn: str) -> Dict[str, Any]:
|
|
254
|
+
"""POST /v1/invoices/{irn}/transmit — re-queue for transmission."""
|
|
255
|
+
return self._c.request(
|
|
256
|
+
"POST", f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/transmit"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def update_status(
|
|
260
|
+
self,
|
|
261
|
+
irn: str,
|
|
262
|
+
*,
|
|
263
|
+
payment_status: str,
|
|
264
|
+
reference: Optional[str] = None,
|
|
265
|
+
) -> Dict[str, Any]:
|
|
266
|
+
"""POST /v1/invoices/{irn}/status — record a payment-state change."""
|
|
267
|
+
body: Dict[str, Any] = {"payment_status": payment_status}
|
|
268
|
+
if reference is not None:
|
|
269
|
+
body["reference"] = reference
|
|
270
|
+
return self._c.request(
|
|
271
|
+
"POST",
|
|
272
|
+
f"/v1/invoices/{urllib.parse.quote(irn, safe='')}/status",
|
|
273
|
+
body=body,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class WebhooksAPI:
|
|
278
|
+
def __init__(self, client: LinkbridgeClient) -> None:
|
|
279
|
+
self._c = client
|
|
280
|
+
|
|
281
|
+
def create(
|
|
282
|
+
self,
|
|
283
|
+
*,
|
|
284
|
+
url: str,
|
|
285
|
+
events: Iterable[str],
|
|
286
|
+
description: Optional[str] = None,
|
|
287
|
+
) -> Dict[str, Any]:
|
|
288
|
+
"""POST /v1/webhooks. The plaintext ``secret`` is returned on
|
|
289
|
+
the response and never again — store it securely."""
|
|
290
|
+
body: Dict[str, Any] = {"url": url, "events": list(events)}
|
|
291
|
+
if description is not None:
|
|
292
|
+
body["description"] = description
|
|
293
|
+
return self._c.request("POST", "/v1/webhooks", body=body)
|
|
294
|
+
|
|
295
|
+
def list(self) -> Dict[str, Any]:
|
|
296
|
+
"""GET /v1/webhooks."""
|
|
297
|
+
return self._c.request("GET", "/v1/webhooks")
|
|
298
|
+
|
|
299
|
+
def delete(self, webhook_id: str) -> None:
|
|
300
|
+
"""DELETE /v1/webhooks/{id}."""
|
|
301
|
+
self._c.request(
|
|
302
|
+
"DELETE",
|
|
303
|
+
f"/v1/webhooks/{urllib.parse.quote(webhook_id, safe='')}",
|
|
304
|
+
expect_json=False,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class LookupsAPI:
|
|
309
|
+
def __init__(self, client: LinkbridgeClient) -> None:
|
|
310
|
+
self._c = client
|
|
311
|
+
|
|
312
|
+
def tax_codes(self) -> Dict[str, Any]:
|
|
313
|
+
"""GET /v1/lookups/tax-codes."""
|
|
314
|
+
return self._c.request("GET", "/v1/lookups/tax-codes")
|
|
315
|
+
|
|
316
|
+
def hsn_codes(self, *, limit: Optional[int] = None, cursor: Optional[str] = None) -> Dict[str, Any]:
|
|
317
|
+
"""GET /v1/lookups/hsn-codes (paginated)."""
|
|
318
|
+
return self._c.request(
|
|
319
|
+
"GET",
|
|
320
|
+
"/v1/lookups/hsn-codes",
|
|
321
|
+
query={"limit": limit, "cursor": cursor},
|
|
322
|
+
)
|
linkbridge/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Error types for the Linkbridge Python SDK.
|
|
2
|
+
|
|
3
|
+
The API always returns a `{"error": {"code", "message", "trace_id"}}`
|
|
4
|
+
envelope on failure. We surface those fields verbatim on `APIError` so
|
|
5
|
+
callers can switch on the canonical code without parsing the message.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(Exception):
|
|
15
|
+
"""Raised for any non-2xx HTTP response from the Linkbridge API."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
status: int,
|
|
21
|
+
code: str,
|
|
22
|
+
message: str,
|
|
23
|
+
trace_id: Optional[str] = None,
|
|
24
|
+
details: Optional[Any] = None,
|
|
25
|
+
raw: Optional[bytes] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(f"linkbridge: {status} {code}: {message}")
|
|
28
|
+
self.status = status
|
|
29
|
+
self.code = code
|
|
30
|
+
self.message = message
|
|
31
|
+
self.trace_id = trace_id
|
|
32
|
+
self.details = details
|
|
33
|
+
self.raw = raw
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_response(cls, status: int, body: bytes) -> "APIError":
|
|
37
|
+
"""Best-effort decoder. Falls back to opaque codes when the
|
|
38
|
+
response is not the canonical error envelope (e.g. a 502 from a
|
|
39
|
+
load balancer that has no idea what an APIError looks like)."""
|
|
40
|
+
code = "http_error"
|
|
41
|
+
message = body.decode("utf-8", errors="replace")[:500] or f"http {status}"
|
|
42
|
+
trace_id: Optional[str] = None
|
|
43
|
+
details: Any = None
|
|
44
|
+
try:
|
|
45
|
+
decoded = json.loads(body)
|
|
46
|
+
except (ValueError, UnicodeDecodeError):
|
|
47
|
+
decoded = None
|
|
48
|
+
if isinstance(decoded, Mapping):
|
|
49
|
+
err = decoded.get("error")
|
|
50
|
+
if isinstance(err, Mapping):
|
|
51
|
+
code = str(err.get("code") or code)
|
|
52
|
+
message = str(err.get("message") or message)
|
|
53
|
+
trace_id = err.get("trace_id")
|
|
54
|
+
details = err.get("details")
|
|
55
|
+
return cls(
|
|
56
|
+
status=status,
|
|
57
|
+
code=code,
|
|
58
|
+
message=message,
|
|
59
|
+
trace_id=trace_id,
|
|
60
|
+
details=details,
|
|
61
|
+
raw=body,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SignatureError(Exception):
|
|
66
|
+
"""Raised by ``verify_webhook`` when a delivery cannot be trusted."""
|
linkbridge/webhook.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""HMAC-SHA256 verification for Linkbridge webhook deliveries.
|
|
2
|
+
|
|
3
|
+
Spec contract (see openapi.yaml §security and ADR-0006):
|
|
4
|
+
|
|
5
|
+
X-Linkbridge-Signature: t=<unix>,v1=<hex>
|
|
6
|
+
v1 = hex(HMAC-SHA256(secret, f"{t}.{body}"))
|
|
7
|
+
|
|
8
|
+
The receiver MUST reject deliveries whose timestamp falls outside a
|
|
9
|
+
5-minute window to bound replay-attack horizons. We mirror the same
|
|
10
|
+
constant-time comparison logic the API server uses.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hmac
|
|
16
|
+
import time
|
|
17
|
+
from hashlib import sha256
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from .errors import SignatureError
|
|
21
|
+
|
|
22
|
+
SIGNATURE_HEADER = "X-Linkbridge-Signature"
|
|
23
|
+
MAX_WEBHOOK_SKEW_SECONDS = 5 * 60
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def verify_webhook(
|
|
27
|
+
*,
|
|
28
|
+
secret: bytes,
|
|
29
|
+
body: bytes,
|
|
30
|
+
header: str,
|
|
31
|
+
now: Optional[float] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Verify a webhook signature. Raises ``SignatureError`` on any
|
|
34
|
+
failure mode (missing/malformed header, replay-window violation,
|
|
35
|
+
signature mismatch). Returns ``None`` on success.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
secret:
|
|
40
|
+
The raw webhook secret bytes returned by ``POST /v1/webhooks``.
|
|
41
|
+
Do NOT pass the secret as ``str`` — Python would otherwise
|
|
42
|
+
encode it implicitly with the platform's default encoding.
|
|
43
|
+
body:
|
|
44
|
+
The exact request body bytes as read off the wire. JSON
|
|
45
|
+
re-serialisation will alter byte ordering and break verification.
|
|
46
|
+
header:
|
|
47
|
+
The raw value of the ``X-Linkbridge-Signature`` header.
|
|
48
|
+
now:
|
|
49
|
+
Override the current time (epoch seconds). Production code
|
|
50
|
+
should leave this as ``None`` so we use ``time.time()``.
|
|
51
|
+
"""
|
|
52
|
+
if not header:
|
|
53
|
+
raise SignatureError("missing X-Linkbridge-Signature")
|
|
54
|
+
if not isinstance(secret, (bytes, bytearray)):
|
|
55
|
+
raise TypeError("secret must be bytes; pass secret.encode() if it's a str")
|
|
56
|
+
if not isinstance(body, (bytes, bytearray)):
|
|
57
|
+
raise TypeError("body must be bytes; do not pre-decode the request body")
|
|
58
|
+
|
|
59
|
+
t, sig = _parse_header(header)
|
|
60
|
+
current = time.time() if now is None else now
|
|
61
|
+
if abs(current - t) > MAX_WEBHOOK_SKEW_SECONDS:
|
|
62
|
+
raise SignatureError("signature timestamp outside replay window")
|
|
63
|
+
|
|
64
|
+
expected = hmac.new(secret, f"{t}.".encode("ascii"), sha256)
|
|
65
|
+
expected.update(body)
|
|
66
|
+
expected_hex = expected.hexdigest()
|
|
67
|
+
# hmac.compare_digest is constant-time; explicit lower() avoids
|
|
68
|
+
# accidentally rejecting a server that uppercased the hex.
|
|
69
|
+
if not hmac.compare_digest(expected_hex, sig.lower()):
|
|
70
|
+
raise SignatureError("signature mismatch")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _parse_header(header: str) -> tuple[int, str]:
|
|
74
|
+
"""Parse ``t=<unix>,v1=<hex>`` into ``(timestamp_int, hex_str)``.
|
|
75
|
+
|
|
76
|
+
Raises ``SignatureError`` for any structural problem.
|
|
77
|
+
"""
|
|
78
|
+
t: Optional[int] = None
|
|
79
|
+
sig: Optional[str] = None
|
|
80
|
+
for part in header.split(","):
|
|
81
|
+
kv = part.strip().split("=", 1)
|
|
82
|
+
if len(kv) != 2:
|
|
83
|
+
raise SignatureError("malformed signature header")
|
|
84
|
+
key, value = kv[0], kv[1]
|
|
85
|
+
if key == "t":
|
|
86
|
+
try:
|
|
87
|
+
t = int(value)
|
|
88
|
+
except ValueError as exc:
|
|
89
|
+
raise SignatureError("malformed signature timestamp") from exc
|
|
90
|
+
if t <= 0:
|
|
91
|
+
raise SignatureError("malformed signature timestamp")
|
|
92
|
+
elif key == "v1":
|
|
93
|
+
sig = value
|
|
94
|
+
# Unknown keys are tolerated for forward-compat (e.g. v2=).
|
|
95
|
+
if t is None or not sig:
|
|
96
|
+
raise SignatureError("malformed signature header")
|
|
97
|
+
return t, sig
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: linkbridge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Linkbridge e-invoicing API
|
|
5
|
+
Author: Linkbridge
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://docs.linkbridge.ng
|
|
8
|
+
Project-URL: Documentation, https://docs.linkbridge.ng
|
|
9
|
+
Project-URL: Source, https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python
|
|
10
|
+
Project-URL: Issues, https://github.com/Linkbridge-Systems/linkbridge-sdks/issues
|
|
11
|
+
Keywords: linkbridge,e-invoicing,nrs,fbr,fiscalisation
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Provides-Extra: test
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
22
|
+
Requires-Dist: pytest-cov>=4; extra == "test"
|
|
23
|
+
|
|
24
|
+
# linkbridge (Python SDK)
|
|
25
|
+
|
|
26
|
+
Official Python client for the [Linkbridge](https://linkbridge.ng)
|
|
27
|
+
e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
|
|
28
|
+
[Node](../sdk-node) SDKs against the same OpenAPI contract
|
|
29
|
+
(`tools/openapi/openapi.yaml`).
|
|
30
|
+
|
|
31
|
+
* **Zero runtime dependencies** — uses only the Python standard library
|
|
32
|
+
so the package drops cleanly into Lambda layers, vendored POS
|
|
33
|
+
firmware, and air-gapped merchant ERPs.
|
|
34
|
+
* **Sync API** with explicit OAuth2 client-credentials handling and
|
|
35
|
+
automatic token refresh.
|
|
36
|
+
* **Webhook signature verification** with constant-time comparison and
|
|
37
|
+
the same 5-minute clock-skew window enforced server-side.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install linkbridge
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.9 or newer.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from linkbridge import LinkbridgeClient
|
|
51
|
+
|
|
52
|
+
client = LinkbridgeClient(
|
|
53
|
+
base_url="https://api.linkbridge.ng",
|
|
54
|
+
client_id="your-client-id",
|
|
55
|
+
client_secret="your-client-secret",
|
|
56
|
+
scopes=["invoices:write", "invoices:read"],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
accepted = client.invoices.submit({
|
|
60
|
+
"irn": "INV001-SVC01-20260601",
|
|
61
|
+
"invoice_kind": "Standard",
|
|
62
|
+
# …rest of the canonical NRS payload — see
|
|
63
|
+
# packages/schema/invoice.schema.json
|
|
64
|
+
})
|
|
65
|
+
print(accepted["irn"], accepted["status"])
|
|
66
|
+
|
|
67
|
+
# Read back, paginate, retry, mutate payment status:
|
|
68
|
+
record = client.invoices.get(accepted["irn"])
|
|
69
|
+
page = client.invoices.list(limit=20, status="failed")
|
|
70
|
+
requeued = client.invoices.transmit(accepted["irn"])
|
|
71
|
+
paid = client.invoices.update_status(accepted["irn"],
|
|
72
|
+
payment_status="PAID", reference="RCPT-001")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Webhook verification
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from linkbridge import verify_webhook, SignatureError
|
|
79
|
+
|
|
80
|
+
@app.post("/hooks/linkbridge")
|
|
81
|
+
def hook(request):
|
|
82
|
+
try:
|
|
83
|
+
verify_webhook(
|
|
84
|
+
secret=os.environ["WEBHOOK_SECRET"].encode(),
|
|
85
|
+
body=request.body, # raw bytes off the wire
|
|
86
|
+
header=request.headers["X-Linkbridge-Signature"],
|
|
87
|
+
)
|
|
88
|
+
except SignatureError:
|
|
89
|
+
return 401
|
|
90
|
+
# …handle the event
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Errors
|
|
94
|
+
|
|
95
|
+
All non-2xx responses raise `linkbridge.APIError`, which exposes the
|
|
96
|
+
HTTP status, the canonical `error.code`, the human `error.message`, and
|
|
97
|
+
the `trace_id` so that operators can correlate against server logs.
|
|
98
|
+
|
|
99
|
+
## Versioning
|
|
100
|
+
|
|
101
|
+
The package follows the same `0.MINOR.PATCH` cadence as the API surface
|
|
102
|
+
during the beta. Breaking changes will be confined to MINOR bumps until
|
|
103
|
+
the API freezes at `1.0.0`.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
linkbridge/__init__.py,sha256=lw8ZIos96eouCrAZ2qZRo3WcJgfJwIOayaqn8SSs4sw,569
|
|
2
|
+
linkbridge/client.py,sha256=91pJ5602Ww3ZWm_4g4PiKDbC6lv1lcmCQ-gsdq6z4wI,11326
|
|
3
|
+
linkbridge/errors.py,sha256=7h65rWbhJfS6kZIdfv0SBExek_dO5ygKdkkqB2R4vvE,2161
|
|
4
|
+
linkbridge/webhook.py,sha256=7jv66umJJaaZQuMmlghRsbpKieCNDLRWJhZmUcjTMXI,3466
|
|
5
|
+
linkbridge-0.1.0.dist-info/METADATA,sha256=WuBKXIUPDvw1OGjPmUgnSqzIArbcRnW4g3Dq2L4WMb8,3409
|
|
6
|
+
linkbridge-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
linkbridge-0.1.0.dist-info/top_level.txt,sha256=ouOG4YXK5s5ePtI7mN8gRsY6YC5JEFpC8pyKu4Nq3as,11
|
|
8
|
+
linkbridge-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linkbridge
|