linkbridge 0.1.0__tar.gz
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-0.1.0/PKG-INFO +103 -0
- linkbridge-0.1.0/README.md +80 -0
- linkbridge-0.1.0/linkbridge/__init__.py +25 -0
- linkbridge-0.1.0/linkbridge/client.py +322 -0
- linkbridge-0.1.0/linkbridge/errors.py +66 -0
- linkbridge-0.1.0/linkbridge/webhook.py +97 -0
- linkbridge-0.1.0/linkbridge.egg-info/PKG-INFO +103 -0
- linkbridge-0.1.0/linkbridge.egg-info/SOURCES.txt +13 -0
- linkbridge-0.1.0/linkbridge.egg-info/dependency_links.txt +1 -0
- linkbridge-0.1.0/linkbridge.egg-info/requires.txt +4 -0
- linkbridge-0.1.0/linkbridge.egg-info/top_level.txt +1 -0
- linkbridge-0.1.0/pyproject.toml +42 -0
- linkbridge-0.1.0/setup.cfg +4 -0
- linkbridge-0.1.0/tests/test_client.py +269 -0
- linkbridge-0.1.0/tests/test_webhook.py +98 -0
|
@@ -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,80 @@
|
|
|
1
|
+
# linkbridge (Python SDK)
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Linkbridge](https://linkbridge.ng)
|
|
4
|
+
e-invoicing API. Mirrors the surface of the [Go](../sdk-go) and
|
|
5
|
+
[Node](../sdk-node) SDKs against the same OpenAPI contract
|
|
6
|
+
(`tools/openapi/openapi.yaml`).
|
|
7
|
+
|
|
8
|
+
* **Zero runtime dependencies** — uses only the Python standard library
|
|
9
|
+
so the package drops cleanly into Lambda layers, vendored POS
|
|
10
|
+
firmware, and air-gapped merchant ERPs.
|
|
11
|
+
* **Sync API** with explicit OAuth2 client-credentials handling and
|
|
12
|
+
automatic token refresh.
|
|
13
|
+
* **Webhook signature verification** with constant-time comparison and
|
|
14
|
+
the same 5-minute clock-skew window enforced server-side.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install linkbridge
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9 or newer.
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from linkbridge import LinkbridgeClient
|
|
28
|
+
|
|
29
|
+
client = LinkbridgeClient(
|
|
30
|
+
base_url="https://api.linkbridge.ng",
|
|
31
|
+
client_id="your-client-id",
|
|
32
|
+
client_secret="your-client-secret",
|
|
33
|
+
scopes=["invoices:write", "invoices:read"],
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
accepted = client.invoices.submit({
|
|
37
|
+
"irn": "INV001-SVC01-20260601",
|
|
38
|
+
"invoice_kind": "Standard",
|
|
39
|
+
# …rest of the canonical NRS payload — see
|
|
40
|
+
# packages/schema/invoice.schema.json
|
|
41
|
+
})
|
|
42
|
+
print(accepted["irn"], accepted["status"])
|
|
43
|
+
|
|
44
|
+
# Read back, paginate, retry, mutate payment status:
|
|
45
|
+
record = client.invoices.get(accepted["irn"])
|
|
46
|
+
page = client.invoices.list(limit=20, status="failed")
|
|
47
|
+
requeued = client.invoices.transmit(accepted["irn"])
|
|
48
|
+
paid = client.invoices.update_status(accepted["irn"],
|
|
49
|
+
payment_status="PAID", reference="RCPT-001")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Webhook verification
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from linkbridge import verify_webhook, SignatureError
|
|
56
|
+
|
|
57
|
+
@app.post("/hooks/linkbridge")
|
|
58
|
+
def hook(request):
|
|
59
|
+
try:
|
|
60
|
+
verify_webhook(
|
|
61
|
+
secret=os.environ["WEBHOOK_SECRET"].encode(),
|
|
62
|
+
body=request.body, # raw bytes off the wire
|
|
63
|
+
header=request.headers["X-Linkbridge-Signature"],
|
|
64
|
+
)
|
|
65
|
+
except SignatureError:
|
|
66
|
+
return 401
|
|
67
|
+
# …handle the event
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Errors
|
|
71
|
+
|
|
72
|
+
All non-2xx responses raise `linkbridge.APIError`, which exposes the
|
|
73
|
+
HTTP status, the canonical `error.code`, the human `error.message`, and
|
|
74
|
+
the `trace_id` so that operators can correlate against server logs.
|
|
75
|
+
|
|
76
|
+
## Versioning
|
|
77
|
+
|
|
78
|
+
The package follows the same `0.MINOR.PATCH` cadence as the API surface
|
|
79
|
+
during the beta. Breaking changes will be confined to MINOR bumps until
|
|
80
|
+
the API freezes at `1.0.0`.
|
|
@@ -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
|
|
@@ -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
|
+
)
|
|
@@ -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."""
|
|
@@ -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,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
linkbridge/__init__.py
|
|
4
|
+
linkbridge/client.py
|
|
5
|
+
linkbridge/errors.py
|
|
6
|
+
linkbridge/webhook.py
|
|
7
|
+
linkbridge.egg-info/PKG-INFO
|
|
8
|
+
linkbridge.egg-info/SOURCES.txt
|
|
9
|
+
linkbridge.egg-info/dependency_links.txt
|
|
10
|
+
linkbridge.egg-info/requires.txt
|
|
11
|
+
linkbridge.egg-info/top_level.txt
|
|
12
|
+
tests/test_client.py
|
|
13
|
+
tests/test_webhook.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
linkbridge
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "linkbridge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Linkbridge e-invoicing API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Linkbridge" }]
|
|
13
|
+
keywords = ["linkbridge", "e-invoicing", "nrs", "fbr", "fiscalisation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Office/Business :: Financial",
|
|
21
|
+
]
|
|
22
|
+
# Zero runtime dependencies — we deliberately use stdlib (urllib, hmac,
|
|
23
|
+
# json, secrets) so the package installs cleanly into restricted
|
|
24
|
+
# environments (Lambda layers, vendored merchant POS firmware, etc.).
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
test = ["pytest>=7", "pytest-cov>=4"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://docs.linkbridge.ng"
|
|
32
|
+
Documentation = "https://docs.linkbridge.ng"
|
|
33
|
+
Source = "https://github.com/Linkbridge-Systems/linkbridge-sdks/tree/main/sdk-python"
|
|
34
|
+
Issues = "https://github.com/Linkbridge-Systems/linkbridge-sdks/issues"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
include = ["linkbridge*"]
|
|
38
|
+
exclude = ["tests*"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
addopts = "-q"
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Unit tests for the Linkbridge Python SDK client.
|
|
2
|
+
|
|
3
|
+
We rely on the Transport injection point to drive every code path
|
|
4
|
+
hermetically — no real HTTP, no Postgres. The contract is small enough
|
|
5
|
+
(four arguments in, three values out) that tests stay readable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any, List, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from linkbridge import APIError, LinkbridgeClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- helpers ---------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeTransport:
|
|
24
|
+
"""Records every call and returns canned responses."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, responses: List[Tuple[int, Mapping[str, str], bytes]]) -> None:
|
|
27
|
+
self._responses = list(responses)
|
|
28
|
+
self.calls: List[Tuple[str, str, Mapping[str, str], Optional[bytes]]] = []
|
|
29
|
+
self.lock = threading.Lock()
|
|
30
|
+
|
|
31
|
+
def __call__(
|
|
32
|
+
self,
|
|
33
|
+
method: str,
|
|
34
|
+
url: str,
|
|
35
|
+
headers: Mapping[str, str],
|
|
36
|
+
body: Optional[bytes],
|
|
37
|
+
) -> Tuple[int, Mapping[str, str], bytes]:
|
|
38
|
+
with self.lock:
|
|
39
|
+
self.calls.append((method, url, dict(headers), body))
|
|
40
|
+
if not self._responses:
|
|
41
|
+
raise AssertionError(f"FakeTransport: unexpected call {method} {url}")
|
|
42
|
+
return self._responses.pop(0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ok(body: Any, *, status: int = 200) -> Tuple[int, Mapping[str, str], bytes]:
|
|
46
|
+
return status, {"content-type": "application/json"}, json.dumps(body).encode()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _err(status: int, code: str, message: str) -> Tuple[int, Mapping[str, str], bytes]:
|
|
50
|
+
body = {"error": {"code": code, "message": message, "trace_id": "trace-xyz"}}
|
|
51
|
+
return status, {"content-type": "application/json"}, json.dumps(body).encode()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _new_client(transport: FakeTransport, **overrides: Any) -> LinkbridgeClient:
|
|
55
|
+
base: dict[str, Any] = {
|
|
56
|
+
"base_url": "https://api.test",
|
|
57
|
+
"client_id": "cid",
|
|
58
|
+
"client_secret": "csecret",
|
|
59
|
+
"transport": transport,
|
|
60
|
+
}
|
|
61
|
+
base.update(overrides)
|
|
62
|
+
return LinkbridgeClient(**base)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- construction ----------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_constructor_requires_credentials():
|
|
69
|
+
with pytest.raises(ValueError):
|
|
70
|
+
LinkbridgeClient(base_url="https://x")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_constructor_requires_base_url():
|
|
74
|
+
with pytest.raises(ValueError):
|
|
75
|
+
LinkbridgeClient(base_url="", static_token="t")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_static_token_skips_oauth_dance():
|
|
79
|
+
transport = FakeTransport([_ok({"data": [], "next_cursor": None})])
|
|
80
|
+
client = LinkbridgeClient(
|
|
81
|
+
base_url="https://api.test", static_token="static-bearer", transport=transport
|
|
82
|
+
)
|
|
83
|
+
client.invoices.list()
|
|
84
|
+
method, url, headers, _ = transport.calls[0]
|
|
85
|
+
assert method == "GET"
|
|
86
|
+
assert url == "https://api.test/v1/invoices"
|
|
87
|
+
assert headers["authorization"] == "Bearer static-bearer"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --- token caching ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_token_is_fetched_once_and_cached():
|
|
94
|
+
transport = FakeTransport(
|
|
95
|
+
[
|
|
96
|
+
_ok({"access_token": "tok-1", "expires_in": 3600, "token_type": "Bearer"}),
|
|
97
|
+
_ok({"data": [], "next_cursor": None}),
|
|
98
|
+
_ok({"data": [], "next_cursor": None}),
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
client = _new_client(transport)
|
|
102
|
+
client.invoices.list()
|
|
103
|
+
client.invoices.list()
|
|
104
|
+
# Only one /v1/oauth/token call despite two list() calls.
|
|
105
|
+
token_calls = [c for c in transport.calls if c[1].endswith("/v1/oauth/token")]
|
|
106
|
+
assert len(token_calls) == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_token_refreshes_when_near_expiry(monkeypatch: pytest.MonkeyPatch):
|
|
110
|
+
transport = FakeTransport(
|
|
111
|
+
[
|
|
112
|
+
_ok({"access_token": "tok-1", "expires_in": 30, "token_type": "Bearer"}),
|
|
113
|
+
_ok({"data": [], "next_cursor": None}),
|
|
114
|
+
_ok({"access_token": "tok-2", "expires_in": 3600, "token_type": "Bearer"}),
|
|
115
|
+
_ok({"data": [], "next_cursor": None}),
|
|
116
|
+
]
|
|
117
|
+
)
|
|
118
|
+
client = _new_client(transport)
|
|
119
|
+
client.invoices.list() # first call → fetches tok-1
|
|
120
|
+
# Advance time well past tok-1's window. expires_in=30 means cache
|
|
121
|
+
# window is now-60; we cross instantly.
|
|
122
|
+
base = time.time()
|
|
123
|
+
monkeypatch.setattr("linkbridge.client.time.time", lambda: base + 120)
|
|
124
|
+
client.invoices.list() # should refresh
|
|
125
|
+
token_calls = [c for c in transport.calls if c[1].endswith("/v1/oauth/token")]
|
|
126
|
+
assert len(token_calls) == 2
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- error envelopes -------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_api_error_decodes_canonical_envelope():
|
|
133
|
+
transport = FakeTransport(
|
|
134
|
+
[
|
|
135
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
136
|
+
_err(404, "invoice_not_found", "no invoice with that IRN"),
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
client = _new_client(transport)
|
|
140
|
+
with pytest.raises(APIError) as excinfo:
|
|
141
|
+
client.invoices.get("INV-MISSING")
|
|
142
|
+
assert excinfo.value.status == 404
|
|
143
|
+
assert excinfo.value.code == "invoice_not_found"
|
|
144
|
+
assert excinfo.value.trace_id == "trace-xyz"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_api_error_handles_non_envelope_body():
|
|
148
|
+
transport = FakeTransport(
|
|
149
|
+
[
|
|
150
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
151
|
+
(502, {}, b"<html>Bad Gateway</html>"),
|
|
152
|
+
]
|
|
153
|
+
)
|
|
154
|
+
client = _new_client(transport)
|
|
155
|
+
with pytest.raises(APIError) as excinfo:
|
|
156
|
+
client.invoices.get("INV-X")
|
|
157
|
+
assert excinfo.value.status == 502
|
|
158
|
+
# Falls back to opaque code rather than crashing on the HTML body.
|
|
159
|
+
assert excinfo.value.code == "http_error"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --- resource calls --------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_invoices_submit_attaches_idempotency_key_and_body():
|
|
166
|
+
transport = FakeTransport(
|
|
167
|
+
[
|
|
168
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
169
|
+
_ok(
|
|
170
|
+
{"irn": "INV001", "status": "pending", "tracking_url": "/v1/invoices/INV001"},
|
|
171
|
+
status=202,
|
|
172
|
+
),
|
|
173
|
+
]
|
|
174
|
+
)
|
|
175
|
+
client = _new_client(transport)
|
|
176
|
+
out = client.invoices.submit({"irn": "INV001"}, idempotency_key="my-key", mode="async")
|
|
177
|
+
assert out["irn"] == "INV001"
|
|
178
|
+
|
|
179
|
+
method, url, headers, body = transport.calls[1]
|
|
180
|
+
assert method == "POST"
|
|
181
|
+
assert url == "https://api.test/v1/invoices?mode=async"
|
|
182
|
+
assert headers["idempotency-key"] == "my-key"
|
|
183
|
+
assert headers["content-type"] == "application/json"
|
|
184
|
+
assert json.loads(body) == {"irn": "INV001"}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_invoices_submit_generates_idempotency_key_when_omitted():
|
|
188
|
+
transport = FakeTransport(
|
|
189
|
+
[
|
|
190
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
191
|
+
_ok({"irn": "INV", "status": "pending", "tracking_url": "/v1/invoices/INV"}, status=202),
|
|
192
|
+
]
|
|
193
|
+
)
|
|
194
|
+
client = _new_client(transport)
|
|
195
|
+
client.invoices.submit({"irn": "INV"})
|
|
196
|
+
_, _, headers, _ = transport.calls[1]
|
|
197
|
+
assert headers["idempotency-key"].startswith("lb-")
|
|
198
|
+
assert len(headers["idempotency-key"]) == len("lb-") + 32
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_invoices_transmit_targets_correct_path():
|
|
202
|
+
transport = FakeTransport(
|
|
203
|
+
[
|
|
204
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
205
|
+
_ok({"irn": "INV-X", "status": "failed", "tracking_url": "/v1/invoices/INV-X"}, status=202),
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
client = _new_client(transport)
|
|
209
|
+
client.invoices.transmit("INV-X")
|
|
210
|
+
_, url, _, body = transport.calls[1]
|
|
211
|
+
assert url == "https://api.test/v1/invoices/INV-X/transmit"
|
|
212
|
+
assert body is None
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_invoices_update_status_serialises_body():
|
|
216
|
+
transport = FakeTransport(
|
|
217
|
+
[
|
|
218
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
219
|
+
_ok({"irn": "INV-Y", "status": "transmitted"}),
|
|
220
|
+
]
|
|
221
|
+
)
|
|
222
|
+
client = _new_client(transport)
|
|
223
|
+
client.invoices.update_status("INV-Y", payment_status="PAID", reference="RCPT-1")
|
|
224
|
+
_, url, _, body = transport.calls[1]
|
|
225
|
+
assert url == "https://api.test/v1/invoices/INV-Y/status"
|
|
226
|
+
assert json.loads(body) == {"payment_status": "PAID", "reference": "RCPT-1"}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_invoices_list_drops_empty_query_params():
|
|
230
|
+
transport = FakeTransport(
|
|
231
|
+
[
|
|
232
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
233
|
+
_ok({"data": [], "next_cursor": None}),
|
|
234
|
+
]
|
|
235
|
+
)
|
|
236
|
+
client = _new_client(transport)
|
|
237
|
+
client.invoices.list(limit=20)
|
|
238
|
+
_, url, _, _ = transport.calls[1]
|
|
239
|
+
# cursor=None and status=None must not appear in the query string.
|
|
240
|
+
assert url == "https://api.test/v1/invoices?limit=20"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def test_webhooks_create_and_delete():
|
|
244
|
+
transport = FakeTransport(
|
|
245
|
+
[
|
|
246
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
247
|
+
_ok({"id": "wh-1", "url": "https://hook", "events": ["invoice.accepted"]}),
|
|
248
|
+
(204, {}, b""),
|
|
249
|
+
]
|
|
250
|
+
)
|
|
251
|
+
client = _new_client(transport)
|
|
252
|
+
created = client.webhooks.create(url="https://hook", events=["invoice.accepted"])
|
|
253
|
+
assert created["id"] == "wh-1"
|
|
254
|
+
assert client.webhooks.delete("wh-1") is None
|
|
255
|
+
assert transport.calls[-1][0] == "DELETE"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def test_user_agent_includes_sdk_version_and_suffix():
|
|
259
|
+
transport = FakeTransport(
|
|
260
|
+
[
|
|
261
|
+
_ok({"access_token": "t", "expires_in": 3600}),
|
|
262
|
+
_ok({"data": [], "next_cursor": None}),
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
client = _new_client(transport, user_agent="myapp/1.0")
|
|
266
|
+
client.invoices.list()
|
|
267
|
+
_, _, headers, _ = transport.calls[1]
|
|
268
|
+
assert headers["user-agent"].startswith("linkbridge-python/")
|
|
269
|
+
assert headers["user-agent"].endswith("myapp/1.0")
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Tests for the webhook signature verifier.
|
|
2
|
+
|
|
3
|
+
The vector below is reproduced byte-for-byte against the Go SDK's
|
|
4
|
+
``VerifyWebhook`` (see ``packages/sdk-go/webhook_test.go``) so the two
|
|
5
|
+
implementations stay in lockstep.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hmac
|
|
11
|
+
import time
|
|
12
|
+
from hashlib import sha256
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from linkbridge import (
|
|
17
|
+
MAX_WEBHOOK_SKEW_SECONDS,
|
|
18
|
+
SignatureError,
|
|
19
|
+
verify_webhook,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
SECRET = b"shhh-it-is-a-secret"
|
|
24
|
+
BODY = b'{"event":"invoice.accepted","data":{"irn":"INV-1"}}'
|
|
25
|
+
NOW = 1_700_000_000 # fixed reference timestamp
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sign(secret: bytes, body: bytes, t: int) -> str:
|
|
29
|
+
mac = hmac.new(secret, f"{t}.".encode("ascii"), sha256)
|
|
30
|
+
mac.update(body)
|
|
31
|
+
return f"t={t},v1={mac.hexdigest()}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_accepts_well_formed_signature():
|
|
35
|
+
header = _sign(SECRET, BODY, NOW)
|
|
36
|
+
verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_rejects_missing_header():
|
|
40
|
+
with pytest.raises(SignatureError, match="missing"):
|
|
41
|
+
verify_webhook(secret=SECRET, body=BODY, header="", now=NOW)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_rejects_malformed_header():
|
|
45
|
+
with pytest.raises(SignatureError, match="malformed"):
|
|
46
|
+
verify_webhook(secret=SECRET, body=BODY, header="garbage", now=NOW)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_rejects_negative_timestamp():
|
|
50
|
+
with pytest.raises(SignatureError, match="malformed"):
|
|
51
|
+
verify_webhook(secret=SECRET, body=BODY, header="t=-1,v1=abc", now=NOW)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_rejects_replay_outside_window():
|
|
55
|
+
header = _sign(SECRET, BODY, NOW)
|
|
56
|
+
skewed = NOW + MAX_WEBHOOK_SKEW_SECONDS + 1
|
|
57
|
+
with pytest.raises(SignatureError, match="replay"):
|
|
58
|
+
verify_webhook(secret=SECRET, body=BODY, header=header, now=skewed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_rejects_signature_mismatch():
|
|
62
|
+
header = _sign(b"different-secret", BODY, NOW)
|
|
63
|
+
with pytest.raises(SignatureError, match="mismatch"):
|
|
64
|
+
verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_tolerates_unknown_keys_for_forward_compat():
|
|
68
|
+
# The verifier must ignore unknown k=v pairs (e.g. v2= once we add
|
|
69
|
+
# an algorithm bump) so that future signers stay backward-compatible
|
|
70
|
+
# with deployed receivers.
|
|
71
|
+
header = _sign(SECRET, BODY, NOW) + ",v2=ignored"
|
|
72
|
+
verify_webhook(secret=SECRET, body=BODY, header=header, now=NOW)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_uppercased_hex_is_accepted():
|
|
76
|
+
header = _sign(SECRET, BODY, NOW)
|
|
77
|
+
parts = dict(p.split("=", 1) for p in header.split(","))
|
|
78
|
+
upper_header = f"t={parts['t']},v1={parts['v1'].upper()}"
|
|
79
|
+
verify_webhook(secret=SECRET, body=BODY, header=upper_header, now=NOW)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_secret_must_be_bytes():
|
|
83
|
+
header = _sign(SECRET, BODY, NOW)
|
|
84
|
+
with pytest.raises(TypeError):
|
|
85
|
+
verify_webhook(secret="str-secret", body=BODY, header=header, now=NOW) # type: ignore[arg-type]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_body_must_be_bytes():
|
|
89
|
+
header = _sign(SECRET, BODY, NOW)
|
|
90
|
+
with pytest.raises(TypeError):
|
|
91
|
+
verify_webhook(secret=SECRET, body="str-body", header=header, now=NOW) # type: ignore[arg-type]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_default_now_uses_wall_clock(monkeypatch):
|
|
95
|
+
"""When ``now`` is omitted, the verifier must call ``time.time()``."""
|
|
96
|
+
monkeypatch.setattr(time, "time", lambda: float(NOW))
|
|
97
|
+
header = _sign(SECRET, BODY, NOW)
|
|
98
|
+
verify_webhook(secret=SECRET, body=BODY, header=header)
|